C++进阶 — 【哈希】

目录

一、unordered系列关联式容器

二、unordered_map

 1. unordered_map的文档介绍

 2. unordered_map的接口说明

   2.1 unordered_map的容量

   2.2 unordered_map的迭代器 

   2.3 unordered_map的元素访问 

   2.4 unordered_map的查询

   2.5 unordered_map的修改操作 

   2.6 unordered_map的桶操作

三、 底层结构

 1. 哈希概念

 2. 哈希冲突

 3. 哈希函数

 4.常见哈希函数

 5. 闭散列

  5.1 线性探测

  5.2 结构

  5.3 插入

  5.4 查找

  5.5 删除

  5.6 仿函数

  二次探测

 6. 开散列(哈希桶)

  6.1结构

  6.2 插入

  6.3 删除

  6.4 查找

 7.  开散列与闭散列比较

四、 哈希表的改造

 1. 迭代器

 2. 封装unordered_map

 3.改造哈希桶的函数


一、unordered系列关联式容器

在C++98中,STL提供了底层为红黑树结构的一系列关联式容器,在查询时效率可达到O(log_2 N),即最差情况下需要比较红黑树的高度次,当树中的节点非常多时,查询效率也不理想。最好 的查询是,进行很少的比较次数就能够将元素找到,因此在C++11中,STL又提供了4个unordered系列的关联式容器,这四个容器与红黑树结构的关联式容器使用方式基本类似,只是 其底层结构不同。


二、unordered_map

 1. unordered_map的文档介绍

  1. unordered_map是存储键值对的关联式容器,其允许通过key快速的索引到与其对应的value。
  2. 在unordered_map中,键值通常用于惟一地标识元素,而映射值是一个对象,其内容与此键关联。键和映射值的类型可能不同。
  3. 在内部,unordered_map没有对按照任何特定的顺序排序, 为了能在常数范围内找到key所对应的value,unordered_map将相同哈希值的键值对放在相同的桶中。
  4. unordered_map容器通过key访问单个元素要比map快,但它通常在遍历元素子集的范围迭代方面效率较低。
  5. unordered_map实现了直接访问操作符(operator[]),它允许使用key作为参数直接访问 value。
  6. 它的迭代器只有正向迭代器,没有反向迭代器。

 2. unordered_map的接口说明

   2.1 unordered_map的容量

   2.2 unordered_map的迭代器 

   2.3 unordered_map的元素访问 

 注意:该函数中实际调用哈希桶的插入操作,用参数key与V()构造一个默认值往底层哈希桶 中插入,如果key不在哈希桶中,插入成功,返回V(),插入失败,说明key已经在哈希桶中, 将key对应的value返回。

   2.4 unordered_map的查询

注意:unordered_map中key是不能重复的,因此count函数的返回值最大为1 

   2.5 unordered_map的修改操作 

    2.6 unordered_map的桶操作

 这里set和unordered_set的接口也都差不多,详细文档可查看:unordered_set - C++ Reference (cplusplus.com)

set - C++ Reference (cplusplus.com)

unordered_map - C++ Reference (cplusplus.com)

map - C++ Reference (cplusplus.com)


三、 底层结构

unordered系列的关联式容器之所以效率比较高,是因为其底层使用了哈希结构。

 1. 哈希概念

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

理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。

        如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立 一 一映射的关系,那么在查找时通过该函数可以很快找到该元素。

当向该结构中:

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

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

哈希函数设置为:hash(key) = key % capacity;         capacity为存储元素底层空间总的大小。
将key%上capacity 得到的就是映射到数组位置的下标,然后将key存到该下标处。

 2. 哈希冲突

        不同关键字通过相同哈希函数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
引起哈希冲突的一个原因可能是:哈希函数设计不够合理。那么哈希冲突怎么解决呢?

解决哈希冲突两种常见的方法是:闭散列和开散列

 3. 哈希函数

哈希函数设计原则:

  • 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间
  • 哈希函数计算出来的地址能均匀分布在整个空间中
  • 哈希函数应该比较简单

  4.常见哈希函数

1. 直接定址法--(常用)

取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B

