【C++】开放定址法实现哈希表!

🎬 个人主页MSTcheng · CSDN
🌱 代码仓库MSTcheng · Gitee
🔥 精选专栏 : 《C语言
数据结构
《算法学习》
C++由浅入深

💬座右铭: 路虽远行则将至,事虽难做则必成!


前言:在前面的文章中,我们详细讲解了mapset底层实现的红黑树结构,并成功使用红黑树封装了mapset。本篇文章中,我们将继续探讨unordered_mapunordered_set的底层实现------哈希表(Hash)。

文章目录

一、unordered_map和unordered_set的介绍与使用

1.1unordered_map和unordered_set的介绍

1、unordered_set介绍:

  • 第一个模板参数:key就是set底层的数据类型
  • 第二个模板参数:仿函数,是用来将key转成整型,因为key又可能是string不方便直接取模(为什么要取模在后面哈希实现的部分会细讲)。
  • 第三个模板参数:比较器:unordered_set默认要求Key支持比较相等,如果不支持或者想按自己的需求走可以自行实现支持将Key比较相等的仿函数传给第三个模板参数

unordered_set增删查改的效率:由于unordered_set底层使用哈希桶来实现,所以增删查改的效率为O(1),只需要计算对应的位置将值映射就行,所以效率是常熟次。 为了跟set区分,所以取名unordered_set,注意:unordered_set是无序的!!!


2、unordered_map的介绍:

  • 第一和第二个模板参数: keyT就是mapkeyvalue
  • 第三个模板参数:仿函数 ,与unordered_set一样,该仿函数是用来将key转成整型,如果自己实现了仿函数也可以自己传。
  • 第四个模板参数: 比较器,unordered_map也要求key支持比较相等。

unordered_map增删查改的效率:与unordered_set一样他们的底层都是哈希桶,所以增删查改的效率都是O(1)。

1.2unordered_set与unordered_map的使用

cpp 复制代码
#include<iostream>
#include<string>
#include<set>
#include<unordered_set>
#include<unordered_map>
using namespace std;

void test_u_set_map()
{
	unordered_set<int> s1 = {2,3,5,6,7,2};
	s1.insert(45);

	auto it1 = s1.begin();
	while (it1 != s1.end())
	{
		cout << *it1 << " ";
		++it1;
	}
	cout << endl;

	unordered_map<string, string> dict;
	dict.insert({ "insert", "插入" });
	dict.insert({ "sort", "排序" });
	dict.insert({ "test", "测试" });

	//结构化绑定 将dict的pair中的k,v 绑定在[k,v]中
	for (auto& [k, v] : dict)
	{
		cout << k << ":" << v << endl;
	}
}
int main()
{
	test_u_set_map();
	return 0;
}

由于unordered_setunordered_mapsetmap的使用类似这里就不细讲了,如果不了解mapset的使用请点击->:
【C++STL】set / multiset 保姆级教程:从底层原理到实战应用!
【C++STL】map / multimap 保姆级教程:从底层原理到实战应用!

1.3unordered_set与set的使用差异

  1. 第⼀个差异 :对key的要求不同,set要求Key支持小于比较,而unordered_set要求Key支持转成整形且支持等于比较,
  2. 第二个差异 :迭代器的差异,setiterator是双向迭代器 , unordered_set是单向迭代器,其次 set底层是红黑树 ,红黑树是二叉搜索树,走中序遍历是有序 的,所以set迭代器遍历是有序 +去重 。而unordered_set底层是哈希表,迭代器遍历是无序 +去重
  3. 第三个差异 :增删查改效率的差异,set底层是红黑树效率是O(logN)unordered_set底层是哈希表效率是O(1)

1.4unordered_map与map的使用差异

  1. 第⼀个差异 :对key的要求不同,mapt要求Key支持小于比较,而unordered_map要求Key支持转成整形且支持等于比较,
  2. 第二个差异 :迭代器的差异,mapiterator是双向迭代器 , unordered_map是单向迭代器,map的迭代器遍历是有序 +去重 。而unordered_map的迭代器遍历是无序 +去重
  3. 第三个差异 :增删查改效率的差异,map底层是红黑树效率是O(logN)unordered_map底层是哈希表效率是O(1)

至于nordered_mapunordered_multimapunordered_setunordered_multiset,他们的本质使用都是一样的无非就是增加了冗余,哈希表里面能够出现相同的值而已,其他的没有什么本质区别,这里也不过多的赘述了。

下面进入到本篇文章的重点------哈希的实现

二、哈希表

