冲突处理方法(链地址法与开放地址法)
较难3哈希表冲突处理:链地址法与开放地址法(生活版)
你有没有遇到过这种情况?去班级信箱拿信,发现你的信和别人的信都塞进了同一个编号的格子?这就是哈希表里的“冲突”——两个不同的键(比如你的名字和同学的名字)通过计算(哈希函数)得到了同一个位置编号。既然冲突无法避免,我们就得想办法妥善处理。这篇文章就用生活里最熟悉的例子,把两种最常用的冲突处理方法讲明白。
冲突是怎么来的?
哈希表就像一个带编号的储物柜,每个编号对应一个格子。哈希函数就是把你的物品(键)变成格子编号的“翻译官”。比如:编号 = 键 % 柜子总数。但柜子数量有限(比如只有10格),而你可能有无穷多个键,所以不可避免会出现两个不同的键映射到同一编号。这时候就发生了冲突。
方法一:链地址法——把格子变成“一筐钥匙”
链地址法的思路很简单:把每个格子变成一个小篮子(链表或动态数组),所有撞到同一编号的键,全部放进这个篮子里,用一根绳子串起来。找东西的时候,先找到篮子编号,再在篮子里挨个翻找。
生活中的比喻
想象你家里有一个大书架,每层隔板可以放一本书。但今天你和弟弟都想在“第3层”放书。链地址法的做法是:把第3层的隔板换成一个大的收纳盒,你们俩的书都放进去,书脊朝外。以后找书时,先看是哪一层,然后从收纳盒里一本本找。
示意图
哈希表数组(大小M)
+------+ +------+ +------+ +------+
| 0 | | 1 | | 2 | | 3 |
+------+ +------+ +------+ +------+
| | | |
v v v v
[节点] [节点] [节点] [节点]
/ \ / \ / \ / \
... ... ... ...
每个格子是一个链表的头,插入时直接挂到链表末尾或头部。
优点
- 简单直接,像把钥匙挂到挂钩上一样。
- 删除操作非常方便:直接找到链表中的节点,摘掉即可。
- 负载因子(每个格子的平均元素个数)可以大于1,也就是说,即使元素很多,也能正常工作(只是链表变长)。
缺点
- 需要额外内存存储指针(比如C++里链表节点有next指针)。
- 如果哈希函数设计不好,某个格子里的链表会变得特别长,查找就会退化成在长链表里一个个找,速度变慢(O(n))。
新手常犯的错误
- 忘了查找时要遍历整个链表:以为直接取第一个元素就是答案,导致漏掉其他键。
- 链表插入时忘记考虑重复键:应该先检查链表里是否已经有相同的键,如果有则覆盖或返回失败。
- 忽略内存释放:用链表实现时,删除节点后要记得释放内存(C/C++里容易忘记)。
代码示例(C++链地址法,简化版)
#include <iostream>
#include <vector>
#include <list> // 使用标准库链表
using namespace std;
class ChainedHashTable {
private:
int bucket_count; // 桶的个数
vector<list<int>> buckets; // 每个桶是一个链表
int hashFunction(int key) {
return key % bucket_count;
}
public:
ChainedHashTable(int size) : bucket_count(size) {
buckets.resize(bucket_count);
}
// 插入一个键
void insert(int key) {
int idx = hashFunction(key);
// 检查是否已存在(链表遍历)
for (int val : buckets[idx]) {
if (val == key) return; // 已存在,不重复插入
}
buckets[idx].push_back(key); // 插到链表末尾
}
// 查找键是否存在
bool search(int key) {
int idx = hashFunction(key);
for (int val : buckets[idx]) {
if (val == key) return true;
}
return false;
}
// 删除键
bool remove(int key) {
int idx = hashFunction(key);
// list的remove方法会删除所有匹配的元素,这里只删一个
auto it = buckets[idx].begin();
while (it != buckets[idx].end()) {
if (*it == key) {
buckets[idx].erase(it);
return true;
}
++it;
}
return false;
}
void print() {
for (int i = 0; i < bucket_count; ++i) {
cout << i << ": ";
for (int val : buckets[i]) cout << val << " -> ";
cout << "空" << endl;
}
}
};
int main() {
ChainedHashTable ht(5); // 5个桶
ht.insert(10); // 10 % 5 = 0 => 桶0
ht.insert(15); // 15 % 5 = 0 => 桶0(冲突,放在链表里)
ht.insert(22); // 22 % 5 = 2 => 桶2
ht.insert(3); // 3 % 5 = 3 => 桶3
cout << "链地址法哈希表:" << endl;
ht.print();
cout << "查找15: " << (ht.search(15) ? "找到" : "未找到") << endl;
ht.remove(15);
cout << "删除15后查找15: " << (ht.search(15) ? "找到" : "未找到") << endl;
return 0;
}
方法二:开放地址法——找不到空位就往前走一步
开放地址法的思路是:如果目标格子已经被占用了,就按照某种规则去寻找下一个空位。整个哈希表只有一张连续的大桌子,所有键都直接放在桌子上的某个位置,没有额外的链表。
生活中的比喻
你参加一个考试,考场里的座位编号是1到30。你拿到的座位号是13号,但你走到13号发现已经坐了一个人。怎么办?监考老师说:“去找旁边空的座位!”于是你检查14号、15号……直到找到第一个空位坐下。这就是线性探测。但这样容易形成“人堆”:如果连续几个座位都有人,后来的人就要走很远。
三种主要的探测方式
-
线性探测(Linear Probing)
公式:h(key, i) = (h(key) + i) % M,i = 0, 1, 2, …
如果位置被占,就+1、+2……直到找到空位。
优点:简单。缺点:容易产生“聚集”(连续占用的区域越来越大),查找速度会变慢。 -
二次探测(Quadratic Probing)
公式:h(key, i) = (h(key) + i^2) % M,i = 0, 1, 2, …
第一次冲突跳1^2=1步,第二次跳2^2=4步,第三次跳9步……这样能跳开连续区域,减少聚集。
注意:可能跳不满所有位置,但通常设计得当就没问题。 -
双重散列(Double Hashing)
公式:h(key, i) = (h1(key) + i * h2(key)) % M
用第二个哈希函数计算步长,每个键的探测序列都不一样,能有效避免聚集。
新手常犯的错误
- 删除操作后查找出错:如果直接将被删除的位置标记为“空”,会导致后续插入或查找时“断链”。例如:先插入10(位置0)、20(位置1),再删除10,然后把位置0设回空。此时查找20时,从hash(20)=0开始,发现位置0是空,就会误以为20不存在!所以开放地址法删除时,不能简单地置空,而要用“已删除”标记(惰性删除)。
- 哈希表满时无限循环:线性探测时如果表满了,会一直循环直到回到起点,陷入死循环。需要提前判断表是否还有空位。
- 二次探测只检查一半位置:当M是2的幂时,二次探测只能访问一半位置,可能漏掉空位。一般要求M是素数。
代码示例(线性探测,带惰性删除标记)
#include <iostream>
#include <vector>
using namespace std;
enum State { EMPTY, ACTIVE, DELETED }; // 状态:空、有效、已删除
class OpenHashTable {
private:
struct Node {
int key;
State state;
Node() : key(0), state(EMPTY) {}
};
vector<Node> table;
int capacity; // 表大小
int hashFunction(int key) {
return key % capacity;
}
public:
OpenHashTable(int size) : capacity(size) {
table.resize(capacity);
}
// 插入键(如果已存在则返回false)
bool insert(int key) {
int index = hashFunction(key);
int start = index;
while (table[index].state == ACTIVE) {
if (table[index].key == key) {
return false; // 已存在
}
index = (index + 1) % capacity;
if (index == start) {
return false; // 表满了
}
}
table[index].key = key;
table[index].state = ACTIVE;
return true;
}
// 查找键
bool search(int key) {
int index = hashFunction(key);
int start = index;
while (table[index].state != EMPTY) {
if (table[index].state == ACTIVE && table[index].key == key) {
return true;
}
index = (index + 1) % capacity;
if (index == start) break;
}
return false;
}
// 删除键(标记为DELETED)
bool remove(int key) {
int index = hashFunction(key);
int start = index;
while (table[index].state != EMPTY) {
if (table[index].state == ACTIVE && table[index].key == key) {
table[index].state = DELETED;
return true;
}
index = (index + 1) % capacity;
if (index == start) break;
}
return false;
}
void print() {
for (int i = 0; i < capacity; ++i) {
cout << i << ": ";
if (table[i].state == EMPTY) cout << "空";
else if (table[i].state == DELETED) cout << "已删除";
else cout << table[i].key;
cout << endl;
}
}
};
int main() {
OpenHashTable ht(7);
ht.insert(10); // 10%7=3
ht.insert(22); // 22%7=1
ht.insert(31); // 31%7=3 -> 冲突,线性探测到4
ht.insert(4); // 4%7=4 -> 冲突(被31占了),探测到5
ht.insert(15); // 15%7=1 -> 冲突,探测到2
ht.insert(28); // 28%7=0
ht.insert(17); // 17%7=3 -> 冲突,探测到4(已占)->5(已占)->6
cout << "开放地址法(线性探测)表:" << endl;
ht.print();
cout << "查找22: " << (ht.search(22) ? "找到" : "未找到") << endl;
ht.remove(22);
cout << "删除22后查找22: " << (ht.search(22) ? "找到" : "未找到") << endl;
// 再插入一个新键,验证惰性删除不影响
ht.insert(35); // 35%7=0 -> 冲突(0已有28),探测到1(已删除)->插入1
cout << "插入35后:" << endl;
ht.print();
return 0;
}
两种方法对比(表格升级版)
| 特性 | 链地址法 | 开放地址法 |
|---|---|---|
| 内存使用 | 每个桶要存指针(链表节点),额外开销大 | 只需一个数组,无指针开销,但需要更大初始空间(负载因子通常<0.7) |
| 负载因子 | 可以大于1,比如每个桶平均有3个元素 | 必须小于1,否则几乎找不空位,通常建议小于0.7 |
| 删除操作 | 简单,直接从链表移除节点 | 必须用惰性删除(标记),否则会断链;而且标记多了会浪费空间 |
| 缓存性能 | 链表节点在内存不连续,缓存不友好 | 数组连续,访问相邻位置速度快(适合CPU缓存) |
| 实现难度 | 简单,但要注意链表操作 | 较复杂,需处理探测、惰性删除、表满等 |
| 对哈希函数的要求 | 比较宽容,即使分布不均匀,也只是链表变长 | 要求严格,不均匀会导致聚集严重 |
| 常见场景 | 不确定数据量、删除频繁、内存充足(如Python的dict内部用链地址法) | 内存受限、查询密集、数据量可预估(如某些系统库) |
总结与相关指引
- 冲突是哈希表绕不开的问题,两种方法各有千秋。
- 链地址法像“多格收纳盒”,适合元素多且经常变动的情况。
- 开放地址法像“连续座位”,适合元素少且追求速度的情况。
- 掌握这两种方法,你就能自己设计哈希表了!接下来可以学习:
- 哈希函数的设计:如何让键均匀分布,减少冲突。
- 动态扩容:当负载因子过高时,如何把整个表扩大一倍(重新哈希)。
- 完美哈希:如果所有键已知且静态,能否实现零冲突?
现在,你已经知道了哈希表冲突处理的两种经典方法。下次写哈希表时,想想你的数据是像“班级信箱”还是“电影院座位”,选对方法,事半功倍!
例题精讲
哈希表使用链地址法解决冲突时,若哈希函数为H(key)=key mod 7,依次插入关键字12, 22, 29, 36, 15, 7,则哈希地址为1的链表中包含几个关键字?
开放地址法中的线性探测法容易产生“二次聚集”现象,而链地址法不存在这种现象。
以下函数使用链地址法向哈希表插入一个关键字,请补充完整。假设哈希表结构为Node* table[TABLE_SIZE],每个节点有key和next指针。
void insert(int key) {
int index = key % TABLE_SIZE;
Node* newNode = (Node*)malloc(sizeof(Node));
newNode->key = key;
newNode->next = ___;
table[index] = newNode;
}在开放地址法中,若采用二次探测法处理冲突,哈希函数H(key)=key%13,已存入关键字16、29、38、47,现插入45,其探测序列的最后一个探测位置是?
以下函数使用开放地址法(线性探测)在哈希表中查找关键字key,找到返回索引,否则返回-1。请补充完整。
int search(int key) {
int index = key % TABLE_SIZE;
int start = index;
while (table[index] != EMPTY) {
if (table[index] == key) return index;
index = (index + 1) % TABLE_SIZE;
if (index == start) ___;
}
return -1;
}