【C++】数据结构之哈希表(散列表)

本篇文章主要讲解进阶数据结构之哈希表。


1 什么是哈希表

在了解什么是哈希表之前,我们先来了解一下什么是哈希。

哈希

哈希是英文单词 hash 的音译,hash 是把...弄遭、弄乱的意思,其实就代表着一个物体的状态是混乱的,哈希也是由此而来,所以哈希又称为散列

哈希是一种组织数据的方式。哈希就是通过一个哈希函数,将一个键值 key 转换为对应的索引,建立键值与索引之间的映射关系,这样就可以通过该映射关系,给你一个键值 key,采用 O(1) 时间复杂度快速找到索引,从而快速查找到对应的 value。比如之前做过的一道算法题,就用到了哈希的思想:

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

给定一个字符串 s ,找到 它的第一个不重复的字符,并返回它的索引 。如果不存在,则返回 -1

示例 1:

复制代码
输入: s = "leetcode"
输出: 0

示例 2:

复制代码
输入: s = "loveleetcode"
输出: 2

示例 3:

复制代码
输入: s = "aabb"
输出: -1

提示:

  • 1 <= s.length <= 105
  • s 只包含小写字母
cpp 复制代码
class Solution 
{
public:
    int firstUniqChar(string s) 
    {
        //创建一个数组来记录 s 中字符出现的次数
        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;
    }
};

在上面这道题中我们就采用了哈希的思想,其中的哈希函数就是 hash(ch) = ch - 'a',将一个字符 ch 映射为数组 count 的下标,完成对 ch 对应 value 的快速查找。

所以哈希更像是一种以空间换时间的思想,用额外的一些空间来达到 O(1) 时间复杂度的查找效率。


哈希表

哈希表正是利用了哈希的基本思想,用一个哈希函数 hash(key) 来计算 key 经过映射后的索引值,这样我们就可以快速完成插入和查找了。比如我们使用一个哈希函数 hash(key) = key % M,其中 M 是底层容器的大小,这里我们使用 vector 作为底层容器。假设 M 是 11,利用 hash(key) 计算索引插入 vector 的过程就是:

其中对应的容器(例如 vector)加上对应的查找规则(哈希函数)就构成了哈希表。

当然在上面那种情况中,我们发现 31、20、9 都插入到了 9 这个位置,这种由于哈希函数计算出相同的索引,导致多个值放到同一个位置的情况称之为冲突,这种冲突的产生是由于一个哈希函数将多个 key 值映射到了同一个索引,所以我们应该设计一个好的哈希函数来尽量避免冲突;另外,不管多好的哈希函数,都是不可能完全避免冲突的产生的,所以后面我们还需要一定的解决冲突的办法。综合上面两点,一个设计好的哈希表应该包含两个方面,一个就是好的哈希函数来尽量避免冲突,另一个就是有一个好的解决冲突的办法。

这里再提一嘴,之前我们在排序时接触过的技计数排序其实也是哈希思想的体现。在计数排序中哈希函数为 hash(key) = key - minnum,但是计数排序只适合于数据比较集中的情况,否则会特别浪费空间。


2 哈希函数

上面我们说一个好的哈希表应该包含一个好的哈希函数,用来尽量避免冲突的产生。常用的哈希函数主要包括除余散列法、乘法散列法、全域散列法、平方取中法、数学分析法等等。但是在日常使用中,我们主要还是使用除余散列法,其他的用的比较少。所以这里我们主要介绍前三种,剩下的大家感兴趣可以自主学习。

除余散列法

除余散列法又叫做除留余数法。这种方法就是我们上面采用的哈希函数,其核心数学公式为:

其中 M 为哈希表的大小

通过上面的例子,我们可以看到如果哈希表采用除留余数法作为哈希函数,那么其性能大部分是取决于 M 的大小的,如果 M 太小,那么冲突的概率会很高,如果 M 很大,又会浪费空间,所以 M 的选择很重要。

在实际应用中,我们一般将 M 设为远离 2^k 的质数。因为如果我们选取 2^k 作为 M 的大小,那么其实在取余数时,其余数就是二进制中的后 k 位。比如 M = 8,那么 9 % 8 = 1001 % 1000 = 001 = 1;再比如 10 % 8 = 1010 % 1000 = 010 = 2。所以如果取 2^k,其实得到的索引就是二进制的后 k 位,也就是说只有 key 的后 k 位参与了运算,key 的所有位数并不会参与运算,这其实就是增加了冲突的概率。所以我们一般选择远离 2^k 的质数,比如 11,53 等等。

