【C++】哈希表:开散列和闭散列

  • 📝 个人主页 :超人不会飞)
  • 📑 本文收录专栏:《C++的修行之路》
  • 💭 如果本文对您有帮助,不妨点赞、收藏、关注支持博主,我们一起进步,共同成长!

目录

  • 前言
  • 一、基于哈希表的两个容器
    • 1.1 unordered_map
    • 1.2 unordered_set
    • 1.3 小试牛刀
  • 二、哈希表
    • 2.1 哈希的概念
    • 2.2 哈希冲突
    • 2.3 哈希函数
    • 2.4 字符串哈希算法
  • 三、哈希冲突的解决
    • 3.1 闭散列
      • 3.1.1 线性探测
      • 3.1.2 二次探测
    • 3.2 开散列
      • 3.2.1 链地址法的思想
      • 3.2.2 哈希桶的模拟实现
  • 四、封装unordered_map和unordered_set
    • 4.1 哈希表的迭代器
    • 4.2 改造哈希表
    • 4.3 unordered_set的封装
    • 4.4 unordered_map的封装


前言

在前面的文章中,我们学习了红黑树,其查找数据的效率很高,查找的次数最差是树的高度logN次,并且要进行多次比较。而哈希表是一种元素存储位置与元素的关键码建立一一映射关系的结构,无需进行比较,其查找效率更高,最优可以一次直接找到。在C++中,map和set的底层是红黑树结构。那么在详细介绍哈希表之前,我们也要先介绍C++中提供的另外两种关联式容器——unordered_mapunordered_set,它们的底层是哈希表

unordered_mapunordered_set的功能、接口与map和set都大差不差,所以使用起来感觉一样,但是底层可大不相同。

一、基于哈希表的两个容器

1.1 unordered_map

📃文档介绍

对其概念的解释:

  1. Unordered maps是一种存储<key,value>键值对元素的关联式容器,允许通过键值快速地索引到与其对应的实值。

  2. 在unordered_map中,一个键值通常用于唯一地标识元素,而实值是一个对象,其内容与键值相关联。键值类型和实值类型可能不同。
    在这里插入图片描述

  3. 在unordered_map中,元素不以任何特定的顺序排序(与键值和实值都无关),而是根据每个元素的哈希值以桶的形式被组织起来,以达到通过键值(key)快速获取与其对应的实值(value)的目的。

  4. unordered_map容器通过key访问单个元素要比map快,但它通常在遍历元素子集的范围迭代方面效率较低。

  5. unordered_maps实现了直接访问操作符(operator[]),它允许使用key作为参数直接访问value。

  6. 它的迭代器至少是前向迭代器。

📃unordered_map的接口与map类似,通过查文档学习即可,有以下几个比较特殊的接口。

函数声明接口功能
size_t bucket_count() const返回哈希桶中桶的总个数
size_t bucket_size(size_t n) const返回n号桶中元素的个数
size_t bucket(size_t key) const返回元素键值为key的桶编号

1.2 unordered_set

📃文档介绍

对其概念的解释:

  1. Unordered sets是一种无序的、存储单一元素的容器,允许通过键值快速找到元素。

  2. 在unordered_set中,元素的实值(value)同时是元素的键值(key),标识唯一的元素。
    在这里插入图片描述

  3. 键值是永恒不变的,因此,unordered_set只能插入和删除新元素,不可修改其中已存在的元素。

  4. 和unordered_map一样,底层是哈希桶。

  5. unordered_set容器通过key访问单个元素要比set快,但它通常在遍历元素子集的范围迭代方面效率较低。

  6. 它的迭代器至少是前向迭代器。


1.3 小试牛刀

💨对于unordered_map和unordered_set,来几道OJ以熟练掌握它们的使用!

1.在长度2N的数组中找出重复N次的元素

class Solution {
public:
    int repeatedNTimes(vector<int>& nums) {
        // 统计每个元素出现的个数
        unordered_map<int,int> countMap;
        for(auto e:nums)
        {
            countMap[e]++;
        }

        // 找出出现N次的元素
        int N = nums.size() / 2;
        for(auto& kv:countMap)
        {
            if(kv.second == N)
            {
                return kv.first;
            }
        }
        return -1;
    }
};

2.两个数组的交集

class Solution {
public:
    vector<int> intersect(vector<int>& nums1, vector<int>& nums2) {
    	// 交集必然存在于较小集合中
        // 将较小的数组哈希映射,然后较大数组到较小数组的哈希表中找交集
        // O(max(n,m,max(n,m))) = O(max(n,m))

        // 1.找出较小数组
        vector<int> minNums = nums1;
        vector<int> maxNums = nums2;
        if(nums1.size() > nums2.size())
        {
            minNums.swap(maxNums);
        }

        // 2.较小数组哈希映射
        unordered_map<int,int> count1;
        for(auto e:minNums)
        {
            count1[e]++;
        }

        // 3.较大数组计数
        unordered_map<int,int> count2;
        for(auto e:maxNums)
        {
            count2[e]++;
        }

        // 4.count2到count1找交集
        vector<int> ret;
        for(auto& kv:count2)
        {
            auto it = count1.find(kv.first);
            if(it != count1.end())
            {
                // 计算出现的次数
                int times = kv.second < it->second ? kv.second : it->second;
                // 加入交集
                ret.resize(ret.size()+times,kv.first);
            }
        }

        return ret;
    }
};

3.存在重复元素

class Solution {
public:
    bool containsDuplicate(vector<int>& nums) {
        unordered_set<int> us;
        for(auto e:nums)
        {
            // 插入unordered_set,出现插入失败的情况,即nums数组存在重复元素
            if(!(us.insert(e).second))
            {
                return true;
            }
        }
        return false;
    }
};

