【C++:哈希表】从哈希冲突到负载因子:深入探索开放定址与链地址法的核心机密

🔥小叶-duck个人主页

❄️个人专栏《Data-Structure-Learning》《C++入门到进阶&自我学习过程记录》
《算法题讲解指南》--优选算法
《算法题讲解指南》--递归、搜索与回溯算法
《算法题讲解指南》--动态规划算法

未择之路,不须回头
已择之路,纵是荆棘遍野,亦作花海遨游


目录

前言

[一. 哈希表核心概念](#一. 哈希表核心概念)

1、哈希的本质

2、哈希冲突

3、负载因子

4、将关键字转为整数

[二. 哈希函数设计](#二. 哈希函数设计)

1、直接定址法

示例:字符串中的第一个唯一字符

2、除法散列法(除留余数法)(重点)

3、乘法散列法(了解即可)

4、全域散列法(了解即可)

5、其他方法(了解即可)

[三. 哈希冲突解决策略](#三. 哈希冲突解决策略)

1、开放定址法

[1.1 线性探测 (含堆积问题)](#1.1 线性探测 (含堆积问题))

[1.2 二次探测](#1.2 二次探测)

[1.3 双重探测](#1.3 双重探测)

2、详解开放定址法代码

[2.1 哈希表结构](#2.1 哈希表结构)

[2.2 扩容问题](#2.2 扩容问题)

[2.2.1 扩容处理(二倍扩容版本)](#2.2.1 扩容处理(二倍扩容版本))

[2.2.2 扩容处理(质数扩容版本)](#2.2.2 扩容处理(质数扩容版本))

[2.3 key不能取模的问题](#2.3 key不能取模的问题)

开放定址法完整代码实现:

测试代码:

3、详解链地址法代码

[3.1 对比开放定址法和链地址法](#3.1 对比开放定址法和链地址法)

[3.2 哈希桶概念及其示例](#3.2 哈希桶概念及其示例)

[3.3 扩容](#3.3 扩容)

链地址法完整代码实现:

测试代码:

[开放定址法 VS 链地址法 两种实现对比](#开放定址法 VS 链地址法 两种实现对比)

结束语


前言

哈希表是数据结构中的 "效率王者",通过哈希函数 建立key 与存储位置映射 ,实现增删查改平均**O (1)**的时间复杂度,广泛应用于 unordered_set/unordered_map、缓存、字典等场景。但很多朋友只知道怎么使用哈希表却不知道哈希表到底是怎么实现的 ------ 哈希冲突如何解决?不同哈希函数有何差异?开放定址法和链地址法该怎么实现?本文从哈希表的基本概念入手,详解哈希函数设计、哈希冲突解决策略,最终完整实现开放定址法(线性探测) 和链地址法(哈希桶) 两种哈希表,帮你吃透哈希表的底层实现逻辑。

一. 哈希表核心概念

1、哈希的本质

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

  • 哈希函数 :将 key 映射到哈希表的存储位置(下标),公式为h(key) = 存储位置
  • 核心目标 :让 key 均匀分布减少冲突,保证 O (1) 平均效率。

2、哈希冲突

两个不同的 key 通过哈希函数计算出相同的存储位置 ,称为哈希冲突哈希碰撞)。冲突无法避免,只能通过优化哈希函数和冲突解决策略减少影响。

3、负载因子

衡量哈希表拥挤程度的指标,公式为:负载因子(λ) = 存储的元素个数(N) / 哈希表大小(M)

  • λ 越大:冲突概率越高,空间利用率越高;
  • λ 越小:冲突概率越低,空间利用率越低;
  • 实践中:开放定址法 λ 通常控制在 0.7 以内,链地址法 λ 控制在 1 以内。

分析 :假如哈希表中已经映射存储了N个值,哈希表的大小为M,那么通过负载因子 = N/M ,负载因子有些地方也翻译为载荷因子/装载因子等,他的英文为 load factor。负载因子越大,哈希冲突的概率越高,空间利用率越高;负载因子越小,哈希冲突的概率越低,空间利用率越低。

4、将关键字转为整数

我们将关键字映射到数组中位置,一般是整数好做映射计算 (通过哈希函数),如果不是整数,我们要想办法转换成整数,这个细节我们后面代码实现中再进行细节展示。下面哈希函数部分我们讨论时,如果关键字不是整数,那么讨论的Key是关键字转换成的整数。

二. 哈希函数设计

好的哈希函数能让 key 均匀分布,减少冲突,但是实际中却很难做到,但是我们要尽量往这个方向去考量设计,常用设计方法如下:

1、直接定址法

直接用 key 或 key 的线性变换作为存储位置,公式:h(key) = a*key + b

  • 适用场景 :key 范围集中(如 0-99、a-z、A-Z等);
  • 优点:无冲突,效率高;
  • 缺点 :key 范围分散时浪费内存(如 key 为 1、10000,需开 10001 大小的数组)。

分析 :在关键字的范围比较集中 时,直接定值法就是非常高效的方法,比如一组关键字都在[0,99]之间,那么我们开一个100个数的数组,每个关键字1的值直接就算存储位置的下标。再比如一组关键字值都在[a,z]的小写字母,那么我们开一个26个数的数组,每个关键字ascill码 - a的ascii码就是存储位置的下标。也就是说直接定址法本质就是用关键字计算出一个绝对位置或者相对位置。这个方法我们在计数排序部分已经用过了,其次在 string 的学习中一道OJ也用过了:

示例:字符串中的第一个唯一字符

387. 字符串中的第一个唯一字符 - 力扣(LeetCode)

题目描述:

C++算法代码:

cpp 复制代码
class Solution {
public:
    int firstUniqChar(string s) 
    {
        int arr[26]; //存放相对于26个字母对应下标的数组
        for(auto ch : s)
        {
            arr[ch - 'a']++;
        } //计数排序的逻辑
        for(int i = 0; i < s.size(); i++)
        {
            if(arr[s[i] - 'a'] == 1)
            {
                return i;
            }
        }
        return -1;
    }
};

2、除法散列法(除留余数法)(重点)

1、除法散列法也叫做除留余数法,顾名思义,假设哈希表的大小为M,那么通过key除以M的余数作为映射位置的下标,也就是哈希函数为:h(key) = key % M

2、当使用除法散列法时,要尽量避免M为某些值 ,如2的幂,10的幂 等。如果是 2^X,key % 2^X 本质 相当于保留 key 二进制位的后X位 (后X位相同的值),计算出的哈希值都是一样的------就冲突了 。比如:{63,31}看起来没有关联的值,如果M是16,也就是2^4,那么计算出的哈希值都是15,因为63的二进制后8位是00111111,31的二进制后8位是00011111。如果是10^x,就更明显了,保留的都是10进值的后X位,如:[112,12312},如果M是100(10^2),计算出的哈希值都是12。

3、当使用除法散列法时,建议M取不太接近2的整数次幂的一个质数(素数)

那具体怎么取质数,在下面的哈希表扩容操作会进行讲解,而且我也会把二倍扩容的代码展示出来,虽然是要避免哈希表大小2的幂,但是为便于大家理解质数的扩容我也进行展示。

3、乘法散列法(了解即可)

乘法散列法对哈希表大小M没有要求,这里介绍一下大思路:

第一步:用关键字K乘上常数A(0 < A < 1),并抽取出k*A的小数部分;

第二步:后再用M乘以k * A的小数部分,再向下取整。

h(key) = floor(M * ((A * key) % 1.0)),其中floor表示对表达式进行下取整,A(0 , 1),%1.0是为了取小数,这里最重要的是A的值应该如何设定,Knuth------这又是一位大佬------他认为A = (5 - 1) / 2 = 0.6180339887...(黄金分割点)比较好。

乘法散列法对哈希表大小M是没有要求的,假设M为1024,key为1234,A = 0.6180339887,A * key = 762.6539420558,取小数部分为0.6539420558,M * ((A * key) % 1.0) = 0.6539420558*1024 = 669.6366651392,那么h(1234) = 669。

4、全域散列法(了解即可)

如果存在这样一个恶意的对手,他针对我们提供的散列函数,特意构造出一个发生严重冲突的数据集,比如,让所有关键字全部落入同一个位置中------这种情况是可以存在的,只要散列函数是公开且确定的,就可以实现此攻击。解决方法自然是见招拆招,给散列函数增加随机性,攻击者就无法找出确定可以导致最坏情况的数据。这种方法叫做全域散列。

hab(key) = ((a * key + 6) % P) % M,P需要选一个足够大的质数,a可以随机选[1 , P - 1]之间的任意整数,b可以随机选[0 , P - 1]之间的任意整数,这些函数构成了一个P * (P - 1)组全域散列函数组。假设P = 17,M = 6,a = 3,b = 4,则h34(8) = ((3 * 8 + 4) % 17) % 6 = 5。

需要注意的是每次初始化哈希表时,随机选取全域散列函数组中的一个散列函数使用,后续增删查改都固定使用这个散列函数,否则每次哈希都是随机选一个散列函数,那么插入是一个散列函数,查找又是另一个散列函数,就会导致找不到插入的key了。

5、其他方法(了解即可)

上面的几种方法是在《算法导论》这本书籍中讲解的方法。

《殷人昆数据结构:用面向对象方法与C++语言描述 (第二版)》和《[数据结构 (C语言版).严蔚

敏,吴伟民》等教材型书籍上面还给出了平方取中法折叠法随机数法数学分析法 等,这些方

法相对更适用于一些局限的特定场景,大家如果有兴趣可以去看看这些书籍。

三. 哈希冲突解决策略

实践中哈希表一般还是选择除法散列法作为哈希函数,当然哈希表无论选择什么哈希函数也避免不了冲突,因为冲突是避免不了的,我们只能减少冲突。冲突解决是哈希表实现的核心,主流分为 开放定址法链地址法,其中链地址法更加重要一点,下面分别详解实现。

1、开放定址法

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

1.1 线性探测 (含堆积问题)

我们先简单谈谈"堆积 / 群积问题":

如下图所示:

1.2 二次探测

1.3 双重探测

2、详解开放定址法代码

开放定址法在实践中是不如下面会介绍的链地址法的 ,因为开放定址法解决冲突不管使用哪种方法,占用的都是哈希表中的空间,始终存在互相影响的问题------正因如此,开放定址法我们简单选择线性探测实现即可。

2.1哈希表结构

cpp 复制代码
// 状态标识
enum State
{
	EMPTY, // 空位置 
	EXIST, // 已存储元素
	DELETE // 已删除元素
};

// 哈希表结点结构
template<class K, class V>
struct HashDate
{
	pair<K, V> _kv;       // 存储key-value对
	State _state = EMPTY; //初始状态为空
};

// 哈希函数仿函数
template<class K>
struct HashFunc
{
	size_t operator()(const K& key)
	{
		return (size_t)key; // 默认可支持直接转换
	}
};

// 开放定址法实现哈希表(线性探测)
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:

private:
	vector<HashDate<K, V>> _tables; // 哈希表数组
	size_t _n = 0; // 已存储的数据个数(作用:用于负载因子的计算,当负载因子不满足要求需要对_tables进行扩容)
};

要注意的是这里需要给每个存储值的位置加一个状态标识,否则删除一些值以后,会影响后面冲突的值的查找。如下图所示,我们删除30,会导致查找20失败,当我们给每个位置加一个状态标识**{EXIST,EMPTY,DELETE}** ,删除30就可以不用删除值 ,而是把状态改为DELETE,那么查找20时是遇到EMPTY才能,就可以找到20。

h(19) = 8,h(30) = 8,h(5) = 5,h(36) = 3,h(13) = 2,h(20) = 9,h(21) = 10,h(12) = 1。

2.2 扩容问题

这里我们哈希表负载因子控制在0.7,当负载因子到0.7以后我们就需要扩容了 ,我们还是按照2倍的方式扩容,但是同时我们要保持哈希表大小是一个质数 ,第一个是质数,2倍后就不是质数了。如何解决?

方案是SGI版本的哈希表使用的方法,给了一个近似2倍的质数表,每次去质数表获取扩容后的大小:

2.2.1 扩容处理(二倍扩容版本)
cpp 复制代码
// 开放定址法实现哈希表(线性探测)
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
	// 构造函数(二倍扩容版本)
	HashTable()
		:_tables(4)
	{ }

	// 插入 key-value对(去重)
	bool insert(const pair<K, V>& kv)
	{
		// 1.先查找,避免重复插入(如果存在则返回false)
		if (Find(kv.first))
		{
			return false;
		}

		// 2.负载因子 >=0.7,扩容
		if ((double)_n / (double)_tables.size() >= 0.7)
		{
			//创建一个HashTable对象而不是HashTable的vector对象原因是:
			//当扩容转移数据的时候,vector对象也需要执行下面的插入操作(代码冗余)
			//如果是HashTable对象我们就可以巧妙的调用自己的insert函数
			HashTable<K, V, Hash> newht;
			//二倍扩容版本:不推荐(冲突的情况更加频繁)
			newht._tables.resize(2 * _tables.size());

			// 3.迁移旧表元素到新表
			//这里是重点:哈希表的扩容转移数据和前面学习的vector等容器不一样
			//vector的扩容转移数据只需要扩容后直接拷贝即可
			//但是哈希表扩容后_tables.size()发生了改变,
			//相应的负载因子分母也就变大了,即所有数据的映射关系全部发生了改变,所以需要重新映射
			for (int i = 0; i < _tables.size(); i++)
			{
				if (_tables[i]._state == EXIST)
				{
					newht.insert(_tables[i]._kv);
					//这里有人看到函数自己调用自己就觉得是不是递归有没有问题
					//其实这里并不会一直递归下去,很显然newht的负载因子一定是小于0.7
					//所以每次for循环只会进行插入数据给newht
				}
			}
			// 4.交换新旧表
			_tables.swap(newht._tables);
		}

		// 5.线性探测找空闲位置插入数据
		//kv.first为整型:
		size_t hash0 = kv.first % _tables.size();

		// 线性探测
		size_t i = 1;
		size_t hashi = hash0;
		while (_tables[hashi]._state == EXIST)
		{
			// 冲突,线性探测下一个位置
			hashi = (hash0 + i) % _tables.size();
			i++;
		}
		// 6.插入元素
		_tables[hashi]._kv = kv;
		_tables[hashi]._state = EXIST;
		_n++; //插入数据后哈希表数组存储数据个数加1
		return true;
	}
private:
	vector<HashDate<K, V>> _tables;
	size_t _n = 0;
};
2.2.2 扩容处理(质数扩容版本)
cpp 复制代码
// 开放定址法实现哈希表(线性探测)
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
	// 构造函数
	HashTable()
		:_tables(__stl_next_prime(0))
	{ }

	// 插入 key-value对(去重)
	bool insert(const pair<K, V>& kv)
	{
		// 1.先查找,避免重复插入(如果存在则返回false)
		if (Find(kv.first))
		{
			return false;
		}

		// 2.负载因子 >=0.7,扩容
		if ((double)_n / (double)_tables.size() >= 0.7)
		{
			HashTable<K, V, Hash> newht;

			//质数扩容版本:推荐
			newht._tables.resize(__stl_next_prime(_tables.size() + 1));

			// 3.迁移旧表元素到新表
			for (int i = 0; i < _tables.size(); i++)
			{
				if (_tables[i]._state == EXIST)
				{
					newht.insert(_tables[i]._kv);
				}
			}
			// 4.交换新旧表
			_tables.swap(newht._tables);
		}

		// 5.线性探测找空闲位置插入数据
		//kv.first为整型:
		size_t hash0 = kv.first % _tables.size();

		// 线性探测
		size_t i = 1;
		size_t hashi = hash0;
		while (_tables[hashi]._state == EXIST)
		{
			// 冲突,线性探测下一个位置
			hashi = (hash0 + i) % _tables.size();
			i++;
		}
		// 6.插入元素
		_tables[hashi]._kv = kv;
		_tables[hashi]._state = EXIST;
		_n++;
		return true;
	}

private:
	vector<HashDate<K, V>> _tables;
	size_t _n = 0;
};

2.3 key不能取模的问题

当key是string / Date等类型时,key不能取模,我们需要给HashTable增加一个仿函数 ,这个仿函数支持把key转换成一个可以取模的整型 ,如果key可以直接转换为整型 并且不容易冲突,那么这个仿函数就用默认参数 即可,如果这个Key不能转换为整型 ,我们就需要自己实现一个仿函数 传给这个参数,实现这个仿函数的要求 就是尽量key的每值都参与到计算中,让不同的key转换出的整型值,以此避免冲突频繁。

cpp 复制代码
// 哈希函数仿函数
template<class K>
struct HashFunc
{
	size_t operator()(const K& key)
	{
		return (size_t)key; // 默认可支持直接转换
	}
};

// 特化string类型的哈希函数
template<>
struct HashFunc<string>
{
	// BKDR字符串哈希算法
	size_t operator()(const string& key)
	{
		size_t hash = 0;
		for (auto ch : key)
		{
			// 字符串转换成整形,可以把字符ascii码相加即可 
			// 但是直接相加的话,类似"abcd"和"bcad"这样的字符串计算出是相同的,冲突就会比较频繁
			// 这里我们使用BKDR哈希的思路,用上次的计算结果去乘以一个质数131
			hash += ch;// 累加字符ASCII码
			hash *= 131;// 乘质数131,减少冲突
		}
		return hash;
	}
};

开放定址法完整代码实现:

cpp 复制代码
#include<iostream>
#include<vector>
#include<string>
using namespace std;

// 状态标识
enum State
{
	EMPTY, // 空位置 
	EXIST, // 已存储元素
	DELETE // 已删除元素
};

// 哈希表结点结构
template<class K, class V>
struct HashDate
{
	pair<K, V> _kv;       // 存储key-value对
	State _state = EMPTY; //初始状态为空
};

// 质数表(SGI STL 同款,用于扩容)
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
};

inline unsigned long __stl_next_prime(unsigned long n)
{
	const unsigned long* first = __stl_prime_list;
	const unsigned long* last = __stl_prime_list + __stl_num_primes;
	// >= n
	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)
	{
		return (size_t)key; // 默认可支持直接转换
	}
};

// 特化string类型的哈希函数
template<>
struct HashFunc<string>
{
	// BKDR字符串哈希算法
	size_t operator()(const string& key)
	{
		size_t hash = 0;
		for (auto ch : key)
		{
			hash += ch;
			hash *= 131;
		}
		return hash;
	}
};

// 开放定址法实现哈希表(线性探测)
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
	//// 构造函数(二倍扩容版本)
	//HashTable()
	//	:_tables(4)
	//{ }

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

	// 插入 key-value对(去重)
	bool insert(const pair<K, V>& kv)
	{
		// 1.先查找,避免重复插入(如果存在则返回false)
		if (Find(kv.first))
		{
			return false;
		}

		// 2.负载因子 >=0.7,扩容
		if ((double)_n / (double)_tables.size() >= 0.7)
		{
			HashTable<K, V, Hash> newht;

			////二倍扩容版本:不推荐(冲突的情况更加频繁)
			//newht._tables.resize(2 * _tables.size());

			//质数扩容版本:推荐
			newht._tables.resize(__stl_next_prime(_tables.size() + 1));

			// 3.迁移旧表元素到新表
			for (int i = 0; i < _tables.size(); i++)
			{
				if (_tables[i]._state == EXIST)
				{
					newht.insert(_tables[i]._kv);
				}
			}
			// 4.交换新旧表
			_tables.swap(newht._tables);
		}

		// 5.线性探测找空闲位置插入数据
		////kv.first为整型:
		//size_t hash0 = kv.first % _tables.size();

		//kv.first为非整型类型(使用仿函数):
		Hash hs;
		size_t hash0 = hs(kv.first) % _tables.size();

		// 线性探测
		size_t i = 1;
		size_t hashi = hash0;
		while (_tables[hashi]._state == EXIST)
		{
			// 冲突,线性探测下一个位置
			hashi = (hash0 + i) % _tables.size();
			i++;
		}
		// 6.插入元素
		_tables[hashi]._kv = kv;
		_tables[hashi]._state = EXIST;
		_n++;
		return true;
	}

	// 查找key,返回节点指针(nullptr表示未找到)
	HashDate<K, V>* Find(const K& key)
	{
		////key为整型:
		//size_t hash0 = key % _tables.size();

		//kv.first为非整型类型(使用仿函数):
		Hash hs;
		size_t hash0 = hs(key) % _tables.size();

		// 线性探测
		size_t hashi = hash0;
		size_t i = 1;
		while (_tables[hashi]._state != EMPTY)
		{
			if (_tables[hashi]._state == EXIST && _tables[hashi]._kv.first == key)
			{
				return &_tables[hashi];
			}
			// 当前位置不符合要求,则线性探测下一个位置
			hashi = (hash0 + i) % _tables.size();
			i++;
		}
		return nullptr;
	}

	// 删除key(仅修改状态为DELETE,不实际删除元素,后续插入操作遇到DELETE状态直接覆盖即可)
	bool  Erase(const K& key)
	{
		HashDate<K, V>* ret = Find(key);
		if (ret)
		{
			ret->_state = DELETE;
			_n--;
		}
		return false;
	}

private:
	vector<HashDate<K, V>> _tables;
	size_t _n = 0;
};

测试代码

cpp 复制代码
#include"HashTable.h"

void test_hashtable1()
{
	HashTable<int, int> ht1;
	int arr[] = { 19, 30, 5, 36, 13, 20, 21, 12 };
	for (auto e : arr)
	{ 
		ht1.insert({ e, e });
	}

	cout << ht1.Find(5) << endl;
	cout << ht1.Find(20) << endl;

	ht1.Erase(5);

	cout << ht1.Find(5) << endl;
	cout << ht1.Find(20) << endl;

}

void test_hashtable2()
{
	HashTable<string, string> dict;
	dict.insert({ "string","字符串" }); // string正常无法直接取模,需要通过哈希函数转换成整型(需要手动实现仿函数)
	dict.insert({ "string","字符串1" });
	dict.insert({ "left","左边" });
	dict.insert({ "right","右边" });

	cout << dict.Find("string") << endl;
	cout << dict.Find("left") << endl;
	cout << dict.Find("insert") << endl;
}
int main() 
{
	cout << "测试一:删除5后再查找" << endl;
	test_hashtable1();
	cout << endl;
	cout << "测试二:测试string类型" << endl;
	test_hashtable2();
	return 0;
}

3、详解链地址法代码

3.1 对比开放定址法和链地址法

开放定址法:

链地址法:

3.2 哈希桶概念及其示例

开放定址法 中所有的元素都放到哈希表 里,链地址法 中所有的数据不再直接存储在哈希表 中,哈希表中存储一个指针 ,没有数据映射这个位置时,这个指针为空,有多个数据映射到这个位置 时,把这些冲突的数据链接成一个链表 ,挂在哈希表这个位置下面,像是一个个挂在晾衣杆的水桶,链地址法也叫做拉链法 或者哈希桶

以 { 19,30,5,36,13,20,21,12,24,96 } 这一组值为例,映射到M = 11的表中:

h(19) = 8,h(30) = 8,h(5) = 5,h(36) = 3,h(13) = 2,h(20) = 9,h(21) = 10,

h(12) = 1, h(24) = 2,h(96) = 88

3.3 扩容

开放定址法负载因子必须小于1,链地址法的负载因子就没有限制了,可以大于1。负载因子越大,哈希冲突的概率越高,空间利用率越高;负载因子越小,哈希冲突的概率越低,空间利用率越低。stl中unordered_xxx的最大负载因子基本控制在1(负载因子平均是1,但是这是理想的情况,当然没有那么平均,有的哈希桶不挂,有的挂2~3个),大于1就扩容。我们就以负载因子 == 1为条件进行扩容。

需要注意的是:链地址法的扩容和开放地址法的扩容实现有所区别。

链地址法完整代码实现:

cpp 复制代码
#include<iostream>
#include<vector>
#include<string>
using namespace std;

// 质数表(SGI STL 同款,用于扩容)
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
};

inline unsigned long __stl_next_prime(unsigned long n)
{
	const unsigned long* first = __stl_prime_list;
	const unsigned long* last = __stl_prime_list + __stl_num_primes;
	// >= n
	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)
	{
		return (size_t)key; // 默认可支持直接转换
	}
};

// 特化string类型的哈希函数
template<>
struct HashFunc<string>
{
	// BKDR字符串哈希算法
	size_t operator()(const string& key)
	{
		size_t hash = 0;
		for (auto ch : key)
		{
			// 字符串转换成整形,可以把字符ascii码相加即可 
			// 但是直接相加的话,类似"abcd"和"bcad"这样的字符串计算出是相同的,冲突就会比较频繁
			// 这里我们使用BKDR哈希的思路,用上次的计算结果去乘以一个质数131
			hash += ch;// 累加字符ASCII码
			hash *= 131;// 乘质数131,减少冲突
		}
		return hash;
	}
};

// 链地址法哈希表(哈希桶)
namespace hash_bucket
{
	// 哈希桶节点结构(链表节点)
	template<class K, class V>
	struct HashNode
	{
		pair<K, V> _kv;
		HashNode* _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()
		{
			for (size_t i = 0; i < _tables.size(); i++)
			{
				Node* cur = _tables[i];
				while (cur)
				{
					Node* next = cur->_next;
					_tables[i] = next;
					delete cur;
					cur = next;
				}
			}
			_n = 0;
		}

		//拷贝构造(深拷贝)
		HashTable(const HashTable<K, V>& ht)
		{
			this->_tables.resize(ht._tables.size());
			for (size_t i = 0; i < ht._tables.size(); i++)
			{
				Node* cur = ht._tables[i];
				while (cur)
				{
					insert(cur->_kv);
					cur = cur->_next;
				}
			}
		}

		void Swap(HashTable<K, V>& ht)
		{
			std::swap(_tables, ht._tables);
			std::swap(_n, ht._n);
		}

		//赋值重载operator=(现代写法)
		HashTable<K, V>& operator=(HashTable<K, V> ht)
		{
			if (this != &ht)
			{
				Swap(ht);
			}
			return *this;
		}
	
		// 插入key-value对(头插法,去重需先查找)
		bool insert(const pair<K, V>& kv)
		{
			if (Find(kv.first))
			{
				return false;
			}

			Hash hs;
			if (_n == _tables.size())
			{
				/*HashTable<K, V> newht;
				newht._tables.resize(2 * _tables.size());
				for (int i = 0; i < _tables.size(); i++)
				{
					Node* cur = _tables[i];
					while (cur)
					{
						newht.insert(cur);
						cur = cur->_next;
					}
				}
				_tables.swap(newht._tables);*/

				//迁移旧表节点到新表(直接移动节点,不新建,效率更高)
				vector<Node*> newtables(__stl_next_prime(_tables.size() + 1);
				for (int i = 0; i < _tables.size(); i++)
				{
					// 遍历旧表,旧表节点重新映射,挪动到新表
					Node* cur = _tables[i];
					while (cur)
					{
						//重新计算节点在新表的位置
						size_t hashi = hs(cur->_kv.first) % newtables.size();
						//头插入新表
						Node* next = cur->_next;
						cur->_next = newtables[hashi];
						newtables[hashi] = cur;
						cur = next;
					}//while循环结束说明第i位置的链表结点已经全部挪走了,需要置空
					_tables[i] = nullptr;
				}
				_tables.swap(newtables);
			}

			//头插入当前节点
			size_t hashi = hs(kv.first) % _tables.size();
			Node* newnode = new Node(kv);
			newnode->_next = _tables[hashi];//newnode的下一个位置指向hashi位置的头节点
			_tables[hashi] = newnode; //让newnode变为头节点
			_n++;
			return true;
		}

		// 查找key,返回节点指针(nullptr表示未找到)
		Node* Find(const K& key)
		{
			Hash hs;
			size_t hashi = hs(key) % _tables.size();
			Node* cur = _tables[hashi];
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					return cur;
				}
				cur = cur->_next;
			}
			return nullptr;
		}

		// 删除key(链表节点删除)
		bool Erase(const K& key)
		{
			Hash hs;
			size_t hashi = hs(key) % _tables.size();
			Node* cur = _tables[hashi];
			Node* prev = nullptr; //提前获取链表当前结点的上一个结点
			//用于判断删除的是头节点还是中间结点
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					if (prev == nullptr)
					{
						//prev为空说明删除链表头节点
						_tables[hashi] = cur->_next;
					}
					else
					{
						//prev不为空说明删除中间结点
						prev->_next = cur->_next;
					}
					delete cur;
					_n--;
					return true;
				}
				else
				{
					prev = cur;
					cur = cur->_next;
				}
			}
			return false;
		}

	private:
		vector<Node*> _tables;// 指针数组(存储每个链表头指针)
		size_t _n = 0;
	};
}

测试代码

cpp 复制代码
void test_hashbucket1()
{
	int arr2[] = { 19, 30, 5, 36, 13, 20, 21, 12, 24, 96 };
	hash_bucket::HashTable<int, int> ht2;
	for(auto e : arr2)
	{
		ht2.insert({ e, e });
	}

	//拷贝构造:
	hash_bucket::HashTable<int, int> ht3 = ht2;

	//赋值重载
	hash_bucket::HashTable<int, int> ht4;
	ht4 = ht3;

	cout << ht2.Find(96) << endl;
	cout << ht2.Find(30) << endl;
	cout << ht2.Find(19) << endl;

	ht2.Erase(96);
	ht2.Erase(30);
	ht2.Erase(19);

	cout << ht2.Find(96) << endl;
	cout << ht2.Find(30) << endl;
	cout << ht2.Find(19) << endl;
}

void test_hashbucket2()
{
	hash_bucket::HashTable<string, string> dict;
	dict.insert({ "string", "字符串" });
	dict.insert({ "string", "字符串1" });
	dict.insert({ "left", "左边" });
	dict.insert({ "right", "右边" });

	cout << dict.Find("string") << endl;
	cout << dict.Find("left") << endl;
	cout << dict.Find("insert") << endl;
}

int main() 
{
	cout << "哈希桶测试一:插入、查找、拷贝、赋值 + 删除" << endl;
	test_hashbucket1();
	cout << endl;
	cout << "哈希桶测试二:测试string类型" << endl;
	test_hashbucket2();
	return 0;
}

关键细节:

  • 节点迁移:扩容时直接移动旧表节点到新表,不新建节点,减少内存开销;
  • 链表操作:插入用头插法(效率高),删除需记录前驱节点;

开放定址法 VS 链地址法 两种实现对比

对比维度 开放定址法(线性探测) 链地址法(哈希桶)
空间利用率 较低(需预留空闲位置,装载因子 λ 通常 ≤ 0.7) 较高(冲突元素链成链表,装载因子 λ 可以 ≥ 1)
冲突处理 线性探测,易产生"一次群集"现象 链表存储,冲突元素被归入同一桶中,无群集问题
实现复杂度 较高(需处理状态标识、扩容迁移逻辑复杂) 较低(主要是链表操作,逻辑相对简单)
查找效率 平均O(1),最坏O(N)(群集严重时退化) 平均O(1),最坏O(k)(k为单个桶的链表长度)
适用场景 空间充足、数据量固定或可预测的场景 高频插入删除、数据量动态变化的场景(如C++ unordered_map/unordered_set)
缓存性能 更好(数据连续存储, locality 高) 较差(链表节点在内存中不连续,访问可能跳跃)
扩容操作 成本高(所有元素需要重新哈希并迁移到新表) 成本相对较低(只需重新哈希,节点可重新挂载)

结束语

到此,哈希表的实现就讲解完了。哈希表的核心是 "哈希函数 + 冲突解决":好的哈希函数保证 key 均匀分布,合理的冲突策略保证效率稳定。开放定址法适合空间充足的场景,链地址法因实现简单、无群集问题,成为工业级实现的首选(如 C++ 的 unordered_map / unordered_set)。

本文实现的两种哈希表,覆盖了哈希表的核心细节:质数扩容、字符串哈希、元素迁移、状态标识等。掌握这些细节后,不仅能理解 STL 容器的底层实现,更能根据实际场景选择合适的哈希表设计。希望对大家学习C++能有所收获!

C++参考文档:
https://legacy.cplusplus.com/reference/
https://zh.cppreference.com/w/cpp
https://en.cppreference.com/w/

相关推荐
Shadow(⊙o⊙)3 小时前
专题一双指针
数据结构
小辉同志3 小时前
739. 每日温度
c++·算法·leetcode
Via_Neo3 小时前
二进制枚举
数据结构·算法·leetcode
瑶总迷弟3 小时前
Python入门第6章:字典(键值对数据结构)
java·数据结构·python
羊小猪~~3 小时前
Redis学习笔记(数据类型、持久化、事件、管道、发布订阅等)
开发语言·数据库·c++·redis·后端·学习·缓存
春栀怡铃声4 小时前
常考排序的梳理
数据结构·算法·排序算法
第二只羽毛4 小时前
第六章 图
大数据·数据结构·算法·深度优先·图论·广度优先·宽度优先
小菜鸡桃蛋狗4 小时前
C++——模板
c++
程序喵大人4 小时前
C++依赖关系分析:5个工具理清模块关系
开发语言·c++