但是在实际应用中,M 也不一定会完全取成远离 2^k 的质数。比如 Java 中的 HashMap 就使用了 2^k 作为 M,但是其会先对 key 进行扰动计算(高位与低位异或),使更多比特参与哈希值生成,再对 key 进行 2^k 取模,这样就充分利用了 key 的每一位,也会降低冲突的概率。

但是使用除余散列法时会有一个缺点,那就是被除数 key 必须为 size_t 类型,因为如果不为整形,那就无法进行模运算;而且如果是负数的话,那么取模出来依然是一个负数,放入底层结构 vector 时就会越界(下标从 0 开始),所以如果 key 为 string,我们就需要将其转换为 size_t 类型。

乘法散列法

乘法散列法是一种通过小数部分的均匀分布特性实现关键字均匀映射的哈希函数设计方法,其核心优势在于对哈希表的长度 M 没有特殊要求,且能有效避免除法散列法中因模数选择不当导致的冲突概率增加问题。

乘法散列法的核心数学公式为:

其中 A 为 (0, 1) 之间的一个小数常数,常取黄金分割点 ,floor 是向下取整的符号。(A * key) % 1.0 会得到一个小于 1 的小数,然后再乘以 M,再向取整,就得到了索引。

比如 key = 10,M = 8,A 取黄金分割点,那么 hash(10) = floor(8 * (6.1803) % 1.0) = floor(8 * 0.1803) = floor(1.4424) = 1。

乘法散列法的好处就是冲突概率跟哈希表的大小 M 没有关系,比除余散列法的冲突概率小很多。但是缺点也很明显,计算效率与实现复杂度会比除余散列法高很多,而且除余散列法在实际应用中的冲突概率还是可以接受的,所以一般都会选择除余散列法。

全域散列法

全域散列法主要是为了预防恶意攻击者攻击而存在的一种哈希函数。在传统的哈希方法中,哈希函数是固定的。如果攻击者提前知道了这个函数,就可以故意构造出一大批映射到同一个地址的关键字,也就是哈希碰撞攻击。然后哈希表中的冲突概率就会急剧增加,导致哈希表的性能大幅下降。

所以全域散列法就会构造一大批散列函数,称为散列函数族。其核心数学公式为

其中 P 是一个很大的质数,a 在 [1, P - 1] 中随机选择一个数字,b 在 [0, P - 1] 中随机选择一个数字,这样 a,b 不同,哈希函数就不同,a,b 一共能构成 P* (P-1) 个函数,所以上面那个哈希函数就构成了一个哈希函数族。

总之,全域散列法是一种通过随机化策略来防御哈希冲突的哈希函数设计思想。与前几种固定的哈希算法不同,它的核心在于随机选择。在程序运行时,从一个精心设计的哈希函数族中随机挑选一个函数来映射数据。这种方法能有效对抗恶意攻击者精心构造的冲突数据,保护了哈希函数的性能。


3 解决哈希冲突

不管是用上面的哪种哈希函数,当数据量很大时,难免会发生冲突。所以我们还需要一个处理冲突的方法来解决哈希表中的冲突。解决冲突的方法一共有两种:开放定址法以及链地址法

开放定址法

开放定址法,也叫闭散列法,顾名思义,就是所有的元素都要呆在哈希表内部。当发生冲突时,会寻找下一个哈希表为空的位置,然后将数据放进去。其核心数学公式为:

其中 M 就是哈希表的大小;d_i 为发生冲突时的增量序列,也就是发生冲突时将当前数据放在相对于冲突位置的下一个位置的增量。比如 M = 11,key = 20,经过 hash(20) = 20 % 11 = 9,得到了索引 9 之后,但是 9 位置已经有数据了,但是 10 位置没有数据,就可以将 20 放在 10 位置,此时的 d_i = 1。

开放定址法主要有三种,接下来我们一一进行讲解。不过在讲解三种方法之前,我们先了解一个负载因子的概念。

负载因子