4.两句话中的不常见单词

class Solution {
public:
    vector<string> uncommonFromSentences(string s1, string s2) {
        //key值是单词,value值是出现的次数
        unordered_map<string,int> cnt1;
        unordered_map<string,int> cnt2;

        // 词频统计
        // 统计s1中每个单词出现的次数
        int begin1 = 0, end1 = 0;
        while(begin1 < s1.size())
        {
            end1 = s1.find(' ',begin1);
            if(end1 == string::npos)
            {
                end1 = s1.size();
            }

            string word(s1.begin() + begin1,s1.begin() + end1);
            
            cnt1[word]++;

            begin1 = end1+1;
        }

        // 统计s2中每个单词出现的次数
        int begin2 = 0, end2 = 0;
        while(begin2 < s2.size())
        {
            end2 = s2.find(' ',begin2);
            if(end2 == string::npos)
            {
                end2 = s2.size();
            }

            string word(s2.begin() + begin2,s2.begin() + end2);
            
            cnt1[word]++;

            begin2 = end2+1;
        }

        //寻找两句话中的不常见单词
        vector<string> ret;
        for(auto& kv:cnt1)
        {
            if(kv.second == 1 && cnt2.find(kv.first) == cnt2.end())
            {
                ret.push_back(kv.first);
            }
        }
        for(auto& kv:cnt2)
        {
            if(kv.second == 1 && cnt1.find(kv.first) == cnt1.end())
            {
                ret.push_back(kv.first);
            }
        }

        return ret;
    }
};


二、哈希表

💭OK,掌握了unordered_map和unordered_set的上层接口,接着,我们就要着重来研究其底层的哈希表了~

2.1 哈希的概念

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

📍 哈希表(又名散列表)是一种无需进行比较即可完成查找的数据结构。它通过哈希函数(hashFunc)使元素的存储位置与键值之间建立一一映射的关系,那么在查找时即可迅速找到元素。当向哈希表中插入或查询时,先以元素的键值结合哈希函数计算出该元素的存储位置,这个位置称为哈希地址,在哈希地址上插入或查询即可(可能会发生哈希冲突,后面会讲),最优情况一次就能查找到插入位置/对应元素。

🌰举个栗子,设哈希函数为 hashi(key) = key % capacity,且capacity=10,并插入一些元素,则有如下哈希表。

在这里插入图片描述


2.2 哈希冲突

上面举的例子中哈希函数:hashi = key % capacity,是一个比较常用的哈希函数,称之为除留余数法求哈希值。延用上面这个例子,两个不同的key,使用除留余数,可能会求出相同的哈希地址。如图:在这里插入图片描述

可见,若想插入键值为13的元素(方便起见,后面称键值为i的元素为元素i),计算hashi(13)=3。但发现hashi=3的位置已经存在元素3,此时发生了冲突,键值13不能直接插入hashi=3的位置,因为该位置已经被占用了。这种冲突称之为哈希冲突

概念:

  • 对于两个数据元素的关键字 k i k_i ki k j k_j kj(i != j),有 k i k_i ki != k j k_j kj,但有:Hash( k i k_i ki) == Hash( k j k_j kj),即:不同关键字通过相同哈希函数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
  • 把具有不同关键码key而具有相同哈希地址的数据元素称为“同义词”。

2.3 哈希函数

🍖优秀的哈希函数可以尽量避免哈希冲突(注意,只能避免,无法消除),因此哈希函数的设计十分重要

哈希函数的设计原则:

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

常用的哈希函数:

  1. 直接定址法(常用)

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

优点: 简单、均匀
缺点: 需要事先知道关键字的分布情况、可能会造成空间浪费
适用场景: 数据区间比较集中、连续

  1. 除留余数法(最常用)

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

优点: 简单、运算速度快
缺点: 均匀性差,易发生冲突

  1. 平方取中法(了解)

假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址;再比如关键字4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况。

  1. 折叠法(了解)

折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况

  1. 随机数法(了解)

选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中random为随机数函数。通常应用于关键字长度不等时采用此法

  1. 数学分析法(了解)

设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。例如:电话号码的后四位、身份证后六位。数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀的情况


2.4 字符串哈希算法

💭哈希函数hash(key)中,传入的key必须是整型才能计算出哈希地址。而实际中数据的键值不一定是整型,可能是浮点类型、字符串、自定义类型等等,而这些非整型类型的键值就需要先转换成整型再去进行映射(插入时,查询时都需要转换成整型,才能计算hashi)。像浮点数这种能够直接强转成整型了,强转即可。而字符串、自定义类型之类的就需要专门设计算法去转换了,实际中键值为字符串的情况比较常见,因为我们讨论字符串(转整型)的哈希算法。

📍优秀的字符串哈希算法能够尽量少的出现不同字符串转换为重复的整型值,从而降低冲突发生的概率。此处给出比较常用的两种字符串哈希算法。

// 转换整型的哈希算法设计为仿函数,方便通过模板参数传递给哈希表

// 通用的转换整型哈希算法,K类型能强制类型转换为int类型
template <class K>
struct Hash
{
	int operator()(const K& key)
	{
		return key;
	}
};

// 模板的特化
template <>
struct Hash<string>
{
	//1.字符串每个字符相加
	//int operator()(const string& str)
	//{
	//	int key = 0;
	//	for (auto ch : str)
	//	{
	//		key += ch - 'a';
	//	}
	//	return key;
	//}

