目录

引言

一. 哈希表的概念

1.1 直接定址法

1.2 哈希冲突

1.3 负载因子(load factor)

1.4 将关键字转为整数

二. 哈希函数

2.1 除法散列法

2.2 乘法散列法

2.3 全域散列法

三. 处理哈希冲突

3.1 开放定址法

3.1.1 线性探测

3.1.2 二次探测

3.1.2 双重探测

3.2 开放定址法代码实现

3.2.1 开放定址法的哈希表结构

3.2.2 扩容

3.2.3 key不能取模的问题

3.2.4 插入 

3.2.5 完整代码实现

3.3 链地址法

3.4 链地址法代码实现

3.4.1 链地址法的哈希表的结构

3.4.2 扩容

3.4.3 插入

3.4.4 完整代码实现

四. 红黑树 VS 哈希表

总结


引言

        哈希表(Hash Table)是现代软件系统中应用最广泛的数据结构之一,它以近似常数时间的查找、插入和删除效率而闻名。无论是编程语言标准库中的 unordered_map、数据库索引、缓存系统,还是编译器符号表,其底层几乎都离不开哈希表。

        本文围绕哈希表的核心原理、哈希函数、冲突处理策略,并结合 C++ 模板技术详细展示开放定址法与链地址法的完整实现,从原理到实践全面掌握哈希表的构建方法


一. 哈希表的概念

哈希表(Hash Table),又称散列表,是一种基于哈希函数的数据结构,用于高效地存储和检索键值对(Key-Value)。其核心思想是通过哈希函数将任意键映射到固定大小的数组(哈希表)中的一个位置,从而实现平均时间复杂度为O(1)的插入、查找和删除操作。哈希表广泛应用于数据库索引、缓存系统(如Redis)、编程语言的字典(如Python的dict、C++的unordered_map)等场景。然而,哈希表并非完美无缺。键的分布可能导致“哈希冲突”(多个键映射到同一位置),这需要通过巧妙的冲突解决策略来处理。


1.1 直接定址法

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

字符串中的第一个唯一字符

class Solution {
public:
    int firstUniqChar(string s) {
        int count[26]={0};
        int i=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;
    }
};

1.2 哈希冲突

直接定址法的缺点也非常明显,当关键字的范围比较分散时,就很浪费内存甚至内存不够用。假设我们只有数据范围是[0,9999]的N个值,我们要映射到一个M个空间的数组中(一般情况下M >= N),那么就要借助哈希函数(hash function)hf,关键字key被放到数组的h(key)位置,这里要注意的是h(key)计算出的值必须在[0,M)之间。这⾥存在的⼀个问题就是,两个不同的key可能会映射到同⼀个位置去,这种问题叫做哈希冲突, 或者哈希碰撞。理想情况是找出一个好的哈希函数避免冲突,但是实际场景中,冲突是不可避免的, 所以我们尽可能设计出优秀的哈希函数,减少冲突的次数,同时也要去设计出解决冲突的方案。


1.3 负载因子(load factor)

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


1.4 将关键字转为整数

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


二. 哈希函数

一个好的哈希函数应该让N个关键字被等概率的均匀的散列分布到哈希表的M个空间中,但是实际中很难做到,但是我们要尽量往这个方向去考量设计。

2.1 除法散列法

  • 除法散列法也叫做除留余数法,顾名思义,假设哈希表的大小为M,那么通过key除以M的余数作为映射位置的下标,也就是哈希函数为:h(key) = key % M。
  • 当使用除法散列法时,要尽量避免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
  • 当使用除法散列法时,建议M取不太接近2的整数次幂的一个质数(素数)。
  • 需要说明的是,Java的HashMap采用除法散列法时就是2的整数次幂做哈希表的大小M,这样的话,就不用取模,而可以直接位运算,相对而言位运算比模更高效一些。但是他不是单纯的去取模,比如M是2^16次方,本质是取后16位,那么用key’ = key>>16,然后把key和key'异或的结果作为哈希值。也就是说我们映射出的值还是在[0,M)范围内,但是尽量让key所有的位都参与计算,这样映射出的哈希值更均匀一些即可。

