您现在的位置是:首页 >技术杂谈 >【C++】哈希表:开散列和闭散列网站首页技术杂谈

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

超人不会飞) 2023-05-19 04:00:04
简介【C++】哈希表:开散列和闭散列
  • 📝 个人主页超人不会飞)
  • 📑 本文收录专栏:《C++的修行之路》
  • 💭 如果本文对您有帮助,不妨点赞、收藏、关注支持博主,我们一起进步,共同成长!


前言

在前面的文章中,我们学习了红黑树,其查找数据的效率很高,查找的次数最差是树的高度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;
	};
}

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

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。