完整教程:【C++升华篇】学习C++就看这篇--->哈希&哈希表详细剖析

 个人主页:HABuo

 个人专栏:《C++系列》《Linux系列》《数据结构》《C语言系列》《Python系列》《YOLO系列》

⛰️ 如果再也不能见到你,祝你早安,午安,晚安


目录

一、哈希概念

二、哈希表的核心工作原理->哈希函数

1.哈希函数 —— “计算地址”

2.哈希冲突

3.哈希函数列举

三、哈希表的核心工作原理->处理冲突

1.闭散列

①线性探测

②二次探测

2.开散列

四、总结


前言:

前面我们了解了二叉搜索树、AVLTree、红黑树等相关知识,今天我们来了解另外一个非常重要的数据结构哈希表。它是一个非常高效的且在以后的工作中应用非常广泛的数据结构,闲话少叙,让我们一起来揭开它神秘的面纱!

一、哈希概念

想象一下,我们有一个巨大的图书馆,里面有成千上万本书。

①笨办法(顺序查找/二分查找):如果你想找一本叫《C++ Primer》的书,你需要从第一个书架开始,一本一本地看过去(顺序查找),或者根据书名排序后快速定位到一个区域(二分查找)。当书非常多的时候,这还是很慢。

②聪明办法(哈希表思路):图书管理员做了一个神奇的规则:根据书名的每个字母计算出一个唯一的编号(书架号 + 层号)。比如《C++ Primer》通过计算,直接得出它在“第5个书架,第3层”。你直接走过去就能拿到书,速度极快!

这个“聪明办法”就是哈希表的核心思想。它是一种 “键-值对” 存储结构,能通过一个,近乎瞬间地找到对应的

  • :就是你要找的东西的名字,比如书名《C++ Primer》。

  • :就是这个名字背后对应的数据,比如这本书本身。

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

二、哈希表的核心工作原理->哈希函数

1.哈希函数 —— “计算地址”

这个步骤的目的,就是把任何类型的“键”(字符串、对象等)转换成一个整数下标,从而可以直接定位到数组中的某个位置。

举个例子:

用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快,但是会出现这样的问题:按照上述哈希方式,向集合中插入元素44,会发现位置已经被4占用了,怎么办?

2.哈希冲突

上面出现的问题就是哈希冲突,即不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。理想情况下,每个键都会计算出唯一的索引。但现实是,数组空间是有限的,而可能的键是无限的。而哈希核心的核心就在于如何解决这些冲突,让我们继续看下文:

3.哈希函数列举

①直接定址法--(常用):取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B。使用场景:适合查找比较小且连续的情况。

例如:

②除留余数法--(常用):设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数, 按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址。如上面哈希函数那张图所示。

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

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

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

数学分析法--(了解):设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定 相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只 有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散 列地址。如:

注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突

三、哈希表的核心工作原理->处理冲突

哈希冲突 是必然发生的:两个不同的键,经过哈希函数计算后,得到了相同的数组索引。如何解决呢?解决冲突的两种主流方法:闭散列和开散列

1.闭散列

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

线性探测

比如上述介绍哈希函数中的场景,现在需要插入元素44,先通过哈希函数计算哈希地址,hash地址为4, 因此44理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突。

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

// 线性探测:h(k, i) = (hash(k) + i) % table_size
index = (original_index + 1) % size;  // 探测下一个
index = (original_index + 2) % size;  // 再下一个
index = (original_index + 3) % size;  // 再下一个...

// 哈希表每个空间给个标记
// EMPTY此位置空, EXIST此位置已经有元素, DELETE元素已经删除
enum State{EMPTY, EXIST, DELETE};

有个问题想必大家应该都想到了,如果数组满了该怎么办?该怎么增容呢?什么时候增容呢?难道非要满了才增容?这样哈希冲突不就很多了,乱成一锅粥?

闭散列哈希表不能满了再增容,因为如果哈希表快满了时插入数据,冲突的概率很大,效率会很低。如何解决:快接近满的时候就增容,提出一个概念:负载因子=表中的数据个数/表的大小,一般情况,闭散列的哈希表中,负载因子到0.7就可以开始增容。
一般情况下,负载因子越小,冲突概率越低,效率越高。相反,负载因子越大,冲突概率越高,效率越低。但是负载因子也不敢控制得太小,会导致大量的空间浪费。其实控制负载因子是一种以空间换时间的思路

对于开放定址法,负载因子是特别重要因素,应严格限制在0.7-0.8以下。超过0.8,查表时的CPU缓存不命中(cache missing)按照指数曲线上升。因此,一些采用开放定址法的hash库,如Java的系统库限制了荷载因子为0.75,超过此值将resize散列表。