2.2 乘法散列法

  • 乘法散列法对哈希表大小M没有要求,他的大思路第一步:用关键字K乘上常数A(0<A<1),并抽取出 k*A 的小数部分。第二步:再用M乘以k*A的小数部分,再向下取整。
  • h(key) = floor(M × ((A × key)%1.0)) ,其中floor表示对表达式进行下取整,A∈(0,1),这里最重要的是A的值应该如何设定,Knuth认为0.6180339887.... (黄金分割点) 比较好。
  • 乘法散列法对哈希表大小M是没有要求的,假设M为1024,key为1234,A = 0.618033988,A*key = 762.6539420558,取小数部分为0.6539420558,M×((A×key)%1.0) =  0.6539420558*1024 = 669.6366651392,那么h(1234) = 669。

2.3 全域散列法

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

三. 处理哈希冲突

实践中哈希表一般还是选择除法散列法作为哈希函数,当然哈希表无论选择什么哈希函数也避免不了冲突,那么插入数据时,如何解决冲突呢?主要有两种方法:开放定址法链地址法

3.1 开放定址法

解决冲突的思路:

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


3.1.1 线性探测

  • 从发生冲突的位置开始,依次线性向后探测,直到寻找到下一个没有存储数据的位置为止,如果走到哈希表尾,则回绕到哈希表头的位置。
  • h(key) = hash0 =  key % M,hash0位置冲突了,则线性探测公式为:hc(key,i) = hashi = (hash0 + i) % M, i  =  {1, 2, 3, ..., M − 1},因为负载因子表于1,则最多探测M-1次,一定能找到一个存储key的位置。 
  • 线性探测的比较简单且容易实现,线性探测的问题假设,hash0位置连续冲突,hash0,hash1, hash2位置已经存储数据了,后续映射到hash0,hash1,hash2,hash3的值都会争夺hash3位 置,这种现象叫做群集/堆积。下面的二次探测可以⼀定程度改善这个问题。

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


