【数据结构】哈希表

目录

[1. 哈希表的核心组成(三部分):](#1. 哈希表的核心组成(三部分):)

[2. 哈希冲突](#2. 哈希冲突)

[3. 负载因子](#3. 负载因子)

[4. 哈希函数](#4. 哈希函数)

[4.1 直接定址法(最简单,无冲突,局限性大)](#4.1 直接定址法(最简单,无冲突,局限性大))

[4.2 除留余数法(最通用,工业级常用)](#4.2 除留余数法(最通用,工业级常用))

[4.3 乘法散列法(高效,哈希分布均匀)了解](#4.3 乘法散列法(高效,哈希分布均匀)了解)

[4.4 全域散列法(抗攻击、抗极端数据)了解](#4.4 全域散列法(抗攻击、抗极端数据)了解)

[4.5 其它方法 了解](#4.5 其它方法 了解)

[5. 哈希冲突解决方式](#5. 哈希冲突解决方式)

[5.1 开放定址法](#5.1 开放定址法)

[5.1.1 线性探测](#5.1.1 线性探测)

[5.1.2 平方探测(二次探测)](#5.1.2 平方探测(二次探测))

[5.1.3 双重哈希探测(了解)](#5.1.3 双重哈希探测(了解))

开放定址法模拟实现

(1)设计逻辑

(2)负载因子阈值

(3)扩容重哈希逻辑

(4)无法直接取模问题

(5)代码实现

[5.2 链地址法](#5.2 链地址法)

链地址法模拟实现

(1)负载因子阈值

[(2)析构函数写 or 不写](#(2)析构函数写 or 不写)

(3)扩容重哈希逻辑

(4)链地址法的不同实现(有无红黑树)

(5)代码实现


哈希表是编程中最常用的高效数据结构之一,它的核心思想通过哈希函数将键(Key)映射到数组的特定位置(称为"桶"或"槽",Bucket),从而实现平均O(1)时间复杂度的增、删、改、查操作。

1. 哈希表的核心组成(三部分):

  • 数组(桶数组):存储键值对的基础结构,每个元素称为一个"桶"。
  • 哈希函数(Hash Function):将任意类型的键转换为数组的索引(桶位置)。
  • 冲突处理机制:解决不同键映射到同一桶的问题。

2. 哈希冲突

当两个不同的键通过哈希函数得到相同的桶索引时,称为哈希冲突。冲突不可避免,但好的哈希函数可以减少冲突。

3. 负载因子

负载因子描述了哈希表的"拥挤程度"------哈希表中已存储的元素数量占哈希表总桶(数组)容量的比例。比例越高,哈希表越拥挤,冲突概率越高;比例越低,哈希表越宽松,空间浪费越多。

负载因子(loadFactor)= 哈希表当前元素数(size) / 哈希表总容量(capacity)

4. 哈希函数

哈希函数是哈希表的"灵魂",直接决定了哈希表的性能。

4.1 直接定址法(最简单,无冲突,局限性大)

公式:H(key) = key 或 H(key) = a * key + b(线性变换)。

直接用Key本身或key的线性变换(如H(key) = key )作为哈希值,适合key的取值范围连续且数值范围不大的场景。
这种方法无哈希冲突、计算极快,但是key范围较大时(比如1~10000000),哈希桶数量对应也要扩大,空间浪费严重

4.2 除留余数法(最通用,工业级常用)

将键除以某个质数M(通常是桶数组的长度),取余数作为索引。

公式:H(key) = key % M。

M的选择是关键,**M选择 "不接近 2 的整数次幂的质数",**本质是同时规避两类冲突问题:

1. 选 "质数":规避 "公约数扎堆" 问题

质数的约数只有1和自身,能避免key因公约数集中映射到同一哈希值,比如:

  • 若M=12(合数),key=6、12、18、24的公约数都是6,哈希值全为0,冲突暴增;
  • 若M=13(质数),上述key的哈希值分别为6、12、5、11,分布均匀。

2. 选 "不接近 2 的整数次幂":规避 "低位主导" 问题

2的整数次幂(如 16=2⁴、32=2⁵)的致命缺陷是:**key % 2ⁿ等价于取key的二进制最后n位,高位信息完全丢失。**即使把M选为"接近2的幂的质数"(如17=16+1、31=32-1)。依然会残留"低位主导"的问题------因为这类质数和2的幂差值极小,key % M的结果仍主要由key的低位决定,高位信息利用不足。

接近2的幂的质数(M=17质数,接近16=2⁴)VS远离2的幂的质数(M=19 质数,远离16=2⁴),key分别取16、32、48、64、80:

  • 当M=17时,key % 17哈希值依次为16、15、14、13、12,呈现连续递减规律,仍受低位主导,冲突风险高。
  • 当M=19时,key % 17哈希值依次为16、13、10、7、4,分布更分散,高位信息充分利用,冲突风险低。
    若M=2ⁿ,对 2ⁿ取模,num % 2ⁿ = num & (2ⁿ - 1) = 保留数字二进制的最后 n 位,高位所有信息都被丢弃。对 2 的幂取模只保留最后 n 位,那么就说明二进制后n位相同的值,哈希值就相同,会增加冲突概率。比如{25,41},如果p是16即2^4,25的二进制后8位是00011001,41的二进制后8位是00101001,那么计算出的哈希值都是9,保留的是它们的后4位1001。

若M=10ⁿ,num % 10ⁿ = num的最后n位十进制数字,对 10ⁿ取模,等价于只保留数字十进制的最后 n 位,与上面同理。

Java HashMap的处理逻辑

Java HashMap并没有直接使用"选质数做除留余数"的通用方案,而是对除留余数法做了工业级优化: Java的HashMap实际采用2的幂作为数组长度,通用的2的幂取模会丢失高位信息,但是它底层用32位扰动函数(hashCode ^ (hashCode >> 16))通过移位+异或的方式将高低位混合,让高位也参与后续的取模运算,彻底解决"丢失高位信息"的致命缺陷。

假设key = 0x12345678(十六进制,对应十进制305419896),桶数组的长度M = 16 (2^4):

  • h>>16 = 0x00001234;(把原始哈希值右移16位,无符号右移,高位补0)
  • hash = 0x12345678 ^ 0x00001234 = 0x1234444C;(十进制305418316,混合后的哈希值既保留了高16位0x1234的特征,又融合了低16位0x5678的信息)。
  • 用位运算代替取模(效率高):index = hash & (M- 1)= 305418316 & 15 =12 等价于305418316 % 16 = 12;最终被映射到数组下标12的位置。

4.3 乘法散列法(高效,哈希分布均匀)了解

乘法散列法是哈希函数设计的经典方法,对哈希表的大小M没有要求,它通过 "键值 × 黄金比例常数 ,提取乘积的小数部分 ,再用哈希表大小M乘以该小数部分并对结果向下取整, 得到哈希地址。

公式:**h(key) = floor(M × ((A × key)%1.0)),**其中floor表示对表达式向下取整,A是黄金比例常数,取值为= ( √5 − 1)/2 = 0.6180339887....(由knuth提出,该值能最大化哈希值的分布均匀性,有效降低冲突概率)。
例如已知M=1024、key=5678、A=0.6180339887,计算 h(5678):

A*key=0.6180339887×5678=3509.1969878386,取小数部分为0.1969878386,M × ((A × key)%1.0)=1024×0.1969878386=201.7155467264,那么h(5678)=201。

4.4 全域散列法(抗攻击、抗极端数据)了解

如果存在一个恶意的对手,它针对我们提供的散列函数,特意构造出一个发生严重冲突的数据集,导致所有关键字集中冲突(哈希表性能大幅下降),就像快递分拣站一样,每个快递的快递单key,哈希函数就是分拣规则,比如按手机号后4位分货架,如果有人恶意搞事,比如所有快递是手机号后四位都填"6666",按这个规则,所有快递都堆一个货架(冲突爆炸),分拣站直接瘫痪(哈希表就会退化成链表,效率从 O (1) 变 O (n)。
全域散列法通过给散列函数增加随机性,让对手无法针对性构造冲突数据,避免最坏情况。

公式:h (key) = ((a × key + b)%P )%M,其中P需选一个大于所有可能键值的质数,a随机选取[1,P-1]之间的整数,b随机选取[0,P-1]之间的整数,由a、b取不同值,可构成P×(P−1)个散列函数的 "全域函数组",初始化哈希表时,从全域函数组中随机选一个散列函数,后续的增、删、查、改操作都固定使用该函数。

例如:P=17,M=6,a=3,b=4,则h(8) = ((3×8 + 4) % 17) % 6 = 5;
综上所述全域散列法就是给哈希表准备一堆 "靠谱的哈希函数",每次用的时候随机挑一个,既保证了哈希表 O (1) 的操作效率,又提升了其抗攻击、抗极端数据的稳定性。

注:乘法散列法和全域散列法可参照《算法导论》

4.5 其它方法 了解

有些书籍中还提到了数字分析法、折叠法、平方取中法、随机数法等,这些方法更局限于一些特定的场景。

数字分析法(了解)

取键的某些特征位(如身份证号的出生日期部分)作为索引,适用于键是固定长度的数字串。

折叠法(了解)

将键拆分成若干段,叠加后取模得到索引(如键是"123456",拆成"12"+"34"+"56"=102,再取模)。

平方取中法(了解)

将键平方后,取中间几位作为索引(如键是"123",平方得"15129",取中间三位"512"作为索引)。

随机数法(了解)

以键本身作为 "种子",通过随机数生成器生成一个与键绑定的随机整数,再对哈希表的桶数(M)取模,得到该键对应的哈希地址。

5. 哈希冲突解决方式

5.1 开放定址法

当通过哈希函数计算出的桶位置被占用(冲突)时,不创建链表,而是按照某种探测规则在哈希桶数组中"挨个找",直到找到空闲的桶位置存放元素。

开放定址法的 3 种经典探测方式:

5.1.1 线性探测

冲突后每次往后找一个位置,**最简单但易"堆积",**冲突元素扎堆成"块",后续探测需遍历整个块,效率骤降。

hashi = (hash0 + i) % 表长M

其中:hash0:初始哈希位置(Key % M),i = {1,2,3......M-1},因为负载因子小于1, 则最多探测M-1次,一定能找到一个存储key的位置。

5.1.2 平方探测(二次探测)

线性探测的探测序列是hash0+1、hash0+2、hash0+3......,容易出现"一次聚集"(冲突的桶连成一片);二次探测通过平方数偏移打破聚集,探测序列更分散,公式:

hashi = (hash0 ± i²) % 表长M

其中hash0:初始哈希位置(Key % M),i = {1,2,3......M/2 },二次探测当 (hash0 - i²)<0时需要+=M

注:模拟实现以单向平方探测为例

平方探测的短板:

平方探测其步长仅由 "探测次数 i" 决定,**与 key 本身无关,**若两个不同 key 的初始哈希位置相同(比如 key1=12、key2=25,m=13 时h1均为12),则它们的探测序列完全一致(i=1→12+1=13 %13=0;i=2→12+4=16 %13=3;i=3→12+9=21%13=8...);

这种 "初始位置相同→探测序列完全重叠" 的现象称为二次聚集 ,会导致冲突 key 扎堆在同一组探测序列上,即使数组有其他空闲桶,也只能在重叠序列上竞争,最终探测次数急剧增加。平方探测存在天然的探测盲区, 平方探测的探测序列最多只能覆盖约 50% 的桶

5.1.3 双重哈希探测(了解)

双重散列是 开放定址法 中性能最优、聚集问题最少的冲突解决策略,核心思想是:用 两个独立的哈希函数共同生成探测序列,彻底解决线性探测的 "一次聚集"、平方探测的 "二次聚集" 问题,让探测序列的随机性、均匀性达到开放定址法的最优水平。是开放定址法在工业级场景(如高性能哈希库、嵌入式开发)的主流选择。
第一个哈希函数h1(key):计算初始哈希位置(和普通哈希函数作用一致);

第二个哈希函数h2(key):计算探测步长(步长随键变化,而非固定值);

公式:h​(key) = (h1​(key) + i×h2​(key)) % M

第二个哈希函数的约束条件

  • h2(key)不等于0:步长不能为 0,否则每次探测都停在初始位置,永远无法解决冲突;
  • h2(key)与M互为质数:确保探测序列能遍历哈希表中所有桶,避免探测盲区

第二个哈希函数的设计方案

  • M为质数:可设计为h2(key) = 1 + (key % (M - 1))(步长范围[1,M-1],天然与质数M互质)。

  • M为 2 的幂(如 16):h2(key) = 奇数 (奇数与2^k互质,确保遍历所有桶)。
    例如,M=13 质数,设两个哈希函数为:

  • h1(key) = key % 13(初始位置,利用质数M保证初始分布均匀);

  • h2(key) = 1 + (key % 12)(步长:范围1~12,与13互质,满足双重散列的约数);

待插入key = 27,h1​(27) = 1,假设位置12已被占用,进入探测:

h2​(27) = 1 + (27 % 12) = 4-->步长为4;

  • 第一次探测(i = 1):pos(1) = (1 + 1 * 4) % 13 = 5-->探测位置5。
  • 第二次探测(i = 2):pos(2) = (1 + 2 * 4) % 13 = 9-->探测位置9。
  • 第三次探测(i = 3):pos(3) = (1 + 3 * 4) % 13 = 0-->探测位置0。
  • 第四次探测(i = 4):pos(4) = (1 + 4 * 4) % 13 = 4-->探测位置4。
  • 第五次探测(i = 5):pos(5) = (1 + 5 * 4) % 13 = 8-->探测位置8。

依此类推,直到找到空闲桶。

待插入key = 40,h1​(40) = 1,h2 = 1+(key%12) = 5 探测序列为6-->11-->3-->8-->0-->......。即使两个key的初始位置相同(如key=27与key=40,h1均为1),只要h2(key)不同(几乎必然,因为h2是独立哈希),探测序列就会完全不同,对比它们的探测序列,很少重叠,彻底消除二次聚集,冲突不会"扎堆",哈希分布更均匀。

开放定址法模拟实现

(1)设计逻辑

开放定址法的核心是哈希冲突时通过探测序列寻找空闲桶存储元素,但直接删除桶内元素会破坏探测链,导致后续冲突元素查找失败(如下图删除值 30 后,查找与其冲突的 20 时会因探测链断裂而失败)。为解决该问题,需为哈希表的每个存储位置增设状态标识位{EXIST,EMPTY,DELETE},删除值 30 后,将它的状态位设为DELETE,那么查找20时遇到 EMPTY 就停止,遇到DELETE就继续查找,就可以找到20。

(2)负载因子阈值

不同探测方式的负载因子控制策略:

开放定址法对负载因子的敏感度高于链地址法------负载因子过高会直接导致探测链急剧变长,性能从O(1)断崖式下跌到O(n),核心是:
通用安全阈值:≤0.7(无论使用哪种开放定址法,只要不超这个值,就能保证哈希表的性能基本稳定);

细分阈值:

  • 线性探测(最易聚集):≤0.5(工业级实践如 Java ThreadLocalMap (ThreadLoal 的底层存储组件)就严格控制在 0.5 左右);
  • 平方探测:≤0.7(聚集问题减轻,可适度放宽);
  • 双重哈希探测(分布最均匀):≤0.8(阈值上限,仍不建议接近);
(3)扩容重哈希逻辑

扩容时,必须对原表中所有有效元素执行重新哈希映射到新表,而非直接复制原表数据, 因为"探测序列依赖桶数组长度" 这一核心特性。

(4)无法直接取模问题

工业级字符串哈希实现(BKDRHash,最常用)

当哈希表的key是string类型或像Date这种自定义类类型,无法直接取模,那么就在哈希表的模版类中增加一个哈希仿函数,让它默认能处理整型,对string等特殊类型做模版特化,实现低冲突的哈希转换,自定义类型(如Date)则手动实现仿函数。

仿函数的作用:仅将key转换为整型size_t;
字符串转换成整型,可以把字符的ASCII码值相加即可,但是直接相加的话,像"abcd"和"bcda"两个字符串计算出的结果是相同的,那么使用除留余数法计算出的哈希值就是冲突的,"累加字符ASCII"哈希函数仅适合学习演示,实际开发中绝对不能用,核心问题是字符顺序无关导致的冲突爆炸。
BKDRHash是多项式哈希的优化版,由Brian Kernighan 和 Dennis Ritchie 设计,兼顾效率和低冲突,是各大编程语言标准库的首选(如C++std::hash<string>底层基于此)。工业级字符串哈希的核心是多项式哈希hash*BASE + 字符值),工业级优先选BKDRHash(BASE = 131/13131等质数 )。

复制代码
template<class K>
struct HashFunc
{
	size_t operator()(const K& key) 
	{
		return (size_t)key;  //处理如果key是负数等情况
	}
};

//string类型的模版特化
template<>
struct HashFunc<string>  
{
	size_t operator()(const string& s)
	{
		const size_t BASE = 131; //将131作为基数
		size_t hash = 0;
		for (auto ch : s)
		{
			//BKDR算法(对比直接累加字符实现,减少冲突) 
			hash = hash * BASE + ch;
		}
		return hash;
	}
};
(5)代码实现
复制代码
namespace open_address //开放定址法模拟实现
{
	//质数表
	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;
	}

	//哈希仿函数
	template<class K>
	struct HashFunc
	{
		size_t operator()(const K& key) const
		{
			return (size_t)key;  //处理如果key是负数等情况
		}
	};

	//string类型的模版特化
	template<>
	struct HashFunc<string>
	{
		size_t operator()(const string& s) const
		{
			const size_t BASE = 131; //核心:多项式哈希,BASE选131(质数,低冲突)
			size_t hash = 0;
			for (auto ch : s)
			{
				//BKDR算法(对比直接累加字符实现,减少冲突) 
				hash = hash * BASE + ch;
			}
			return hash;
		}
	};

	//桶的状态枚举
	enum State { EMPTY, EXIST, DELETE }; //定义桶的状态:空、已占用、已删除

	//存储数据类型
	template<class K, class V>
	struct HashData
	{
		pair<K, V> _kv;
		State _state = EMPTY;
	};

	//哈希表结构
	template<class K, class V, class Hash = HashFunc<K>>
	class HashTable
	{
	public:
		HashTable()
			:_tables(__stl_next_prime(0))//初始容量为质数表中≥0的最小质数(53)
			, _n(0)
		{}

		bool Insert(const pair<K, V>& kv)
		{
			//检查是否已存在,存在则返回false
			if (Find(kv.first))
				return false;

			//负载因子检查:负载因子控制在0.7以下  >= 0.7就扩容
			double load_factor = _n * 1.0 / _tables.size();
			if (load_factor >= 0.7)
			{
				Rehash(); //扩容
			}

			size_t hash0 = hashfunc(kv.first) % _tables.size(); //除留余数法 初始哈希位置
			size_t i = 0;//探测次数
			size_t hashi = hash0;

			//探测可用桶,仅当桶为EXIST时继续探测,EMPTY/DELETE均可复用
			while (_tables[hashi]._state == EXIST)
			{
				//线性探测
				i++;
				hashi = (hash0 + i) % _tables.size(); //取模,避免越界

				////平方探测+1 +4 +9 +16 +...... (这里仅实现单向平方探测)
				//i++;
				//hashi = (hash0 + i * i) % _tables.size(); //取模,避免越界
			}

			//插入数据
			_tables[hashi]._kv = kv;
			_tables[hashi]._state = EXIST;
			++_n;

			return true;
		}

		void Print()const
		{
			for (size_t i = 0; i < _tables.size(); ++i)
			{
				if (_tables[i]._state == EXIST)
				{
					cout << "位置" << i << " " << _tables[i]._kv.first << " " << _tables[i]._kv.second << endl;
				}
			}
		}

		HashData<K, V>* Find(const K& key)
		{
			size_t hash0 = hashfunc(key) % _tables.size(); //除留余数法
			size_t i = 0;
			size_t hashi = hash0;

			while (_tables[hashi]._state != EMPTY)
			{
				if (_tables[hashi]._state == EXIST && _tables[hashi]._kv.first == key)
				{
					return &_tables[hashi];
				}

				//线性探测
				i++;
				hashi = (hash0 + i) % _tables.size(); //取模,避免越界

				////平方探测
				//i++;
				//hashi = (hash0 + i * i) % _tables.size(); //取模,避免越界
			}
			return nullptr;
		}

		bool Erase(const K& key)
		{
			HashData<K, V>* ret = Find(key);

			if (ret)
			{
				ret->_state = DELETE;
				_n--; //有效数据-1
				return true;
			}
			return false;
		}

	private:
		//扩容逻辑
		void Rehash()
		{
			//创建新表,新容量为质数表中大于等于_tables.size() + 1 的下一个质数			
			vector<HashData<K, V>> newtables(__stl_next_prime(_tables.size() + 1));

			for (const auto& data : _tables)
			{
				if (data._state == EXIST)
				{
					size_t hash0 = hashfunc(data._kv.first) % newtables.size(); //除留余数法
					size_t i = 0;
					size_t hashi = hash0;

					while (newtables[hashi]._state == EXIST)
					{
						//线性探测找空桶
						i++;
						hashi = (hash0 + i) % newtables.size();

						////平方探测+1 +4 +9 +16 +...... 
						//i++;
						//hashi = (hash0 + i * i) % _tables.size(); //取模,避免越界
					}

					newtables[hashi]._kv = data._kv;
					newtables[hashi]._state = EXIST;
				}
			}

			//新旧表交换
			_tables.swap(newtables);
		}

	private:
		vector<HashData<K, V>> _tables; //桶数组
		size_t _n = 0;//有效数据个数
		Hash hashfunc;// 哈希函数仿函数对象
	};
}

5.2 链地址法

链地址法**(开散列 / 拉链法 / 哈希桶)** 是哈希表的核心冲突解决策略,其核心思想是:将哈希表设计为 "桶数组 + 每个桶挂载独立链表 / 平衡树" 的双层结构,冲突元素不占用数组空闲位置,而是直接挂载到对应桶的链式结构中,是工业级通用哈希表(如 C++ unordered_map、Java HashMap)的首选实现方案。
哈希表的核心是 "桶数组",数组节点不直接存储数据 ,仅存储指向链式结构(链表 / 红黑树)的指针

  • 无数据映射到该桶时,指针为空;
  • 多个数据哈希冲突到该桶时,冲突数据被组织成链表(或红黑树),挂在该桶指针下;

链地址法模拟实现

(1)负载因子阈值

开放定址法的负载因子必须<1,而链地址法的负载因子就没有限制了,可以 >1。STL 的unordered_map/unordered_set 等容器,将最大负载因子控制在 1,当负载因子超过 1 时触发扩容,以此平衡冲突概率与空间利用率。

(2)析构函数写 or 不写

哈希表不定义析构函数,自动vector的析构函数可行吗?
不可以。

关于vector<Node*>析构:

因为Node*是内置类型(指针类型),内置类型的析构什么都不做,vector的析构逻辑是:销毁数组中每个元素(销毁Node*指针时,只是把指针变量本身从内存中清除,比如0x123456这个值被删掉,但指针指向的Node对象依然存在,变成"孤儿内存"),再释放vector自身的底层数组内存。

所以在析构哈希表时,我们就需要自定义析构函数,替vector完成它"管不到"的清理操作

vector<Node*>释放内存的核心是:先手动遍历 delete 每个指针指向的对象,再清空 vector。

(3)扩容重哈希逻辑

思路1:新建哈希表对象newHT 并扩容其桶数组,遍历原桶数组链表,为每个原节点复制新节点(new创建)并插入新表,再交换原 / 新表的桶数组,局部变量newHT析构时利用哈希表析构函数释放原节点。(不推荐,太拉胯
缺点:性能极差:N 个节点对应 N 次new/delete,系统调用开销大;内存浪费:Rehash 过程中原 / 新节点同时存在,临时占用 2 倍内存,创建完整的HashTable对象,但只用到它的_tables,其余成员都是冗余的,浪费内存。

实现

复制代码
//扩容逻辑(不推荐!!!!!)
void Rehash()
{
	//创建新哈希表,新容量为质数表中大于等于_tables.size() + 1 的下一个质数
	HashTable<K, V, Hash> newHT;
	newHT._tables.resize(__stl_next_prime(_tables.size() + 1));

	for (size_t i = 0; i < _tables.size(); i++)
	{
		Node* cur = _tables[i];
		while (cur)
		{
			Node* newnode = new Node(cur->_kv.first, cur->_kv.second); //复制节点
			size_t hash = hashfunc(cur->_kv.first) % newHT._tables.size();/除留余数法算出映射在新数组的位置
			newnode->_next = newHT._tables[hash];
			newHT._tables[hash] = newnode;

			cur = cur->_next;
		}
	}
	_tables.swap(newHT._tables); //交换原表和新表的桶数组
	//局部变量newHT会自动调用自定义析构函数销毁
}

思路2:新建对应容量的桶数组newtables ,采用 "迁移节点" 策略不构造新节点,仅将原节点的指针 "摘下来",重新挂载到新表的桶下,然后交换原 / 新桶数组,vector类型局部变量newtables析构时调用自己的析构函数,因为其内部存储的是一堆nullptr(来自原桶数组的置空操作),不存在内存泄漏问题。迁移节点(最优实现,工业级首选)
性能最优:仅指针移动,避免了new/delete 的开销,时间 / 内存开销极小;内存高效:无冗余节点,无额外内存占用;

实现:

复制代码
//扩容逻辑(推荐写法!!!)
void Rehash()
{
	//创建新的桶数组,新容量为质数表中大于等于_tables.size() + 1 的下一个质数
	vector<Node*> newtables(__stl_next_prime(_tables.size() + 1));

	for (size_t i = 0; i < _tables.size(); i++)
	{
		Node* cur = _tables[i];
		while (cur)
		{
			Node* next = cur->_next; //暂存下一个节点
			//仅移动指针,将原节点挂载到新桶(无new,无复制)
			size_t hash = hashfunc(cur->_kv.first) % newtables.size(); //除留余数法算出映射在新数组的位置
			cur->_next = newtables[hash];
			newtables[hash] = cur;

			cur = next;
		}

		_tables[i] = nullptr; //原桶数组节点置空
	}
	_tables.swap(newtables);
    //局部变量newtables会自动调用vector的析构函数销毁(里面元素都是nullptr)
}
(4)链地址法的不同实现(有无红黑树)

Java HashMap 和 C++ unordered_map 的链地址法对比(异同):

  • 相同:都是桶数组 + 链地址法;
  • 不同:Java 1.8 + 有红黑树优化,C++ unordered_map(GCC)只有单向链表,无红黑树优化;Java 扩容是 2 倍(2 的幂),C++ 是 > 2 倍的最小质数;Java 用位运算算索引,C++ 用除留余数法。
(5)代码实现
复制代码
namespace SeparateChaining //链地址法模拟实现
{
	//质数表
	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;
	}

	//哈希仿函数
	template<class K>
	struct HashFunc
	{
		size_t operator()(const K& key) const
		{
			return (size_t)key;  //处理如果key是负数等情况
		}
	};

	//string类型的模版特化
	template<>
	struct HashFunc<string>
	{
		size_t operator()(const string& s) const
		{
			const size_t BASE = 131; //核心:多项式哈希,BASE选131(质数,低冲突)
			size_t hash = 0;
			for (auto ch : s)
			{
				//BKDR算法(对比直接累加字符实现,减少冲突) 
				hash = hash * BASE + ch;
			}
			return hash;
		}
	};

	//哈希节点	
	template<class K, class V>
	struct HashNode
	{
		pair<K, V> _kv;
		HashNode<K, V>* _next;

		HashNode(const pair<K, V>& kv)
			:_kv(kv)
			, _next(nullptr)
		{}
	};

	//哈希表结构
	template<class K, class V, class Hash = HashFunc<K>>
	class HashTable
	{
		typedef HashNode<K, V> Node;

	public:
		//构造函数
		HashTable()
			:_tables(__stl_next_prime(0))
			, _n(0)
		{}

		//拷贝构造函数
		HashTable(const HashTable& other)
		{
			//初始化当前表的桶数组
			_tables.resize(other._tables.size());
			_n = other._n;

			//遍历每个桶,深拷贝节点
			for (size_t i = 0; i < other._tables.size(); i++)
			{
				Node* cur = other._tables[i];
				Node* tail = nullptr;//尾指针

				while (cur)
				{
					Node* newnode = new Node(cur->_kv);
					if (_tables[i] == nullptr)
					{
						_tables[i] = newnode;
						tail = newnode;
					}
					else
					{
						tail->_next = newnode;
						tail = newnode;
					}
					cur = cur->_next;
				}
			}

		}

		//赋值运算符重载
		HashTable& operator=(HashTable tmp)
		{
			_tables.swap(tmp._tables);
			swap(_n, tmp._n);
			return *this;
		}

		//析构函数
		~HashTable()
		{
			clear();
		}

		//清空表
		void clear()
		{
			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;
		}

		//插入
		bool Insert(const pair<K, V>& kv)
		{
			//检查是否已存在,存在则返回false
			if (Find(kv.first))
				return false;

			//负载因子==1时扩容
			if (_n == _tables.size())
			{
				Rehash();
			}

			size_t hash = hashfunc(kv.first) % _tables.size();

			//头插
			Node* newnode = new Node(kv);
			newnode->_next = _tables[hash];
			_tables[hash] = newnode;
			++_n;

			return true;
		}

		//查找
		Node* Find(const K& key)
		{
			size_t hash = hashfunc(key) % _tables.size();

			Node* cur = _tables[hash];
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					return cur;
				}
				cur = cur->_next;
			}

			return nullptr;
		}

		//删除
		bool Erase(const K& key)
		{
			size_t hash = hashfunc(key) % _tables.size();
			Node* cur = _tables[hash];
			Node* prev = nullptr;

			while (cur)
			{
				if (cur->_kv.first == key)
				{
					//分两种情况讨论
					//1.如果删除的是链的第一个节点
					if (prev == nullptr)
					{
						_tables[hash] = cur->_next;
					}
					else //2.如果删除的是链的中间节点或最后一个节点
					{
						prev->_next = cur->_next;
					}
					delete cur;
					--_n;

					return true;
				}
				else
				{
					prev = cur;
					cur = cur->_next;
				}
			}
			return false;
		}

		//判空
		bool Empty()
		{
			return _n == 0;
		}

		//获取有效元素个数
		size_t Size()
		{
			return _n;
		}

		void Print()const
		{
			for (size_t i = 0; i < _tables.size(); ++i)
			{
				Node* cur = _tables[i];
				while (cur)
				{
					cout << "位置" << i << " " << cur->_kv.first << " " << cur->_kv.second << endl;
					cur = cur->_next;
				}
			}
		}

	private:
		//扩容逻辑(推荐写法!将原链表节点摘取下来)
        void Rehash()
        {
	        //创建新的桶数组,新容量为质数表中大于等于_tables.size() + 1 的下一个质数
	        vector<Node*> newtables(__stl_next_prime(_tables.size() + 1));

	        for (size_t i = 0; i < _tables.size(); i++)
	        {
		        Node* cur = _tables[i];
		        while (cur)
		        {
			        Node* next = cur->_next; //暂存下一个节点
			        //仅移动指针,将原节点挂载到新桶(无new,无复制)
			        size_t hash = hashfunc(cur->_kv.first) % newtables.size(); //除留余数法算出映射在新数组的位置
			        cur->_next = newtables[hash];
			        newtables[hash] = cur;

			        cur = next;
		        }

		        _tables[i] = nullptr; //原桶数组节点置空
	        }
	        _tables.swap(newtables);
	        //局部变量newtables会自动调用vector的析构函数销毁(里面元素都是nullptr)
        }

	private:
		vector<Node*> _tables; //指针数组
		size_t _n = 0; //表中存储的数据个数
		Hash hashfunc; //哈希仿函数对象
	};
}
相关推荐
郝学胜-神的一滴2 小时前
Linux C++会话编程:从基础到实践
linux·运维·服务器·开发语言·c++·程序人生·性能优化
AA陈超2 小时前
LyraStarterGame_5.6 Experience系统分析
开发语言·c++·笔记·学习·ue5·lyra
历程里程碑2 小时前
C++ 8:list容器详解与实战指南
c语言·开发语言·数据库·c++·windows·笔记·list
小尧嵌入式2 小时前
C++11线程库的使用(上)
c语言·开发语言·c++·qt·算法
蓝色汪洋3 小时前
luogu填坑
开发语言·c++·算法
暗然而日章3 小时前
C++基础:Stanford CS106L学习笔记 9 类模板(Class Templates)
c++·笔记·学习
小年糕是糕手3 小时前
【C++同步练习】类和对象(三)
开发语言·jvm·c++·程序人生·考研·算法·改行学it
jllws13 小时前
数据结构_输入法的实现&五笔输入法浅析
数据结构
Fcy6483 小时前
C++ set和multiset的使用
开发语言·c++·stl·map·multimap