优点:简单、均匀
缺点:需要事先知道关键字的分布情况
使用场景:适合查找比较小且连续的情况
2. 除留余数法--(常用)
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,
按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址
3. 平方取中法
假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址;
再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址
平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况
4. 折叠法
折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这
几部分叠加求和,并按散列表表长,取后几位作为散列地址。
5. 随机数法
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中
random为随机数函数。
通常应用于关键字长度不等时采用此法
6. 数学分析法
设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定
相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只
有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散
列地址。
数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的
若干位分布较均匀的情况
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突


 5. 闭散列

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

  5.1 线性探测

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

  5.2 结构

    enum State// 三种状态
	{
		EMPTY,//空
		EXIST,//存在
		DELETE,//删除
	};
    template<class K, class V>
	struct HashData
	{
		pair<K, V> _data;
		State state = EMPTY;
	};
    template<class K, class V, class Hash = HashFunc<K>>
	class HashTable
	{
		typedef HashData<K, V> Data;
	public:
		HashTable()
			:_n(0)
		{	//提前开好空间,避免第一次插入的地方计算负载因子时报错
			_tables.resize(10);
		}
    private:
		vector<Data> _tables;
		int _n;
	};

  5.3 插入

        还是以K,V结构为例,先不考虑扩容和存不存在的问题,先求出要映射的下标,那么这个key值它可能是整形,也有可能不是整形,整形的话可以直接取模,不是整形的话怎么办呢?这时候仿函数就派上用场了,把其他类型用仿函数去转化成整形,然后再取模,求出下标。这里我为什么要% size呢?那是因为后面要实现[ ],而[ ]的判断是下标位置是否小于size,这是如果%的是capacity,那么下标如果不在size里,用[ ]时就会报错。

        扩容的话这里用负载因子去判断,负载因子当然越小越好。负载因子过大导致插入时会一直向后寻找位置,导致效率变低,所以这里扩容后重新插入,会更改原来的映射关系,把数据分散开来。

		bool insert(const pair<K, V>& data)
		{	//如果key存在就不插入
			if (Find(data.first))
			{
				return false;
			}
			//负载因子大于0.7就扩容,浮点数不好比较就直接乘10,按整形比
			if (_n * 10 / _tables.size() > 7)
			{
				HashTable newHT;
				newHT._tables.resize(_tables.size() * 2);
				for (auto& e : _tables)//开一个二倍size的哈希表,把旧表数据插入新表,
				{
					if (e.state == EXIST)
					{
						newHT.insert(e._data);
					}
				}
				_tables.swap(newHT._tables);
			}
			Hash hf;//仿函数
			//求映射下标,如果是其他类型用仿函数转化为整形取模
			size_t hashi = hf(data.first) % _tables.size();
			while (_tables[hashi].state == EXIST)
			{
				++hashi;
				hashi %= _tables.size();
			}
			_tables[hashi]._data = data;
			_tables[hashi].state = EXIST;
			++_n;
			return true;
		}

  5.4 查找

        查找这里跟插入很相似,都是找下标,而后找数据存不存在。因为删除的缘故,这里要加一个判断状态是否为存在,只有存在该数据才可以被找到。因为删除这里只是修改状态,如果不设置这个条件,可能会出现数据已经被删除了而又被找到的情况。而返回值是Data* 是因为查找后可以直接进行修改,很方便。

		Data* Find(const K& key)
		{
			Hash hf;
			size_t hashi = hf(key) % _tables.size();//求下标
            size_t start = hashi;
			while (_tables[hashi].state != EMPTY)
			{	//下标位置的数据状态要为存在才可以被找到
				if (_tables[hashi].state == EXIST && _tables[hashi]._data.first == key)
				{
					return &_tables[hashi];
				}
				++hashi;
				hashi %= _tables.size();
//极端场景下,表里的状态没有空,全是存在和删除,如果不写这个可能导致死循环
                if(start == hashi)	
				    break;
			}
			return nullptr;
		}

  5.5 删除

        删除很简单,就是通过查找,获取该数据的地址,把状态改为删除即可,

bool erase(const K& key)
		{
			Data* ret = Find(key);
			if (ret)
			{
				ret->state = DELETE;
				_n--;
				return true;
			}
			else
			{
				return false;
			}
		}

  5.6 仿函数

template<class K>
	struct HashFunc
	{	//将key转化为整形
		size_t operator()(const K& key)
		{
			return (size_t)key;
		}
	};
	template<>//模板特化
	struct HashFunc<string>
	{	//将字符串转化为整形,
	    //BKDR算法
		size_t operator()(const string& key)
		{
			size_t num = 0;
			for (auto& ch : key)
			{
				num *= 131;//减少冲突概率
				num += ch;
			}
			return num;
		}
	};