3.1.2 二次探测

  • 从发生冲突的位置开始,依次左右按二次方跳跃式探测,直到寻找到下一个没有存储数据的位置为 止,如果往右走到哈希表尾,则回绕到哈希表头的位置;如果往左走到哈希表头,则回绕到哈希表 尾的位置。
  • h(key) = hash0 =  key % M,hash0位置冲突了,则二次探测公式为:hc(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的表中:


3.1.2 双重探测

  • 第一个哈希函数计算出的值发生冲突,使用第二个哈希函数计算出一个跟key相关的偏移量值,不 断往后探测,直到寻找到下一个没有存储数据的位置为止。
  • h1 (key) = hash0 =  key % M,hash0位置冲突了,则双重探测公式为: hc(key,i) = hashi = (hash0 +  i ∗ h2 (key)) % M,i  =  {1, 2, 3, ..., M}
  • 要求 h2 (key) < M 且和M互为质数,有两种简单的取值方法:1、当M为2整数幂时,h2 (key)从[0,M-1]任选一个奇数;2、当M为质数时,h2 (key)  =  key % (M − 1)  +  1
  • 保证 h2 (key)与M互质是因为根据固定的偏移量所寻址的所有位置将形成⼀个群,若最大公约数p = gcd(M, h1 (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


3.2 开放定址法代码实现

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

3.2.1 开放定址法的哈希表结构

// 状态
enum State
{
    EXIST, // 存在
    EMPTY, // 空
    DELETE // 删除
};

template<class K, class V>
struct HashData
{
    pair<K, V> _kv;
    State _state = EMPTY;
};

template<class K, class V>
class HashTable
{
    private:
    vector<HashData<K, V>> _tables;
    size_t _n = 0; // 表中存储数据个数
};

这里有如下三个问题:

Q1:vector就有size,为什么这里还有给一个_n ?

A1:_n中存储实际数据个数,用于计算负载因子(负载因子 == _n/_tables.size())

Q2:为什么使用vector作为底层容器?

A2:可以直接使用vector中的扩容等方法,更加方便

Q3:为什么不直接存储pair<K,V>,而是HashData?

A3:如果一个位置的值删除了,要找的值是由于冲突而放在空位置之后的值,那么会先找这个空位置,再往后找,但是遇到空就停止了,所以要找的那个值存在但是没找到;通过状态标记将空和删除分开处理,这样就能解决上述情况

Q3情况详细说明:
如下图,我们删除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


3.2.2 扩容

这里我们哈希表负载因子控制在0.7,当负载因子到0.7以后我们就需要扩容了,我们还是按照2倍扩容,但是同时我们要保持哈希表大小是一个质数,第一个是质数,2倍后就不是质数了。那么如何解决,一种方案就是上面除法散列中Java HashMap的使用2的整数幂,但是计算时不能直接取模的改进方法。另外一种方案是sgi版本的哈希表使用的方法,给了一个近似2倍的质数表,每次去 质数表获取扩容后的大小。

static const int __stl_num_primes = 28;
// 质数表(这些值几乎是2倍左右间隔的)
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; // 如果找不到,就返回最大值
	// 不会达到最大,因为内存就没那么大,不用担心扩容问题
}

3.2.3 key不能取模的问题

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

// 仿函数:把key转成无符号整形
template<class K>
struct HashFunc
{
	size_t operator()(const K& key)
	{
		return (size_t)key;
	}
};

// 特化
// 因为string是非常常用的做哈希表key的类型,特化之后就不用写了
// 当你是其他类型的时候会走原模板,如果是string就会走特化这个版本
template<>
struct HashFunc<string>
{
    // 字符串转换成整形,可以把字符ascii码相加即可
    // 但是直接相加的话,类似"abcd"和"bcad"这样的字符串计算出是相同的
    // 这里我们使用BKDR哈希的思路,用上次的计算结果去乘以一个质数,这个质数一般去31, 131
等效果会比较好
	size_t operator()(const string& key)
	{
		// 把每个字母的ASCII码值加起来
		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:
private:
    vector<HashData<K, V>> _tables;
    size_t _n = 0; // 表中存储数据个数
};

3.2.4 插入 

bool Insert(const pair<K, V>& kv)
{
	// 如果值已经存在,插入失败
	if (Find(kv.first))
		return false;

	// 负载因子 >= 0.7 就扩容
	if ((double)_n / (double)_tables.size() >= 0.7) // 比如 7 / 10 不是0.7而是0,所以要转为double
	{
		HashTable<K, V, Hash> newht;
		// 提前开好空间 
		// newht._tables.resize(_tables.size() * 2); // 这里乘2之后就不是质数了
		newht._tables.resize(__stl_next_prime(_tables.size()) + 1); // 这样扩容比较好
		// 遍历旧表,旧表数据插入到newht
		for (size_t i = 0; i < _tables.size(); i++)
		{
			if (_tables[i]._state == EXIST)
			{
				newht.Insert(_tables[i]._kv); // 复用insert
			}
		}

		_tables.swap(newht._tables);
	}

	// 先利用仿函数将key转为整型
	Hash hs;
	// 这里是模size还是模capacity ?  size
	// 因为模完之后要将数据放在哈希表中,只能在size以内的位置放值	
	// 后面要尽可能让size和capacity保持一致,不要开额外的空间
	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;
}

Q1:为什么在insert内部创建一个新的类newht,而不是创建一个vector对象

A1:扩容之后,有些值可能还是映射在原来的位置,但是也可能映射不再原来的位置,我们还要再走一遍映射和线性探测的逻辑,这是有一定代价的,所以我们可以创建一个类newht,复用insert的逻辑,然后将数据与原表交换即可,出作用域后,newht旧自定调用析构销毁了。

3.2.5 完整代码实现

完整代码就不再这里展示了,感兴趣的读者可以点击下方链接到博主的gitee网址上面查看

哈希表开放定址法代码实现


3.3 链地址法

解决冲突的思路:

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


{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.4 链地址法代码实现

3.4.1 链地址法的哈希表的结构

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;
private:
    vector<Node*> _tables; // 指针数组
    size_t _n = 0; // 表中存储的数据个数
};

3.4.2 扩容

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

极端场景:

如果极端场景下,某个桶特别长怎么办?其实我们可以考虑使用全域散列法,这样就不容易被针对 了。但是假设不是被针对了,用了全域散列法,但是偶然情况下,某个桶很长,查找效率很低怎么 办?这里在Java8的HashMap中当桶的长度超过一定阀值(8)时就把链表转换成红黑树。一般情况下, 不断扩容,单个桶很长的场景还是比较少的,下面实现就不实现这么复杂了。


3.4.3 插入

bool Insert(const pair<K, V>& kv)
{
	// 如果哈希表中有这个数据,那么就不插入
	if (Find(kv.first))
		return false;

	Hash hs;
	// 负载因子 == 1 就扩容		为什么取1:平均每个桶挂一个
	// 扩容会让平均每个桶变短
	if (_n == _tables.size())
	{
		std::vector<Node*> newtables(__stl_next_prime(_tables.size() + 1), nullptr); // 直接构造的时候给,不用调resize
		for (size_t i = 0; i < _tables.size(); i++)
		{
			// 遍历旧表,旧表的节点重新映射,挪动到新表
			Node* cur = _tables[i];
			while (cur)
			{
				// 下面会更改next指针,所以要提前保存
				Node* next = cur->next;

				// 算出在新表对应的位置
				size_t hashi = hs(cur->_kv.first) % newtables.size();
				// 头插
				cur->next = newtables[hashi]; // cur的next节点指向这个哈希桶中的第一个节点
				newtables[hashi] = cur; // 这个桶里面的第一个节点变为cur

				cur = next;
			}
			_tables[i] = nullptr;
		}

		_tables.swap(newtables);
	}

	size_t hashi = hs(kv.first) % _tables.size();
	// 谁在前谁在后都可以,因为我们不知道哪个节点被访问的频率高
	// 所以头插,尾差都可以。但是头插效率更高,所以用头插
	// 头插(桶是否为空都不影响)
	Node* newnode = new Node(kv); // 注意new的时候会调构造,没写的话要补上
	newnode->next = _tables[hashi];
	_tables[hashi] = newnode;

	++_n;
	return true;
}

Q1:为什么不像开放定址法一样在insert内部创建一个HashTable类,复用insert的头插逻辑,而是创建一个新的vecror,再重新写一遍头插逻辑

A1:如果在insert内部创建一个局部变量newht,那么它还要将旧表中的节点new一份新的,然后挂在新表上,出了作用域后,newht旧销毁了,又要调用析构将空间释放,这时一种浪费;所以我们是在insert内部创建一个新的vector对象,这样出作用域时,只需调用vector的析构即可


3.4.4 完整代码实现

哈希表链定址法代码实现


四. 红黑树 VS 哈希表

对比维度 红黑树(Red-Black Tree) 哈希表(Hash Table)
所属数据结构类型 自平衡二叉查找树(属于有序字典/有序集合) 无序的键值映射表(散列表)
平均时间复杂度 查找、插入、删除:O(log n) 查找、插入、删除:O(1)(理想情况下)
最坏时间复杂度 仍然是 O(log n)(因为自平衡) O(n)(极端哈希冲突时退化成链表)
迭代器 迭代输出有序(中序遍历即为键的升序) 无序(遍历顺序与插入顺序、键大小无关)
是否支持范围查询 支持高效范围查询、前驱/后继、排名、区间统计等 不支持(只能一个个查找,无法高效获取区间)
内存开销 每个节点需要左右孩子指针、父指针、颜色位,约 3~4 个指针 + 颜色 通常是数组 + 链表/红黑树(开源冲突),开销可控但可能更大
扩容代价 低 (局部指针调整) 高 (Rehash 所有元素)
缓存友好性 较差(指针跳跃,缓存不友好) 较好(数组连续,开放寻址时缓存极友好)
实现复杂度 实现较复杂(旋转、着色规则多) 简单(好的哈希函数 + 冲突解决策略即可)
确定性性能 性能非常稳定,几乎不会退化 依赖哈希函数质量和负载因子,可能出现最坏情况
典型语言实现 C++ STL:std::mapstd::set Java:TreeMapTreeSet C++:std::unordered_mapstd::unordered_set Java:HashMapHashSet

总结:

  • 要速度、无序 → 哈希表
  • 要有序、范围查询、最坏情况保证 → 红黑树

两者不是完全替代关系,而是互补:很多系统里两者会同时使用(比如 Redis 的 hash + sorted set,Linux 内核的 hash + rbtree)。选择时先看“是否需要保持顺序”,这个需求几乎决定了答案。


4.1 哈希表优缺点及使用场景

优点:

  • 平均情况下单点查找、插入、删除最快(O(1)),无敌。
  • 实现简单,常量因子小(好的哈希函数下)。
  • 对键的类型几乎无要求,只要能算哈希就行。

缺点:

  • 最坏情况退化到 O(n)(哈希冲突严重时)。
  • 不支持范围查询、有序遍历、前驱后继等操作。
  • 无序,遍历顺序随机。
  • 内存开销大(每个槽都要预留,负载因子通常 ≤ 0.7,需要扩容)。
  • 哈希函数质量直接决定性能,容易被恶意输入攻击(Hash DoS)。

适用场景(选哈希表):

  • 纯键值映射,不需要顺序(最常见的缓存、计数、去重)。
  • 数据量巨大,追求极致单点查询速度(亿级用户ID → 用户信息)。
  • 键是字符串、复合类型等不好比较的类型。
  • 内存不是极度紧张,可以接受一定浪费。
  • 典型实现:unordered_map、unordered_set、Java HashMap、HashSet、Python dict、set、Go 的 map。

4.2 红黑树优缺点及使用场景

优点:

  • 所有操作时间复杂度都是确定性的 O(log n),最坏情况也有保证。
  • 天然有序,支持高效的范围查询、有序遍历、找第k大、找前驱后继等。
  • 内存利用率高(没有空槽浪费)。
  • 缓存局部性比哈希表好(树结构连续性更强)。
  • 不会被哈希攻击。

缺点:

  • 单点操作比哈希表慢一个数量级(常量大,log n ≈ 30 时约 30 倍指针跳转)。
  • 实现复杂(旋转、着色)。
  • 需要键支持严格弱序(< 操作),对自定义类型要求更高。

适用场景(选红黑树):

  • 需要保持键的有序性(排行榜、时间线)。
  • 频繁做范围查询(比如查找 [l, r] 区间内所有键)。
  • 需要有序遍历(std::map 的迭代器顺序就是键序)。
  • 需要找第k大、区间统计、动态顺序统计(配合顺序统计树)。
  • 对最坏情况性能有严格要求(金融、高并发低延迟系统有时反而选树避免哈希退化)。
  • 数据量中等(几万到几百万),log n 仍然很小。
  • 典型实现:std::map、std::set、Java TreeMap、TreeSet、C++ 多集 multimap、multiset。

总结

        哈希表的高效依赖于优秀的哈希函数合理的冲突处理机制。在实际工程(如 C++ STL)中,链地址法因其优秀的稳定性和对高负载因子的容忍度,成为了标准库的首选实现方案。而开放定址法在某些对内存碎片极其敏感的特定嵌入式场景中,依然占有一席之地。

如有不足或改进之处,欢迎大家在评论区积极讨论,后续我也会持续更新C++相关的知识。文章制作不易,如果文章对你有帮助,就点赞收藏关注支持一下作者吧,让我们一起努力,共同进步!

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