负载因子,也叫做载荷因子、装载因子,英文名称为 load facto。负载因子是指数据量 N 和哈希表容量 M 之间的比值,即 N/M,其描述了一个哈希表内部的空间利用率,也描述了哈希表内部发生冲突的概率大小。比如,哈希表容量为 10,但是里面已经有 7 个元素了,负载因子就为 0.7。当负载因子很小时,哈希表内部比较空旷,此时发生冲突的概率比较低,但同时空间利用率就会很小;当负载因子很大是,哈希表内部比较拥挤,虽然空间利用率大了,但是发生冲突的概率也会响应的变得很高。所以负载因子是平衡哈希表空间利用率与性能效率的杠杆,在设计时,我们应该给一个合适的负载因子,既要考虑性能又要考虑空间利用率。

线性探测法

线性探测法就是从冲突的位置开始,一直向后线性探测,直到找到下一个没有存储的位置为止,如果走到了哈希表的结尾,那么就返回哈希表的头部再线性寻找。所以其核心数学公式为:

上面那个哈希表如果采用线性探测法解决冲突的过程如下:

可以看到线性探测法其实就是占据了本来属于别人的位置,使得本来属于那个位置的元素又不得不去占据别的元素的位置;当一个哈希表中 hash0、hash1、hash2 都有元素时,后续映射到 hash0、hash1、hash2 的元素都会去争夺 hash3 这个位置,就会导致一个位置发生冲突,其周围的位置都会很快被占满,这样的现象称为群集或者堆积,会大大降低哈希表的效率。产生这种现象的原因就是哈希表的负载因子太高了,整个哈希表很拥挤,所以线性探测法的负载因子不应该太高,一般设为 2/3 或者 0.7、0.8 左右。

二次探测法

二次探测法为了解决线性探测法聚集的问题,将探测方式变为了跳跃式探测,即 di = 0,1^2,-1^2,2^2,-2^2,......,(M/2)^2,-(M/2)^2。其核心数学公式为:

如果 hashi < 0 了,那么 hashi 需要加上 M。上面那个哈希表根据二次探测解决冲突的过程如下:

虽然二次探测解决了聚集的问题,但是容易产生二次聚集的问题,也就是相同冲突位置的元素还是采用相同的路径进行探测。

双重散列法

双重散列法为了彻底解决上面的冲突问题,当发生冲突时,其偏移量是使用另一个哈希函数 hash2(key) 计算出来,也就是 di = i * hash2(key)。其核心数学公式为:

其中hash2(key) 与 M 一般互为质数,有两种取法:(1)M 取 2^k,hash2(key) 在 [0, M-1] 之间随机选择一个奇数(2)M 取一个质数,h2(key) = key % (M - 1) + 1。如果按照第二种方法,M = 11,hash2(key) = key % 10 + 1,那么上面那个哈希表按照双重散列法解决冲突的过程为:

其中蓝色代表进行了一次探测,紫色代表进行了两次探测。

虽然双重探测可以有效减免聚集现象的产生,但是还是会占用其他元素的空间,使得本来不冲突的元素也发生冲突。所以不管是上面哪种开放定址法,都会产生相互影响。所以我们主要采用后面的链地址法来解决冲突

使用线性探测法实现哈希表

在使用线性探测法实现哈希表之前,我们需要注意几个点。

哈希表大小的选择以及扩容注意事项

哈希表的底层结构我们选择的是 vector,也就是之前在继承章节了解过的组合形式。我们说哈希表的大小最好选成一个质数,所以这里我们可以参考一下库中的实现方法。库中用了一个全是质数的数组,而且后一个差不多是前一个的两倍:

cpp 复制代码
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;
	const unsigned long* pos = std::lower_bound(first, last, n);
	return pos == last ? *(last - 1) : *pos;
}

其中核心函数就是 **unsigned long __stl_next_prime(unsigned long n)**函数,这个函数用到了库中的 lower_bound 函数,这个函数与我们在 map 和 set 中用到了 lower_bound 函数相同,只要你给他一个迭代器区间和一个值 n,他就会帮你返回容器中第一个 >= n 位置的迭代器。所以 __stl_next_prime 函数的核心作用就是返回 __stl_prime_list 数组中 >= n 的第一个值。

我们在扩容时就可以利用这个函数,每次扩容只要将大小设置为 __stl_next_prime(ht.size() + 1) 即可。另外,在扩容之后需要注意一点:要对哈希表内的数据重新计算映射关系,因为哈希表的大小变了,索引也会改变。所以对于哈希表来说,扩容的代价是比较大的,不仅要遍历哈希表,还要重新计算映射索引。