线性探测优点:实现非常简单,
线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同
关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降
低。

  完整代码:HashTable/HashTable/HashTable(闭).h · 晚风不及你的笑/作业库 - 码云 - 开源中国 (gitee.com)https://gitee.com/auspicious-jing/job-library/blob/master/HashTable/HashTable/HashTable(%E9%97%AD).h

  二次探测

        线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置的方法有关(逐个往后去找)。

        因此二次探测为了避免该问题,找下一个空位置的方法为: H_i = (H_0 + i ^ 2) % m, 或者: H_i = (H_0 - i ^ 2) % m。其中: i = 0,1,2,3……H_0是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小。

研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任
何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在
搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出
必须考虑增容。


闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。


 6. 开散列(哈希桶)

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

   6.1结构

        开散列结构用一个数组存放映射位置,把每个映射位置看成一个桶,用单链表把映射位置相同的元素“挂起来”。开散列的仿函数和闭散列的一样,我就不写了。

//单链表节点
template<class T>
struct HashBucketNode
{
	HashBucketNode(const T& data)
		:_data(data)
		,_next(nullptr)
	{}
	T _data;
	HashBucketNode<T>* _next;
};

template<class K,class T,class Hash = HashFunc<K>>
class HashBucket
{
	typedef HashBucketNode<T> Data;
public:
	
	HashBucket()
		:_n(0)
	{
		_tables.resize(10);
	}
private:
	vector<Data*> _tables;    
	size_t _n;//哈希表里存储元素的个数
};

   6.2 插入

        不考虑相同元素和扩容问题,插入十分简单,先将元素通过仿函数转化成整形,求出它的映射位置,用该元素创建一个新的单链表节点,用头插的方式插入这个映射位置(尾插效率低)。

        相同元素的处理可以等查找实现了再来处理,本质就是查找该元素是否在表里,如果存在就返回该节点,不存在就继续插入。

        扩容这里跟闭散列不一样,闭散列的负载因子不能过大,过大会影响效率,而开散列这里,最好的情况就是每个映射位置下面都只有一个元素,也就是说负载因子等于1就可以扩容了(负载因子过小,浪费空间,过大,效率变差,标准也规定了负载因子要为1)。

        创建一个是旧数组二倍大小的新数组,再将旧数组的链表一个一个取过来,重新定义映射位置插入新数组,每把旧数组的一个映射位置的链表取完,就将该映射位置置空。全部取完后,旧数组里就没有链表了,就是一个光秃秃的数组,再将新旧数组一交换(还是找个打工人),扩容就完成了。

ps:这里取旧数组的链表,一是因为旧数组析构时,链表并不会被析构,会造成内存泄漏;二是旧数组反正也要被析构,他的东西我直接拿过来用,还能减少重新插入的时间消耗,节省效率;需要注意的是取旧数组的链表,不能直接一把把桶里的链表原模原样的放到新数组里,因为扩容后映射关系会变,所以要注意!

Data* Insert(const T& data)
	{
		Data* node = Find(data);
		if (node)
			return node;
		//扩容
		if (_tables.size() == _n)
		{
			vector<Data*> newHT;
			newHT.resize(_tables.size() * 2);
			for (size_t i = 0; i < _tables.size(); ++i)
			{
				Data* cur = _tables[i];
				
				while (cur)
				{
					Data* next = cur->_next;
					size_t hashi = Hash()(cur->_data) % newHT.size();
					cur->_next = newHT[hashi];
					newHT[hashi] = cur;
					cur = next;
				}
				_tables[i] = nullptr;
			}
			_tables.swap(newHT);
		}
		//插入
		size_t hashi = Hash()(data) % _tables.size();
		Data* newNode = new Data(data);
		newNode->_next = _tables[hashi];
		_tables[hashi] = newNode;
		++_n;
		return newNode;
	}

  6.3 删除

        删除某个值,就是删除映射位置下单链表的节点,单链表我们也知道,只有一个节点是无法做到删除的,所以要加一个前置指针,指向该链表的前一个节点,此时在遍历单链表找要删除的位置,找到后就让前一个节点的next指针指向当前节点的下一个节点,最后删除掉当前节点,删除完成返回真,如果找不到就返回假。

