数据结构——哈希表
哈希表
一、什么是哈希表?
1. 从查找说起
在之前学习了顺序查找、折半查找和索引查找。这些查找方法的共同点是通过比较关键字来定位元素,要么逐个比较,要么在有序表中折半比较,要么先在索引表中比较再在块内比较。
但有没有一种方法,可以不经过任何比较,直接通过关键字找到元素的位置呢?
就像去体育馆存包——储物柜上有编号,你根据手环上的号码直接走到对应的柜子前,打开就能存取物品,完全不需要挨个去试哪个柜子是空的。这就是哈希表(Hash Table)的核心思想。
哈希表(Hash Table,又称散列表)是一种通过关键字(Key)直接计算出存储位置的数据结构。它利用一个函数(称为哈希函数 / 散列函数)将关键字映射到表中的某个位置,从而实现 $O(1)$ 时间内的查找、插入和删除。
2. 哈希表的基本思想
哈希表的基本思想可以用一个公式概括:
$$\text{存储位置} = \text{Hash}(\text{关键字})$$
其中 $\text{Hash}$ 是一个函数,它接收关键字作为输入,输出该关键字在表中的存储位置(通常是一个整型下标)。
// 哈希表最理想的样子:直接通过关键字找到位置
int position = Hash(key); // 算出位置
data[position] = value; // 直接存取
3. 哈希表的几个重要概念
| 概念 | 含义 |
|---|---|
| 关键字(Key) | 用来标识数据元素的唯一标识 |
| 哈希函数(Hash Function) | 将关键字映射为存储位置的函数 |
| 哈希表(Hash Table) | 存储数据元素的数组(或结构) |
| 哈希地址(Hash Address) | 哈希函数计算出的存储位置 |
| 冲突(Collision) | 不同关键字映射到同一个位置 |
| 同义词(Synonym) | 发生冲突的多个关键字 |
注意:哈希表是一种存储结构(物理结构),属于"散列存储"。它与顺序存储、链式存储、索引存储并列,是四种基本的存储方式之一。
二、哈希函数
1. 哈希函数的核心性质
哈希函数是将关键字映射为存储位置的函数,它有两个最核心的性质:
① 确定性(Determinism)——相同的参数必须返回相同的值
这是哈希表能工作的前提。如果用同一个关键字 key 调用哈希函数,有时得到地址 3、有时得到地址 7,那查找就完全无法进行了。不管调用多少次,Hash("张三") 都必须返回同一个地址。
$$
\text{Hash}(key) \quad \text{每次调用结果都相同}
$$
② 均匀性(Uniformity)——不同的参数应尽可能返回不同的值
理想情况下,不同的关键字应该映射到不同的存储位置,这样才能一对一地存放,查找时一步到位。
$$
\text{如果 } key_1 \neq key_2 \text{,应尽量让 } \text{Hash}(key_1) \neq \text{Hash}(key_2)
$$
满足上述两个性质后,哈希表的工作方式就是:
- 存:计算
position = Hash(key),把数据放到该位置 - 取:计算
position = Hash(key),直接从该位置读取数据
整个过程只需要一次函数计算,没有任何比较——这就是哈希表能做到 $O(1)$ 的原因。
2. 冲突的出现
但现实中没有那么完美——哈希表的地址空间是有限的(比如只有 100 个位置),而关键字的取值范围可能非常大(比如学号有 8 位数,共 10^8 种可能)。当关键字数量超过地址空间时,必然会有不同的关键字映射到同一个地址。
即使关键字数量不超过地址空间,由于哈希函数是对关键字的一种"压缩映射"(将大范围映射到小范围),不同关键字算出的地址偶然相同也在所难免:
$$\text{Hash}(key_1) = \text{Hash}(key_2) \quad \text{但} \quad key_1 \neq key_2$$
这就是冲突(Collision)。因此,冲突是哈希表无法完全避免的。当冲突发生时,需要有策略来解决。常见的冲突处理方法分为两大类:开放地址法和链地址法,将在下一章详细讨论。
3. 什么是好的哈希函数?
哈希函数的设计直接影响哈希表的性能和冲突的多少。一个好的哈希函数应当满足:
- 计算简单:哈希函数的计算时间应尽可能短,不能比直接查找还慢
- 分布均匀:不同关键字应尽可能均匀地映射到整个地址空间,减少冲突
- 冲突尽可能少:即使是相似的关键字(如 "abc" 和 "abd"),也应映射到不同的位置
4. 常见的哈希函数
(1)直接定址法
直接取关键字本身或关键字的某个线性函数作为哈希地址:
$$\text{Hash}(key) = a \times key + b$$
其中 $a$ 和 $b$ 为常数。
// 例子:key 本身是学号(如 2024001),直接作为地址
int Hash(int key) {
return key - 2024000; // a = 1, b = -2024000
}
特点:
- 不会产生冲突(因为是一一映射)
- 但要求关键字分布连续且范围较小,否则会造成大量空间浪费
适用场景:关键字是连续的整数,或者可以映射为连续的整数。
(2)除留余数法
用关键字除以一个不大于哈希表长度的正整数 $p$,取余数作为哈希地址:
$$\text{Hash}(key) = key \bmod p$$
其中 $p$ 一般取不大于表长 $m$ 的最大质数(或至少是一个质数),这样可以使关键字分布更均匀。
#define M 100 // 哈希表长度
int Hash(int key) {
return key % 97; // 97 是不大于 100 的最大质数
}
为什么 $p$ 要选质数? 如果 $p$ 是合数,比如 $p = 2^k$,那么实际上只取了 key 的最低 k 位,高位完全被丢弃,分布极不均匀。而质数可以保留关键字的全部信息,减少周期性模式带来的聚集。
特点:
- 实现最简单,应用最广泛
- 需要精心选择 $p$ 以减小冲突
(3)平方取中法
先计算关键字平方的中间几位作为哈希地址。因为平方的中间几位与关键字的每一位都有关,分布较为均匀。
// 例子:key = 1234
// key^2 = 1522756,取中间两位 22 作为哈希地址
int Hash(int key) {
int square = key * key;
return (square / 100) % 100; // 取中间两位
}
特点:
- 地址分布均匀,与关键字每一位都相关
- 适合关键字位数较少、且不了解关键字分布的情况
(4)折叠法
将关键字分割成位数相同的几部分(最后一部分位数可以不同),然后将这几部分叠加求和(舍弃进位)作为哈希地址。
// 例子:key = 1234567890,每 3 位一组
// 123 + 456 + 789 + 0 = 1368,取后 3 位 368
int Hash(int key) {
int sum = 0;
while (key > 0) {
sum += key % 1000; // 每次取低 3 位
key /= 1000;
}
return sum % 1000; // 取后 3 位
}
特点:
- 适合关键字位数较长的情况
- 不需要了解关键字的分布
(5)数字分析法
分析关键字的各位数字分布情况,选取分布较均匀的几位作为哈希地址。
例如:有 100 个学生的学号:
20240301、20240302、……、20240400。前 4 位 "2024" 全部相同(无区分度),后 2 位 "01"~"40" 变化较大,可取后 2 位作为哈希地址。
特点:
- 需要预先知道关键字的分布情况
- 适合关键字位数较多、且分布已知的场景
(6)字符串的哈希函数
对于字符串类型的关键字,需要将字符串转换为整数再进行哈希:
// 一种简单的字符串哈希:将字符串视为 base 进制数
int HashString(char *str) {
int hash = 0;
while (*str) {
hash = hash * 31 + (*str); // 31 是一个常用的质数因子
str++;
}
return hash % M;
}
5. 哈希函数的选择
| 方法 | 适用场景 | 冲突概率 |
|---|---|---|
| 直接定址法 | 关键字连续分布 | 无冲突 |
| 除留余数法 | 通用,最常用 | 较低($p$ 选质数时) |
| 平方取中法 | 不了解关键字分布 | 较低 |
| 折叠法 | 关键字位数较长 | 较低 |
| 数字分析法 | 关键字分布已知 | 最低(分析得当) |
三、处理冲突的方法
如上所述,冲突是哈希表无法避免的。当不同关键字映射到同一个地址时,需要有具体的策略来解决。常见的方法分为两大类:开放地址法和链地址法。
1. 开放地址法
开放地址法(Open Addressing)的思想是:当发生冲突时,按照某种规则在哈希表中寻找另一个空闲位置来存放元素。
$$H_i = (H(key) + d_i) \bmod m$$
其中 $H(key)$ 是初始哈希地址,$d_i$ 是增量序列,$m$ 是表长。
根据增量序列 $d_i$ 的不同,开放地址法又分为以下几种:
(1)线性探测法
线性探测法(Linear Probing)的增量序列是:$d_i = 1, 2, 3, \dots, m-1$。即冲突后逐个往后找,直到找到空位或回到起点。
示例:哈希表长度 $m = 7$,哈希函数为 $\text{Hash}(key) = key \bmod 7$,依次插入关键字:50、62、43、57、48。
| 步骤 | 插入 key | $\text{Hash}(key)$ | 过程 | 结果数组 |
|---|---|---|---|---|
| ① | 50 | $50 \bmod 7 = 1$ | 位置 1 空,直接放入 | [ ][50][ ][ ][ ][ ][ ] |
| ② | 62 | $62 \bmod 7 = 6$ | 位置 6 空,直接放入 | [ ][50][ ][ ][ ][ ][62] |
| ③ | 43 | $43 \bmod 7 = 1$ | 位置 1 被占,查 2 空,放入 | [ ][50][43][ ][ ][ ][62] |
| ④ | 57 | $57 \bmod 7 = 1$ | 1、2 均被占,3 空,放入 | [ ][50][43][57][ ][ ][62] |
| ⑤ | 48 | $48 \bmod 7 = 6$ | 6 被占,查 0(回到开头)空,放入 | [48][50][43][57][ ][ ][62] |
查找过程:要查找 key = 57,先算得 $\text{Hash}(57) = 1$,检查位置 1 发现是 50(不等),继续往后直到位置 3 找到 57。如果要查找 key = 58($\text{Hash}=2$),位置 2、3、4 都不等,直到位置 5 为空,说明 58 不存在。
线性探测的缺点是聚集。 如上例所示,43、57 等本应分散的元素因为冲突而连续聚集在一起,形成"堆积"(Clustering)。堆积区域越长,后续插入和查找需要探测的位置就越多,性能下降越严重。
#define M 100
typedef struct {
int key; // 关键字
int val; // 值
int flag; // 标志:0 空,1 占用,2 已删除(惰性删除)
} HashEntry;
typedef struct {
HashEntry data[M];
int count; // 当前元素个数
} HashTable;
// 线性探测插入
int HashInsert(HashTable *H, int key, int val) {
int addr = key % M; // 计算哈希地址
int i = 0;
while (H->data[addr].flag == 1 && H->data[addr].key != key) {
i++;
addr = (addr + 1) % M; // 线性探测下一个位置
if (i >= M) return 0; // 表已满
}
H->data[addr].key = key;
H->data[addr].val = val;
H->data[addr].flag = 1;
H->count++;
return 1;
}
// 线性探测查找
int HashSearch(HashTable H, int key) {
int addr = key % M; // 计算哈希地址
while (H.data[addr].flag != 0) {
if (H.data[addr].flag == 1 && H.data[addr].key == key)
return addr; // 查找成功,返回位置
addr = (addr + 1) % M; // 继续探测
}
return -1; // 查找失败
}
惰性删除:线性探测中不能直接删除一个元素(将
flag置为 0),否则会切断查找路径,导致后续元素查找不到。例如上例中删除 43 后将位置 2 置空,再查找 57 时,从位置 1 开始探测,到位置 2 发现为空就会误以为 57 不存在。正确的做法是将flag置为 2(已删除),查找时遇到已删除标记继续探测,插入时才可用该位置。
(2)平方探测法
平方探测法(Quadratic Probing)的增量序列是:$d_i = 1^2, -1^2, 2^2, -2^2, \dots$。即冲突后按下标跳 $\pm 1^2, \pm 2^2, \pm 3^2, \dots$ 的位置探测。
$$H_i = (H(key) + i^2) \bmod m \quad \text{或} \quad (H(key) - i^2) \bmod m \quad (i = 1, 2, 3, \dots)$$
示例:$m = 11$,$\text{Hash}(key) = key \bmod 11$,插入 47、36、58、69:
| 步骤 | 插入 key | $\text{Hash}$ | 冲突探测过程 | 存入位置 |
|---|---|---|---|---|
| ① | 47 | $47 \bmod 11 = 3$ | 位置 3 空,直接放入 | 3 |
| ② | 36 | $36 \bmod 11 = 3$ | 位置 3 被占,探测 $3+1^2=4$ 空 | 4 |
| ③ | 58 | $58 \bmod 11 = 3$ | 3、4 被占,探测 $3-1^2=2$ 空 | 2 |
| ④ | 69 | $69 \bmod 11 = 3$ | 3、4、2 被占,探测 $3+2^2=7$ 空 | 7 |
平方探测的优点:探测位置跳跃式分布,有效避免了线性探测的"聚集"问题。
平方探测的缺点:可能探测不到所有空位。有定理证明:当表长 $m$ 为 $4k+3$ 的质数时(如 7、11、19、23 等),平方探测法一定能探测到整个表空间。
(3)双散列法
双散列法(Double Hashing)使用两个哈希函数:第一个计算初始位置,第二个计算探测步长。
$$H_i = (H_1(key) + i \times H_2(key)) \bmod m$$
其中 $H_2(key)$ 必须与 $m$ 互质,且不能为 0。
int H1(int key) { return key % M; }
int H2(int key) { return (M - 2) - key % (M - 2); } // 确保与 M 互质
// 双散列插入
int DoubleHashInsert(HashTable *H, int key, int val) {
int addr = H1(key);
int step = H2(key); // 探测步长
int i = 0;
while (H->data[addr].flag == 1 && H->data[addr].key != key) {
i++;
addr = (addr + step) % M; // 按步长跳跃探测
if (i >= M) return 0;
}
H->data[addr].key = key;
H->data[addr].val = val;
H->data[addr].flag = 1;
H->count++;
return 1;
}
双散列法让每个关键字的探测步长各不相同,即使初始地址相同,后续的探测路径也不同,基本上避免了聚集问题,是最优秀的开放地址法之一。
2. 链地址法
链地址法(Separate Chaining)的思路与开放地址法完全不同,它不试图在表中找空位,而是让每个哈希地址对应一个链表,所有映射到同一地址的元素都存入该链表中。
哈希表数组 链表
[0] → NULL
[1] → [43|·] → [50|·] → [57|NULL]
[2] → NULL
[3] → [62|NULL]
[4] → NULL
[5] → [48|NULL]
[6] → NULL
(1)链地址法的实现
#define M 100
// 链表结点
typedef struct Node {
int key;
int val;
struct Node *next;
} Node;
// 哈希表:每个位置是一个链表的头指针
typedef struct {
Node *head[M]; // 每个位置存储一个链表头指针
int count; // 元素总数
} HashTable;
// 初始化
void InitHashTable(HashTable *H) {
for (int i = 0; i < M; i++)
H->head[i] = NULL;
H->count = 0;
}
// 插入(头插法,也可以尾插)
int ChainInsert(HashTable *H, int key, int val) {
int addr = key % M;
Node *p = (Node *)malloc(sizeof(Node));
p->key = key;
p->val = val;
p->next = H->head[addr]; // 新结点指向原来的链表头
H->head[addr] = p; // 更新链表头
H->count++;
return 1;
}
// 查找
int ChainSearch(HashTable H, int key) {
int addr = key % M;
Node *p = H.head[addr];
while (p) {
if (p->key == key)
return p->val; // 查找成功
p = p->next;
}
return -1; // 查找失败
}
// 删除
int ChainDelete(HashTable *H, int key) {
int addr = key % M;
Node *p = H->head[addr];
Node *prev = NULL;
while (p) {
if (p->key == key) {
if (prev == NULL)
H->head[addr] = p->next; // 删除第一个结点
else
prev->next = p->next; // 跳过 p
free(p);
H->count--;
return 1;
}
prev = p;
p = p->next;
}
return 0; // 未找到
}
为什么链地址法不需要惰性删除? 因为删除是对链表结点的直接操作——修改指针、释放内存,不会影响其他关键字的查找路径。每个关键字只在自己的链表中,删除一个不会"切断"其他人的路。
(2)链地址法的优缺点
优点:
- 处理冲突简单,不会出现开放地址法中的"聚集"现象
- 删除操作方便,不需要惰性删除
- 表长可以灵活设定,即使装填因子接近 1 也能正常工作
- 适合元素个数不确定的场景,链表可以动态增长
缺点:
- 需要额外存储指针,存储密度较低
- 查找时要遍历链表,缓存不友好(链表结点在内存中不连续)
- 当链表过长时,查找性能退化为 $O(n)$
3. 两种冲突处理方法的对比
| 对比维度 | 开放地址法 | 链地址法 |
|---|---|---|
| 存储密度 | 高(只存数据) | 较低(额外存指针) |
| 查找性能 | 可能产生聚集,性能退化 | 链表变长时性能退化 |
| 删除操作 | 惰性删除,不能直接删除 | 直接修改指针删除 |
| 装填因子 | 通常 $\alpha \le 0.75$ | $\alpha$ 可以接近或超过 1 |
| 缓存友好 | 好(连续内存访问) | 差(链表结点分散) |
| 实现复杂度 | 较低 | 略高(需要指针操作) |
四、装填因子与性能分析
1. 装填因子
装填因子(Load Factor)$\alpha$ 是衡量哈希表"有多满"的指标:
$$\alpha = \frac{\text{表中已有的元素个数}}{\text{哈希表的长度}}$$
- $\alpha$ 越大,表越满,冲突概率越高,查找/插入效率越低
- $\alpha$ 越小,表越空,冲突概率越低,但空间浪费越大
2. 各种查找方法的综合对比
| 查找方法 | 时间复杂度 | 空间复杂度 | 适用条件 |
|---|---|---|---|
| 顺序查找 | $O(n)$ | $O(1)$ | 无要求 |
| 折半查找 | $O(\log n)$ | $O(1)$ | 有序顺序表 |
| 索引查找 | $O(\sqrt{n})$ | $O(n)$ | 块间有序 |
| 哈希查找 | $O(1)$(平均) | $O(m)$ | 需要设计哈希函数 |
总结:哈希表用"空间换时间",通过额外的空间和巧妙的映射函数,将查找时间从 $O(n)$ 降到了 $O(1)$。但这是平均情况,最坏情况下(所有关键字映射到同一位置)会退化为 $O(n)$。
五、总结
哈希表是一种通过关键字直接计算存储位置的数据结构,它利用哈希函数将关键字映射为地址,并在冲突发生时通过开放地址法或链地址法来解决。追求查找速度、不需要有序遍历时选 哈希表
哈希表的核心优势是平均 $O(1)$ 的查找、插入和删除性能,是所有查找方法中平均效率最高的。但它也有缺点:
- 需要额外空间:为了减少冲突,哈希表通常预留比实际数据更多的空间
- 最坏情况性能差:当哈希函数设计不当或装填因子过高时,可能退化为 $O(n)$
- 不支持有序操作:哈希表中的元素是无序的,无法进行范围查询或排序输出
哈希表的设计权衡:表长越大,冲突越少,速度越快,但空间浪费越多;表长越小,空间利用率越高,但冲突增多、速度下降。装填因子 $\alpha$ 就是用来衡量这个平衡的指标,通常控制在 0.5~0.75 之间最为理想。

浙公网安备 33010602011771号