C++之哈希详解

本文深入探讨了哈希的概念、哈希冲突及其解决方案,包括闭散列和开散列方法。详细介绍了闭散列的线性探测和二次探测,以及开散列的链地址法。此外,文章还提供了哈希表的插入、查找和删除操作的实现细节,强调了负载因子在决定何时扩容中的重要性,并讨论了如何处理字符串键的哈希问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

哈希

目录

哈希

哈希的概念

哈希冲突

常见哈希函数:

哈希冲突的解决:

闭散列

线性探测的缺陷是产生冲突的数据堆积在一块,相比线性探测的好处,如果一个位置有很多值映射,冲突剧烈,那么它们存储时相对会比较分散,不会引发一片一片的冲突

开散列

闭散列的实现

数据的存储结构

哈希表结构

哈希表的插入

哈希的查找

哈希的删除

哈希闭散列的完整代码

开散列

开散列的概念

数据的存储结构

哈希表的结构

哈希表的插入

哈希表的查找

哈希表的删除

哈希桶的析构函数

开散列完整代码


哈希的概念

顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。我们在顺序查找中可以得知时间复杂度为O(N),平衡树中为树的高度,即O(log2N),搜索的效率取决于搜索过程中元素的比较次数。

理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。

当向该哈希结构中:

  • 插入元素:根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
  • 搜索元素:对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功

该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表
(Hash Table)(或者称散列表)

那么,例如我们有数据{1,3,7,4,8};

哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。

用该方法可以避免对关键字的比较,可以达到快速搜索。但是,如果我们此时再插入44,44%10得到的同样是4,但是位置已经被占用,所以引出了以下的问题:

哈希冲突

 对于两个数据元素的关键字 和 (i != j),有 != ,但有:Hash( ) == Hash( ),即:不同关键字通过
相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
发生哈希冲突该如何处理呢?

常见哈希函数:

直接定址法

缺点:需要事先知道关键字的分布情况

使用场景:适合查找比较小且连续的情况

除留余数法

散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函
数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址

哈希冲突的解决:

闭散列

闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置呢?

方法:

线性探测:index+i (i = 1,2,3,4…)

从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止

二次探测:index+i^2(i = 1,2,3,4…)

线性探测的缺陷是产生冲突的数据堆积在一块,相比线性探测的好处,如果一个位置有很多值映射,冲突剧烈,那么它们存储时相对会比较分散,不会引发一片一片的冲突

开散列

开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。

闭散列的实现

当我们实现哈希表时,数据是怎么存储呢?数据的结构又是什么呢?数据的存储很好想到,可以用一个vector线性表存就好了,那么数据的结构除了存储数据还需要什么呢?比如我们有以下数据:

 当我们查找一个值时,我们通过哈希函数求得位置,如果对应位置不是,那么很可能是遇到了冲突,所以我们需要继续往后查找,遇到空位置时说明这个值是不存在的,因为如果存在,肯定存储在了第一个空位置,那么当我们删除一个值,直接删除的话这个位置变成了空,但是这样有问题:使得查找一个值提早遇到空,那么就找不到这个值。

就比如我们删除333,当我们查找14时,14%10=4,我们锁定到了4的位置,但是由于删除333后,4的位置是空的,那么就会形成一个14存在,却无法查找的到的局面,

所以我们需要通过枚举来引入三个状态,每个位置存储值得同时再存储一个状态标记:空、满、删除。

三种状态:

enum Status
{
	EXIST,//存在
	EMPTY,//空
	DELETE//删除
};

数据的存储结构

我们使用KV模型

template<class K,class V>
struct HashData
{
	Status _status;//当前状态
	pair<K, V> _kv;
};

哈希表结构

vector用来存储HashData,_n则用来记录表中的有效数据个数:

template<class K,class V>
class HashTable
{
public:

private:
	vecor<HashData<K, V>> _tables;
	size_t _n=0;
};

哈希表的插入

我们为了避免哈希冲突变多,我们引入一个负载因子,当负载因子过大时需要对哈希表进行增容。

  1. 如果哈希表中已经存在该键值对,则插入失败返回false
  2. 判断该哈希表的大小以及负载因子,确定是否需要增容
  3. 将键值对插入哈希表
  4. ++_n

当我们使用线性探测时,会发现产生冲突的数据会堆积在一起,连续位置比较多,会引发踩踏洪水效应,所以在这里我们使用了二次探测。

我们解决增容的问题,首先我们确定的是_table.size() == 0时需要增容,其次我们设置负载因子=有效数据/表的大小,如果负载因子大于0.7时就增容。那么如何增容呢?