bool Erase(const K& key)
	{
		size_t hashi = Hash()(key) % _tables.size();
		Data* cur = _tables[hashi];
		Data* prev = nullptr;
		while (cur)
		{
			if (cur->_data == key)
			{
				//删除
				if (cur == _tables[hashi])
				{
					_tables[hashi] = cur->_next;
				}
				else
				{
					prev->_next = cur->_next;
				}
				delete cur;
				return true;
			}
			else
			{
				prev = cur;
				cur = cur->_next;
			}
			
		}
		return false;
	}

  6.4 查找

        查找很简单,就是先找映射位置,再找映射位置里的与要查找的值相同的那个节点,找到后返回当前节点即可,如果找不到就返回空,证明表里没有这个值。

Data* Find(const K& key)
	{
		size_t hashi = Hash()(key) % _tables.size();
		Data* cur = _tables[hashi];
		while (cur)
		{
			if (cur->_data == key)
			{
				return cur;
			}
			cur = cur->_next;
		}
		return nullptr;
	}

 7.  开散列与闭散列比较

        应用链地址法处理溢出,需要增设链接指针,似乎增加了存储开销。事实上:由于开地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子a <=0.7,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间。


四、 哈希表的改造

 1. 迭代器

        这里为了方便,定义了一个哈希表成员,在++的地方会用到。那么构造这里就要将两个成员都初始化。

        因为哈希表的结构特殊,是单链表,所以迭代器只有正向没有反向,也就是说没有--。++的实现也不难,迭代器遍历本质就是把表内所有的单链表都走一遍,如果下一个节点不为空,就走向下一个节点;如果下一个节点为空,那么就找下一个不为空的桶,我们只知道节点,不知道映射位置怎么找呀?简单,通过当前节点的值就可以找到当前的映射位置,这样再找下一个不为空的桶就很简单了,需要注意的是,如果所有桶都遍历完了,证明走到end了,和之前红黑树一样,end为空,证明迭代器结束了。

//前置声明
template<class K, class T, class Hash, class KeyOfT>
class HashBucket;

template<class K, class T, class Hash, class KeyOfT>
struct _IteratorHash
{
	typedef HashBucketNode<T> Data;
	typedef _IteratorHash<K, T, Hash, KeyOfT> Self;
	//为了实现简单,在哈希桶的迭代器类中需要用到hashBucket本身
	typedef HashBucket<K, T, Hash, KeyOfT> HT;

	_IteratorHash(Data* node,HT* ht)
		:_node(node)
		,_ht(ht)
	{}
	T& operator*()
	{
		return _node->_data;
	}
	T* operator->()
	{
		return &_node->_data;
	}
	Self& operator++()
	{	//如果桶内还有节点,那就取他的下一个节点
		if (_node->_next)
		{
			_node = _node->_next;
		}
		else
		{
			Hash hs;
			KeyOfT kot;
			size_t hashi = hs(kot(_node->_data)) % _ht->_tables.size();//找当前桶的映射位置
			while (++hashi < _ht->_tables.size())	//找到下一个不为空的桶
			{
				if (_ht->_tables[hashi])
				{
					_node = _ht->_tables[hashi];
					break;
				}

			}
			if (hashi == _ht->_tables.size())	//数组已经走完了,都遍历一遍了
				_node = nullptr;
		}
		return *this;
	}
    //后置
	//Self operator++(int)
	//{
	//	Self ret = *this;
	//	if (_node->_next)
	//	{
	//		_node = _node->_next;
	//	}
	//	else
	//	{
	//		Hash hs;
	//		KeyOfT kot;
	//		size_t hashi = hs(kot(_node->_data)) % _ht->_tables.size();
	//		while (++hashi < _ht->_tables.size())
	//		{
	//			if (_ht->_tables[hashi])
	//			{
	//				_node = _ht->_tables[hashi];
	//				break;
	//			}

	//		}
	//		if (hashi == _ht->_tables.size())
	//			_node = nullptr;
	//	}
	//	return ret;
	//}

	bool operator!=(const Self& s)const
	{
		return _node != s._node;
	}

	bool operator==(const Self& s)const
	{
		return _node == s._node;
	}
	HT* _ht;
	Data* _node;
};

 begin:找第一个不为空的桶,桶的最上面就是第一个节点,

 end:end为空,走到空,证明遍历节点完毕

	template<class K, class T,class Ref,class Ptr, class Hash, class KeyOfT>
	friend struct _IteratorHash;	//友元