增容代码实现:

            if (_n * 10 / _table.size() >= 7)根据负载因子去判断是否增容
			{//乘10的目的是为了使0.7变为7因为除法不出现小数
				// 负载因子>=0.7,需要扩容,扩容后需要重新插入
				size_t newSz = _table.size() * 2;
				HashTable newHT;
				newHT._table.resize(newSz);
				for (size_t i = 0; i < _table.size(); i++)
				{
					if (_table[i]._state == EXIST)
						newHT.Insert(_table[i]._kv);
				}
				_table.swap(newHT._table);
			}

线性探测的代码实现:

namespace open_address
{
	enum State{
		EXIST,
		EMPTY,
		DELETE
	};
	template
	struct HashData{
		pair _kv;
		State _state = EMPTY;
	};
	template>
	class HashTable{
	public:
		HashTable()
			_table.resize(10);
		bool Insert(const pair& kv){
			if (_n * 10 / _table.size() >= 7)根据负载因子去判断是否增容
			{
				// 平衡因子>=0.7,需要扩容,扩容后需要重新插入
				size_t newSz = _table.size() * 2;
				HashTable newHT;
				newHT._table.resize(newSz);
				for (size_t i = 0; i < _table.size(); i++){
					if (_table[i]._state == EXIST)
						newHT.Insert(_table[i]._kv);
				}
				_table.swap(newHT._table);
			}
			// 插入过程
			HashFunc hf;
			size_t hashi = hf(kv.first) % _table.size();
			while (EXIST == _table[hashi]._state){
				++hashi;
				hashi %= _table.size();
			}
			_table[hashi]._kv = kv;
			_table[hashi]._state = EXIST;
			++_n;
			return true;
		}
		HashData* Find(const K& key){
			HashFunc hf;
			size_t hashi = hf(key) % _table.size();
			while (_table[hashi]._state != EMPTY){
				if (_table[hashi]._kv.first == key)
					return &_table[hashi];
			}
			return nullptr;
		}
		bool Erase(const K& key){
			if (Find(key)){
				Find(key)->_state = DELETE;
				return true;
			}
			return false;
		}
		void Print(){
	    	for (size_t i = 0; i < _table.size(); i++){
				if (_table[i]._state == EXIST)
					cout << i << " -> " << _table[i]._kv.first << "," << _table[i]._kv.second << endl;
				else
					cout << i << " -> NULL" << endl;
			}
		}
	private:
		vector> _table;
		size_t _n = 0;  // 表中存储数据个数
	};
}

线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。如何缓解呢?一些聪明人就想到了下述方法:

二次探测

线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:

h(k, i) = (h(k) + i²) % table_size

示例如下:

假设初始哈希值 h(k) = 5,表大小 size = 11

探测次数 i计算过程探测位置
i = 0(5 + 0²) % 11 = 55
i = 1(5 + 1²) % 11 = 66
i = 2(5 + 2²) % 11 = 99
i = 3(5 + 3²) % 11 = 33
i = 4(5 + 4²) % 11 = 1010
i = 5(5 + 5²) % 11 = 88

可以看到,探测位置是:5 → 6 → 9 → 3 → 10 → 8 → ... 不再是线性的连续移动,而是跳跃式探测。 

优点

  • 避免初级聚集:探测步长呈平方增长,不会形成长的连续占用块

  • 缓存友好:数据都在连续数组中,相比链地址法有更好的缓存局部性

  • 无需动态内存分配:不需要创建链表节点

缺点

  • 次级聚集:不同键可能产生相同的探测序列

  • 可能找不到空位:即使表未满,二次探测也可能找不到空位置

  • 表大小限制:为了确保能探测所有位置,表大小最好是质数

二次探测代码实现:

二次探测仍然要考虑负载因子的问题,因此闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。下面我们来认识一下另一种最重要也是使用最广泛的解决冲突的方式:开散列。

2.开散列

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

链地址法:这是最常用、最直观的方法。

  • 思路:数组的每个位置(称为一个“桶”或“槽”),不再直接存储一个值,而是存储一个链表(或红黑树)的头节点。

  • 过程:当发生冲突时,新的键值对就会以节点的形式,插入到这个位置对应的链表中。

  • 查找:先通过哈希函数定位到数组索引,然后在这个索引的链表中,顺序查找匹配的键。

从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。事实上,当哈希冲突过多的时候开散列仍然面临着效率下降的问题,怎么解决呢?一方面我们仍然可以通过负载因子,控制增容的方式来调节,因为一增容,映射的位置也就变了,需要重新计算。另一方面,如果冲突的数据实在过多,我们就可以数组存放链表的地址变更为存放一个红黑树头节点的地址,使它链到红黑树上,这样即使冲突的过多但是我们实在红黑树上去查找,效率就不会太慢

链地址法的插入和查找

插入 (Key, Value):

  1. 计算 hash(Key) 得到索引 i

  2. 找到数组第 i 个位置。

  3. (链地址法):将 (Key, Value) 作为一个新节点,添加到该位置的链表末尾(或头部)。