哈希表中如何删除元素

删除哈希表中的元素时,我们不能将一个元素直接删除因为会出现后面的元素找不到的情况

比如图中直接删除了 9 这个元素,导致哈希表中索引 9 的位置为空了,那么我们查找 20 这个元素时,由于 20 的索引也为 9,但是 9 索引位置为空了,此时就会判断 20 找不到,但实际上 20 是存在的。

为了防止上述情况的发生,我们可以为每一个哈希表位置设置一个标记,{EMPTY, EXIST, DELETE},此时 9 就不是空了,而是 DELETE 标记,但是还是存在的,只有找到 EMPTY 标记才代表真的为空。

将键值转为整数

我们在哈希表中采用的除留余数法哈希函数,但是除留余数法只能对整数进行取模,而且必须是 0 或者正整数取模才可以,那么当键值是负数或者不能转换为整数类型怎么办呢?其实,我们只需要在 HashTable 中添加一个 Hash 模板参数,传递将 key 值转换为整数的仿函数就可以了。

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

//由于在日常生活中 string 作为键值非常常用,所以这里将模板进行特化
template<>
struct HashFunc<std::string>
{
	size_t operator()(const std::string& key)
	{
		//这里对 string 进行了 BKDR_Hash 算法,避免不同字符串之间产生相同的整形值
		size_t hash = 0;
		for (auto ch : key)
		{
			hash *= 131;
			hash += ch;
		}

		return hash;
	}
};

代码实现

cpp 复制代码
//HashTable.hpp

#pragma once

#include <iostream>
#include <string>
#include <vector>

//库中的计算 hashtable 容量大小的数组及函数
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;
	const unsigned long* pos = std::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<std::string>
{
	size_t operator()(const std::string& key)
	{
		//这里对 string 进行了 BKDR_Hash 算法,避免不同字符串之间产生相同的整形值
		size_t hash = 0;
		for (auto ch : key)
		{
			hash *= 131;
			hash += ch;
		}

		return hash;
	}
};

namespace open_address
{
	enum State
	{
		EXIST,
		EMPTY,
		DELETE
	};

	template<class K, class V>
	struct HTData
	{
		std::pair<K, V> _data;
		State _state = EMPTY; // 需要一个字段来标记当前位置的状态
	};

	template<class K, class V, class Hash = HashFunc<K>>
	class HashTable
	{
	public:
		HashTable(size_t size = __stl_next_prime(0))
			:_ht(size)
			,_n(0)
		{}

		bool Insert(const std::pair<K, V>& kv)
		{
			//不能插入相同值
			if (Find(kv.first) >= 0)
				return false;

			//0.如果负载因子超过了 0.7,那么就对当前的 hash 表进行扩容
			if ((double)_n / (double)_ht.size() >= 0.7)
			{
				//扩容到下一个质数
				HashTable<K, V> newht(__stl_next_prime(_ht.size() + 1));

				//复用当前 Insert
				for (size_t i = 0; i < _ht.size(); i++)
				{
					//只插入当前状态为存在的值
					if (_ht[i]._state == EXIST)
					{
						newht.Insert(_ht[i]._data);
					}
				}

				//将当前表与新表交换
				_ht.swap(newht._ht);
			}

			Hash hs;
			//1. 根据哈希函数计算出当前 key 所存放的索引
			size_t hashi = hs(kv.first) % _ht.size();
			//2. 如果索引位置已经存在数据,那就进行线性探测
			if (_ht[hashi]._state == EXIST)
			{
				while (_ht[hashi]._state == EXIST)
				{
					++hashi;
					hashi %= _ht.size();
				}
			}

			_ht[hashi]._data = kv;
			_ht[hashi]._state = EXIST;
			++_n;

			return true;
		}

		bool Erase(const K& key)
		{
			int hashi = Find(key);
			if (hashi == -1)
				return false;

			_ht[hashi]._state = DELETE;
            --_n;
			return true;
		}

		int Find(const K& key)
		{
			Hash hs;
			//1.  根据哈希函数计算出当前 key 所存放的索引
			size_t hashi = hs(key) % _ht.size();
			//2. 如果索引位置已经存在数据,那就进行线性探测
			while (_ht[hashi]._state != EMPTY)
			{
				if (_ht[hashi]._state == EXIST && _ht[hashi]._data.first == key)
					return hashi;

				++hashi;
				hashi %= _ht.size();
			}

			return -1;
		}