2.1哈希表的概念

哈希(hash)又称散列,是⼀种组织数据的方式。从译名来看,有散乱排列的意思。本质就是通过哈希函数把关键字Key跟存储位置建立⼀个映射关系,查找时通过这个哈希函数计算出Key存储的位置,进行快速查找。

2.2实现哈希表的两种方法

要实现一个哈希表最关键的一步就是确定,映射关系,而确定映射关系的方式有很多种,比如直接定址法,除留余数法。

1、直接定址法

直接定址法主要讲究的是每一个值都有且仅有一个对应的位置,比如我们有一组关键字的值都在[a,z]的小写字母,那么我们就去开一个26个数的数组,每个关键字的ascii码就作为该值所存储的下标。那么当我们要查找这个值的时候只需要去计算它对应的下标即可。也就是说直接定址法本质就是用关键字计算出⼀个绝对位置或者相对位置, 直接定址法在之前数据结构有使用过,可以参考之前的文章:计数排序

2、除留余数法

除留余数法/除法散列法:即假设哈希表的大小为M,那么通过key除以M的余数作为映射位置的下标,也就是哈希函数为:h(key) = key % M。 上面的直接定址法是通过ascll码唯一确定一个位置,那么除留余数法就是通过余数来确定唯一的映射位置。

举个例子:

以上就是除留余数法的思想,利用值模上表的大小从而得到一个余数,然后往这个余数的位置去映射值。那么我们实现哈希表使用的就是除留余数法,因为直接定制法通常需要开比较大的空间,开了很大的空间只映射了几个值就会造成空间的浪费,而除留余数法能够动态扩容,且空间利用率只取决于负载因子与键值的范围关。所以我们选择除留余数法。

2.3哈希表的实现框架

2.3.1哈希冲突

在正式实现之前,其实还有一个问题,以上面除留余数法的数组为例,表的大小M=11,如果数组中出现了很多11的整数倍的数,那么这些数取完模后的余数都是相同的,而对于这些余数相同的数怎么映射呢?这其实就是哈希冲突。

举个例子:

而对于哈希冲突我们也有相应的解决方案------线性探测。


2.3.2开放定址法与负载因子

开放定址法 : 在开放定址法中所有的元素都放到哈希表⾥,当⼀个关键字key⽤哈希函数计算出的位置冲突了,则按照某种规则找到⼀个没有存储数据的位置进行存储,开放定址法中负载因子一定是小于的。这里的规则有三种:线性探测、二次探测、双重探测。

那么这里主要以线性探测的方法来解决哈希冲突!

线性探测的思路: 从发生冲突的位置开始,依次线性向后探测,直到寻找到下一个没有存储数据的位置为止,如果走到哈希表尾,则回绕到哈希表头的位置。
线性探测的公式: 比如 hash0=key%M(hash0是数组第一个下标),如果第一个下标处的元素的冲突了,那么hashi=(hash0+i)%M ,(这个公式是防止hashi往后走到越界的时候通过一个取模运算将hashi拉回来)。文字可能有点抽象下面直接上图:

关于线性探测有几个要注意的点:

  1. 线性探测既然要去找找下一个空的位置,那我们如何直到该位置是否为空呢? 显然我们需要定义一个枚举类型来表示每一个位置的状态通过这个位置的状态来判断该位置是否是空的,或者是被删除的。
  2. hash0其实就是起始冲突的位置,hashi就是去寻找下一个为空的位置每一次就是hash0+ii从1开始往后加直到所有的冲突值都找到了对于的位置,如果hashi走到了最后一个位置那么下一次将会越界,而为了避免它越界我们就对hashi%M,(M为哈希表的大小)使得hashi退回到哈希表的表头!
  3. 探测次数最多为M-1次,因为一个值模上11,余数只可能是在0-10之间,所以,映射的位置是不可能超过10的。 另外还有一点一般当负载因子大于或等于0.7的时候我们就会去扩容。

负载因子: 假设哈希表中已经映射存储了N个值,哈希表的大小为M,那么 ,负载因子有些地方也翻译为载荷因子/装载因子等。负载因子越大,空间利用率越高,冲突概论越大效率越低;负载因子越小,空间利用率越低,冲突概论越小效率越高。
一个哈希表,冲突越多那么在计算余数的时候就要再加上走过相应的位置才能找到,所以说效率是越低的,理想情况下每个数都在对于的位置上,那么通过计算余数直接找到,这样效率是最高的。

下面我们给出哈希表实现的一个框架:

