数据结构:闭散列 (Closed Hashing)-开放定址法 (Open Addressing)
目录
根本思想的转变
如何标记房间状态?
结构设计与代码框架
线性探测 (Linear Probing)
逐步完善代码:insert 和 search
二次探测 (Quadratic Probing)
逐步完善代码:insert 和 search
双重哈希 (Double Hashing)
逐步完善代码:insert 和 search
完整代码示例
我们深入探讨另一种解决哈希冲突的哲学:闭散列 (Closed Hashing),它更广为人知的名字是 开放定址法 (Open Addressing)。
这个名字听起来有点矛盾,“Closed Hashing” 又叫 “Open Addressing”?
-
闭散列 (Closed Hashing):这个名字强调的是“数据必须留在哈希表数组内部,是封闭的”,不能像拉链法那样“外出”到外部的链表结构中。
-
开放定址法 (Open Addressing):这个名字强调的是“地址是开放的”。如果我的目标地址被占了,我可以去寻找其他开放的、可用的地址。
这两个名字说的是同一件事的两个方面。
根本思想的转变
我们先回顾一下拉链法(Separate Chaining)的本质:
当
hashTable[i]
这个“房间”发生冲突时,我们在房间门口挂一个链条,把所有冲突的人都挂在这个链条上。这是一种“纵向扩展”的思路。
而开放定址法的哲学完全不同。它的核心原则是:
所有元素都必须存放在哈希表这个数组内,绝不外挂任何额外的数据结构。
那么,当 hashTable[i]
这个房间有人时,新来的人怎么办? 唯一的选择是:
去数组里的其他房间找一个空位住下。
这个“寻找下一个空位”的过程,就叫做 探测 (Probing)。根据寻找策略的不同,就衍生出了几种不同的探测方法。
开放定址法的本质,是将冲突解决方式从“在冲突位置进行纵向扩展(链表)”,转变为“在哈希表内部进行横向移动,寻找下一个可用空间”。
如何标记房间状态?
在拉链法中,一个位置 hashTable[i]
要么是 NULL
(空),要么是一个指向链表的指针。但在开放定址法中,数组里存放的是真实的数据项。这就带来一个新问题:
📍想象一个场景:
-
张三哈希到位置 5,住下。
-
李四也哈希到位置 5,发生冲突。他向后探测,住到了空闲的位置 6。
-
现在,我们把张三(位置 5 的元素)删除了。如果只是简单地把位置 5 标记为“空”,会发生什么?
-
此时我们去查找李四。计算哈希值得到 5,发现位置 5 是“空”的。程序会认为“既然初始位置是空的,那李四肯定不存在”,于是返回查找失败。但这显然是错的!
为了解决这个问题,每个槽位(slot)必须有三种状态:
EMPTY
:此位置为空,从未被占用过。查找时遇到它,表示查找失败。
OCCUPIED
:此位置已被占用。
DELETED
:此位置曾经被占用,但现在已删除。我们叫它“墓碑(tombstone)”。查找时遇到它,不能停,必须继续探测;但插入时遇到它,可以把它当作空位来使用。
结构设计与代码框架
第一步:定义数据项和状态
我们需要一个结构体来表示哈希表中的每个槽位,这个结构体必须包含数据和状态。
#include <iostream>
#include <string.h>// 使用枚举类型来清晰地表示三种状态
enum State { EMPTY, OCCUPIED, DELETED };// 哈希表中的数据项
struct DataItem {int id;char name[50];State state; // 每个槽位都有自己的状态
};
第二步:定义哈希表并初始化
哈希表就是一个 DataItem
的数组。初始化时,我们需要把所有槽位的状态都设置为 EMPTY
。
const int TABLE_SIZE = 11; // 同样,最好是素数// 哈希表本体
DataItem hashTable[TABLE_SIZE];// 哈希函数
int hash_function(int key) {return key % TABLE_SIZE;
}// 初始化哈希表
void init_hash_table() {for (int i = 0; i < TABLE_SIZE; ++i) {hashTable[i].state = EMPTY;hashTable[i].id = -1; // 初始化 id 为一个无效值}
}
好了,我们的舞台已经搭好。接下来,不同的探测方法,就是在这个舞台上表演的不同“走位”策略。
线性探测 (Linear Probing)
这是最直观、最简单的探测思想。
推导过程: 如果 hash(key)
计算出的位置 p
被占用了,怎么办?
最简单的想法:看看下一个位置 p+1
行不行?
如果
p+1
还被占用,就看p+2
... 以此类推,直到找到一个空位。如果到了数组末尾,就绕回到数组开头继续找。
👉这个探测序列就是:p, p + 1, p + 2, p + 3, ...
用公式表达就是: probe_index = (hash(key) + i) % TABLE_SIZE
,其中 i
是探测次数(从 0 开始)。
逐步完善代码:insert
和 search
插入 (Insert):
// 插入(线性探测)
void insert_linear(int id, const char* name) {int index = hash_function(id);int i = 0;// 循环探测while (i < TABLE_SIZE) {int probe_index = (index + i) % TABLE_SIZE;// 找到了一个可以插入的位置(空的或已删除的)if (hashTable[probe_index].state != OCCUPIED) {hashTable[probe_index].id = id;strcpy(hashTable[probe_index].name, name);hashTable[probe_index].state = OCCUPIED;std::cout << "ID " << id << " 插入到位置 " << probe_index << std::endl;return;}// 如果此位置已被占用,继续下一次探测i++;}// 如果循环走完还没找到位置,说明表满了std::cout << "哈希表已满,无法插入!" << std::endl;
}
查找 (Search):
// 查找(线性探测)
DataItem* search_linear(int id) {int index = hash_function(id);int i = 0;while (i < TABLE_SIZE) {int probe_index = (index + i) % TABLE_SIZE;// 如果遇到 EMPTY,说明要找的元素肯定不存在if (hashTable[probe_index].state == EMPTY) {return NULL;}// 如果遇到 OCCUPIED,需要比对 idif (hashTable[probe_index].state == OCCUPIED && hashTable[probe_index].id == id) {// 找到了!return &hashTable[probe_index];}// 如果是 DELETED 或者 id 不匹配,都继续探测i++;}// 探测了一整圈都没找到return NULL;
}
线性探测的致命弱点:一次聚集 (Primary Clustering)
当多个哈希值相近的元素被插入时,它们会形成连续的“区块”。后来的元素,一旦哈希到这个区块中的任何一个位置,就必须一直探测到区块的末尾才能插入。
这导致这个区块越来越长,查找和插入的效率急剧下降,这就是“聚集”问题。
二次探测 (Quadratic Probing)
线性探测的问题在于,每次探测的步长都是固定的 +1
。如何避免元素“抱团”?
一个自然的想法是:让探测的步子迈得大一点,而且越来越大。
二次探测就是这样一个策略。它的探测序列是:p, p+1^2, p+2^2, p+3^2, ... 即 p, p+1, p+4, p+9, ...
📌用公式表达就是: probe_index = (hash(key) + i*i) % TABLE_SIZE
这个方法可以有效地打散线性探测造成的“一次聚集”。
逐步完善代码:insert
和 search
代码结构和线性探测几乎完全一样,我们只需要修改计算 probe_index
的那一行。
// 插入(二次探测)
void insert_quadratic(int id, const char* name) {int index = hash_function(id);int i = 0;while (i < TABLE_SIZE) {// 唯一的区别在这里:探测步长是 i*iint probe_index = (index + i * i) % TABLE_SIZE;if (hashTable[probe_index].state != OCCUPIED) {hashTable[probe_index].id = id;strcpy(hashTable[probe_index].name, name);hashTable[probe_index].state = OCCUPIED;std::cout << "ID " << id << " 插入到位置 " << probe_index << " (经过 " << i << " 次探测)" << std::endl;return;}i++;}std::cout << "哈希表已满,无法插入!" << std::endl;
}// 查找(二次探测)
DataItem* search_quadratic(int id) {int index = hash_function(id);int i = 0;while (i < TABLE_SIZE) {// 唯一的区别在这里int probe_index = (index + i * i) % TABLE_SIZE;if (hashTable[probe_index].state == EMPTY) {return NULL;}if (hashTable[probe_index].state == OCCUPIED && hashTable[probe_index].id == id) {return &hashTable[probe_index];}i++;}return NULL;
}
二次探测的弱点:二次聚集 (Secondary Clustering)
虽然它解决了“一次聚集”,但它引入了新问题。
如果两个不同的 Key (k1
, k2
) 经过主哈希函数后得到相同的地址(hash(k1) == hash(k2)
),那么它们后续的整个探测序列 (p+1, p+4, p+9, ...)
将会是一模一样的。
它们仍然在争夺同一批槽位,只是争夺的模式不同了。
双重哈希 (Double Hashing)
推导过程: 二次聚集的根源是:探测序列的“模式”是固定的(+i*i
),与 Key 本身无关。
那么,终极解决方案就是:让每个 Key 的探测步长,由它自己来决定!
探测序列就变成了:p, p + 1·step, p + 2·step, p + 3·step, ...
✅用公式表达就是: probe_index = (hash1(key) + i * hash2(key)) % TABLE_SIZE
我们需要第二个哈希函数 hash2()
来专门计算这个“步长”。
📍hash2()
的设计要求:
-
绝不能返回 0。否则步长为 0,探测将卡在原地。
-
返回值应与
TABLE_SIZE
互质。这样才能保证探测序列能走遍整个哈希表。
一个常见的、简单的设计是: hash2(key) = R - (key % R)
,其中 R
是一个小于 TABLE_SIZE
的素数。
逐步完善代码:insert
和 search
我们先定义第二个哈希函数。
// 选择一个小于 TABLE_SIZE 的素数
const int R = 7; int hash2(int key) {return R - (key % R);
}
然后,我们再次修改 insert
和 search
中的 probe_index
计算方法。
// 插入(双重哈希)
void insert_double(int id, const char* name) {int index1 = hash_function(id); // 主哈希int step = hash2(id); // 第二个哈希函数计算步长int i = 0;while (i < TABLE_SIZE) {// 探测步长由 hash2 决定int probe_index = (index1 + i * step) % TABLE_SIZE;if (hashTable[probe_index].state != OCCUPIED) {hashTable[probe_index].id = id;strcpy(hashTable[probe_index].name, name);hashTable[probe_index].state = OCCUPIED;std::cout << "ID " << id << " 插入到位置 " << probe_index << " (步长 " << step << ")" << std::endl;return;}i++;}std::cout << "哈希表已满,无法插入!" << std::endl;
}// 查找(双重哈希)
DataItem* search_double(int id) {int index1 = hash_function(id);int step = hash2(id);int i = 0;while (i < TABLE_SIZE) {int probe_index = (index1 + i * step) % TABLE_SIZE;if (hashTable[probe_index].state == EMPTY) {return NULL;}if (hashTable[probe_index].state == OCCUPIED && hashTable[probe_index].id == id) {return &hashTable[probe_index];}i++;}return NULL;
}
双重哈希是开放定址法中性能最好、分布最均匀的方法,因为它极大地减少了各种聚集问题。
完整代码示例
下面是使用双重哈希的完整、可运行的代码,因为它是在开放定址法中综合性能最好的。
#include <iostream>
#include <string.h>// 1. 定义状态和数据项
enum State { EMPTY, OCCUPIED, DELETED };struct DataItem {int id;char name[50];State state;
};// 2. 定义哈希表和相关常量
const int TABLE_SIZE = 11;
const int R = 7; // 用于第二个哈希函数的素数DataItem hashTable[TABLE_SIZE];// 3. 实现哈希函数
// 主哈希函数
int hash1(int key) {return key % TABLE_SIZE;
}// 第二个哈希函数,用于计算步长
int hash2(int key) {return R - (key % R);
}// 4. 实现核心操作
// 初始化
void init_hash_table() {for (int i = 0; i < TABLE_SIZE; ++i) {hashTable[i].state = EMPTY;hashTable[i].id = -1;}
}// 插入(双重哈希)
void insert(int id, const char* name) {int index1 = hash1(id);int step = hash2(id);int i = 0;while (i < TABLE_SIZE) {int probe_index = (index1 + i * step) % TABLE_SIZE;if (hashTable[probe_index].state != OCCUPIED) {hashTable[probe_index].id = id;strcpy(hashTable[probe_index].name, name);hashTable[probe_index].state = OCCUPIED;std::cout << "插入成功: ID " << id << " (" << name << ") at index " << probe_index << std::endl;return;}i++;}std::cout << "错误: 哈希表已满,无法插入 " << id << std::endl;
}// 查找(双重哈希)
DataItem* search(int id) {int index1 = hash1(id);int step = hash2(id);int i = 0;while (i < TABLE_SIZE) {int probe_index = (index1 + i * step) % TABLE_SIZE;if (hashTable[probe_index].state == EMPTY) {return NULL; // 遇到空,说明不存在}if (hashTable[probe_index].state == OCCUPIED && hashTable[probe_index].id == id) {return &hashTable[probe_index]; // 找到了}// 遇到 DELETED 或者 id 不匹配,继续探测i++;}return NULL; // 找了一圈,不存在
}// 删除操作
void remove(int id) {DataItem* item = search(id);if (item != NULL) {item->state = DELETED;std::cout << "删除成功: ID " << id << std::endl;} else {std::cout << "删除失败: 未找到 ID " << id << std::endl;}
}// 打印哈希表状态,用于调试
void print_table() {std::cout << "------ 哈希表状态 ------" << std::endl;for (int i = 0; i < TABLE_SIZE; ++i) {std::cout << "Index " << i << ": ";if (hashTable[i].state == EMPTY) {std::cout << "--- EMPTY ---" << std::endl;} else if (hashTable[i].state == DELETED) {std::cout << "--- DELETED ---" << std::endl;} else {std::cout << "ID=" << hashTable[i].id << ", Name=" << hashTable[i].name << std::endl;}}std::cout << "-------------------------" << std::endl;
}// 5. 主函数测试
int main() {init_hash_table();// 插入数据,故意制造冲突// 5, 16, 27 都会哈希到 5insert(5, "Alice");insert(16, "Bob"); // 冲突insert(27, "Charlie"); // 再次冲突insert(8, "David");insert(19, "Eve"); // 19 % 11 = 8, 与 David 冲突print_table();// 查找测试int ids_to_find[] = {16, 8, 99};for (int id : ids_to_find) {DataItem* result = search(id);if (result) {std::cout << "查找成功: ID=" << result->id << ", Name=" << result->name << std::endl;} else {std::cout << "查找失败: 未找到 ID=" << id << std::endl;}}// 删除和之后再查找的测试remove(16); // 删除 Bobprint_table();std::cout << "删除 Bob(16) 后再次查找 Charlie(27)..." << std::endl;DataItem* charlie = search(27);if (charlie) {std::cout << "查找成功: ID=" << charlie->id << ", Name=" << charlie->name << std::endl;} else {std::cout << "查找失败: 未找到 ID=27" << std::endl;}return 0;
}