	//2.BKDR算法
	int operator()(const string& key)
	{
		int hash = 0;
		for (auto ch : key)
		{
			hash = hash * 131 + ch;
		}
		return hash;
	}
};

参考文章:《各种字符串哈希函数》

三、哈希冲突的解决

📍解决哈希冲突是实现哈希表的关键,常见的解决方法有两种:闭散列和开散列

3.1 闭散列

闭散列:又称开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把元素存放到冲突位置中的下一个空位置中去。

那如何寻找下一个空位置呢?常见的有两种方法,线性探测和二次探测。我们主要讨论线性探测这种方法,并借此深入了解哈希表的设计。

注意:以下举的例子皆以除留余数法求哈希地址

3.1.1 线性探测

从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。就像找车位一样,在一排占满的车位中,挨个找到下一个空车位即可

  • 插入

    💦如下为过程:要插入key值为16的元素,求出hashi为6,发生哈希冲突,线性探测找下一个空位置插入。

    请添加图片描述

  • 删除

    💦采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响 其他元素的搜索。比如删除元素8,如果直接删除掉,查找元素16可能会受影响,查找的路径断裂了。 因此线性探测采用标记的伪删除法来删除一个元素。

    在这里插入图片描述

    解决方法:给哈希表每一个空间设置一个状态值,标志该位置 为空、有值、已被删除 三种状态。

    enum State
    {
    	EMPTY, // 为空
    	EXIST, // 已存在元素
    	DELETE // 元素被删除
    };
    
  • 开放定址法的负载因子

    🔎哈希表的特性往往会使其陷入两难之境。已知哈希表的空间是一个连续的数组:
    若其空间利用率较高,那么经过哈希函数计算的哈希地址很可能已经存有数据了,则哈希冲突发生的概率也会增高,发生哈希冲突需要消耗更多成本去查找,那么插入和查找的效率就会下降。但如果为了降低哈希冲突发生的概率,扩大空间,又会导致空间浪费,空间利用率较低。

    负载因子的出现就是为了解决这个问题。

    负载因子 = 哈希表的有效元素个数 / 表的长度

    负载因子表示哈希标志数据元素的填满程度。若负载因子较大,表示哈希表空间利用率较高,但发生冲突的概率也大。若负载因子较小,表示发生冲突的概率较低,但哈希表空间利用率也低了。因此,必须在“冲突的概率”与“空间利用率”之间,寻找一种平衡与折中,也就是求一个合适的负载因子上限,超过这个上限就要扩容,以降低负载因子。(还要考虑上限过低可能增加扩容的次数,降低效率)。

    💡综合各种因素,经过大量的实践和数学计算,开放定址法的负载因子应严格控制在0.7 ~ 0.8之间。因此,一些采用开放定址法的hash库(C++标准库中的哈希表并不是采用开放定址法),如Java的系统库限制了负载因子为0.75。

    参考文章:HashMap的负载因子为什么是0.75?

  • 扩容的门道

    哈希表的扩容也是有讲究的。不像vector的扩容,先开一块大的新空间,再将数据直接拷贝到新空间上。哈希表的扩容需要重新计算每个元素的哈希映射地址,再拷贝到新空间的相应位置上。因为哈希函数总是和空间大小有关,空间变大了,每个元素的哈希地址都有可能改变。

    📍哈希冲突频发,使用线性探测处理冲突后可能出现堆积现象,这种堆积现象会随着哈希表的扩容而得到缓解。

    在这里插入图片描述

    🔎如图,负载因子已达到0.7,随便插入一个元素2,会发生扩容。扩容之前,hashi=6的元素堆积在一起,扩容之后堆积现象得到缓解。这个概念和密度的概念很像,想象一杯很浓的咖啡,加水之后变得没那么浓了,因为咖啡因稀释开了,这里是堆积的元素稀释开了,一个道理。

  • 💬线性探测的代码实现

namespace closeHash
{
	// 哈希表空间的状态
	enum State
	{
		EMPTY,
		EXIST,
		DELETE
	};

	// 哈希表中的元素
	template <class K, class V>
	struct hash_data
	{
		pair<K, V> _data;// 键值对
		State _state = EMPTY;// 状态值
	};

	// 哈希表主体
	// HashToKey为键值转换为整型的仿函数类型
	template <class K, class V, class HashToKey>
	class hash_table
	{
		typedef hash_data<K, V> Data;
	public:
		hash_table()
			:_table(10)//调用vector的n个val的构造函数
			//尽量保持底层vector的size和capacity相等,避免没必要的空间浪费
			,_n(0)
		{}
		
		// 查询键值为k的元素
		Data* find(const K& k)
		{
			int hashi = HashToKey()(k) % _table.size();

			int start = hashi;// 起始位
			while (_table[hashi]._state != EMPTY)
			{
				// 存在且key值为k,查找成功
				if (_table[hashi]._state == EXIST && _table[hashi]._data.first == k)
				{
					return &_table[hashi];
				}

				// 存在但key值不为k/已经被删除,继续查找
				hashi = (hashi + 1) % _table.size();

				if (hashi == start)// 极端情况下(无空位,除了EXIST就是DELETE),最多走一圈
				{
					break;
				}
			}
			return nullptr;
		}