cpp 复制代码
#include<vector>
#include<iostream>
using namespace std;
//===========================
//定义一个状态的枚举 在插入的时候我们要更具位置的状态去插入 如果是空和删除才插入
//不能直接覆盖!!              
//===========================    
enum Status
{
	EXIST,//存在
	EMPTY,//空
	DELETE //删除    
				//注意枚举定义的方式,最后一个变量不加逗号或分号
};

//===========================
//因为哈希底层使用的是vector 所以vector的每个位置存的是一个结构体
//该结构体包含一个pair和一个状态Status
//===========================
template<class k,class v>
struct HashData
{
	pair<k, v> _kv;
	Status _status = EMPTY;
};


template<class k,class v>
class Hash
{
public:
	Hash()
		:_tables(11) //表的大小默认给11
		,_n(0)
	{}
private:
	std::vector<HashData<k, v>> _tables;
	size_t _n;//有效数据个数
};

注意:

  • 我们模拟实现哈希表所使用的容器是vectorvector中每个元素所存的是一个结构体该结构体内部包含一个pair和一个枚举变量Status用于表示该位置的状态。

2.4哈希表的插入

cpp 复制代码
bool insert(const pair<k, v>& kv)
{	
	//如果上来发现已经找到了该值 那么直接返回false 因为不支持数据冗余
	if (Find(kv.first))//Find函数实现逻辑与插入类似 后面会展示
	{
		return false;
	}
	
	//寻找冲突位置 默认认为第一个位置是冲突的 即使没有冲突那么照样按照余数去映射值
	//如果冲突了就线性探测
	size_t Hash0 = (kv.first) % _tables.size();
	size_t Hashi = Hash0;
	size_t i = 1;
	//线性探测 解决哈希冲突
	while (_tables[Hashi]._status == EXIST)
	{
		//这一步是防止越界让hashi回到哈希表表头
		Hashi = (Hash0 + i) % _tables.size();
		i++;
	}
	//跳出循环或者没有进入循环说明 该位置没有冲突 找到了一个位置为空填值即可
	//注意_tables[Hashi]拿到的是一个结构体 要访问内部的变量使用.来访问!!!
	_tables[Hashi]._kv =kv;
	_tables[Hashi]._status = EXIST;
	_n++;//有效数据个数加加
	
	return true;
}

以上实现的就是上面所讲的使用线性探测来解决哈希冲突,但是还有一个问题就是扩容问题,如果负载因子接近1了说明空间快要填满了,此时为了高效的效率我们就要对哈希表进行扩容。


2、扩容
这里我们的负载因子控制在0.7左右,大于0.7就要扩容了,但是这里扩容需要注意:

我们首先要明白为什么会造成哈希冲突?
因为N%M得到相同的余数而每次的N是不同的,那么问题就出现在了M上, 我们每次选取的M(哈希表的大小)最好是一个质数 ,如果我们选择一个偶数比如选择10,那么像21,31,41,51这样的数映射的位置都是1就会造成大量的冲突这便是造成冲突的原因 。所以我们对于M到底取什么数要格外注意。

再来说说扩容: 如果我们仅仅只是二倍扩容,那么如果我们原来选择的是一个质数那么二倍之后就会变成一个偶数,显然会造成更多的冲突怎么办呢?实际上STL库中是有一些特定的值的这些值可以避免冲突:

cpp 复制代码
template<class k,class v>
class Hash
{
public:
	Hash()
		//:_tables(11)
		:_tables(__stl_next_prime(1))//这里给哈希表的默认大小就是53 
									//可以将它__stl_next_prime看成是一个数组
		,_n(0)
	{}

	inline unsigned long __stl_next_prime(unsigned long n)
	{
		// Note: assumes long is at least 32 bits.
		static const int __stl_num_primes = 28;
		static const unsigned long __stl_prime_list[__stl_num_primes] =
		{
			53, 97, 193, 389, 769,
			1543, 3079, 6151, 12289, 24593,
			49157, 98317, 196613, 393241, 786433,
			1572869, 3145739, 6291469, 12582917, 25165843,
			50331653, 100663319, 201326611, 402653189, 805306457,
			1610612741, 3221225473, 4294967291
		};
		const unsigned long* first = __stl_prime_list;
		const unsigned long* last = __stl_prime_list + __stl_num_primes;
		const unsigned long* pos = lower_bound(first, last, n);
		return pos == last ? *(last - 1) : *pos;
	}