查找 Value by Key:

  1. 计算 hash(Key) 得到索引 i

  2. 找到数组第 i 个位置。

  3. (链地址法):遍历该位置的链表,比较每个节点的 Key,直到找到匹配的。

开散列代码实现:

template
	class HashTable
	{
		// 友元声明
		template
		friend struct HTIterator;
		typedef HashNode Node;
	public:
		typedef HTIterator Iterator;
		typedef HTIterator ConstIterator;
		Iterator Begin()
		{
			if (_n == 0)
				return End();
			for (size_t i = 0; i < _tables.size(); i++)
			{
				if (_tables[i])
					return Iterator(_tables[i], this);
			}
			return End();
		}
		Iterator End()
		{
			return Iterator(nullptr, this);
		}
		ConstIterator Begin() const
		{
			if (_n == 0)
				return End();
			for (size_t i = 0; i < _tables.size(); i++)
			{
				if (_tables[i])
					return ConstIterator(_tables[i], this);
			}
			return End();
		}
		ConstIterator End() const
		{
			return ConstIterator(nullptr, this);
		}
		HashTable()
			:_tables(__stl_next_prime(1), nullptr)
			, _n(0)
		{}
		~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;
		}
		pair Insert(const T& data)
		{
			KeyOfT kot;
			if (auto it = Find(kot(data)); it != End())
				return { it, false };
			Hash hs;
			// 负载因子 == 1就开始扩容
			if (_n == _tables.size())
			{
				std::vector newtables(__stl_next_prime(_tables.size() + 1), nullptr);
				for (size_t i = 0; i < _tables.size(); i++){
					// 遍历旧表,旧表节点重新映射,挪动到新表
					Node* cur = _tables[i];
					while (cur){
						Node* next = cur->_next;
						// 头插
						size_t hashi = hs(kot(cur->_data)) % newtables.size();
						cur->_next = newtables[hashi];
						newtables[hashi] = cur;
						cur = next;
					}
					_tables[i] = nullptr;
				}
				_tables.swap(newtables);
			}
			size_t hashi = hs(kot(data)) % _tables.size();
			// 头插
			Node* newnode = new Node(data);
			newnode->_next = _tables[hashi];
			_tables[hashi] = newnode;
			++_n;
			return { Iterator(newnode, this), true };
		}
		Iterator Find(const K& key){
			KeyOfT kot;
			Hash hs;
			size_t hashi = hs(key) % _tables.size();
			Node* cur = _tables[hashi];
			while (cur){
				if (kot(cur->_data) == key)
					return { cur, this };
				cur = cur->_next;
			}
			return { nullptr, this };
		}
		bool Erase(const K& key){
			KeyOfT kot;
			Hash hs;
			size_t hashi = hs(key) % _tables.size();
			Node* prev = nullptr;
			Node* cur = _tables[hashi];
			while (cur){
				if (kot(cur->_data) == key){
					// 删除
					if (prev == nullptr)
						_tables[hashi] = cur->_next;// 桶中第一个节点
					else
						prev->_next = cur->_next;
					--_n;
					delete cur;
					return true;
				}
				prev = cur;
				cur = cur->_next;
			}
			return false;
		}
	private:
		std::vector _tables; // 指针数组
		size_t _n;
	};
}

四、总结

核心思想“键” -> (哈希函数) -> 数组索引 -> 直接访问。用空间换时间,实现平均 O(1) 的查找、插入速度。

三大核心

  • 哈希函数:负责将键映射为数组索引。好的哈希函数应分布均匀,减少冲突。

  • 数组:是哈希表的骨架,提供 O(1) 的随机访问能力。

  • 冲突解决机制:是哈希表的血肉。链地址法(数组+链表)是最经典、最需要掌握的实现。

性能关键

  • 负载因子 = 元素总数 / 桶的数量。负载因子越高,冲突概率越大,性能越差。

  • 当负载因子超过某个阈值(如0.75),需要进行 “扩容”:创建一个更大的新数组,然后重新计算所有键的哈希值并插入到新数组中去。这是一个耗时操作,但能保证长期的性能。

C++中的实现

  • std::unordered_map 就是一个哈希表容器。

  • 它底层通常就是用链地址法实现的,当链表过长时,可能会转换为红黑树来保证最坏情况下的性能。

哈希(散列):将存储的数据跟存储的位置使用哈希函数建立出映射关系,方便我们进行高效查找。
哈希冲突:不同的值映射到了相同的位置。
解决哈希冲突:1、闭散列一开放定制法(我的位置被占了,我就去占别的位置)-->不推荐
2、开散列一拉链法(冲突的数据链式结构挂起来)-->推荐


posted @ 2026-01-26 16:35  clnchanpin  阅读(0)  评论(0)    收藏  举报