		bool insert(const pair<K, V>& kv)
		{
			// 已存在不插入
			if (find(kv.first))
			{
				return false;
			}

			// 再插入超出负载因子上限(0.7),需扩容后再插入
			if ((double)_n / _table.size() >= 0.7)
			{
				//建立新的哈希表(容量为原来的2倍)
				hash_table<K, V, HashToKey> newHashTable;
				auto& newTable = newHashTable._table;

				newTable.resize(_table.size() * 2);

				// 遍历旧表,在新表中建立新的哈希映射
				for (auto& e : _table)
				{
					if (e._state == EXIST)
					{
						newHashTable.insert(e._data);
					}
				}

				//两个空间交换
				_table.swap(newTable);
			}
			
			int hashi = HashToKey()(kv.first) % _table.size();

			// 若发生哈希冲突,用线性探测解决
			// 碰到 空or删除,即可插入
			while (_table[hashi]._state == EXIST)
			{
				hashi = (hashi + 1) % _table.size();
			}

			_table[hashi]._data = kv;
			_table[hashi]._state = EXIST;
			_n++;

			return true;
		}

		// 删除键值为k的元素
		bool erase(const K& k)
		{
			Data* p = find(k);
			if (p)
			{
				p->_state = DELETE;
				_n--;
				return true;
			}
			else
			{
				return false;
			}
		}

	private:
		vector<Data> _table;
		size_t _n; // 哈希表中有效数据的个数
	};
}

3.1.2 二次探测

💭二次探测是线性探测的优化,一定程度上缓解了线性探测中因为堆积现象而导致的查找、插入效率低下的问题。

二次探测找下一个空位置的方法为: H i H_i Hi = ( H 0 H_0 H0 + i 2 i^2 i2 )% m, 或者: H i H_i Hi = ( H 0 H_0 H0 - i 2 i^2 i2 )% m。其中:i =1,2,3…, H 0 H_0 H0是通过哈希函数Hash(x)对元素的关键码 key 进行计算得到的哈希地址,m是表的大小。

  • 插入

💦拿上面用线性探测解决插入时遇到哈希冲突的例子,试着用二次探测解决。如下为过程:要插入key值为16的元素,求出hashi为6,发生哈希冲突,线性探测找下一个空位置插入,要查找5次才能找到下一个空位置。

在这里插入图片描述

而用二次探测只需要三次即可找到下一个空位。在这里插入图片描述

可见,二次探测的效率一般优于线性探测。

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

💬二次探测关键部分代码

int hash0 = HashToKey()(kv.first) % _table.size();
int hashi = 0, i = 0;
while (_table[hashi]._state == EXIST)
{
	hashi = (hash0 + i * i) % _table.size();
	i++;
}

3.2 开散列

3.2.1 链地址法的思想

开散列的概念:

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

开散列的思想:

💭总而言之,开散列解决哈希冲突的思想就是将发生冲突的元素都存储在一个集合中,这个存放冲突键值元素的集合称为桶。物理结构上是一个单链表。

⭕哈希桶的结构如图所示:

在这里插入图片描述

可见,哈希桶有一个指针数组,数组中的指针指向桶的头结点(无桶则为NULL),每一个桶中存放的都是发生哈希冲突的元素。

3.2.2 哈希桶的模拟实现

💭其实,unordered_map和unordered_set的底层就是用哈希桶封装的,有了红黑树封装map和set的知识储备,接下来我们模拟实现哈希桶时,尽可能考虑设计为方便unordered_map和unordered_set封装的模式。

  • 哈希桶的节点
template <class T>
// T是value_type
// unordered_map的T是pair<K,V>
// unordered_set的T是K

struct hash_node
{
	hash_node(const T& data)
		:_data(data)
		,_next(nullptr)
	{}

	T _data; // 数据域
	hash_node<T>* _next; // 指向同一个桶的下一个节点
};
  • 哈希桶的大致框架
// T在set中是K,在map中是pair<K,V>
// Hash是将key值转换为整型的哈希算法
// KeyOfT是用于取出节点数据中的键值key的仿函数类型

template <class K, class T, class Hash, class KeyOfT>
class hash_table
{
	typedef hash_node<T> Node; 
public:
	// 构造函数
	hash_table()
		:_table(10, nullptr)
		, _n(0)
	{}

	// 析构函数
	~hash_table()
	{
		for (auto cur : _table)
		{
			while (cur)
			{
				Node* next = cur->_next;
				delete cur;
				cur = next;
			}
		}
	}
	
	//各类接口
	//...
private:
	vector<Node*> _table;// 指针数组,存放每一个桶的头指针
	size_t _n;// 有效元素个数
};
  • 哈希桶的插入
  1. 检测哈希桶中是否存在键值重复的元素,已存在则不插入,不存在则继续下一步;
  2. 检测负载因子是否到达上限,若是则需扩容后再进行下一步;
  3. 通过哈希函数,计算插入元素的哈希地址,即元素对应的桶号;
  4. 元素进入相应的桶,头插(对于单链表,头插的效率比尾插高)。

1️⃣查重

要想找出哈希桶中是否存在键值重复的元素,写一个find函数并复用是比较简单的。哈希桶的查找很简单:若想查找到键值为key的元素,先通过哈希函数计算桶编号,再从对应的桶中遍历找键值为key的元素即可。平均时间复杂度为O(1)

💬代码实现

Node* find(const K& key)
{
	// 计算桶号
	size_t hashi = Hash()(key) % _table.size();
	
	// 遍历桶查找key(遍历链表)
	Node* cur = _table[hashi];
	while (cur)
	{
		if (KeyOfT()(cur->_data) == key) // KeyOfT()用于取出节点数据中的键值key
		{
			return cur;
		}
		cur = cur->_next;
	}

	return nullptr;
}

2️⃣ 哈希桶的扩容

💭开散列(哈希桶)一般规定负载因子为1,即有效个数和指针数组长度相等时,再插入需要先扩容。