	bool insert(const pair<k, v>& kv)
	{
		if (Find(kv.first))
		{
			return false;
		}

		//如果一直插入 使负载因子变大 大于0.7 则需要扩容
		if ((double)_n / _tables.size() >= 0.7)
		{
			Hash<k,v> NewHash;
			//NewHash._tables.resize(2 * _tables.size());//2倍扩容不符合预期
			//stl库里面专门设计了一些扩容的倍数来避免冲突
			NewHash._tables.resize(__stl_next_prime(_tables.size()+1));
													//_tables.size()+1就是54 54大于53 那么就会选到第二个值97
			//遍历旧表映射到新表
			for (auto& data : _tables)
			{
				if (data._status == EMPTY)//如果该位置的状态为空那么就调用插入
				{
					//复用插入逻辑
					NewHash.insert(data._kv);

				}
			}

			//扩容后将新旧空间的指针交换
			_tables.swap(NewHash._tables);
		}
		HashF hs;

		size_t Hash0 = hs(kv.first) % _tables.size();
		size_t Hashi = Hash0;
		size_t i = 1;
		//线性探测
		while (_tables[Hashi]._status == EXIST)
		{
			//这一步是防止越界让hashi回到哈希表表头
			Hashi = (Hash0 + i) % _tables.size();
			i++;
		}
		//跳出循环此时一定找到了一个位置为空
		_tables[Hashi]._kv =kv;
		_tables[Hashi]._status = EXIST;
		_n++;//有效数据个数加加

		return true;
	}
private:
	std::vector<HashData<k, v>> _tables;
	size_t _n;//有效数个数
};

关于哈希表的插入有几点注意事项:

  1. 哈希表扩容是不能在原来数组的基础上扩容的,因为M变大了,所以余数不同了那么我们就要对原来的值进行重新映射,而重新映射的逻辑与也是直接插入然后冲突了就线性探测,所以直接复用插入逻辑。
  2. 扩容要注意的是,我们不需要看懂inline那一部分的代码 ,我们只需要知道我们对于M的取值是使用那里面的值,初始情况下使用第一个数53(不容易冲突),等到需要扩容我们只需要在53的基础上加一它就会走到第二个数97,像一个数组一样。

2.5哈希表的查找删除

Hash这个类中:

cpp 复制代码
HashData<k, v>* Find(const k& key)
{
	HashF hs;
	size_t Hash0 = hs(key) % _tables.size();
	size_t Hashi = Hash0;
	size_t i = 1;
	while (_tables[Hashi]._status != EMPTY)//该位置的状态必须不等于空才有意义
	{
		//状态不等于空 那么可能为删除或存在 那我们只需要找存在的位置的值即可
		if (_tables[Hashi]._status == EXIST
			&& _tables[Hashi]._kv.first == key)
		{
			return &_tables[Hashi];
		}
		//下面这两行代码就是使得哈希表++的 单向遍历 走到尾就绕回来
		Hashi = (Hash0 + i) % _tables.size();
		i++;
	}

	return nullptr;
}

对于查找我们返回的是一个HashData,因为哈希表中每一个位置存的都是一个HashData,而哈希表的迭代器是一个单向迭代器,通过状态去单向的遍历整个哈希表如果找到该位置的状态为存在,且该位置的键(key)等于我们所传入的键那么就返回该位置的地址。


2、哈希表的删除

Hash这个类中:

cpp 复制代码
bool Erase(const k& key)
{
	auto* ptr = Find(key);
	if (ptr)
	{
		//找到了 将状态置为删除
		ptr->_status = DELETE;
		--_n;//有效数据个数减减
		return true;
	}
	else
	{
		//未找到返回false
		return false;
	}
}

删除则更加简单直接复用查找的逻辑即可,找到了不要忘记修改该位置的状态。

以上就是较为完整的开放定址法的哈希表的实现了,但是还有一个问题就是,前面说了unordered_setunordered_mapkey要支持转成整型,如果key是负数或者是一个string那么是不方便取模的,所以我们要实现一个仿函数去支持负数和string取模!


2.6仿函数的实现

Hash.h中:

cpp 复制代码
//负数是不能直接进行取模运算的 所以我们要提供一个仿函数将key强转一下
//无论是正数还是负数都可以走这个仿函数 反转强转一下代价不大
template<class k>
struct HashFunc
{
	size_t operator()(const k& key)
	{
		return (size_t)key;
	}
};