我们先创建一个临时的新表,将新表容量开扩到新的容量,此时复用Insert函数来将旧表的内容进入到新表中,最后再将新表与旧表交换。

if (_tables.size() == 0 || (double)(_n / _tables.size() >= 0.7))
		{
			size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
			HashTable<K, V> newHT;//开辟一个新表
			newHT._tables.resize(newsize);
			for (auto& e : _tables)
			{
				if (e._status == EXIST)
				{
					newHT.Insert(e._status);//复用Insert
				}
			}
			_tables.swap(newHT._tables);//旧表与新表的交换
		}

插入哈希表的代码(首先计算出插入的位置,如果该位置存在的话就遍历后面的位置,遍历的过程中如果等于的表的末尾,则返回起始位置继续遍历,直到遇到空或者删除):

        size_t start = kv.first % _tables.size();
		size_t i = 0;
		size_t index = start + i;
		while (_tables[index]._status == EXIST)
		{
			++i;
			index = start + i*i//二次探测;
			index %= _tables.size();
		}
		_tables[index]._kv = kv;
		_tables[index]._status = EXIST;
		++_n;

下面为插入的完整代码:

bool Insert(const pair<K, V>& kv)
	{
		if (Find(kv.first)
		{
			return false;
		}
		if (_tables.size() == 0 || (double)(_n / _tables.size()) > 0.7)
		{
			size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
			HashTable<K, V> newHT;
			newHT._tables.resize(newsize);
			for (auto& e : _tables)
			{
				if (e._status == EXIST)
				{
					newHT.Insert(e._status);
				}
			}
			_tables.swap(newHT._tables);
		}
		size_t start = kv.first % _tables.size();
		size_t i = 0;
		size_t index = start + i;
		while (_tables[index]._status == EXIST)
		{
			++i;
			index = start + i*i//二次探测;
			index %= _tables.size();
		}
		_tables[index]._kv = kv;
		_tables[index]._status = EXIST;
		++_n;

		return true;
	}

哈希的查找

哈希表的查找逻辑很简单:先计算出查找的key的位置,当这个位置不等于空时,判断key值是不是相等并且状态位为存在,如果是的话返回,不是的话进行线性探测或者二次探测,直到遇到位置为空状态。

HashData<K, V>* Find(const K& key)
	{
		if (_tables.size() == 0)
		{
			return nullptr;
		}
		size_t start = key % _tables.size();
		size_t i = 0;
		size_t index = start + i;
		while (_tables[index]._status != EMPTY)
		{
			if (_tables[index]._kv.first == key && _tables[index]._status == EXIST)
			{
				return &_tables[index];
			}
			else
			{
				++i;//通过二次探测去查找
				index = start + i*i;
				index %= _tables.size();
			}
		}
		return nullptr;
	}

哈希的删除

通过查找来找到这个点,如果找到则状态设置为删除,并且_n--。

bool Erase(const K& key)
	{
		HashData<K, V>* ret = Find(key);
		if (ret == nullptr)
		{
			return false;
		}
		else
		{
			ret->_status = DELETE;
			_n--;
			return true;
		}
	}

哈希初次全部代码:

enum Status
{
	EXIST,
	EMPTY,
	DELETE
};
template<class K,class V>
struct HashData
{
	Status _status=EMPTY;//设置一个缺省值
	pair<K, V> _kv;
};
template<class K,class V>
class HashTable
{
public:
	bool Insert(const pair<K, V>& kv)
	{
		if (Find(kv.first)
		{
			return false;
		}
		if (_tables.size() == 0 || (double)(_n / _tables.size()) > 0.7)
		{
			size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
			HashTable<K, V> newHT;
			newHT._tables.resize(newsize);
			for (auto& e : _tables)
			{
				if (e._status == EXIST)
				{
					newHT.Insert(e._kv);
				}
			}
			_tables.swap(newHT._tables);
		}
		size_t start = kv.first % _tables.size();
		size_t i = 0;
		size_t index = start + i;
		while (_tables[index]._status == EXIST)
		{
			++i;
			index = start + i*i//二次探测;
			index %= _tables.size();
		}
		_tables[index]._kv = kv;
		_tables[index]._status = EXIST;
		++_n;

		return true;
	}
	HashData<K, V>* Find(const K& key)
	{
		if (_tables.size() == 0)
		{
			return nullptr;
		}
		size_t start = key % _tables.size();
		size_t i = 0;
		size_t index = start + i;
		while (_tables[index]._status != EMPTY)
		{
			if (_tables[index]._kv.first == key && _tables[index]._status == EXIST)
			{
				return &_tables[index];
			}
			else
			{
				++i;
				index = start + i*i;
				index %= _tables.size();
			}
		}
		return nullptr;
	}
	bool Erase(const K& key)
	{
		HashData<K, V>* ret = Find(key);
		if (ret == nullptr)
		{
			return false;
		}
		else
		{
			ret->_status = DELETE;
			_n--;
			return true;
		}
	}
private:
	vector<HashData<K, V>> _tables;
	size_t _n=0;
};

我们对这段代码进行测试时,我们会发现一些问题:如果是key是string,上面的代码是编不过去的,因为key是string,无法计算位置,所以此时就用到了仿函数,仿函数通过重载方法将string转化为整形用来做key,这里运用了BKDR Hash思想来完成string到key的转换,发生冲突的概率很小。

template<class K>
struct Hash
{
    size_t operator()(const K&key)
    {
        return key;
    }
};    
struct HashStr
{
    size_t operator()(const string& str)
    {
        //BKDR Hash思想
        size_t value = 0;
        for(size_t i = 0;i<str.size();++i)
        {
            value*=131;
            value += str[i];//转成整形
        }
        return value;
};    

如果我们写成这样我们创建string为key的哈希表的时候就需要这样写,将HashFuncString仿函数传进去,初始化时则为:

HashTable<string, string, HashStr> dict;

但我们查看unordered_map底层时是不需要传这个仿函数进去的,而且我们如果这样写会需要进行string和其他的区分,所以我们这里进行了模板的特化:

template<class K>
struct Hash
{
	size_t operator()(const K& key)
	{
		return key;
	}
};
template<>
struct Hash<string>
{
	size_t operator()(const string& str)
	{
		//BKDR
		size_t value = 0;
		for (int i = 0; i < str.size(); i++)
		{
			value *= 31;
			value += str[i];
		}
		return value;
	}
};

同时哈希表的木板参数也变化为:

template<class K,class V,class HashFunc=Hash<K>>

不传仿函数过去,它会自己推演,是K是int类型就调用Hash<int>,是string就调用更匹配的Hash<string>。

哈希闭散列的完整代码

enum Status
	{
		EXIST,
		EMPTY,
		DELETE
	};

	template<class K, class V>
	struct HashData
	{
		pair<K, V> _kv;
		Status _status = EMPTY;
	};

	template<class K>
	struct Hash
	{
		size_t operator()(const K& key)
		{
			return key;
		}
	};

	// 特化
	template<>
	struct Hash < string >
	{
		size_t operator()(const string& s)
		{
			// BKDR
			size_t value = 0;
			for (auto ch : s)
			{
				value *= 31;
				value += ch;
			}
			return value;
		}
	};

	template<class K, class V, class HashFunc = Hash<K>>
	class HashTable
	{
	public:
		bool Erase(const K& key)
		{
			HashData<K, V>* ret = Find(key);
			if (ret == nullptr)
			{
				return false;
			}
			else
			{
				--_n;
				ret->_status = DELETE;
				return true;
			}
		}

		HashData<K, V>* Find(const K& key)
		{
			if (_tables.size() == 0)
			{
				return nullptr;
			}

			HashFunc hf;
			size_t start = hf(key) % _tables.size();
			size_t i = 0;
			size_t index = start;
			// 线性探测 or 二次探测
			while (_tables[index]._status != EMPTY)
			{
				if (_tables[index]._kv.first == key && _tables[index]._status == EXIST)
				{
					return &_tables[index];
				}

				++i;
				
				index = start + i*i;

				index %= _tables.size();
			}

			return nullptr;
		}

		bool Insert(const pair<K, V>& kv)
		{
			HashData<K, V>* ret = Find(kv.first);
			if (ret)
			{
				return false;
			}

			if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7)
			{
				// 扩容
				size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2;
				HashTable<K, V, HashFunc> newHT;
				newHT._tables.resize(newSize);
				for (size_t i = 0; i < _tables.size(); ++i)
				{
					if (_tables[i]._status == EXIST)
					{
						newHT.Insert(_tables[i]._kv);
					}
				}

				_tables.swap(newHT._tables);
			}

			HashFunc hf;
			size_t start = hf(kv.first) % _tables.size();
			size_t i = 0;
			size_t index = start;
			// 线性探测 or 二次探测
			while (_tables[index]._status == EXIST)
			{
				++i;
				//index = start + i*i;
				index = start + i*i;

				index %= _tables.size();
			}

			_tables[index]._kv = kv;
			_tables[index]._status = EXIST;
			++_n;

			return true;
		}

	private:
		vector<HashData<K, V>> _tables;
		size_t _n = 0;	// 有效数据个数
	};

开散列

开散列的概念

开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。

数据的存储结构

因为桶里面是单链表结构,所以需要有一个指向下一个节点的指针_next,还有一个pair来存储数据。

template<class K,class V>
	struct HashNode
	{
		HashNode(const pair<K,V>& kv)
			:_kv(kv),_next(nullptr)
		{}
		pair<K, V> _kv;
		HashNode<K, V>* _next;
	};

哈希表的结构

vector里面需要存储Node*而不是Node,如果是Node会出现二义性:存节点了还是没有存,因为定义对象出现时节点都申请出来了,都进行了初始化。所以这里需要存储Node*,指向第一个节点。

class HashTable
 {
     typedef HashNode<K, V> Node;
     private:
     size_t _n = 0;//哈希表中的有效数据
     vector<Node*> _tables;
 };

上面的闭散列我们提到了哈希冲突,那么开散列是如何解决的呢?

开散列是通过单链表链接分方式处理哈希冲突的,所以并不需要给每个节点设置状态,只需要将哈希地址相同的元素都放到同一个哈希桶里以单链表链接,开散列可以无限的插入,如果表的大小太小,那么每个桶中的单链表就会链接的节点越多,这样找一个节点时效率就会变低,所以开散列我们也要通过负载因子来判断是否增容,通过增容,每个哈希桶的链接的元素相对变少,效率也就好了些,所以我们需要一个_n变量来记录哈希表中的有效数据,来计算负载因子。

注意:负载因子:数据个数/表的大小。负载因子越低,冲突的概率越低,空间浪费越高,负载因子越高,冲突的概率越高,空间浪费越低。

哈希表的插入

哈希表的插入过程:

  1. 对该键值对查找,如果哈希表中存在该键值对,那么久返回false。
  2. 判断哈希表的大小和负载因子,来判断是否需要增容。
  3. 通过单链表形式将该键值对插入。
  4. _n++。

首先当我们遇到扩容问题的时候,扩容问题是有两种情况的,一个为_tables.size()==0时,这是需要扩容的情况。还有当我们的负载因子为1时,需要扩容。此时我们可以直接选择扩容二倍。

 此时的表已经发生变化,所以需要重新计算映射位置,这会导致计算量的增加,会造成十分混乱。

所以我们需要开辟一个新表,该表是原哈希表的两倍,之后遍历原有的哈希表,重新插入新表中,之后将旧表与新表交换。

 当我们将旧表数据重新插入到新表时,如果我们像闭散列那样复用insert,,我们会发现会创建重复的节点,把已经存在的节点再创建一遍,到最后又要将旧表中和新表中重复的节点释放一次,这是因为闭散列我们是存储的数据,而我们的开散列储存的则是指针。

所以我们只需要通过遍历旧哈希表的哈希桶,将旧表哈希桶的数据头插到新表的哈希桶中,然后将旧表的头节点的next置为空,最后新表和旧表交换即可。

bool Insert(const pair<K, V>& kv)
		{
			Node* ret = Find(kv.first);
			if (ret)
				return false;

			// 负载因子 == 1时扩容
			HashFunc hf;
			if (_n == _tables.size())
			{
				size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2;
				// ...
				vector<Node*> newTables;
				newTables.resize(newSize);
				for (size_t i = 0; i < _tables.size(); i++)
				{
					Node* cur = _tables[i];
					while (cur)
					{
						Node* next = cur->_next;
						size_t index = hf(cur->_kv.first) % newSize;
						//头插
						cur->_next = newTables[index];
						newTables[index] = cur;
						cur = next;
					}
					_tables[i] = nullptr;
				}
				_tables.swap(newTables);
			}

			
			size_t index = hf(kv.first) % _tables.size();
			Node* newnode = new Node(kv);
			// 头插
			newnode->_next = _tables[index];
			_tables[index] = newnode;

			++_n;
			return true;
		}

哈希表的查找

  1. 如果表的大小等于0,返回nullptr
  2. 找到桶的位置,然后进行遍历桶,比较值是否相等,相等则返回,不相等继续下一个节点
  3. 遍历完没找到返回nullptr
Node* Find(const K& key)
		{
			if (_tables.size() == 0)
			{
				return nullptr;
			}
			size_t index = key % _tables.size();
			Node* cur = _tables[index];
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					return cur;
				}
				else
				{
					cur = cur->_next;
				}
			}
			return nullptr;
		}

