【C++】哈希表实现

目录

[一 哈希概念](#一 哈希概念)

[1 定义](#1 定义)

[2 直接定址法](#2 直接定址法)

[3 示例OJ题](#3 示例OJ题)

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

[5 除于散列法/除留余数法](#5 除于散列法/除留余数法)

[6 哈希冲突](#6 哈希冲突)

[7 乘法散列法(了解)](#7 乘法散列法(了解))

[8 全域散列法(了解)](#8 全域散列法(了解))

[二 处理哈希冲突](#二 处理哈希冲突)

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

(1)负载因子

(1)线性探测

(3)二次探测

(4)双重散列(了解)

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

(1)插入

(2)find函数

(3)扩容

(4)销毁

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


一 哈希概念

1 定义

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

2 直接定址法

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

3 示例OJ题

https://leetcode.cn/problems/first-unique-character-in-a-string/description/

cpp 复制代码
class Solution {
public:
 int firstUniqChar(string s) {
 // 每个字⺟的ascii码-'a'的ascii码作为下标映射到count数组,数组中存储出现的次数 
 int count[26] = {0};
 
 // 统计次数 
 for(auto ch : s)
 {
 count[ch-'a']++;
 }
 for(size_t i = 0; i < s.size(); ++i)
 {
 if(count[s[i]-'a'] == 1)
 return i;
 }
 return -1;
 }
};

4 哈希函数

一个优质的哈希函数,应能让 N 个关键字 以等概率的方式,均匀地散列分布 到哈希表的 M 个存储空间中。不过在实际场景中,要完全实现这一理想效果难度极大,但我们在设计哈希函数时,仍需朝着这个核心目标尽力考量与优化。

5 除于散列法/除留余数法

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

  • 当使用除法散列法时,要尽量避免M取某些值,比如2的幂、10的幂等。如果M是2的X次幂,那么key % M本质相当于保留key的后X位,那些后X位相同的值,计算出的哈希值都会一样,从而产生冲突。例如:{63, 31}这两个看似没有关联的值,如果M是16(即2的4次幂),计算出的哈希值都会是15------因为63的二进制后4位是00111111,31的二进制后4位是00011111。如果M是10的X次幂,情况就更明显了,此时保留的是key十进制的后X位,例如:{112, 12312}这两个值,如果M是100(即10的2次幂),计算出的哈希值都会是12。

  • 当使用除法散列法时,建议M取一个不太接近2的整数次幂的质数 (素数)。(但只是建议,不是规定)

  • 需要说明的是,实践中的用法可谓八仙过海、各显神通。Java的HashMap采用除法散列法时,就选择2的整数次幂作为哈希表的大小M。这样做的好处是无需进行取模运算,而是可以直接通过位运算实现,相对而言位运算比取模运算效率更高。但它并非单纯的取模,比如当M是2^16时,本质是取key的后16位,此时会先通过key' = key >> 16(将key右移16位),再把key和key'进行异或运算,最终的运算结果作为哈希值。也就是说,映射出的值依然在[0, M)范围内,但通过让key的所有位都参与计算,尽量使映射出的哈希值更均匀。所以,我们上面提到的"M取不太接近2的整数次幂的质数"这一理论,是大多数数据结构书籍中记载的经典理论,但在实践中,更需要灵活运用、抓住本质,而不能死读书。(了解)

6 哈希冲突

直接定址法的缺点同样十分显著:当关键字的取值范围较为分散时,会造成严重的内存浪费,甚至可能出现内存不足的情况。假设我们仅有 N 个取值范围在 [0, 9999] 内的数据,需要将其映射到一个包含 M 个存储空间的数组中(通常情况下 M ≥ N),这时就需要借助哈希函数(hash function)hf------关键字 key 会被存储到数组的 h(key) 位置,此处需注意,h(key) 计算得出的结果必须落在 [0, M) 区间内。

这一过程中存在一个问题:两个不同的 key 有可能被映射到数组的同一个位置,这种情况被称为哈希冲突,也叫哈希碰撞 。理想状态下,我们希望找到一个优质的哈希函数来避免冲突,但在实际应用场景中,冲突是无法完全避免的。因此,我们既要尽可能设计出性能优异的哈希函数以减少冲突发生的次数,同时也需要制定切实可行的冲突解决方案。

我们发现,19和30映射到M=11时,都对应8这个位置。但是不可能在一个位置存储两个所以30只能存储在9这个位置,这就叫做哈希冲突。而20映射完也是存储在9这个位置,以此类推,20存储在10这个位置。

但是如果哈希冲突过多,代码查找的效率就会变低,所以需要效率更高的方法,就用到了除于散列法

7 乘法散列法(了解)

  • 乘法散列法对哈希表大小M没有要求,其核心思路分为两步:第一步,用关键字K乘上常数A(0<A<1),并抽取出K×A的小数部分;第二步,用M乘以这个小数部分,再对结果进行向下取整。

  • 对应的哈希函数为:h(key) = floor(M × ((A × key) % 1.0)),其中floor表示对表达式进行下取整,A的取值范围是(0,1)。这里最关键的是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。

8 全域散列法(了解)

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

  • 全域散列的哈希函数定义为:hₐᵦ(key) = ((a × key + b) % P) % M。其中P需要选择一个足够大的质数,a可以随机选取[1, P-1]之间的任意整数,b可以随机选取[0, P-1]之间的任意整数,这些函数共同构成了一个包含P×(P-1)个函数的全域散列函数组。举个例子:假设P=17,M=6,a=3,b=4,那么h₃₄(8) = ((3×8 + 4) % 17) % 6 = 5。

-需要注意的是,每次初始化哈希表时,要从全域散列函数组中随机选取一个散列函数,后续的增、删、查、改操作都要固定使用这个选定的散列函数。否则,若每次哈希都随机选择一个散列函数,可能出现插入时用一个散列函数、查找时用另一个散列函数的情况,最终导致无法找到已插入的key。


二 处理哈希冲突

实践中,哈希表通常会选择除法散列法作为哈希函数。但无论选用何种哈希函数,哈希冲突都无法完全避免,因此在插入数据时需要专门的冲突解决方案。主要的解决方法有两种:开放定址法和链地址法。

1 开放定址法

开放定址法的核心是所有元素都直接存储在哈希表的数组空间中 。当关键字key通过哈希函数计算出的位置发生冲突时,会按照某种预设规则重新查找哈希表中未存储数据的空闲位置,将key存入其中。需要注意的是,开放定址法的负载因子一定小于1实现这一规则的常见方式有三种: 1. 线性探测 2. 二次探测 3. 双重探测

开放定址法的缺陷在于自己的位置被占了,就去占别人的位置。相邻位置的冲突会互相影响

当存储数据到达一种情况时,需要增容,但是这种情况不是全部存满(存满会导致冲突的概率变高),判断是否需要扩容就要用到负载因子

(1)负载因子

假设哈希表中已映射存储了 N 个数据,哈希表的大小为 M,那么负载因子(部分场景也翻译为载荷因子、装载因子,英文为 load factor)的计算公式为 α = N/M。 负载因子的大小与哈希冲突概率、空间利用率直接相关:

- 负载因子越大,哈希冲突的概率越高,哈希表的空间利用率也越高;

- 负载因子越小,哈希冲突的概率越低,哈希表的空间利用率也越低。

(1)线性探测
  • 线性探测的规则是:从发生冲突的初始位置(hash₀)开始,依次线性向后探测,直到找到下一个未存储数据的空闲位置为止;若探测到哈希表尾部仍未找到空闲位置,则回绕到哈希表的起始位置继续探测。

  • 假设通过哈希函数计算得到初始位置:h(key) = hash₀ = key % M,当hash₀位置发生冲突时,线性探测的公式为:h_c(key, i) = hash_i = (hash₀ + i) % M(其中i = {1, 2, 3, ..., M - 1})。由于开放定址法的负载因子小于1,哈希表中一定存在空闲位置,因此最多探测M - 1次,必然能找到可存储该key的位置。

  • 线性探测的优点是逻辑简单、易于实现,但也存在明显问题:假设hash₀位置发生连续冲突,且hash₀、hash₁、hash₂位置均已存储数据,那么后续映射到hash₀、hash₁、hash₂、hash₃的关键字都会争夺hash₃这个位置,这种多个关键字集中争夺连续哈希位置的现象叫做群集(也称为堆积)。后面要介绍的二次探测,能够在一定程度上改善这种群集问题。

如果我们设计成员变量如下:

就会出错:

如果我们删除数据30,然后find(20),显示当前位置为空,就会找不到,这就是哈希冲突带来的缺陷。而且还会有当前位置没有存储数据,就是为空的情况。所以我们要这样设计数据(如下)

插入代码如下:

cpp 复制代码
bool Insert(const pair<K, V> kv)
	{
		
		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;
		}

		_tables[hashi]._kv = kv;
		_tables[hashi]._state = EXIST;
		++_n;

		return true;
	}

但是不能让表变满,所以我们要引入扩容思路:

哈希表的扩容代价还是比较大的:不仅要扩容,而且扩容之后,数据的位置会发生改变

两种扩容思路:注释部分和没有注释部分·、

扩容代码如下:

cpp 复制代码
// 负载因子 >= 0.7就扩容
		if ((double)_n / (double)_tables.size() >= 0.7)
		{
			//std::vector<HashData> newtables(_tables.size()*2);
			//for (size_t i = 0; i < _tables.size(); i++)
			//{
			//	if (_tables[i]._state == EXIST)
			//	{
			//		// 重新映射到新表
			//		// ...
			//	}
			//}

			//_tables.swap(newtables);

			HashTable<K, V, Hash> newht;
			newht._tables.resize(__stl_next_prime(_tables.size()+1));
			for (size_t i = 0; i < _tables.size(); i++)
			{
				// 遍历旧表,旧表数据插入到newht
				if (_tables[i]._state == EXIST)
				{
					newht.Insert(_tables[i]._kv);
				}
			}

			_tables.swap(newht._tables);
		}

查找代码如下:

cpp 复制代码
HashData<K, V>* Find(const K& key)
	{
		Hash hs;
		size_t hash0 = hs(key) % _tables.size();
		// 线性探测
		size_t i = 1;
		size_t hashi = hash0;
		while (_tables[hashi]._state != EMPTY)
		{
			if (_tables[hashi]._state != DELETE &&
				_tables[hashi]._kv.first == key)
			{
				return &_tables[hashi];
			}

			hashi = (hash0 + i) % _tables.size();
			++i;
		}

		return nullptr;
	}

销毁代码如下:

cpp 复制代码
bool Erase(const K& key)
 	{
 		HashData<K, V>* ret = Find(key);
 		if (ret)
 		{
 			ret->_state = DELETE;
 			--_n;
 			return true;
 		}
 		else
 		{
 			return false;
 		}
 	}

将string转化为无符号整型:

cpp 复制代码
// 作用:将任意键类型K转换为size_t类型哈希值,适配哈希表桶位计算
template<class K>
struct HashFunc
{
    // 重载()运算符,使结构体可作为函数对象调用
    // 参数:const K& key - 待计算哈希的键(const引用避免拷贝,保证键不被修改)
    // 返回值:size_t - 无符号整数哈希值,用于哈希表桶位索引
    size_t operator()(const K& key)
    {
        // 整数类型直接转换为size_t:简洁高效,适配int、long、size_t等整数类型
        // 直接转换是整数作为键的最优方式,无额外计算开销
        return (size_t)key;
    }
};

// 模板特化:为string类型提供专用哈希算法(解决默认模板无法处理字符串的问题)
template<>
struct HashFunc<string>
{
    // 字符串专属哈希计算:将字符串内容映射为size_t哈希值
    size_t operator()(const string& key)
    {
        size_t hash = 0;  // 哈希值初始化为0
        // 遍历字符串每个字符,累积计算哈希值
        for (auto ch : key)
        {
            hash += ch;    // 累加当前字符的ASCII码值
            hash *= 131;   // 乘以131(经典质数基数),让哈希值分布更均匀,减少冲突
        }

        return hash;  // 返回最终计算的字符串哈希值
    }
};
```
(3)二次探测

・从发生冲突的位置开始,依次左右按二次方跳跃式探测,直到寻找到下一个没有存储数据的位置为止,如果往右走到哈希表尾,则回绕到哈希表头的位置;如果往左走到哈希表头,则回绕到哈希表尾的位置;

・hash0 位置冲突了,则二次探测公式为:h (key) = hash0 = key % Mhc (key,i) = hashi = (hash0 ± i²) % M,i = {1, 2, 3, ..., M/2}

・二次探测当 hashi = (hash0 − i²)% M 时,当 hashi<0 时,需要 hashi += M

・下面演示 {19,30,52,63,11,22} 等这一组值映射到 M=11 的表中。

(4)双重散列(了解)

• 第一个哈希函数计算出的值发生冲突,使用第二个哈希函数计算出一个跟key相关的偏移量值,不断往后探测,直到寻找到下一个没有存储数据的位置为止。

• hash0位置冲突了,则双重探测公式为: h1(key) = hash0 = key % M hc(key,i) = hashi = (hash0 + i ∗ h2(key)) % M,i = {1, 2, 3, ..., M}

• 要求h2(key) < M 且h2(key) 和M互为质数,有两种简单的取值方法:1、当M为2的整数幂时,h2(key) 从[0,M-1]任选一个奇数;2、当M为质数时,h2(key) = key % (M − 1) + 1

• 保证h2(key) 与M互质是因为根据固定的偏移量所寻址的所有位置将形成一个群,若最大公约数p = gcd(M, h2(key)) > 1,那么所能寻址的位置的个数为M/P < M,使得对于一个关键字来说无法充分利用整个散列表。举例来说,若初始探查位置为1,偏移量为3,整个散列表大小为12,那么所能寻址的位置为{1, 4, 7, 10},寻址个数为12/gcd(12, 3) = 4

• 下面演示 {19,30,52,74} 等这一组值映射到M=11的表中,设h2(key) = key%10 + 1

2 链地址法

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

哈希桶里,vector的本质是一个指针数组,是一个指向链表节点的指针

(1)插入

哈希桶里的数据谁在前谁在后是没有要求的:因为桶里面都是冲突的数据,对于冲突的数据,谁在前谁在后时没有影响的。

所以在这里实现的时候头插法更合适(尾插法还需要找到尾节点,且数据谁在前谁在后没有影响)

例如,我们要在5这个位置,在存储数据为5的节点前面插入存储数据为16的节点

cpp 复制代码
bool Insert(const pair<K, V>& kv)
{
    size_t hashi = kv.first % _tables.size();
    // 头插
    Node* newnode = new Node(kv);
    newnode->_next = _tables[hashi];
    _tables[hashi] = newnode;

    ++_n;
    return true;
}

那如若插入的时候,桶是空桶呢?这个代码也可以满足

(2)find函数
cpp 复制代码
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;
		}

我们可以在Insert中加入find部分,如果找到了,则不能插入

(3)扩容

哈希桶也是需要扩容的,那怎么判断满没满呢?通过控制负载因子

开放定址法负载因子必须小于1,链地址法的负载因子就没有限制了,可以大于1。负载因子越⼤,哈 希冲突的概率高,空间利用率越高;负载因子越小,哈希冲突的概率越低,空间利用率越低;stl中 unordered_xxx的最⼤负载因子基本控制在1,大于1就扩容,我们下面实现也使用这个方式。

方法一:新建哈希表+重新插入

cpp 复制代码
Hash hs;
// 负载因子 == 1就开始扩容
if (_n == _tables.size())
{
	HashTable<K, V> newht;
	newht._tables.resize(_tables.size()*2);
	for (size_t i = 0; i < _tables.size(); i++)
	{
	 // 遍历旧表,旧表数据插入到newht
	 Node* cur = _tables[i];
	 while(cur)
      {
			newht.Insert(cur->_kv);
			cur = cur->_next;
		}
	}

	_tables.swap(newht._tables);
}

但是这种方法的内存消耗较大

方法二:节点复用+素数容量

cpp 复制代码
Hash hs;
// 负载因子 == 1就开始扩容
if (_n == _tables.size())
{
 // 新建更大的哈希表数组(newtables)
  std::vector<Node*> newtables(__stl_next_prime(_tables.size()+1), nullptr);
  for (size_t i = 0; i < _tables.size(); i++)
  {
    // 取旧表第i个桶的链表头
    Node* cur = _tables[i];
    while (cur)  // 遍历这个桶的所有节点
    {
        // 1. 先保存当前节点的下一个节点(避免后续挪动节点后丢失链表)
        Node* next = cur->_next;

        // 2. 计算当前节点在新表中的哈希位置(桶的索引)
        size_t hashi = hs(cur->_kv.first) % newtables.size();
        // - % newtables.size():把哈希值映射到新表的桶索引范围内

        // 3. 头插法将当前节点插入新表的对应桶
        cur->_next = newtables[hashi];  // 新节点的next指向新桶的原有头节点
        newtables[hashi] = cur;         // 新桶的头节点更新为当前节点

        // 4. 遍历下一个旧节点
        cur = next;
    }
    // 5. 旧表的第i个桶已遍历完毕,置空(避免野指针)
    _tables[i] = nullptr;
}
  • __stl_next_prime(x):STL 中的工具函数,返回大于 x 的最小素数 (这里 x = 旧表长度 + 1)。
  • 为什么用素数?
    • 素数作为哈希表容量,能减少哈希值的 "聚集效应"(避免某些哈希值集中映射到同一个桶),进一步降低冲突。

所以,插入的完整代码变为:

(4)销毁
cpp 复制代码
bool Erase(const K& key)
		{
			Hash hs;
			size_t hashi = hs(key) % _tables.size();
			Node* prev = nullptr;
			Node* cur = _tables[hashi];
			while (cur)
			{
				if (cur->_kv.first == 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;
		}

3 key不能取模的问题

当key是string/Date等类型时,key不能取模,那么我们需要给HashTable增加⼀个仿函数,这个仿函数支持把key转换成⼀个可以取模的整形,如果key可以转换为整形并且不容易冲突,那么这个仿函数就用默认参数即可,如果这个Key不能转换为整形,我们就需要自己实现⼀个仿函数传给这个参数,实现这个仿函数的要求就是尽量key的每值都参与到计算中,让不同的key转换出的整形值不同。string 做哈希表的key非常常见,所以我们可以考虑把string特化⼀下。

cpp 复制代码
template<class K>
struct HashFunc
{
 size_t operator()(const K& key)
 {
 return (size_t)key;
 }
};

// 特化 
template<>
struct HashFunc<string>
{
 // 字符串转换成整形,可以把字符ascii码相加即可 
 // 但是直接相加的话,类似"abcd"和"bcad"这样的字符串计算出是相同的 
 // 这⾥我们使⽤BKDR哈希的思路,⽤上次的计算结果去乘以⼀个质数,这个质数⼀般去31, 131
等效果会⽐较好 
 size_t operator()(const string& key)
 {
 size_t hash = 0;
 for (auto e : key)
 {
hash *= 131;
 hash += e;
 }
 return hash;
 }
};

template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
private:
 vector<HashData<K, V>> _tables;
 size_t _n = 0; // 表中存储数据个数 
};
相关推荐
Elias不吃糖2 小时前
C++ 中“编译器自动帮我传参”和“我自己写初始化”的本质区别
c++
阿巴~阿巴~2 小时前
TCP服务器实现全流程解析(简易回声服务端):从套接字创建到请求处理
linux·服务器·网络·c++·tcp·socket网络编程
Elias不吃糖2 小时前
LeetCode每日一练(189, 122)
c++·算法·leetcode
赖small强2 小时前
【Linux C/C++开发】第20章:进程间通信理论
linux·c语言·c++·进程间通信
赖small强2 小时前
【Linux C/C++开发】第24章:现代C++特性(C++17/20)核心概念
linux·c语言·c++·c++17/20
-森屿安年-2 小时前
LeetCode 11. 盛最多水的容器
开发语言·c++·算法·leetcode
ouliten2 小时前
C++笔记:std::stringbuf
开发语言·c++·笔记
@卞3 小时前
高阶数据结构 --- 单调栈
数据结构
fashion 道格4 小时前
深入理解队列的艺术
数据结构·算法