	private:
		std::vector<HTData<K, V>> _ht;
		size_t _n;
	};

	void Test_Hashtable01()
	{
		HashTable<int, int> ht(11);
		ht.Insert({ 1, 1 });
		ht.Insert({ 3, 1 });
		ht.Insert({ 9, 1 });
		ht.Insert({ 13, 1 });
		ht.Insert({ 20, 1 });
		ht.Insert({ 31, 1 });
		ht.Insert({ 5, 1 });
		ht.Insert({ 70, 1 });
		ht.Insert({ 28, 1 });
		ht.Insert({ 50, 1 });
		ht.Insert({ 51, 1 });
		ht.Insert({ 59, 1 });

		int ret = ht.Find(31);
		std::cout << ret << std::endl;
		ret = ht.Find(50);
		std::cout << ret << std::endl;

		ht.Erase(20);
		std::cout << ht.Find(20) << std::endl;
		ret = ht.Find(31);
		std::cout << ret << std::endl;
	}

	void Test_Hashtable02()
	{
		HashTable<std::string, std::string> ht;
		ht.Insert({"left", "左边"});
		ht.Insert({"right", "右边"});
		ht.Insert({"sort", "排序"});
		ht.Insert({"algorithm", "算法"});

		int ret = ht.Find("left");
		std::cout << ret << std::endl;
		ret = ht.Find("right");
		std::cout << ret << std::endl;

		ht.Erase("left");
		std::cout << ht.Find("left") << std::endl;
	}
}

链地址法

链地址法主要是利用了链表这一数据结构来解决了冲突的问题。此时,哈希表内部不再存储真正的元素,而是存储单链表的头节点指针,每个哈希表节点连接的单链表才是真正存储数据的数据结构。所以如果采用链地址法,上面的哈希表就会变成:

然后查找也很简单:

所以链地址法就解决了元素之间相互影响。根据上面的逻辑结构,链地址法也有两个比较形象的别名 --- 拉链法和哈希桶。

但是哈希桶有一个缺点,就是极端情况下一个桶下面的单链表可能会很长,会影响哈希表的查找效率,所以此时我们就可以将一个桶下面的单链表变为红黑树,这样查找效率就会大大提高(Java 的 HashMap 就是这样做的)。

链地址法实现哈希表

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <vector>

//库中的计算 hashtable 容量大小的数组及函数
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;
	const unsigned long* pos = std::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<std::string>
{
	size_t operator()(const std::string& key)
	{
		//这里对 string 进行了 BKDR_Hash 算法,避免不同字符串之间产生相同的整形值
		size_t hash = 0;
		for (auto ch : key)
		{
			hash *= 131;
			hash += ch;
		}

		return hash;
	}
};


namespace Bucket
{
	template<class K, class V>
	struct HTNode
	{
		std::pair<K, V> _kv;
		HTNode<K, V>* _next;//需要有指向下一个节点的指针

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

	template<class K, class V, class Hash = HashFunc<K>>
	class HashTable
	{
		//using 的另一用法,类似于 typedef
		using Node = HTNode<K, V>;
	public:
		HashTable(size_t size = __stl_next_prime(0))
			:_tables(size)
			, _n(0)
		{}