哈希表的删除

  1. 如果表的大小为0,返回false
  2. 找到桶的位置,需要注意删除节点需要保存节点的前一个节点,遍历桶找要删除的节点进行删除,需要注意头删和非头删,头删时prev为空,非头删prev不为空
  3. 删除完成–_n,返回true,否则返回false
bool Erase(const K& key)
		{
			if (_tables.size() == 0)
			{
				return false;
			}
			size_t index = key % _tables.size();
			Node* prev = nulltr;
			Node* cur = _tables[index];
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					if (prev == nulltr)
					{
						_tables[index] = cur->_next;
						delete cur;
					}
					else
					{
						prev->_next = cur->_next;
						delete cur;
					}
					--_n;
					return true;
				}
				else
				{
					prev = cur;
					cur = cur->_next;
				}
			}
			return false;
		}

开散列和闭散列一样,都需要通过仿函数处理string的情况:

template<class K>
struct HashFunc
{
	size_t operator()(const K& key)
	{
		return key;
	}
};
// 特化
template<>
struct HashFunc < string >
{
	size_t operator()(const string& key)
	{
		// BKDR Hash思想
		size_t hash = 0;
		for (size_t i = 0; i < key.size(); ++i)
		{
			hash *= 131;
			hash += key[i];
		}

		return hash;
	}
};