🔎tips:如果开散列的扩容采用闭散列的方式(遍历旧表,重新计算旧表中元素的映射位置,插入新表),因为开散列是以链表的形式存在,而非连续空间,那么,创建新节点插入新表,删除旧表的的节点,将会是一个很大的消耗。因此,哈希桶的扩容应该将旧表的节点取而用之,而非先新建再删旧。

后两步都很简单,不必过多解释,直接上最终insert函数的代码。

bool insert(const T& data)
{
	KeyOfT kot;

	// 1.已存在不插入
	if (find(kot(data)))
	{
		return false;
	}

	// 2.当负载因子==1时,扩容
	if (_n == _table.size())
	{
		vector<Node*> newTable(2 * _table.size(), nullptr);

		for (int i = 0; i < _table.size(); i++)
		{
			Node* cur = _table[i];
			while (cur)
			{
				Node* next = cur->_next;

				// 将当前节点插到新表对应的位置中
				size_t new_hashi = Hash()(kot(cur->_data)) % newTable.size();
			
				cur->_next = newTable[new_hashi];
				newTable[new_hashi] = cur;

				cur = next;
			}

			// 清除旧表中遗留的指针,避免二次释放空间
			_table[i] = nullptr;
		}

		//交换两个表
		_table.swap(newTable);
	}
	
	// 3.计算哈希地址
	size_t hashi = Hash()(kot(data)) % _table.size();

	Node* newNode = new Node(data);

	// 4.头插,有效元素个数+1
	newNode->_next = _table[hashi];
	_table[hashi] = newNode;

	++_n;

	return true;
}
  • 哈希桶的元素删除
bool erase(const K& key)
{
	size_t hashi = Hash()(key) % _table.size();

	Node* cur = _table[hashi];
	Node* prev = nullptr;
	while (cur)
	{
		if (KeyOfT()(cur->_data) == key)
		{
			// 删除

			if (cur == _table[hashi])//删头要特殊处理
			{
				_table[hashi] = cur->_next;
			}
			else
			{
				prev->_next = cur->_next;
			}

			delete cur;
			--_n;
			return true;
		}
		prev = cur;
		cur = cur->_next;
	}

	return false;
}


四、封装unordered_map和unordered_set

💭接下来我们要模拟C++标准库中,用哈希桶来封装unordered_map和unordered_set。

4.1 哈希表的迭代器

💭与list、rb_tree的迭代器一样,哈希表的迭代器也需要一个指针来指向节点。不同的是,由于哈希表的桶是分散的,即有多条链表,因此迭代器前进时有可能会从一个桶跳转到另一个桶

如图,it指向31时,指向++it,检测到当前桶已经没有下一个元素,故跳转到下一个桶的头结点。

在这里插入图片描述

要想做到能找到下一个不为空的桶,迭代器就必须要能访问哈希表的指针数组,下面是hashIterator类的实现

注意:由于哈希桶单链表的结构,所以它的迭代器是单向的,只能++不能--。

template <class K, class T, class Hash, class KeyOfT>
struct hashIterator
{
	typedef hash_node<T> Node;
	typedef hashIterator<K, T, Hash, KeyOfT> Self;
	typedef hash_table<K, T, Hash, KeyOfT> hash_table;

	hashIterator(Node* node, hash_table* pht) // 构造函数
		:_node(node)
		, _pht(pht)
	{}

	T& operator*()
	{
		return _node->_data;
	}

	T* operator->()
	{
		return &_node->_data;
	}

	Self& operator++()
	{
		if (_node->_next)
		{
			_node = _node->_next;
		}
		else
		{
			KeyOfT kot;
			size_t hashi = Hash()(kot(_node->_data)) % _pht->_table.size();

			// 找下一条链
			size_t i = 0;
			for (i = hashi + 1; i < _pht->_table.size(); i++)
			{
				if (_pht->_table[i])
				{
					_node = _pht->_table[i];
					break;
				}
			}
			// 找不到
			if (i == _pht->_table.size())
			{
				_node = nullptr;
			}
		}
		return *this;
	}

	bool operator==(const Self& it) const
	{
		return _node == it._node && _pht == it._pht;
	}

	bool operator!=(const Self& it) const
	{
		return !operator==(it);
	}

	Node* _node; // 指向节点的指针
	hash_table* _pht;// 指向哈希表指针数组的指针
};

4.2 改造哈希表

💭改造hash_table类,使得其能够适配unordered_map和unordered_set的封装。

template <class K, class T, class Hash, class KeyOfT>
class hash_table
{
	// 迭代器中可能会访问hash_table类,所以要设置为友元类
	template <class K, class T, class Hash, class KeyOfT>
	friend struct hashIterator;

	template <class K, class T, class Hash, class KeyOfT>
	friend struct const_hashIterator;

	typedef hash_node<T> Node;

public:

    // 为什么普通迭代器和const迭代器不封装成一个类?见下文分析
	typedef hashIterator<K, T, Hash, KeyOfT> iterator;
	typedef const_hashIterator<K, T, Hash, KeyOfT> const_iterator;
	
	hash_table()
		:_table(10, nullptr)
		, _n(0)
	{}

	~hash_table()
	{
		for (auto cur : _table)
		{
			while (cur)
			{
				Node* next = cur->_next;
				delete cur;
				cur = next;
			}
		}
	}

	// 迭代器的接口
	iterator begin()
	{
		// 找第一个元素即可
		for (int i = 0; i < _table.size(); i++)
		{
			if (_table[i])
			{
				return iterator(_table[i], this);
			}
		}
		return iterator(nullptr, this);
	}

	iterator end()
	{
		// 设空迭代器为end
		return iterator(nullptr, this);
	}

