【C++进阶】哈希表实现

目录

一、哈希核心概念

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

[1.2 哈希冲突(哈希碰撞)](#1.2 哈希冲突(哈希碰撞))

[1.3 负载因子 load‑factor](#1.3 负载因子 load‑factor)

[1.4 关键字转为整数](#1.4 关键字转为整数)

二、常见哈希函数

[2.1 除留余数法(除法散列法,工程最常用)](#2.1 除留余数法(除法散列法,工程最常用))

[2.2 乘法散列法(了解,极少手写)](#2.2 乘法散列法(了解,极少手写))

[2.3 全域散列法(了解,对抗恶意哈希攻击)](#2.3 全域散列法(了解,对抗恶意哈希攻击))

[2.4 字符串哈希 BKDR(面试手写高频)](#2.4 字符串哈希 BKDR(面试手写高频))

三、冲突两大解决方案

[方案一:开放定址法(Open Addressing)](#方案一:开放定址法(Open Addressing))

⚠开放定址法删除的致命问题

开放定址完整代码(线性探测版本)

[方案二:链地址法(拉链法 / 哈希桶,STL 底层实现)](#方案二:链地址法(拉链法 / 哈希桶,STL 底层实现))

链地址法完整实现

[四、rehash 重哈希机制](#四、rehash 重哈希机制)

[五、开放定址法 vs 链地址法对比](#五、开放定址法 vs 链地址法对比)

六、大厂面试高频问答

Q1:什么是哈希冲突?冲突能不能完全避免?

Q2:负载因子是什么?负载因子为什么不能无限放大?

[Q3:开放定址法为什么删除不能直接清空位置?为什么要用 DELETE 标记?](#Q3:开放定址法为什么删除不能直接清空位置?为什么要用 DELETE 标记?)

[Q4:链地址法 rehash 做什么?rehash 之后迭代器会怎么样?](#Q4:链地址法 rehash 做什么?rehash 之后迭代器会怎么样?)

[Q5:除留余数法 M 为什么建议取质数?Java HashMap 为什么 M 是 2 的幂?](#Q5:除留余数法 M 为什么建议取质数?Java HashMap 为什么 M 是 2 的幂?)

[Q6:链地址法的最坏情况?Java8 HashMap 如何优化?C++ unordered 有没有这个优化?](#Q6:链地址法的最坏情况?Java8 HashMap 如何优化?C++ unordered 有没有这个优化?)

[Q7:BKDR 字符串哈希思路?为什么不能直接累加 ascii 码?](#Q7:BKDR 字符串哈希思路?为什么不能直接累加 ascii 码?)

Q8:开放定址线性探测、二次探测优缺点?

七、开发易错坑清单


一、哈希核心概念

哈希(散列 hash):建立关键字 key 与存储位置之间直接映射关系 。 通过哈希函数,输入 key,直接算出该 key 应当存放在数组的下标位置;查找时直接计算下标访问,理想情况下不需要遍历比较,实现O (1) 查找。

普通二叉搜索树:依靠比较大小,\(O(logN)\); 哈希表:直接算地址,理想平均\(O(1)\)。

1.1 直接定址法

最简单哈希,key 本身或者简单运算结果就是数组下标,不需要复杂哈希函数。 适用场景:关键字范围集中、连续。 公式:

\(h(key) = key \quad \text{或者} \quad h(key)=key-offset\)

示例:小写字母,ch-'a'得到 0‑25,作为数组下标;

力扣 387 字符串第一个唯一字符就是典型直接定址法。387. 字符串中的第一个唯一字符 - 力扣(LeetCode)

cpp 复制代码
class Solution {
public:
    int firstUniqChar(string 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;
    }
};

缺点:key 范围分散,会开辟巨大数组,内存浪费严重。比如 key 取值 0~10000,但实际只有十几个数据,直接定址法不可行。

1.2 哈希冲突(哈希碰撞)

不同的 key,经过哈希函数计算,得到同一个数组下标,叫做哈希冲突 / 哈希碰撞。

现实中,冲突无法完全杜绝,只能:

  1. 设计优秀哈希函数,尽可能减少冲突概率;
  2. 设计冲突解决算法,发生冲突时可以正常存、取数据。

1.3 负载因子 load‑factor

\(load\_factor = \frac{有效元素数量N}{哈希表桶/数组总大小M}\)

  • 负载因子越大:数组装的越满,冲突概率越高,空间利用率高;
  • 负载因子越小:冲突概率低,但是大量数组位置空闲,内存浪费。

开放定址法:负载因子必须小于 1 ,没有多余空位就无法存放冲突元素; 链地址法(拉链法):负载因子可以大于 1;STL unordered_xxx 默认max_load_factor=1.0,超过就 rehash 扩容。

1.4 关键字转为整数

哈希计算下标,需要输入是整数; 如果 key 是string、自定义结构体,不能直接取模,需要哈希仿函数,把 key 转换成size_t无符号整数,之后再对 M 取模得到下标。

二、常见哈希函数

2.1 除留余数法(除法散列法,工程最常用)

\(h(key)=key \bmod M\) M 是哈希表数组大小,得到结果范围 \(0, M-1\)。

理论建议:M 尽量选择质数,不要取 2 的幂、10 的幂。 如果 M 是\(2^x\),取模等价只保留 key 二进制后 x 位;高位信息全部丢失,容易大量冲突。
补充实践特例(Java HashMap): Java HashMap 哈希桶大小强制是 2 的整数次幂;不直接key%M,做扰动函数:把 key 高 bit 和低 bit 异或,让高位也参与哈希计算,弥补 M 为 2 幂的缺陷。 理论教材建议 M 质数,工程库可以做扰动函数后用 2 次幂,两种思路都可行。

2.2 乘法散列法(了解,极少手写)

\(h(key)= floor\big(M \times ((A\times key)\bmod 1.0)\big)\) \(0<A<1\),Knuth 推荐黄金分割 \(A=(\sqrt5-1)/2≈0.618\)。 优点:对 M 没有质数要求。

2.3 全域散列法(了解,对抗恶意哈希攻击)

攻击者可以针对固定哈希函数,专门构造一批 key 全部落到同一个桶,直接退化 O (N)。 全域散列:初始化的时候随机从一组哈希函数挑选一个,程序运行全程固定这一个哈希函数。

注意:插入、查找必须用同一个哈希函数,不能每次随机选,否则找不到数据。

2.4 字符串哈希 BKDR(面试手写高频)

string 不能直接转整数;BKDR 哈希算法,每个字符参与计算,减少冲突。

cpp 复制代码
template<>
struct HashFunc<string>
{
    size_t operator()(const string& key)
    {
        size_t hash = 0;
        for(auto ch : key)
        {
            hash *= 131; // 质数31、131、13331都常用
            hash += ch;
        }
        return hash;
    }
};

不能简单直接累加字符 ascii 码,"abcd"与"bcad"字符和相等,发生哈希冲突。

三、冲突两大解决方案

方案一:开放定址法(Open Addressing)

所有数据全部存放在哈希数组内部;冲突就按照规则继续向后寻找数组内下一个空闲位置。 ⚠开放定址负载因子必须小于 1,数组必须有空位。

细分三种探测策略:

1.线性探测 冲突位置hash0,不断 + 1 取模向后找空位:

\(hash_i = (hash0+i)\bmod M,\quad i=1,2...\) ✅实现简单;

❌严重缺陷:聚集(群集),连续冲突的数据扎堆挤在一起,查找探测距离越来越长,性能恶化。

2.二次探测

\(hash_i=(hash0 \pm i^2)\bmod M\) 冲突后按照 i 的平方跳跃,缓解聚集问题;依然会有二次聚集。

3.双重散列(双重探测) 准备第二个哈希函数算出偏移量:

\(hash_i=(hash0+i*h_2(key))\bmod M\) 要求\(h_2(key)\)与 M 互质,可以遍历整个哈希数组。

⚠开放定址法删除的致命问题

不能直接把位置置空!

举例:19、30 发生冲突,19 存 hash0,30 存 hash0+1;如果直接清空 30 位置;后续查找 30,探测遇到EMPTY直接终止,找不到后面合法有效数据。

✅解决:每个数组单元增加状态标记,三种状态:

cpp 复制代码
enum State
{
    EXIST,   //有效存在数据
    EMPTY,   //未被使用,空
    DELETE   //逻辑删除,曾经存过,现在删除
};

删除操作:只修改状态为 DELETE,不置为空;查找遇到 EMPTY 才停止,遇到 DELETE 继续向后探测。只有插入的时候可以覆盖 DELETE 位置。

开放定址完整代码(线性探测版本)
cpp 复制代码
#include<iostream>
#include<vector>
#include<string>
#include<algorithm>
using namespace std;

template<class K>
struct HashFunc
{
    size_t operator()(const K& key)
    {
        return (size_t)key;
    }
};

template<>
struct HashFunc<string>
{
    size_t operator()(const string& key)
    {
        size_t hash = 0;
        for(auto ch : key)
        {
            hash *= 131;
            hash += ch;
        }
        return hash;
    }
};

enum State
{
	EXIST,
	EMPTY,
	DELETE
};

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

//STL质数表
inline unsigned long __stl_next_prime(unsigned long n)
{
	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,class V,class Hash=HashFunc<K>>
class HashTable
{
private:
	vector<HashData<K,V>> _tables;
	size_t _n = 0; //有效元素个数
public:
	HashTable()
	{
		_tables.resize(__stl_next_prime(0));
	}

	HashData<K,V>* Find(const K& key)
	{
		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;
	}

	bool Insert(const pair<K,V>& kv)
	{
		if(Find(kv.first) != nullptr)
			return false;
		//负载因子大于0.7触发扩容
		if(_n * 10 / _tables.size() >= 7)
		{
			HashTable<K,V,Hash> newHT;
			newHT._tables.resize(__stl_next_prime(_tables.size()+1));
			for(size_t i=0;i<_tables.size();i++)
			{
				if(_tables[i]._state == EXIST)
				{
					newHT.Insert(_tables[i]._kv);
				}
			}
			_tables.swap(newHT._tables);
		}

		Hash hs;
		size_t hash0 = hs(kv.first) % _tables.size();
		size_t hashi = hash0;
		size_t i =1;
		while(_tables[hashi]._state == EXIST)
		{
			hashi = (hash0+i) % _tables.size();
			i++;
		}
		_tables[hashi]._kv = kv;
		_tables[hashi]._state = EXIST;
		_n++;
		return true;
	}

	bool Erase(const K& key)
	{
		auto ret = Find(key);
		if(ret == nullptr)
			return false;
		ret->_state = DELETE; //逻辑删除,不是清空
		_n--;
		return true;
	}
};

开放定址法缺点总结:

  1. 删除不能直接置空,只能逻辑删除标记 DELETE;
  2. 聚集问题,冲突多之后探测距离变长;
  3. 负载因子必须小于 1,数组不能填满。

C++ STL 的unordered_set/unordered_map没有使用开放定址法,使用链地址法。

方案二:链地址法(拉链法 / 哈希桶,STL 底层实现)

哈希数组每个下标存放单向链表的头指针; key 算出下标,如果冲突,冲突结点直接挂载到同一个桶下的单向链表。 数组叫哈希桶数组,每个数组元素就是一个 bucket 桶。

特点:

  1. 允许负载因子 > 1;STL 默认 max_load_factor=1.0,超过就 rehash;
  2. 冲突元素直接挂链表,不会干扰别的桶;没有开放定址的聚集问题;
  3. 极端最坏情况:所有 key 全部冲突落到同一个桶,退化成单链表,增删查 O (N)。

下⾯演⽰ {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

Java8 HashMap 优化:链表长度超过阈值(8)链表转红黑树,降低最坏时间复杂度。C++ 标准库 unordered 容器没有这个优化。

链地址法完整实现
cpp 复制代码
#include<iostream>
#include<vector>
#include<algorithm>
#include<string>
using namespace std;

template<class K>
struct HashFunc
{
    size_t operator()(const K& key)
    {
        return (size_t)key;
    }
};

template<>
struct HashFunc<string>
{
    size_t operator()(const string& key)
    {
        size_t hash = 0;
        for(auto ch : key)
        {
            hash *= 131;
            hash += ch;
        }
        return hash;
    }
};

inline unsigned long __stl_next_prime(unsigned long n)
{
	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;
}

namespace hash_bucket
{
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; //有效结点数量
public:
	HashTable()
	{
		_tables.resize(__stl_next_prime(0),nullptr);
	}

	~HashTable()
	{
		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;
		}
	}

	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;
	}

	bool Insert(const pair<K,V>& kv)
	{
		if(Find(kv.first))
			return false;
		Hash hs;
		//负载因子达到1,触发rehash扩容
		if(_n == _tables.size())
		{
			vector<Node*> newtables(__stl_next_prime(_tables.size()+1),nullptr);
			for(size_t i=0;i<_tables.size();i++)
			{
				Node* cur = _tables[i];
				while(cur)
				{
					Node* next = cur->_next;
					size_t newhashi = hs(cur->_kv.first) % newtables.size();
					//头插迁移结点,不需要new新结点
					cur->_next = newtables[newhashi];
					newtables[newhashi] = cur;
					cur = next;
				}
				_tables[i] = nullptr;
			}
			_tables.swap(newtables);
		}
		size_t hashi = hs(kv.first) % _tables.size();
		Node* newnode = new Node(kv);
		newnode->_next = _tables[hashi];
		_tables[hashi] = newnode;
		_n++;
		return true;
	}

	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;
				}
				delete cur;
				_n--;
				return true;
			}
			prev = cur;
			cur = cur->_next;
		}
		return false;
	}
};
}

rehash 扩容关键点:链地址法扩容不需要 new 新结点,直接移动旧结点指针,头插到新哈希桶数组,性能优于重新 Insert。

四、rehash 重哈希机制

  1. 判断条件:链地址法 STL 默认load_factor > max_load_factor(1.0)触发 rehash;开放定址一般阈值 0.7。
  2. 开辟更大哈希数组,新数组大小取质数表下一个质数;
  3. 遍历旧表全部结点,每个 key 重新计算哈希值,挂载到新数组对应桶;
  4. swap 交换新旧数组,释放旧数组资源。

rehash 后果:

  • 开放定址:全部迭代器失效;
  • 链地址法 STL unordered:rehash 发生,全部迭代器失效;没有 rehash,insert 不会让迭代器失效。

业务可以调用reserve(n)提前预分配桶,减少运行期 rehash 带来性能抖动。

五、开放定址法 vs 链地址法对比

表格

对比项 开放定址法 链地址法(拉链法)
冲突存储 全部数据存哈希数组内部 冲突结点挂单向链表,数组存链表头指针
负载因子 必须 <1,一般 0.7 可以大于 1,STL 默认阈值 1.0
删除 不能直接清空,逻辑标记 DELETE 直接 delete 结点,正常链表删除
聚集问题 存在聚集,探测距离变长 无聚集
最坏复杂度 O(M) O (N)(单条链表过长)
STL 使用 C++ 标准库未使用 unordered_set /unordered_map 底层实现

六、大厂面试高频问答

Q1:什么是哈希冲突?冲突能不能完全避免?

不同 key 计算得到相同哈希下标叫哈希冲突。冲突无法 100% 避免;只能靠优秀哈希函数降低冲突概率,再依靠冲突解决算法处理已经发生的冲突。

Q2:负载因子是什么?负载因子为什么不能无限放大?

\(load\_factor=元素数量/桶数组总大小\);负载因子越大冲突概率越高,探测 / 链表遍历变长,性能退化。开放定址必须小于 1,没有空位就无法插入。

Q3:开放定址法为什么删除不能直接清空位置?为什么要用 DELETE 标记?

如果直接置为空,查找探测遇到 EMPTY 就终止,会跳过后面冲突存放在后面位置的有效数据,查找失败。使用 DELETE 逻辑删除标记,查找遇到 DELETE 继续向后探测,插入的时候可以覆盖 DELETE 位置。

Q4:链地址法 rehash 做什么?rehash 之后迭代器会怎么样?

负载因子超过阈值,开辟更大哈希桶数组;全部旧结点重新计算哈希,移动挂载到新数组;旧数组释放。rehash 发生,所有迭代器全部失效。

Q5:除留余数法 M 为什么建议取质数?Java HashMap 为什么 M 是 2 的幂?

M 质数可以减少后几位重复 key 集中冲突;M 取 2 的幂会只保留二进制低位,高位信息丢失,容易冲突。Java HashMap 增加扰动函数,把 key 高低位做异或,让高位也参与哈希计算,弥补 M=2 幂的缺陷,同时利用位运算代替取模,提升计算效率。两种方案各有取舍。

Q6:链地址法的最坏情况?Java8 HashMap 如何优化?C++ unordered 有没有这个优化?

大量 key 哈希落到同一个桶,链表过长,退化 O (N)。Java8:链表长度 > 8,链表转为红黑树。C++ 标准 unordered 容器没有链表转红黑树的优化。

Q7:BKDR 字符串哈希思路?为什么不能直接累加 ascii 码?

hash = hash * 质数 + ch,每一个字符都参与哈希运算。直接累加 ASCII:字符相同顺序不同的字符串,总和一样,发生哈希冲突。

Q8:开放定址线性探测、二次探测优缺点?

线性探测:代码简单,但是严重聚集;二次探测缓解聚集,但依然存在二次聚集。

七、开发易错坑清单

  1. ❌开放定址删除直接赋值 EMPTY,造成查找失败;必须逻辑 DELETE 标记。
  2. ❌rehash 之后继续使用旧迭代器,解引用未定义行为。
  3. ❌链地址扩容的时候,不要 new 新结点,直接移动旧结点指针,否则内存浪费。
  4. ❌哈希函数只取低位,高位完全丢弃,冲突概率暴涨。
  5. ✅已知数据规模,调用 reserve 预分配桶,减少运行期 rehash。
相关推荐
m0_380743871 小时前
VisualStudio中OpenCV的创建与配置使用小结
开发语言·c++
凉茶钱2 小时前
【C++】动态内存管理完整解析:从内存划分到 new delete 底层原理
c语言·开发语言·c++·内存管理·动态内存
cu1432 小时前
细谈GM8775C的具体功能和应用
c语言·c++·人工智能·嵌入式硬件
小七在进步2 小时前
类和对象(五)
java·开发语言·算法
无名猿2 小时前
std::optional 完全指南:别再用 -1 和 nullptr 表达「没有值」
c++·标准库·现代c++·语法基础
Rebecca_aLi2 小时前
码匠教育:零基础学 Python,标识符到循环全套基础语法解析
开发语言·python·正则表达式
IvanCodes2 小时前
Python 正则表达式(十四):文本匹配、查找与替换
开发语言·python·正则表达式
夜雪一千2 小时前
Python URL编码踩坑:单层编码、双重编码实战解析
开发语言·python
孙启超2 小时前
【AI开发之Rust】第 21 课:双端集成与出包 —— Android(.so→AAR)与 iOS(xcframework)
开发语言·后端·rust