哈希表(Hash Table)是计算机科学中最重要的数据结构之一,它能够在平均 O(1) 时间内完成数据的插入、删除和查找操作。本文将围绕**拉链法(Chaining)**的实现,结合代码示例和图示,深入讲解哈希表的核心原理和设计细节。
1. 哈希表简介
哈希表通过哈希函数将键(key)映射到一个固定大小的数组中,从而实现快速的数据访问。它的核心优势在于:
-
高效查找:理想情况下,查找时间复杂度为 O(1)。
-
灵活扩展:可通过动态扩容适应数据增长。
-
广泛应用:常用于数据库索引、缓存(如Redis)、编译器符号表等。
哈希表的核心组件
-
哈希函数(Hash Function):将键映射到数组索引。
-
冲突解决策略:当不同键映射到同一位置时,如何存储它们(本文重点讲解拉链法)。
-
动态扩容机制(可选):当数据量增长时调整哈希表大小。
哈希表的一些小内容:
1. 哈希表是一种高效的数据结构,它通过哈希函数将健映射到表中一个位置来访问记录,以实现快速的数据查找,插入和删除操作
2.哈希函数把任意大小的数据映射到固定大小的值(哈希值)
3.哈希冲突:当不同的数通过哈希函数计算出相同的哈希值时,就发生了冲突
4.映射:将一段较大的数映射到一段相对较小的数组
5.映射的目的是为了划分数,把一堆数划分不同的部分
2. 拉链法(Chaining)的实现
拉链法是最常用的冲突解决方法之一,它的核心思想是:
如果多个键映射到同一个哈希桶(bucket),就用链表将它们存储在一起。
2.1 数据结构定义
以下是基于C语言的拉链法哈希表实现:
#define N 100003 // 哈希表大小(通常取大质数) int h[N]; // 哈希桶数组,h[i] 表示哈希值为 i 的链表头 int e[N]; // 存储元素值 int ne[N]; // 存储下一个节点的索引(模拟链表指针) int idx; // 当前可用存储位置
变量说明
变量 | 作用 |
---|---|
h[N] | 哈希表主体,每个 h[i] 存储链表头索引 |
e[N] | 存储实际插入的元素值 |
ne[N] | 存储链表的下一个节点索引(类似 next 指针) |
idx | 指向当前可用的存储位置 |
2.2 插入操作(insert
)
void insert(int x) { int t = (x % N + N) % N; // 计算哈希值,处理负数 e[idx] = x; // 存储元素值 ne[idx] = h[t]; // 新节点指向原链表头 h[t] = idx++; // 更新链表头 }
插入流程详解
-
计算哈希值
t
:-
使用
(x % N + N) % N
而非x % N
,确保结果非负(C/C++ 的%
运算对负数可能返回负值)。 -
这里N的值一般为质数,这样可以减少哈希冲突的概率
-
如果使用%来计算索引, 把哈希表的长度设计为素数(质数)可以大大减小哈希冲突
比如
10%8 = 2 10%7 = 3
20%8 = 4 20%7 = 6
30%8 = 6 30%7 = 2
40%8 = 0 40%7 = 5
50%8 = 2 50%7 = 1
60%8 = 4 60%7 = 4
70%8 = 6 70%7 = 0
-
存储元素:
-
e[idx] = x
:将x
存入e
数组。 -
ne[idx] = h[t]
:新节点的next
指针指向当前链表头。 -
h[t] = idx++
:更新链表头,并移动idx
指针。
-
示例:插入 14
, 21
, 28
, 35
(假设 N=7
)
操作 | 哈希值 t | 链表变化 |
---|---|---|
插入 14 | 0 | h[0] → 14 → NULL |
插入 21 | 0 | h[0] → 21 → 14 → NULL |
插入 28 | 0 | h[0] → 28 → 21 → 14 → NULL |
插入 35 | 0 | h[0] → 35 → 28 → 21 → 14 → NULL |
h[0] → 3 (35) → 2 (28) → 1 (21) → 0 (14) → NULL e[] = [14, 21, 28, 35, ...] ne[] = [-1, 0, 1, 2, ...]
2.3 查询操作(query
)
int query(int x) { int t = (x % N + N) % N; // 计算哈希值 for (int i = h[t]; i != -1; i = ne[i]) { // 遍历链表 if (e[i] == x) return 1; // 找到返回 1 } return 0; // 未找到返回 0 }
查询流程
-
计算
x
的哈希值t
。 -
遍历
h[t]
的链表,检查是否有匹配的值。 -
找到返回
1
,否则返回0
。
示例:
-
查询
28
:-
计算
t = 0
,遍历链表35 → 28 → ...
,找到28
,返回Yes
。
-
-
查询
15
:-
计算
t = 1
,h[1]
为空,返回No
。
-
3.细节流程
有些人可能对数组是怎么存储的,怎么把他们链接起来的有点疑惑,这里讲解用案列讲解一下
假设:
-
哈希表大小
N = 7
(为演示方便,实际代码中N=100003
) -
初始状态:
h[]
全部为-1
,idx = 0
-
依次插入元素:
14
,21
,28
,35
(它们的哈希值均为t = 0
,因为14%7=0
,21%7=0
,28%7=0
,35%7=0)
逐步执行流程
1. 初始状态
h[]: [-1, -1, -1, -1, -1, -1, -1] // 所有链表为空 e[]: [] ne[]: [] idx = 0
2. 插入 14
-
计算哈希值:
t = (14 % 7 + 7) % 7 = 0
-
执行插入:
-
e[0] = 14
e[]: [14, _, _, _, _, _, _]
-
ne[0] = h[0] = -1
ne[]: [-1, _, _, _, _, _, _]
-
h[0] = idx = 0
,然后idx++
h[]: [0, -1, -1, -1, -1, -1, -1]
idx = 1
-
此时链表结构:
h[0] → 0 (e[0]=14) → -1
3. 插入 21
-
计算哈希值:
t = (21 % 7 + 7) % 7 = 0
-
执行插入:
-
e[1] = 21
e[]: [14, 21, _, _, _, _, _]
-
ne[1] = h[0] = 0
ne[]: [-1, 0, _, _, _, _, _]
-
h[0] = idx = 1
,然后idx++
h[]: [1, -1, -1, -1, -1, -1, -1]
idx = 2
-
此时链表结构:
h[0] → 1 (e[1]=21) → 0 (e[0]=14) → -1
4. 插入 28
-
计算哈希值:
t = (28 % 7 + 7) % 7 = 0
-
执行插入:
-
e[2] = 28
e[]: [14, 21, 28, _, _, _, _]
-
ne[2] = h[0] = 1
ne[]: [-1, 0, 1, _, _, _, _]
-
h[0] = idx = 2
,然后idx++
h[]: [2, -1, -1, -1, -1, -1, -1]
idx = 3
-
此时链表结构:
h[0] → 2 (e[2]=28) → 1 (e[1]=21) → 0 (e[0]=14) → -1
5. 插入 35
-
计算哈希值:
t = (35 % 7 + 7) % 7 = 0
-
执行插入:
-
e[3] = 35
e[]: [14, 21, 28, 35, _, _, _]
-
ne[3] = h[0] = 2
ne[]: [-1, 0, 1, 2, _, _, _]
-
h[0] = idx = 3
,然后idx++
h[]: [3, -1, -1, -1, -1, -1, -1]
idx = 4
-
最终链表结构:
h[0] → 3 (e[3]=35) → 2 (e[2]=28) → 1 (e[1]=21) → 0 (e[0]=14) → -1
内存布局总结
数组 | 存储内容 |
---|---|
h[] | [3, -1, -1, -1, -1, -1, -1] |
e[] | [14, 21, 28, 35, _, _, _] |
ne[] | [-1, 0, 1, 2, _, _, _] |
链表逻辑结构:
哈希桶 0: ┌───┐ ┌───┐ ┌───┐ ┌───┐ │35 │ → │28 │ → │21 │ → │14 │ → NULL └───┘ └───┘ └───┘ └───┘ ↑ h[0]
关键点说明
-
头插法:新元素总是插入链表头部,
h[t]
直接指向新节点。 -
冲突处理:所有哈希到
t=0
的元素通过ne[]
指针链接在一起。 -
时间复杂度:插入操作始终是 O(1),因为只需修改头指针。
-
负数处理:
(x % N + N) % N
确保哈希值为正。
全部流程:
初始: h[0] = -1 插入14: h[0] → 0 (14) → -1 插入21: h[0] → 1 (21) → 0 (14) → -1 插入28: h[0] → 2 (28) → 1 (21) → 0 (14) → -1 插入35: h[0] → 3 (35) → 2 (28) → 1 (21) → 0 (14) → -1
4. 哈希冲突与性能分析
4.1 哈希冲突
当不同键映射到同一哈希桶时(如 14
和 21
都映射到 t=0
),拉链法通过链表存储冲突元素。
4.2 时间复杂度
操作 | 平均情况 | 最坏情况 |
---|---|---|
插入 insert(x) | O(1) | O(n) |
查询 query(x) | O(1 + α) | O(n) |
5. 拉链法 vs. 开放寻址法
特性 | 拉链法 | 开放寻址法 |
---|---|---|
冲突解决 | 链表存储冲突元素 | 线性/二次探测新位置 |
内存占用 | 需额外指针空间 | 无指针开销,更紧凑 |
查询效率 | 稳定,受装载因子影响小 | 受聚集现象影响大 |
适用场景 | 数据量动态变化 | 内存敏感,数据量固定 |
6. 完整代码示例
#include <stdio.h> #include <string.h> #define N 100003 //选择一个大质数作为哈希表的大小,可以减少哈希冲突的概率 /* 如果使用%来计算索引, 把哈希表的长度设计为素数(质数)可以大大减小哈希冲突 比如 10%8 = 2 10%7 = 3 20%8 = 4 20%7 = 6 30%8 = 6 30%7 = 2 40%8 = 0 40%7 = 5 50%8 = 2 50%7 = 1 60%8 = 4 60%7 = 4 70%8 = 6 70%7 = 0 */ //哈希表的“表头”数组h,h[i]表示哈希值为i的链表的头节点(初始化为-1表示空链表) //e数组存储实际插入的值 //ne存储链表的下一个节点(类似链表的next指针) //idx当前可用的存储位置(类似于动态分配的指针) int h[N], e[N], ne[N], idx; //插入函数 void insert(int x) { //计算哈希值t //这里使用(x % N + N) % N不使用x%N,是为处理x为负数的情况,确保t在[0,N-1]范围内 int t = (x % N + N) % N; //插入链表 e[idx] = x; //存储x在e数组中 ne[idx] = h[t]; //新节点的next指向当前链表的头节点 h[t] = idx++; //不断更新链表的头节点为当前新节点,并移动idx指针 } //查询函数 int query(int x) { //计算哈希值 int t = (x % N + N) % N; //遍历链表h for (int i = h[t]; i != -1; i = ne[i]) { if (e[i] == x) { //如果存在返回1 return 1; } } //如果不存在返回0 return 0; } int main() { //初始化哈希表,将h数组所有数据初始化为-1,表示所有链表初始为空 memset(h, -1, sizeof(h)); int n, x; scanf("%d", &n); while (n--) { char c; scanf("\n%c%d", &c, &x); if (c == 'I') { insert(x); } else { if (query(x)) { printf("Yes\n"); } else { printf("No\n"); } } } return 0; }