Appearance
哈希表:拉链法
2026 大纲 六(七)散列(hash)表 · 基本概念、散列函数构造与拉链法(开放定址法见另一篇)。
用一次计算,代替一串比较
前面所有查找方法都在做同一件事:靠比较把候选范围一点点压小。顺序查找每次排除一个,折半查找每次排除一半,B 树每次排除
散列查找换了条路:由关键字直接算出地址,一步到位。
代价也很直接:记录的位置与关键字的大小再无关系。于是散列表不能做范围查询、不能有序输出——这是它换来
而这条路上有一个绕不开的麻烦:
🔴
却 ,这叫冲突,两者互称同义词。冲突只能减少,不能消除。
为什么消不掉?因为关键字的取值集合通常远远大于地址空间。以标识符为例,不超过 8 位、字母开头的字母数字串有
所以散列查找必须同时研究两个问题:怎么构造散列函数(少产生冲突),以及冲突了怎么办。本篇讲前者和拉链法,开放定址法在另一篇。
先动手看一眼
除留余数法: 的两条要求各管一件事
五种构造方法里除留余数法最常用、适用范围最广:
这两条要求各管各的:
保证算出的地址落在表内——这是硬性的正确性要求。 取质数保证关键字里隐含的周期性不被 放大成地址聚集。
第二条值得验算一下。若
| 5 | 15 | 25 | 35 | 45 | |
|---|---|---|---|---|---|
| 5 | 5 | 5 | 5 | 5 |
五个关键字全撞在地址 5 上,其余九个地址一个都用不到。换成质数
| 5 | 15 | 25 | 35 | 45 | |
|---|---|---|---|---|---|
| 5 | 4 | 3 | 2 | 1 |
完全散开。质数没有除 1 和自身以外的因子,所以关键字里隐含的任何周期性都不会被
⚠️
与表长 未必相等( 时 取 97)。 时下标 永远不会成为任何一次查找的起点——这条会直接决定失败 ASL 的分母,是本章最大的失分点之一。
其余四种方法列在这里,考到的多是"哪种方法适用于什么情形":
| 方法 | 做法 | 适用情形 |
|---|---|---|
| 直接定址法 | 关键字小且连续,绝不冲突,但浪费空间 | |
| 数字分析法 | 挑分布最均匀的若干位做地址 | 关键字集合事先已知、各位分布不均 |
| 平方取中法 | 取 | 各位分布不均且不知分布规律 |
| 折叠法 | 分成等长几段相加,取和的后几位 | 地址位数少、关键字位数多 |
拉链法:同义词各挂各的链
散列地址相同的记录放在同一条单链表(同义词链表)里,HT[0..m-1] 存各链表的头指针。
c
#define M 13 // 散列表槽位数,本例中 p 也取 13
typedef struct Node { int key; struct Node *next; } Node;
typedef struct { Node *head; } HashTable[M];
// 查找:返回结点指针,NULL 表示失败
Node* search(HashTable ht, int key) {
int idx = key % M; // 这一步是计算,不算关键字比较
Node *p = ht[idx].head;
while (p != NULL) {
if (p->key == key) return p;
p = p->next; // 每前进一步就是一次关键字比较
}
return NULL;
}
// 删除:找到并摘除结点,返回是否成功
int delete_key(HashTable ht, int key) {
int idx = key % M;
Node *p = ht[idx].head, *pre = NULL;
while (p != NULL && p->key != key) { pre = p; p = p->next; }
if (p == NULL) return 0;
if (pre == NULL) ht[idx].head = p->next; // 🔴 删的是链头,要改数组里的头指针
else pre->next = p->next;
free(p);
return 1;
}⚠️ pre == NULL 那一支不能少:漏掉它,删除每条链的第一个结点都会失效。
这个结构带来三条性质,每一条都和开放定址法形成对照:
- 表不会满,
可以大于 1。 链表想挂多长挂多长, 只是让平均链长超过 1。 - 删除直接摘结点,不需要墓碑标记。 开放定址法必须打墓碑,否则截断探测链。
- 不产生堆积。 不同的散列地址在不同的链上,互不干扰;开放定址法的线性探测则会让非同义词互相争抢。
这三条合起来就是"频繁插入删除时优先选拉链法"的完整理由。
还有一条:头插还是尾插,对成功 ASL 没有影响——一条长度
装填因子:ASL 是 的函数,不是 的函数
🔴 散列表的平均查找长度是
的函数,而不是记录个数 的函数。 这句话就是"散列查找接近 "的准确含义——表长跟着记录数一起涨、 保持不变时,查找代价就不随 增长。
拉链法的平均查找长度是
怎么触发最坏情形:
失败 ASL 的分母,以及两种计数算法
成功 ASL 的分母是元素个数
失败 ASL 的分母是散列函数的值域大小——不是
但拉链法这里还多一层麻烦,是真实存在的教材分歧:
| 算法 | "走到链尾发现 NULL"算不算比较 | 长度 | 全表结果 |
|---|---|---|---|
| A(严蔚敏教材) | 算 | ||
| B(只数关键字比较) | 不算 |
(
题目写明"空指针也算一次比较"或给出教材算法 → 算法 A;只说"求平均查找长度" → 多数复习材料按算法 B(此时失败 ASL 恰等于
⚠️ 开放定址法没有这个分歧:它遇到空位必须算 1 次(那是一次真实的单元访问,判定失败正是靠它)。两种冲突处理方法失败 ASL 的数法本来就不同,别把一边的习惯搬到另一边。
建表与两个 ASL 的完整推演(第一次学、或想手动模拟时展开)
设
| 19 | 14 | 23 | 1 | 68 | 20 | 84 | 27 | 55 | 11 | 10 | 79 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 6 | 1 | 10 | 1 | 3 | 7 | 6 | 1 | 3 | 11 | 10 | 1 |
得到散列表:
| 槽位 | 链表 | 链长 |
|---|---|---|
| 0 | 空 | 0 |
| 1 | 14 → 1 → 27 → 79 | 4 |
| 2 | 空 | 0 |
| 3 | 68 → 55 | 2 |
| 4 | 空 | 0 |
| 5 | 空 | 0 |
| 6 | 19 → 84 | 2 |
| 7 | 20 | 1 |
| 8 | 空 | 0 |
| 9 | 空 | 0 |
| 10 | 23 → 10 | 2 |
| 11 | 11 | 1 |
| 12 | 空 | 0 |
校验:
成功 ASL(
通用写法:一条长度为
代入验证:
失败 ASL(分母 = 值域大小 13)。算法 A(空槽比 1 次,长度 4 的链比 5 次):
算法 B(只统计与真实关键字的比较,空槽 0 次):
两个结果恰好相差 1,因为每个入口都差了"和 NULL 比的那一次"。
理论 ASL 公式与直接计算为什么对不上(题目只给装填因子时展开)
在等概率、散列函数均匀的假设下:
| 指标 | 公式 | 与上面算例对照 |
|---|---|---|
⚠️ 对不上是正常的。 理论公式的前提是"关键字在各地址上均匀随机分布",而具体的一张表总有它自己的偏斜。题目给了具体的表就必须直接计算,不许套公式;只有题目只给
构造散列函数的完整考量与复杂度来历(想补齐基本概念的完整表述就展开)
构造散列函数要考虑的五个因素:散列表长度、关键字长度、关键字的分布情况、计算函数的时间、记录的查找频次。
两条原则:① 计算要简单,每个关键字只对应一个散列地址;② 函数的值域必须落在表长范围内,且算出的地址分布应尽量均匀。
复杂度的来历:
| 操作 | 平均 | 最坏 | 怎么来的 |
|---|---|---|---|
| 查找 | 1 次计算散列地址 + 平均扫过 | ||
| 插入 | 先查找再头插( | ||
| 删除 | 先查找再摘链结点, |
空间复杂度 next 指针)。
插入代码(先查后插,避免重复;头插
c
void insert(HashTable ht, int key) {
if (search(ht, key) != NULL) return;
int idx = key % M;
Node *s = (Node*)malloc(sizeof(Node));
s->key = key;
s->next = ht[idx].head;
ht[idx].head = s;
}拉链法与开放定址法的逐项对照(在两者之间做选择时展开)
| 比较维度 | 拉链法 | 开放定址法 |
|---|---|---|
| 冲突处理 | 同义词挂在同一条链表上 | 按探测序列在表内找空位 |
| 装填因子 | 可以 | 必须 |
| 删除操作 | 直接摘除链结点 | 只能逻辑删除(打墓碑),否则截断探测链 |
| 堆积(聚集) | 不会产生——不同地址在不同链上 | 会产生,线性探测尤其严重 |
| 空间开销 | 每个结点多一个指针,结点动态申请 | 无指针开销,但要预留空位 |
| 表长是否需预知 | 不需要 | 需要,且要留出余量 |
| 适用场景 | 表长不确定、频繁插入删除 | 表长已知、较少删除 |
| 失败 ASL 遇空时 | 有两种算法(空指针算不算一次) | 必算 1 次,无分歧 |
考点速记
三条结论:
- 冲突只能减少不能消除(关键字空间远大于地址空间);散列查找必答"构造函数"和"处理冲突"两件事。
- ASL 是
的函数,不是 的函数——这是"接近 "的准确表述。 - 拉链法三特性:
可 、删除直接摘结点、不产生堆积。
这一节在真题里被考过的形式(下方「真题练习」逐题对应):
- 提高散列表查找效率的措施:设计冲突少的散列函数 ✓、处理冲突时避免堆积 ✓、增大装填因子 ✗(方向反了)。这道题同时挂在拉链法和开放定址法下,因为三个选项对两种方法都成立。
- 作为其他题目的算法载体:拉链法本身在近年的选择题里不单独成题,开放定址法那边的计算题密度高得多。复习时把重心放在开放定址法的 ASL 计算上,拉链法只需记住三条特性和"分母是值域"这一条。
易错:失败 ASL 的分母是散列函数的值域大小,
而表长 16 时分母是 13。
易错:拉链法失败 ASL 有两种算法,开放定址法没有。 前者的"走到 NULL"算不算比较,两种教材不一致,答题写明用的是哪一种;后者遇到空位必算 1 次。
易错:"增大装填因子能提高效率"是反的。
越大,平均链长越长,ASL 越大。
教材出处
- 严蔚敏《数据结构(C 语言版)》(第 2 版)7.4.1 节「散列表的基本概念」,p220: 散列函数与散列地址、散列表、冲突和同义词的定义—— "对不同的关键字可能得到同一散列地址……这种现象称为冲突。 具有相同函数值的关键字对该散列函数来说称作同义词"; 以及"散列查找法主要研究以下两方面的问题:(1)如何构造散列函数;(2)如何处理冲突"。
- 同书 7.4.2 节「散列函数的构造方法」,p221–p223:构造散列函数要考虑的五个因素与两条原则; 数字分析法、平方取中法、折叠法、除留余数法。其中除留余数法: "假设散列表表长为
,选择一个不大于 的数 ,用 去除关键字, 除后所得余数为散列地址……这个方法的关键是选取适当的 ,一般情况下, 可以选 为小于表长的最大质数。例如,表长 ,可取 "。 - 同书 7.4.3 节,p224:链地址法——"把具有相同散列地址的记录放在同一个单链表中, 称为同义词链表。有
个散列地址就有 个单链表, 同时用数组 HT[0..m-1]存放各个链表的头指针";例 7.2 给出的关键字序列与 正是本篇算例的出处。 - 同书 p227–p228:装填因子的定义与"散列表的平均查找长度是
的函数, 而不是记录个数 的函数";表 7.3 给出链地址法的理论平均查找长度 (成功 ,失败 ); p228 用例 7.2 的数据直接计算得 、 (该书把"指针域为空"计作一次比较,即本篇的算法 A),并指出 "链地址法的平均查找长度小于开放地址法"、"链地址法的结点空间是动态申请的, 无需事先确定表的容量,因此更适用于表长不确定的情况"。
相关知识
开放定址法(另一种冲突处理方案,计算题主要出在那边)| 查找基本概念(散列失败 ASL 分母为什么与众不同)| 单链表(每个槽位挂的就是它)| B 树、B+ 树(需要范围查询时的替代)| 查找算法分析与对比