//如果插入的是一个string 那string也是不能直接进行取模运算的 所以我们也需要写一个特化版本
//的HashFunc来支持string取模
template<>
struct HashFunc<string>
{
	//BKDR 哈希法 就是将string中所有字符的ASCII码加起来 得到一个正数 再进行取模运算
	size_t operator()(const string& str)
	{
		size_t hash = 0;
		for (auto ch : str)
		{
			hash += ch;
			hash *= 131;//这一步是为了防止不同 顺序的string映射的整数冲突 
						//例如"abcd" 与"bcad" "aadd"他们的ASCII码都是一样的
		}
		return hash;
	}
};

//写完仿函数数后 直接在后面增加一个仿函数的模板参数即可
template<class k,class v,class HashF=HashFunc<k>>
class Hash
{
	bool insert(const pair<k, v>& kv)
	{
			HashF hs;//定义仿函数对象
			size_t Hash0 = hs(kv.first) % _tables.size();/将kv.first套一层仿函数
			size_t Hashi = Hash0;
			size_t i = 1;
	}
	HashData<k, v>* Find(const k& key)
	{
		HashF hs;//定义仿函数对象
		size_t Hash0 = hs(key) % _tables.size();//将key套一层仿函数
		size_t Hashi = Hash0;
		size_t i = 1;
	}
};

需要看完整代码的请点击---> :哈希实现完整代码(开放定址法)

2.7测试代码

cpp 复制代码
void TestHT1()
{
	Hash<int, int> ht;
	int a[] = { 19,30,5,36,13,20,21,12,58 };
	for (auto e : a)
	{
		ht.insert({ e, e });
	}

	cout << ht.Find(5) << endl;
	cout << ht.Find(58) << endl;

	ht.Erase(5);
	cout << ht.Find(5) << endl;
	cout << ht.Find(58) << endl;

	for (size_t i = 100; i < 200; i++)
	{
		ht.insert({ i, i });
	}
}

void TestHT2()
{
	//HashTable<string, string, StringHashFunc> dict;
	Hash<string, string,HashFunc<string>> dict;
	dict.insert({ "insert", "插入" });

	auto ptr = dict.Find("insert");
	if (ptr)
	{
		cout << ptr->_kv.second << endl;
	}
}

三、总结

以上就是哈希表的全部实现内容了,对于哈希表核心就是去找一种方法使得一个值能映射到特定的位置,那本篇使用的就是除留余数法,还有其他方法感兴趣的可以去了解。 而对于不同元素映射到了相同的位置就造成了哈希冲突,解决哈希冲突我们就使用线性探测,通过占用别人的位置来解决冲突问题。但其实除留余数法也有很多局限实战中可能不经常使用它,经常使用的是使用链地址法实现的哈希表,链地址法实现的哈希表将会在下一篇文章讲解,敬请期待。

html 复制代码
MSTcheng 始终坚持用直观图解 + 实战代码,把复杂技术拆解得明明白白!
👁️ 【关注】 看普通程序员如何用实用派思路搞定复杂需求
👍 【点赞】 给 "不搞虚的" 技术分享多份认可
🔖 【收藏】 把这些 "好用又好懂" 的干货技巧存进你的知识库
💬 【评论】 来唠唠 ------ 你踩过最 "离谱" 的技术坑是啥?
🔄 【转发】把实用技术干货分享给身边有需要的程序员伙伴
技术从无唯一解,让我们一起用最接地气的方式,写出最扎实的代码! 🚀💻

能够看到这里的小伙伴已经打败95%的人了超棒的,为你点赞,休息一下吧!

相关推荐
玖釉-1 小时前
[Vulkan 学习之路] 20 - 顶点缓冲区:创建顶点缓冲区 (Vertex Buffer Creation)
c++·windows·图形渲染
yi.Ist2 小时前
博弈论 Nim游戏
c++·学习·算法·游戏·博弈论
yuanmenghao2 小时前
车载Linux 系统问题定位方法论与实战系列 - 系统 reset / reboot 问题定位
linux·服务器·数据结构·c++·自动驾驶
楼田莉子2 小时前
C++高级数据结构——LRU Cache
数据结构·c++·后端·学习
DYS_房东的猫2 小时前
macOS 上 C++ 开发完整指南(2026 年版)
开发语言·c++·macos
啊吧怪不啊吧2 小时前
C++之模版详解(进阶)
大数据·开发语言·c++
小灰灰搞电子2 小时前
C++ 多线程详解
c++·多线程
闻缺陷则喜何志丹2 小时前
P10160 [DTCPC 2024] Ultra|普及+
数据结构·c++··洛谷
玖釉-2 小时前
[Vulkan 学习之路] 17 - 拒绝摸鱼:多帧并行 (Frames in Flight)
c++·windows·图形渲染