		bool Insert(const std::pair<K, V>& kv)
		{
			//不能插入相同值
			if (Find(kv.first))
				return false;

			Hash hs;
			//0.如果负载因子为1,那么就对当前的 hash 表进行扩容
			if (_n == _tables.size())
			{
				//将原哈希表中的所有节点移到新的哈希表中
				HashTable newht(__stl_next_prime(_tables.size() + 1));
				for (size_t i = 0; i < _tables.size(); i++)
				{
					Node* cur = _tables[i];
					Node* next = nullptr;
					while (cur)
					{
						next = cur->_next;
						int hashi = hs(cur->_kv.first) % newht._tables.size();
						cur->_next = newht._tables[hashi];
						newht._tables[hashi] = cur;

						cur = next;
					}

					_tables[i] = nullptr;
				}

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

			//1. 根据哈希函数计算出当前 key 所存放的索引
			size_t hashi = hs(kv.first) % _tables.size();
			//2. 新添加一个节点,将该节点头插到对应的单链表中
			Node* newnode = new Node(kv);
			Node* cur = _tables[hashi];
			newnode->_next = cur;
			_tables[hashi] = newnode;
			++_n;

			return true;
		}

		bool Erase(const K& key)
		{
			if (!Find(key))
				return false;

			//删掉该节点
			Hash hs;
			//1.  根据哈希函数计算出当前 key 所存放的索引
			size_t hashi = hs(key) % _tables.size();
			//2. 遍历当前单链表
			Node* prev = nullptr;
			Node* cur = _tables[hashi];
			while (cur)
			{
				if (cur->_kv.first == key)
					break;

				prev = cur;
				cur = cur->_next;
			}

			if (prev == nullptr)
			{
				//cur 是头节点
				_tables[hashi] = cur->_next;
			}
			else
			{
				prev->_next = cur->_next;
			}

			delete cur;
			--_n;

			return true;
		}

		Node* Find(const K& key)
		{
			Hash hs;
			//1.  根据哈希函数计算出当前 key 所存放的索引
			size_t hashi = hs(key) % _tables.size();
			//2. 遍历当前单链表
			Node* cur = _tables[hashi];
			while (cur)
			{
				if (cur->_kv.first == key)
					return cur;
				cur = cur->_next;
			}

			return nullptr;
		}

	private:
		std::vector<Node*> _tables;
		size_t _n;
	};

	void Test_Hashtable01()
	{
		HashTable<std::string, std::string> ht;
		ht.Insert({ "left", "左边" });
		ht.Insert({ "right", "右边" });
		ht.Insert({ "sort", "排序" });
		ht.Insert({ "algorithm", "算法" });

		auto ret = ht.Find("left");
		if (ret)
			std::cout << ret->_kv.first << ":" << ret->_kv.second << std::endl;
		else
			std::cout << "Not Find" << std::endl;
		ret = ht.Find("right");
		if (ret)
			std::cout << ret->_kv.first << ":" << ret->_kv.second << std::endl;
		else
			std::cout << "Not Find" << std::endl;

		ht.Erase("left");
		ret = ht.Find("left");
		if (ret)
			std::cout << ret->_kv.first << ":" << ret->_kv.second << std::endl;
		else
			std::cout << "Not Find" << std::endl;
	}

	//测试扩容
	void Test_Hashtable02()
	{
		HashTable<int, int> ht(11);
		const size_t N = 20;
		srand(time(nullptr));
		for (int i = 0; i < N; i++)
		{
			int x = rand();
			//打个条件断点,看一下扩容执行情况
			if (i == 11)
			{
				ht.Insert({ x, x });
				continue;
			}

			ht.Insert({ x, x });
		}
	}
}

总结

哈希表是一个具有 O(1) 查找时间复杂度的数据结构,也是 unordered_map 与 unordered_set 的底层实现结构。一个好的哈希表要具有好的哈希函数以及解决冲突的方法,在日常使用中,最常使用的哈希函数就是除余散列法,最常使用的解决冲突的方法就是哈希桶,stl 中的 unordered_map 与 unordered_set 也是使用哈希桶实现的。

相关推荐
仰泳之鹅12 小时前
【C语言】动态内存管理
c语言·数据结构·算法
LB211212 小时前
C++通讯录课设(西安石油大学)
开发语言·c++·算法
王老师青少年编程13 小时前
2026年全国青少年信息素养大赛初赛真题(算法应用主题赛C++初中组初赛真题1:文末附答案和解析)
c++·真题·全国青少年信息素养大赛·初赛·2026年·算法应用主题赛·初中组
_Evan_Yao13 小时前
数据结构太难了?用画图的方式理解链表和栈和树和图
数据结构·学习·链表
草莓熊Lotso15 小时前
【Linux系统加餐】从原理到封装:基于建造者模式实现System V信号量工业级C++封装
android·linux·运维·服务器·网络·c++·建造者模式
kyle~1 天前
机器视觉---熔池相机(穿透强光的视觉感知)
c++·数码相机·计算机视觉·机器人·焊接机器人
宏笋1 天前
C++ Coroutines(协程) 详解
c++
王老师青少年编程1 天前
csp信奥赛C++高频考点专项训练之前缀和&差分 --【一维前缀和】:求区间和
c++·前缀和·csp·高频考点·信奥赛·求和区间和
kyle~1 天前
机器人时间链路---工程流程示例
c++·3d·机器人·ros2