public:
	typedef _IteratorHash<K, T, Hash, KeyOfT> iterator;
	

	iterator begin()
	{
		for (size_t i = 0; i < _tables.size(); ++i)
		{    //找第一个不为空的桶
			if (_tables[i])
				return iterator(_tables[i], this);
		}
		return iterator(nullptr, this);
	}
	iterator end()
	{
		return iterator(nullptr, this);
	}

 2. 封装unordered_map

        这里还是封装几个的接口,让我们可以简单的使用。这些接口都是用哈希桶封装之后的。

template<class K,class V ,class Hash = HashFunc<K>>
	class unordered_map
	{
		struct mapKeyOfT    
		{    //获取key值
			const K& operator()(const pair<K,V>& kv)
			{
				return kv.first;
			}
		};
	public:
		typedef HashBucket<K, pair<const K,V>, Hash, mapKeyOfT> HaSh;
		typedef typename HashBucket<K, pair<const K, V>, Hash, mapKeyOfT>::iterator iterator;
        iterator begin()
		{
			return _ht.begin();
		}
		iterator end()
		{
			return _ht.end();
		}

		pair<iterator, bool> Insert(const pair<K,V>& kv)
		{
			return _ht.Insert(kv);
		}
		iterator Erase(iterator pos)
		{
			return _ht.Erase(pos->first);
		}
        //bool Erase(const K& key)
        //{
        //    return _ht.Erase(key);
        //}
		V& operator[](const K& key)
		{
			pair<iterator, bool> ret = _ht.Insert(make_pair(key,V()));
			return ret.first->second;
		}

		iterator Find(const K& key)
		{
			return _ht.Find(key);
		}
	private:
		HaSh _ht;
	};

 unordered_set的接口和unordered_map的接口基本一致,这里const迭代器我就不写了,大家有兴趣的可以看看stl源码或者网上搜搜大佬的看法。

 3.改造哈希桶的函数

因为我们要封装,所以要对哈希桶的函数进行一些修改,Find的返回类型改成迭代器,Erase可改可不改,看自己,这里的insert跟红黑树差不过,只不过多了一个参数而已。查找会返回一个迭代器,可以把节点用迭代器构造一个返回,删除这里建议使用原来的那个bool值的erase,更改为迭代器以后删除节点,返回值只能是返回被删除节点的上一个节点。

// 插入
pair<iterator,bool> Insert(const T& data)
	{
		KeyOfT kot;
		Hash hs;
		 //查找表里有没有该值
		iterator node = Find(kot(data));
		if (node != end())
			return make_pair(node,false);
		//扩容
		if (_tables.size() == _n)
		{
			vector<Data*> newHT;
			newHT.resize(_tables.size() * 2);
			for (size_t i = 0; i < _tables.size(); ++i)
			{
				Data* cur = _tables[i];
				
				while (cur)
				{
					Data* next = cur->_next;
					size_t hashi = hs(kot(cur->_data)) % newHT.size();
					cur->_next = newHT[hashi];
					newHT[hashi] = cur;
					cur = next;
				}
				_tables[i] = nullptr;
			}
			_tables.swap(newHT);
		}
		//插入
		size_t hashi = hs(kot(data)) % _tables.size();
		Data* newNode = new Data(data);
		newNode->_next = _tables[hashi];
		_tables[hashi] = newNode;
		++_n;
		return make_pair(iterator(newNode, this), true);
	}
//删除
iterator Erase(const K& key)
	{
		size_t hashi = Hash()(key) % _tables.size();
		Data* cur = _tables[hashi];
		Data* prev = nullptr;
		while (cur)
		{
			if (KeyOfT()(cur->_data) == key)
			{
				//删除
				if (cur == _tables[hashi])
				{
					_tables[hashi] = cur->_next;
				}
				else
				{
					prev->_next = cur->_next;
				}
				delete cur;
				return iterator(prev,this);
			}
			else
			{
				prev = cur;
				cur = cur->_next;
			}
		}
		return iterator(nullptr,this);
	}
//查找
	iterator Find(const K& key)
	{
		size_t hashi = Hash()(key) % _tables.size();
		Data* cur = _tables[hashi];
		while (cur)
		{
			if (KeyOfT()(cur->_data) == key)
			{
				return iterator(cur, this);
			}
			cur = cur->_next;
		}
		return iterator(nullptr,this);
	}

完整代码:

猜你喜欢

转载自blog.csdn.net/weixin_68993573/article/details/129254654
今日推荐