	const_iterator begin() const
	{
		for (int i = 0; i < _table.size(); i++)
		{
			if (_table[i])
			{
				return const_iterator(_table[i], this);//const迭代器构造函数异常?下面具体分析
			}
		}
		return const_iterator(nullptr, this);
	}

	const_iterator end() const
	{
		return const_iterator(nullptr, this);
	}

	iterator find(const K& key)
	{
		size_t hashi = Hash()(key) % _table.size();

		Node* cur = _table[hashi];
		while (cur)
		{
			if (KeyOfT()(cur->_data) == key)
			{
				return iterator(cur, this);
			}
			cur = cur->_next;
		}

		return end();
	}

	const_iterator find(const K& key) const
	{
		size_t hashi = Hash()(key) % _table.size();

		Node* cur = _table[hashi];
		while (cur)
		{
			if (KeyOfT()(cur->_data) == key)
			{
				return const_iterator(cur, this);
			}
			cur = cur->_next;
		}

		return end();
	}

	iterator erase(const K& key)
	{
		size_t hashi = Hash()(key) % _table.size();

		Node* cur = _table[hashi];
		Node* prev = nullptr;
		while (cur)
		{
			if (KeyOfT()(cur->_data) == key)
			{
				// 删除
				iterator next = ++iterator(cur, this);

				if (cur == _table[hashi])//删头要特殊处理
				{
					_table[hashi] = cur->_next;
				}
				else
				{
					prev->_next = cur->_next;
				}

				delete cur;
				--_n;
				return next;
			}
			prev = cur;
			cur = cur->_next;
		}

		return end();
	}

	//insert的返回值类型要修改为pair<iterator, bool>,与库中一致。
	pair<iterator, bool> insert(const T& data)
	{
		KeyOfT kot;

		// 已存在不插入
		iterator fit = find(kot(data));
		if (fit != end())
		{
			return make_pair(fit, false);
		}

		// 当负载因子==1时,扩容
		if (_n == _table.size())
		{
			vector<Node*> newTable(2 * _table.size(), nullptr);

			for (int i = 0; i < _table.size(); i++)
			{
				Node* cur = _table[i];
				while (cur)
				{
					Node* next = cur->_next;

					// 将当前节点插到新表对应的位置中
					size_t new_hashi = Hash()(kot(cur->_data)) % newTable.size();

					cur->_next = newTable[new_hashi];
					newTable[new_hashi] = cur;

					cur = next;
				}

				// 清除旧表中遗留的指针,避免二次释放空间
				_table[i] = nullptr;
			}

			//交换两个表
			_table.swap(newTable);
		}

		size_t hashi = Hash()(kot(data)) % _table.size();

		Node* newNode = new Node(data);

		// 头插
		newNode->_next = _table[hashi];
		_table[hashi] = newNode;

		++_n;

		return make_pair(iterator(newNode, this), true);
	}

private:
	vector<Node*> _table;
	size_t _n;// 有效元素个数
};

⭕这里有一个问题:链表、红黑树的const迭代器和普通迭代器用的是同一个类,只不过用Ref和Ptr两个模板参数来区分它们。而哈希表这里const迭代器和普通迭代器用的是两个类,因为无法用Ref和Ptr两个模板参数来区分它们。原因如下:

看这段代码:

const_iterator begin() const
{
	for (int i = 0; i < _table.size(); i++)
	{
		if (_table[i])
		{
			return const_iterator(_table[i], this);
		}
	}
	return const_iterator(nullptr, this);
}

如果const_iterator底层是hashIterator类,在下面这句代码会出现错误。

return const_iterator(_table[i], this);

因为此时this是const类型的指针,而_table[i]相当于this->_table.operator[](i),由于this是const类型,其指向的类的成员变量也是const类型,所以_table是const类型的vector类,因此_table[i]取出来的值是const Node*类型的。

两个参数都是const指针类型,但hashIterator类的两个成员变量都是非cons指针t类型,构造函数无法完成构造,存在权限放大。

Node* _node;
hash_table* _pht;

因此,const迭代器另起一个类const_hashIterator。

template <class K, class T, class Hash, class KeyOfT>
struct const_hashIterator
{
	typedef hash_node<T> Node;
	typedef const_hashIterator<K, T, Hash, KeyOfT> Self;
	typedef hashIterator<K, T, Hash, KeyOfT> iterator;
	typedef hash_table<K, T, Hash, KeyOfT> hash_table;

	const_hashIterator(const Node* node, const hash_table* pht)
		:_node(node)
		, _pht(pht)
	{}

	// 普通迭代器构造const迭代器
	const_hashIterator(const iterator& it)
		:_node(it._node)
		, _pht(it._pht)
	{}

	const T& operator*()
	{
		return _node->_data;
	}

	const T* operator->()
	{
		return &_node->_data;
	}

	Self& operator++()
	{
		if (_node->_next)
		{
			_node = _node->_next;
		}
		else
		{
			KeyOfT kot;
			size_t hashi = Hash()(kot(_node->_data)) % _pht->_table.size();

			// 找下一条链
			size_t i = 0;
			for (i = hashi + 1; i < _pht->_table.size(); i++)
			{
				if (_pht->_table[i])
				{
					_node = _pht->_table[i];
					break;
				}
			}
			// 找不到
			if (i == _pht->_table.size())
			{
				_node = nullptr;
			}
		}
		return *this;
	}

	bool operator==(const Self& it) const
	{
		return _node == it._node && _pht == it._pht;
	}

	bool operator!=(const Self& it) const
	{
		return !operator==(it);
	}

	const Node* _node;
	const hash_table* _pht;
};