哈希桶的析构函数

~HashTable()
{
    for(size_t i = 0;i<_tables.size();i++)
    {
        Node* cur = _tables[i];
        while(cur)
        {
            Node* next = cur->_next;
            delete cur;
            cur = next;
        }
        tables[i] = nullptr;
    }
    _n = 0;
}

开散列完整代码

namespace LinkHash
{
	template<class K,class V>
	struct HashNode
	{
		HashNode(const pair<K,V>& kv)
			:_kv(kv),_next(nullptr)
		{}
		pair<K, V> _kv;
		HashNode<K, V>* _next;
	};
	template<class K>
	struct Hash
	{
		size_t operator()(const K& key)
		{
			return key;
		}
	};

	// 特化
	template<>
	struct Hash < string >
	{
		size_t operator()(const string& s)
		{
			// BKDR
			size_t value = 0;
			for (auto ch : s)
			{
				value *= 31;
				value += ch;
			}
			return value;
		}
	};
	template<class K, class V, class HashFunc = Hash<K>>
	class HashTable
	{
		typedef HashNode<K, V> Node;
	public:
		Node* Find(const K& key)
		{
			if (_tables.size() == 0)
			{
				return nullptr;
			}
			size_t index = key % _tables.size();
			Node* cur = _tables[index];
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					return cur;
				}
				else
				{
					cur = cur->_next;
				}
			}
			return nullptr;
		}

		bool Erase(const K& key)
		{
			if (_tables.size() == 0)
			{
				return false;
			}
			size_t index = key % _tables.size();
			Node* prev = nulltr;
			Node* cur = _tables[index];
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					if (prev == nulltr)
					{
						_tables[index] = cur->_next;
						delete cur;
					}
					else
					{
						prev->_next = cur->_next;
						delete cur;
					}
					--_n;
					return true;
				}
				else
				{
					prev = cur;
					cur = cur->_next;
				}
			}
			return false;
		}

		bool Insert(const pair<K, V>& kv)
		{
			Node* ret = Find(kv.first);
			if (ret)
				return false;

			// 负载因子 == 1时扩容
			HashFunc hf;
			if (_n == _tables.size())
			{
				size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2;
				// ...
				vector<Node*> newTables;
				newTables.resize(newSize);
				for (size_t i = 0; i < _tables.size(); i++)
				{
					Node* cur = _tables[i];
					while (cur)
					{
						Node* next = cur->_next;
						size_t index = hf(cur->_kv.first) % newSize;
						//头插
						cur->_next = newTables[index];
						newTables[index] = cur;
						cur = next;
					}
					_tables[i] = nullptr;
				}
				_tables.swap(newTables);
			}

			
			size_t index = hf(kv.first) % _tables.size();
			Node* newnode = new Node(kv);
			// 头插
			newnode->_next = _tables[index];
			_tables[index] = newnode;

			++_n;
			return true;
		}
        

	private:
		vector<Node*> _tables;
		size_t _n = 0;  // 有效数据的个数
	};
}

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值