哈希表,也常被称为散列表,它的核心思想非常直接:通过一个哈希函数,在"关键字(Key)"和"存储位置"之间建立一种确定的映射关系 。查找时,只需通过同一个函数计算出位置,就能直接去那个位置取值,从而实现近乎 O(1) 的平均查找效率。
可以把哈希表想象成一个有编号的储物柜。哈希函数就是规则,你告诉它物品的编号(Key),它就能算出该去几号柜子找。
1. 一个朴素但局限的方法:直接定址法
最直观的哈希函数就是直接定址法。比如,我们事先知道所有关键字都在 0 到 99 之间,那就直接开一个大小为 100 的数组,关键字的值就是它的存储下标。
比如 LeetCode 387 题"字符串中的第一个唯一字符"的解法,就是一种直接定址法的应用:
cpp
class Solution {
public:
int firstUniqChar(string s) {
// 小写字母有26个,创建大小为26的数组
// 用字母的ASCII码减去'a'的ASCII码作为下标
int count[26] = {0};
// 第一次遍历,统计每个字母出现的频率
for (auto ch : s) {
count[ch - 'a']++;
}
// 第二次遍历,找到第一个频率为1的字符并返回其索引
for (size_t i = 0; i < s.size(); ++i) {
if (count[s[i] - 'a'] == 1)
return i;
}
return -1;
}
};
优点: 非常简单,查找极快。
致命缺点: 当关键字范围很大、很分散时,比如只有几个数在 0 到 9999 之间,你也要开一个长度为 10000 的数组,会造成巨大的空间浪费,甚至不可能实现。
2. 两个无法回避的核心问题:冲突与负载因子
为了更通用,我们不让Key值直接作为下标,而是用一个哈希函数 h(key) 将其映射到一块有限大小 M 的内存空间里。这时,就一定会遇到两个关键问题。
哈希冲突
不同的关键字,通过哈希函数计算出了相同的存储位置,这种现象就叫哈希冲突。可以证明,冲突是理论上的必然,我们只能尽量设计好的哈希函数来减少冲突,并设计出完善的方案来解决它。
负载因子
负载因子 是衡量哈希表"拥挤程度"的重要指标,公式为 负载因子 = N / M,其中 N 是已存储元素个数,M 是哈希表总容量。负载因子越大,冲突概率越高;负载因子越小,空间利用率越低。我们实现哈希表的关键,就是在这对矛盾中找到一个平衡点。
3. 如何设计一个好的哈希函数
一个好的哈希函数应该能让关键字被等概率、均匀地 散列到整个空间里。由于最终计算需要整数,对于 string 等非整数Key,我们要先想办法把它转换成一个整数。下面介绍几种常见的哈希函数设计。
除法散列法(除留余数法)
这是最常用也最直观的方法:h(key) = key % M。
-
关于 M 的选择:通常建议M是一个不接近2的整数次幂的素数。因为如果M是2的幂,取模操作本质上只保留了key的低位,高位信息被浪费,容易增加冲突。
-
实践中的变通 :像 Java 的
HashMap恰恰使用了 2 的整数次幂作为M,目的是将耗时的取模运算%替换为更快的位运算&。但为了避免上述问题,它在计算哈希值时会对 key 的哈希码进行二次扰动(比如高16位和低16位做异或),让所有位都参与到运算中,最终再与M-1做与运算来定位。这告诉我们,理解原理后要灵活运用。
乘法散列法
这个方法对 M 的大小没有特殊要求。思路是用关键字 K 乘以一个常数 A(0<A<1),提取乘积的小数部分,再用这个小数部分乘以 M 并向下取整。h(key) = floor(M * ((A * key) % 1.0))。常数 A 的取值有讲究,Knuth 认为黄金分割点 (√5 - 1) / 2 ≈ 0.618 是个不错的选择。
全域散列法
如果一个恶意的攻击者知道了你的哈希函数,可以故意构造大量会碰撞的数据,让哈希表退化。全域散列法 通过在程序运行时,从一族哈希函数中随机选择一个来使用,从而让攻击者无法预测和针对。这大大增加了系统的健壮性。
4. 解决冲突的两种根本方法
冲突无法避免,但可以解决。主流的解决方案分为 开放定址法 和 链地址法 两大流派。
方法一:开放定址法
核心思想是:所有数据都存在哈希表本体数组中。如果计算出位置 hash0 已被占用,就按某种规则去探测下一个为空的位置。
为了支持"删除"操作,我们需要给数组的每个存储单元增加状态标记:EXIST(存在)、EMPTY(为空)、DELETE(已删除)。如果直接删除某个元素,会打断探测链,导致后续插入的元素无法被找到。将它的状态改为 DELETE 就能解决这个问题。下面是实现代码,其中包含了核心的 Insert 和扩容逻辑。
cpp
namespace open_address {
// 存储单元的状态
enum State {
EXIST,
EMPTY,
DELETE
};
// 哈希表存储的数据结构
template<class K, class V>
struct HashData {
pair<K, V> _kv;
State _state = EMPTY;
};
// 默认的哈希函数(将key直接强转为size_t)
template<class K>
struct HashFunc {
size_t operator()(const K& key) {
return (size_t)key;
}
};
// 针对string类型的特化哈希函数
// 采用BKDR算法,每次乘以131再加当前字符,能有效避免不同排列的字符串碰撞
template<>
struct HashFunc<string> {
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:
// 插入操作的核心实现
bool Insert(const pair<K, V>& kv) {
// 1. 查重,key已存在则返回false
if (Find(kv.first)) return false;
// 2. 负载因子控制在0.7,超过则扩容
// _n * 10 / _tables.size() >= 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);
}
// 3. 使用线性探测法寻找插入位置
Hash hash;
size_t hash0 = hash(kv.first) % _tables.size();
size_t hashi = hash0;
size_t i = 1;
// 如果当前位置状态为EXIST,则继续探测
while (_tables[hashi]._state == EXIST) {
// 线性探测:每次向后移动一个位置
hashi = (hash0 + i) % _tables.size();
++i;
}
// 4. 找到非EXIST位置(EMPTY或DELETE),插入数据
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
++_n;
return true;
}
private:
// 扩容时寻找下一个合适的素数
inline unsigned long __stl_next_prime(unsigned long n) {
static const unsigned long __stl_prime_list[28] = {
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 + 28;
// lower_bound找到第一个不小于n的素数
const unsigned long* pos = lower_bound(first, last, n);
return pos == last ? *(last - 1) : *pos;
}
vector<HashData<K, V>> _tables;
size_t _n = 0; // 有效数据个数
};
}
-
线性探测:
hashi = (hash0 + i) % M,简单但可能产生"群集(堆积)"问题。 -
二次探测:
hashi = (hash0 ± i²) % M,能一定程度缓解群集效应。
方法二:链地址法(哈希桶)
这是标准库 unordered_map 和 unordered_set 采用的实现方式,也是更主流的做法。
-
核心思想 :哈希表本体不再直接存储数据,而是存储一个指针数组 。每个数组元素指向一个链表(或红黑树等结构),所有映射到这个位置的冲突数据,都挂在这个链表上。因此,它也被形象地称为拉链法 或哈希桶。
-
负载因子:可以大于1,意味着链表允许不止一个节点。通常控制在1左右。
cpp
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;
public:
HashTable() {
_tables.resize(__stl_next_prime(0), nullptr);
}
bool Insert(const pair<K, V>& kv) {
Hash hs;
// 1. 扩容:当负载因子达到1(即_n == _tables.size())时扩容
if (_n == _tables.size()) {
// 创建新的桶数组,大小是下一个素数
vector<Node*> newTables(__stl_next_prime(_tables.size() + 1), nullptr);
// 2. 将旧表的节点,逐个迁移到新表(不是复制)
for (size_t i = 0; i < _tables.size(); i++) {
Node* cur = _tables[i];
while (cur) {
Node* next = cur->_next;
// 重新计算在新表中的桶索引
size_t hashi = hs(cur->_kv.first) % newTables.size();
// 头插法插入新桶
cur->_next = newTables[hashi];
newTables[hashi] = cur;
cur = next;
}
_tables[i] = nullptr; // 旧表指针置空
}
// 交换指针数组,完成扩容
_tables.swap(newTables);
}
// 3. 计算桶索引,并进行头插
size_t hashi = hs(kv.first) % _tables.size();
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
// 查找和删除逻辑清晰,此处省略...
~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;
}
}
private:
// ...(__stl_next_prime函数与开放定址法类似)
vector<Node*> _tables; // 指针数组,每个元素指向一个链表(桶)的头节点
size_t _n = 0; // 有效数据个数
};
}
关于极端场景的优化
如果某个桶的链表过长,查找效率会下降。Java 8 的 HashMap 做法是,当链表长度超过阈值(比如8)且总容量超过64时,将这个链表树化为红黑树,将退化后的 O(N) 查找效率拉回到 O(log N)。这属于更深层次的优化,我们理解了思路即可。
总结与思考
到这里,我们从概念、函数设计到冲突解决的两种代码实现,完整地走了一遍哈希表的底层原理。你可能会觉得内容不少,但核心思想其实很清晰:
-
用一个好函数减少冲突。
-
用一个好策略解决冲突。
-
用一个负载因子平衡时间与空间。
理解了底层,你就能更好地理解 unordered_set 为什么是单向迭代器且遍历无序(因为它是顺着桶和链表走的),以及它为Key同时要求哈希函数和相等比较器的深层原因了。