📍unordered_map和unordered_set的封装与红黑树封装map和set的思路很像,这里就不过多赘述,直接上代码。

4.3 unordered_set的封装

namespace ckf
{
	template <class K, class HashtoKey = Hash<K>>
	class unordered_set
	{
		struct SetKov
		{
			const K& operator()(const K& v)
			{
				return v;
			}
		};

	public:

		typedef typename bucketHash::hash_table<K, K, HashtoKey, SetKov>::const_iterator iterator;
		typedef typename bucketHash::hash_table<K, K, HashtoKey, SetKov>::const_iterator const_iterator;

		//迭代器
		iterator begin() const
		{
			return _ht.begin();
		}

		iterator end() const
		{
			return _ht.end();
		}

		iterator find(const K& key) const
		{
			return _ht.find(key);
		}

		pair<iterator, bool> insert(const K& key)
		{
			pair<iterator, bool> pRet = _ht.insert(key);
			return make_pair(iterator(pRet.first), pRet.second); // 需要将普通迭代器转化为const迭代器
		}

		iterator erase(const K& key)
		{
			return _ht.erase(key);// 这里自动将普通迭代器转化为const迭代器
		}

	private:

		bucketHash::hash_table<K, K, HashtoKey, SetKov> _ht;
	};
}

4.4 unordered_map的封装

namespace ckf
{
	template <class K,class V,class HashtoKey = Hash<K>>
	class unordered_map
	{
		struct MapKov
		{
			const K& operator()(const pair<const K, V>& kv)
			{
				return kv.first;
			}
		};

	public:

		typedef typename bucketHash::hash_table<K, pair<const K, V>, HashtoKey, MapKov>::iterator iterator;
		typedef typename bucketHash::hash_table<K, pair<const K, V>, HashtoKey, MapKov>::const_iterator const_iterator;

		//迭代器
		iterator begin()
		{
			return _ht.begin();
		}

		iterator end()
		{
			return _ht.end();
		}

		const_iterator begin() const
		{
			return _ht.begin();
		}

		const_iterator end() const
		{
			return _ht.end();
		}

		pair<iterator, bool> insert(const pair<const K, V>& kv)
		{
			return _ht.insert(kv);
		}
		
		iterator erase(const K& key)
		{
			return _ht.erase(key);
		}

		V& operator[](const K& key)
		{
			return (_ht.insert(make_pair(key, V())).first)->second;
		}


	private:
		bucketHash::hash_table<K, pair<const K, V>, HashtoKey, MapKov> _ht;
	};
}

🍀本文完。如果这篇文章对你有帮助,动动小手点赞收藏加关注支持博主!你的支持是我最大的动力

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/12006.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

一条更新语句的执行流程又是怎样的呢?

当一个表上有更新的时候&#xff0c;跟这个表有关的查询缓存会失效&#xff0c;所以这条语句就会把表T上所有缓存结果都清空。这也就是我们一般不建议使用查询缓存的原因。 接下来&#xff0c;分析器会通过词法和语法解析知道这是一条更新语句。优化器决定要使用ID这个索引。然…

JAVA+SQL离散数学题库管理系统的设计与开发

题库、试卷建设是教学活动的重要组成部分&#xff0c;传统手工编制的试卷经常出现内容雷同、知识点不合理以及笔误、印刷错误等情况。为了实现离散数学题库管理的信息化而开发了离散数学题库管理系统。 该系统采用C/S 模式&#xff0c;前台采用JAVA&#xff08;JBuilder2006&am…

如何选择合适的网络自动化工具

通过网络自动化工具实现网络自动化是所有网络组织的关键。如果没有合适的网络自动化工具&#xff0c;拥有由许多设备组成的大型网络环境的组织将无法执行重要操作&#xff0c;例如按时备份配置、实时跟踪不需要的更改以及遵守行业法规。当组织未能使用正确的网络自动化工具来执…

四百左右哪款蓝牙耳机比较好?400元价位蓝牙耳机推荐

除了日常通勤以及休息前听歌以外&#xff0c;随着加班变得频繁&#xff0c;工作时也戴起了耳机&#xff0c;由于市面上的耳机种类繁多&#xff0c;因此许多人不知道从而选择&#xff0c;小编发现更多的人是追求性价比&#xff0c;所以整理了一期四百左右性能表现优异的款式给大…

量化择时——LSTM深度学习量化择时(第1部分—因子测算)

之前我们尝试使用SVM&#xff0c;将时序数据转为横截面的数据&#xff0c;使用机器学习的方法进行预测 量化择时——SVM机器学习量化择时&#xff08;第1部分—因子测算&#xff09;&#xff1a; https://blog.csdn.net/weixin_35757704/article/details/129909497 但是因为股…

DHCP及中继(UOS)

DHCP服务器 中继器 客户端 服务器 安装DHCP apt install isc-dhcp-server -y 编辑配置文件 vim /etc/dhcp/dhcpd.conf 重启服务 systemctl restart isc-dhcp-server 配置监听网卡 vim /etc/default/isc-dhcp-server 中继器 安装dhcp yum install dhcp -y nmtui 修改…

pytest测试报告Allure - 动态生成标题生成功能、添加用例失败截图

一、动态生成标题 默认 allure 报告上的测试用例标题不设置就是用例名称&#xff0c;其可读性不高&#xff1b;当结合 pytest.mark.parametrize 参数化完成数据驱动时&#xff0c;如标题写死&#xff0c;其可读性也不高。 那如果希望标题可以动态的生成&#xff0c;采取的方案…

Hadoop 生态圈及核心组件简介Hadoop|MapRedece|Yarn

