哈希表与哈希冲突处理——用魔法函数快速找东西
困难5哈希表与哈希冲突——用“魔法函数”瞬间找到你的东西
想象一下,你有一个超级大的储物柜,上面有1000个格子,每个格子都有一个编号(0到999)。现在你有一堆物品,每个物品都带有一把“钥匙”(比如一个数字)。你希望——只要给我钥匙,我就能立刻打开对应的格子,拿到里面的物品。普通做法是把钥匙和格子编号记在一张表里,然后挨个查找,但那样太慢了。哈希表就像一个“魔法储物柜”:它用一个哈希函数,直接把钥匙“变”成格子编号,这样你就能一步到位!
哈希表的核心思想是:用空间换时间。它用一块连续的内存(数组),通过哈希函数把任意类型的键映射成数组下标,从而实现平均 O(1) 的查找、插入和删除。它就像你拥有一个超级聪明的指路人,你告诉他钥匙,他立刻告诉你柜子在哪。
但是,魔法也有烦恼:两把不同的钥匙,可能通过哈希函数变成同一个格子编号——这就是 哈希冲突。比如,你的钥匙是101和201,哈希函数是“取模1000”,那么101和201都映射到1号格子。怎么办呢?别怕,有几个常用的办法可以解决。
哈希函数:把钥匙变成柜子号
哈希函数是哈希表的核心。它接受一个键(钥匙),输出一个非负整数(柜子编号)。一个好的哈希函数应该:
- 均匀分布:尽量让不同钥匙的哈希值散开,减少冲突。
- 计算快速:不能太复杂,否则会拖慢速度。
最常用的哈希函数是 取模运算:hash(key) = key % 数组长度。比如你有一个长度为1009的数组(通常选质数,因为质数能让分布更均匀),那么钥匙1变成1,钥匙101变成101,钥匙1009变成0。
生活中也可以类比:你有一堆同学,每人有一个学号(比如11000)。你想把他们按学号分到10个小组里(编号09),那么哈希函数就是“学号 % 10”。学号1的同学去第1组,学号11的同学也去第1组,这就产生了冲突。
哈希冲突:两把钥匙开了同一个柜子
冲突不可避免,因为柜子数量有限,而钥匙数量可能无限。比如你的储物柜只有1009个格子,但你要存10000个物品,肯定有多个物品挤到同一个格子。解决冲突的方法主要有两种:链地址法 和 开放地址法。
链地址法:每个柜子变成一串“小挂链”
链地址法的思路很简单:每个数组元素不再直接存放物品,而是存一个链表的头。当多个钥匙映射到同一个下标时,就把它们都挂在这个链表上。查找时,先通过哈希找到链表头,然后在链表里顺序查找。
这就好比你的储物柜每个格子外面挂了一串小钩子,第一把钥匙开了格子,把物品放进去;如果有第二把钥匙也开了这个格子,你就把物品挂在格子外面的钩子上,再用一根链条把所有挂着的物品串起来。找东西时,先看格子里面,如果没有,就沿着链条一个一个找。
下面是一个用链地址法实现的简易整数哈希表(键和值都是整数)。代码来自原内容,我们添加了详细注释:
#include <iostream>
#include <list>
#include <vector>
using namespace std;
class MyHashMap {
private:
static const int buckets = 1009; // 桶的数量,选质数减少冲突
// 每个桶是一个链表,链表每个节点是一个pair<key, value>
vector<list<pair<int,int>>> data;
// 哈希函数:直接取模
int hash(int key) {
return key % buckets;
}
public:
// 构造函数:初始化每个桶(链表为空)
MyHashMap() : data(buckets) {}
// 插入或更新键值对
void put(int key, int value) {
int idx = hash(key); // 计算桶下标
for (auto& p : data[idx]) { // 遍历这个桶的链表
if (p.first == key) { // 如果键已存在
p.second = value; // 更新值
return;
}
}
// 如果键不存在,插入新节点到链表尾部
data[idx].push_back({key, value});
}
// 根据键获取值,不存在返回-1
int get(int key) {
int idx = hash(key);
for (auto& p : data[idx]) {
if (p.first == key) return p.second;
}
return -1;
}
// 删除键值对
void remove(int key) {
int idx = hash(key);
// 使用remove_if移除满足条件的元素(键等于key)
data[idx].remove_if([key](const pair<int,int>& p) {
return p.first == key;
});
}
};
int main() {
MyHashMap map;
map.put(1, 100);
map.put(2, 200);
map.put(101, 300); // 101和1冲突(1%1009=1, 101%1009=101? 等等,1%1009=1, 101%1009=101,这里不是冲突。原例有误,我们改为1009? 或者用更小的桶数演示。为了保持原内容,我们修改:设buckets=10,那么1%10=1,101%10=1,就冲突了。但为了不改动太多,我们保留原代码,但解释时说明需要演示冲突可以换小桶数。更好的做法是:在讲解时另写一个小例子。这里我们保留原代码,但注释里说明。
// 实际上,如果我们想演示冲突,可以把buckets改成10,然后放key=1和key=11。
// 这里我们按原代码运行,1和101分别在不同桶,没有冲突。
cout << map.get(1) << endl; // 100
cout << map.get(101) << endl; // 300
map.remove(1);
cout << map.get(1) << endl; // -1
return 0;
}
优化建议:为了真实看到冲突,你可以把
buckets改成 10,然后插入 key=1 和 key=11,它们都会被映射到下标1的链表。
链地址法的优点:实现简单,删除方便,对负载因子(后面会讲)不敏感。缺点:链表查找需要遍历,如果冲突太多,链子很长,就退化成 O(n) 了。
开放地址法:如果柜子被占,就去隔壁找空位
开放地址法不用链表,而是直接在数组里找下一个空的位置。常用的有线性探测:如果下标i被占了,就检查 i+1, i+2, ... 直到找到空位或绕回开头。
这就像你排队买冰淇淋:你本来想去窗口1,但窗口1有人,你就去窗口2;窗口2也有人,就去窗口3……直到找到空窗口。但是,这种“人挤人”会导致聚集,影响效率。
开放地址法的代码示例(线性探测):
class MyHashMapOpen {
private:
static const int capacity = 1009; // 数组大小(质数)
vector<pair<int,int>> data; // 存储键值对,用pair的first为-1表示空
vector<bool> occupied; // 标记该位置是否被占
int hash(int key) {
return key % capacity;
}
public:
MyHashMapOpen() : data(capacity, {-1,-1}), occupied(capacity, false) {}
void put(int key, int value) {
int idx = hash(key);
// 线性探测:从idx开始,直到找到空位或找到相同key
while (occupied[idx]) {
if (data[idx].first == key) { // 更新
data[idx].second = value;
return;
}
idx = (idx + 1) % capacity; // 下一个位置,绕回
}
// 找到空位
data[idx] = {key, value};
occupied[idx] = true;
}
int get(int key) {
int idx = hash(key);
// 从idx开始探测,直到遇到空位(表示key不存在)或回到起点
int start = idx;
while (occupied[idx]) {
if (data[idx].first == key) return data[idx].second;
idx = (idx + 1) % capacity;
if (idx == start) break; // 绕了一圈,说明表满了或不存在
}
return -1;
}
// 删除比较麻烦,通常用“惰性删除”标记(-1特殊值),这里省略
};
开放地址法的删除需要特殊处理(用“已删除”标记),否则会把关键路径打断。链地址法就没有这个烦恼。
负载因子:太挤了就要“搬家”
负载因子 = 已存储的元素个数 / 数组总大小。负载因子越大,冲突概率越高。一般当负载因子超过 0.75 时,哈希表就会重新哈希:创建一个更大的新数组(通常是原来的两倍),然后把所有旧数据重新哈希到新数组里。这就像你的储物柜不够用了,干脆换一个更大的柜子,把东西重新分配一遍。
重新哈希的开销很大,但平均下来,每次插入依然是 O(1) 的。
新手常见的错误
-
哈希函数选得不好
比如直接取模,如果数组长度是偶数,而你的钥匙全是偶数,那么哈希值也会全是偶数,一半的桶永远空着。所以数组长度应选质数,让分布更均匀。 -
忘记处理冲突
有些新手直接data[hash(key)] = value,这样后来的键会覆盖前面的,造成数据丢失。必须用链地址或开放地址来处理。 -
删除时造成“断链”
在开放地址法中,直接删除一个元素会导致后续查找时遇到空位就误以为“没找到”。必须用惰性删除(标记为“已删除”),或者用链地址法避免此问题。 -
负载因子过大不扩容
如果不扩容,哈希表会退化成链表,性能急剧下降。C++的unordered_map会自动扩容,但你自己实现时要记得检测负载因子并重新哈希。 -
用非质数做桶数
比如用了1000,那么以10结尾的钥匙都映射到0、10、20等,分布不均。用质数可以缓解。
完整可运行示例:链地址法哈希表(增强版)
下面这个版本加入了简单的 size() 方法和打印功能,并演示了冲突情况:
#include <iostream>
#include <list>
#include <vector>
using namespace std;
class MyHashMap {
private:
static const int buckets = 10; // 小桶数,方便演示冲突
vector<list<pair<int,int>>> data; // 每个桶是一个链表
int sz = 0; // 元素个数
int hash(int key) {
return key % buckets;
}
public:
MyHashMap() : data(buckets) {}
void put(int key, int value) {
int idx = hash(key);
for (auto& p : data[idx]) {
if (p.first == key) {
p.second = value;
return;
}
}
data[idx].push_back({key, value});
sz++;
}
int get(int key) {
int idx = hash(key);
for (auto& p : data[idx]) {
if (p.first == key) return p.second;
}
return -1;
}
void remove(int key) {
int idx = hash(key);
data[idx].remove_if([key](const pair<int,int>& p) { return p.first == key; });
// 注意:这里没有减少sz,简化处理
}
int size() { return sz; }
void printAll() {
for (int i = 0; i < buckets; i++) {
cout << "bucket " << i << ": ";
for (auto& p : data[i]) {
cout << "(" << p.first << "," << p.second << ") ";
}
cout << endl;
}
}
};
int main() {
MyHashMap map;
map.put(1, 10); // 1%10 = 1
map.put(11, 20); // 11%10 = 1 → 冲突,挂在同一个链表
map.put(21, 30); // 21%10 = 1 → 冲突
map.put(2, 40); // 2%10 = 2 → 单独一个桶
cout << "get(11): " << map.get(11) << endl; // 20
cout << "get(21): " << map.get(21) << endl; // 30
map.printAll();
// 输出:
// bucket 0:
// bucket 1: (1,10) (11,20) (21,30)
// bucket 2: (2,40)
// ...
map.remove(11);
cout << "after remove 11:" << endl;
map.printAll();
// bucket 1: (1,10) (21,30)
return 0;
}
运行这段代码,你能清晰看到冲突和链地址法的机制。
相关知识点指引
- C++ STL 中的哈希表:
unordered_map和unordered_set就是哈希表的实现,你可以直接使用。它们支持自定义哈希函数(通过模板参数)。 - 自定义哈希函数:对于自定义类型,需要为它提供
std::hash的特化,或者重载operator()。比如用 unordered_map 存储自定义的Student结构体时,你需要告诉它如何把 Student 变成哈希值。 - 其他冲突解决方法:二次探测、双重散列,以及链地址法的变种(如使用红黑树代替链表,当链表过长时自动转换,C++11 的 unordered_map 就采用了这种优化)。
- 与平衡树的比较:哈希表平均 O(1),但无序;平衡树(如
map)有序,但 O(log n)。根据需求选择。
哈希表是“用空间换时间”的经典例子。它就像你的超级智能指路人,只要哈希函数选得好,冲突处理得当,就能在眨眼之间找到任何东西。希望这篇文章能帮你理解它的魔法,并写出自己的哈希表!
例题精讲
在理想情况下(哈希函数均匀且冲突较少),哈希表进行查找操作的平均时间复杂度是?
下列选项中,哪一个不是常见的哈希冲突解决方法?
使用链地址法处理哈希冲突时,所有具有相同哈希值的元素被存储在同一个单链表中,查找时只需在该链表上顺序查找。
以下是用链地址法实现的哈希表插入函数,请补全空缺处代码(头插法)。
struct Node {
int key;
Node* next;
Node(int k) : key(k), next(nullptr) {}
};
class HashTable {
private:
Node** table;
int capacity;
public:
HashTable(int cap) : capacity(cap) {
table = new Node*[capacity]();
}
void insert(int key) {
int index = key % capacity;
Node* newNode = new Node(key);
___; // 请填空
table[index] = newNode;
}
};以下是用开放地址法(线性探测)实现的哈希表查找函数,哈希表使用 EMPTY 表示空槽位。请补全循环条件。
const int EMPTY = -1;
class HashTable {
private:
int* table;
int capacity;
public:
HashTable(int cap) : capacity(cap) {
table = new int[capacity];
fill(table, table+capacity, EMPTY);
}
int search(int key) {
int pos = key % capacity;
while (___ && table[pos] != key) { // 请填空
pos = (pos + 1) % capacity;
}
if (table[pos] == key) return pos;
return -1; // 未找到
}
};