文章目录大数据时代HadoopHadoop概述Hadoop特性优点Hadoop国内外应用Hadoop发行版本Hadoop集群整体概述HDFS分布式文件系统传统常见的文件系统数据和元数据HDFS核心属性HDFS简介HDFS shell操作Map Reduce分而治之理解MapReduce思想分布式计算概念MapReduce介绍MapReduce产生背景…

[STM32F103C8T6]DMA

DMA(Direct Memory Access&#xff0c;直接存储器访问) 提供在外设与内存、存储器和存储器、外设 与外设之间的高速数据传输使用。它允许不同速度的硬件装置来沟通&#xff0c;而不需要依赖于 CPU&#xff0c;在这个时间中&#xff0c;CPU对于内存的工作来说就无法使用。 我自己…

JDBC概述三(批处理+事务操作+数据库连接池)

一&#xff08;批处理&#xff09; 1.1 批处理简介 批处理&#xff0c;简而言之就是一次性执行多条SQL语句&#xff0c;在一定程度上可以提升执行SQL语句的速率。批处理可以通过使用Java的Statement和PreparedStatement来完成&#xff0c;因为这两个语句提供了用于处理批处理…

BGP策略实验

实验要求&#xff1a; 1、使用PreVa1策略&#xff0c;确保R4通过R2到达192.168.10.0/24 2、使用AS_Path策略&#xff0c;确保R4通过R3到达192.168.11.0/24 3、配置MED策略&#xff0c;确保R4通过R3到达192.168.12.0/24 4、使用Local Preference策略&#xff0c;确保R1通过R2到…

公司新招了个腾讯拿38K的人,让我见识到了什么才是测试天花板···

5年测试&#xff0c;应该是能达到资深测试的水准&#xff0c;即不仅能熟练地开发业务&#xff0c;而且还能熟悉项目开发&#xff0c;测试&#xff0c;调试和发布的流程&#xff0c;而且还应该能全面掌握数据库等方面的技能&#xff0c;如果技能再高些的话&#xff0c;甚至熟悉分…

【失业即将到来?】AI时代会带来失业潮吗?

文章目录前言一、全面拥抱AIGC二、AI正在取代这类行业总结前言 兄弟姐妹们啊&#xff0c;AI时代&#xff0c;说抛弃就抛弃&#xff0c;真的要失业了。 一、全面拥抱AIGC 蓝色光标全面暂停外包&#xff1f; 一份文件截图显示&#xff0c;中国知名4A广告公司&#xff0c;蓝色光…

【GPT4】微软 GPT-4 测试报告(5)与外界环境的交互能力

欢迎关注【youcans的AGI学习笔记】原创作品 微软 GPT-4 测试报告&#xff08;1&#xff09;总体介绍 微软 GPT-4 测试报告&#xff08;2&#xff09;多模态与跨学科能力 微软 GPT-4 测试报告&#xff08;3&#xff09;编程能力 微软 GPT-4 测试报告&#xff08;4&#xff09;数…

基于AI分词模型,构建一个简陋的Web应用

文章目录前言1. 效果展示2. 应用设计3. 实现3.1. lac分词模型的服务化部署3.2 使用Flask构建app4. 小结前言 内容纯属个人经验&#xff0c;若有不当或错误之处&#xff0c;还请见谅&#xff0c;欢迎指出。 文中大致介绍了&#xff0c;如何快捷地使用PaddleHub服务化部署一个简…

九龙证券|昨夜,大涨!蔚来5.99%,小鹏15.22%,理想6.39%

当地时间周一&#xff0c;美股三大指数低开高走&#xff0c;尾盘小幅收涨。盘面上&#xff0c;银行股、航空股遍及上涨。 展望本周&#xff0c;包括美联储理事沃勒、鲍曼等官员将迎来下月会议沉默期前的最终说话&#xff0c;投资者需关注其对经济和货币政策前景的看法。此外&am…

如何在TikTok视频描述中提高用户参与度

鑫优尚电子商务&#xff1a;TikTok视频描述&#xff08;包括话题标签&#xff09;有150个字符的限制&#xff0c;因此卖家需要合理撰写出有趣且有实际意义的视频描述。可尝试将描述保持在140个字符以内&#xff0c;将最重要的信息放在前面&#xff0c;并通过多次修改文案以排除…

甘特图控件DHTMLX Gantt入门使用教程【引入】:dhtmlxGantt与ASP.NET Core(上)

DHTMLX Gantt是用于跨浏览器和跨平台应用程序的功能齐全的Gantt图表。可满足项目管理应用程序的大部分开发需求&#xff0c;具备完善的甘特图图表库&#xff0c;功能强大&#xff0c;价格便宜&#xff0c;提供丰富而灵活的JavaScript API接口&#xff0c;与各种服务器端技术&am…

【获奖案例巡展】科技向善之星——中航电梯5G+大数据管理平台

为表彰使用大数据、人工智能等基础软件为企业、行业或世界做出杰出贡献和巨大创新的标杆项目&#xff0c;星环科技自2021年推出了“新科技 星力量” 星环科技科技实践案例评选活动&#xff0c;旨在为各行业提供更多的优秀产品案例&#xff0c;彰显技术改变世界的力量&#xff0…

会话跟踪技术

目录 Cookie基本使用 Cookie原理 Cookie使用细节 Session基本使用 Session原理 Session使用细节 案例 用户登录注册案例 用户注册功能 保存用户信息到数据库 验证码-展示 验证码-校验 会话跟踪技术的概述 会话:用户打开浏览器&#xff0c;访问web服务器的资源&…