第一部分:哈希表(Hash Table)
1. 核心概念与数据结构
哈希表是一种键值对(Key-Value)存储结构,通过哈希函数将键映射到数组的索引位置,实现平均 O (1) 的查找、插入、删除效率。
- 基本结构:由数组和链表(或红黑树等)组成。数组存储 “桶”,每个桶对应一个链表(解决哈希冲突)。
- 哈希函数(Hash Function):
- 作用:将任意长度的键转换为固定范围的索引(如数组下标)。
- 要求:快速计算、分布均匀(减少冲突)、确定性(相同键映射到相同索引)。
- 示例:Python 中字典的哈希函数会结合键的类型(如字符串的字符 ASCII 码求和)和随机盐值(防止哈希碰撞攻击)。
2. 哈希冲突解决策略
当不同键通过哈希函数得到相同索引时,产生冲突,常用解决方法:
- 链地址法(Separate Chaining):
- 每个桶对应一个链表,冲突的键值对按顺序插入链表。
- 查找时,先通过哈希函数找到桶,再遍历链表匹配键。
- 优化:当链表长度过长(如 Java 8 中超过 8),转为红黑树,将查找复杂度从 O (n) 降至 O (log n)。
- 开放地址法(Open Addressing):
- 冲突时,在数组中寻找下一个可用位置(线性探测、二次探测、双重哈希等)。
- 缺点:容易产生 “聚集效应”(连续空位被占用,导致后续查找变慢),适合元素数量远小于数组大小的场景(如 Linux 内核的 dentry 缓存)。
3. 性能分析
- 理想情况(无冲突):查找、插入、删除均为 O (1)。
- 冲突时:时间复杂度取决于链表长度(或树的高度)。假设哈希函数均匀,平均链表长度为 α(负载因子,α= 元素数 / 桶数),则平均时间复杂度为 O (1+α)。
- 负载因子 α:α 越小,冲突概率越低,但空间利用率低;α 越大,空间利用率高,但冲突加剧。通常取 α=0.7~1.0,超过时触发扩容(重建更大的数组,重新哈希所有元素)。
4. 在 Linux 内核中的应用
Linux 内核中,哈希表用于高效管理大量数据,例如:
- 进程调度:通过哈希表快速查找进程 ID 对应的 task_struct 结构体。
- 网络协议:TCP 连接管理,用五元组(源 IP、端口,目标 IP、端口,协议)作为键,哈希表存储连接状态。
- VFS(虚拟文件系统):dentry 缓存(目录项缓存),通过文件名哈希快速查找文件元数据。
内核中的哈希表实现(如hash_table
结构体)通常结合链地址法,每个桶对应一个双向链表,节点包含键和数据指针。
第二部分:链表(Linked List)
1. 基础结构与类型
链表是由节点串联而成的线性数据结构,每个节点包含:
- 数据域:存储具体数据(如整数、结构体指针)。
- 指针域:指向相邻节点(单向链表只有后继指针,双向链表增加前驱指针)。
常见类型:
- 单向链表(Singly Linked List):每个节点只指向下一个节点,头节点唯一,尾节点指针为 NULL。
- 双向链表(Doubly Linked List):每个节点有前驱和后继指针,可双向遍历,如 Linux 内核的
list_head
结构体。 - 循环链表(Circular Linked List):尾节点的后继指针指向头节点,形成环,常用于需要循环遍历的场景(如进程调度队列)。
2. 核心操作与算法
(1)节点操作
- 创建节点:分配内存,初始化数据域和指针域。
- 插入节点:
- 头插法:新节点成为头节点,原头节点变为后继(适用于快速插入)。
- 尾插法:遍历到尾节点,将尾节点的后继指向新节点(适用于按顺序插入)。
- 指定位置插入:找到目标节点的前驱,修改指针(双向链表需同时修改前驱和后继)。
- 删除节点:
- 找到目标节点的前驱,将前驱的后继指针指向目标节点的后继(双向链表还需修改后继节点的前驱指针)。
- 释放目标节点内存,避免内存泄漏。
(2)遍历与查找
- 遍历:从头部开始,依次访问每个节点直到 NULL(单向链表)或回到头部(循环链表)。
- 查找:按值查找需遍历链表,时间复杂度 O (n);按位置查找需记录节点顺序(单向链表只能从头开始数,双向链表可双向加速)。
3. 与数组的对比
特性 | 链表 | 数组 |
---|---|---|
内存分配 | 动态分配,节点内存不连续 | 静态分配(栈或堆),内存连续 |
插入 / 删除 | O (1)(已知前驱时) | O (n)(需移动后续元素) |
随机访问 | O (n)(需遍历) | O (1)(直接通过下标访问) |
空间利用率 | 较低(指针占用额外空间) | 较高(无额外开销) |
适用场景 | 频繁插入 / 删除,元素数量不确定 | 频繁随机访问,元素数量固定或可预估 |
4. 在 Linux 内核中的应用 ——list_head
双向链表
Linux 内核几乎不用标准单向链表,而是通过list_head
结构体实现双向循环链表,优点是与具体数据解耦,实现 “容器感知”(通过节点反推包含它的结构体)。
- 结构体定义:
struct list_head { struct list_head *next, *prev; };
- 使用方式:
- 在自定义结构体中包含
list_head
成员,作为链表节点:struct my_data { int id; struct list_head list; // 链表节点 char name[64]; };
- 通过内核提供的宏操作链表,如:
list_add(&new_node.list, &head.list)
:将新节点插入头节点之后。list_for_each_entry(ptr, &head.list, list)
:遍历链表,ptr
指向my_data
结构体,list
是结构体中的list_head
成员名。
- 在自定义结构体中包含
- 优势:
- 与具体数据类型无关,同一链表可存储不同类型数据(通过指针强制转换)。
- 插入 / 删除操作仅修改指针,无需移动数据,高效且安全。
第三部分:哈希表与链表的结合与优化
1. 哈希表为何需要链表?
- 哈希函数无法完全避免冲突,链表是解决冲突的最简单方式。
- 链表的动态性适合处理不确定数量的冲突元素(对比开放地址法的固定数组大小限制)。
2. 性能优化方向
- 哈希函数优化:减少冲突概率(如使用 MurmurHash、JenkinsHash 等非加密哈希算法,兼顾速度和分布均匀性)。
- 链表转树结构:当链表长度超过阈值(如 Java 8 的 8),转为红黑树,提升极端情况下的查找效率。
- 缓存友好性:链表节点内存不连续,CPU 缓存命中率低;现代哈希表优化会尝试将短链表存储在数组桶内(如 C++11 的
unordered_map
的探测桶),或利用线性探测的开放地址法提升缓存命中率。
3. 典型场景选择
- 高频随机访问:优先哈希表(平均 O (1)),如字典、缓存。
- 高频插入 / 删除:优先链表(O (1) 操作),如队列、栈(链表实现的栈可头插法高效入栈)。
- 有序数据管理:若需要排序,链表需遍历排序(O (n²) 或 O (n log n)),而哈希表不保证顺序,此时更适合树结构(如平衡二叉树、B 树)。
第四部分:实战案例与代码示例
1. 简易哈希表(链地址法)实现(C 语言)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// 定义键值对节点
typedef struct Node {
char* key;
int value;
struct Node* next;
} Node;
// 哈希表结构体(数组大小设为10)
typedef struct HashTable {
int size;
Node** buckets;
} HashTable;
// 哈希函数:计算字符串长度对size取模
int hash(HashTable* ht, char* key) {
int len = strlen(key);
return len % ht->size;
}
// 创建新节点
Node* create_node(char* key, int value) {
Node* new_node = (Node*)malloc(sizeof(Node));
new_node->key = strdup(key);
new_node->value = value;
new_node->next = NULL;
return new_node;
}
// 初始化哈希表
HashTable* init_hash_table(int size) {
HashTable* ht = (HashTable*)malloc(sizeof(HashTable));
ht->size = size;
ht->buckets = (Node**)calloc(size, sizeof(Node*));
return ht;
}
// 插入或更新键值对
void insert(HashTable* ht, char* key, int value) {
int index = hash(ht, key);
Node* current = ht->buckets[index];
// 若键存在,更新值
while (current != NULL) {
if (strcmp(current->key, key) == 0) {
current->value = value;
return;
}
current = current->next;
}
// 若键不存在,头插法插入新节点
Node* new_node = create_node(key, value);
new_node->next = ht->buckets[index];
ht->buckets[index] = new_node;
}
// 查找值
int find(HashTable* ht, char* key) {
int index = hash(ht, key);
Node* current = ht->buckets[index];
while (current != NULL) {
if (strcmp(current->key, key) == 0) {
return current->value;
}
current = current->next;
}
return -1; // 未找到
}
int main() {
HashTable* ht = init_hash_table(10);
insert(ht, "apple", 10);
insert(ht, "banana", 20);
printf("Value of apple: %d\n", find(ht, "apple")); // 输出10
insert(ht, "apple", 30);
printf("Updated value of apple: %d\n", find(ht, "apple")); // 输出30
return 0;
}
2. Linux 内核list_head
使用示例
// 定义包含list_head的结构体
struct task_struct {
pid_t pid;
char comm[16];
struct list_head list; // 链表节点
// 其他进程属性...
};
// 初始化头节点
struct list_head process_list = LIST_HEAD_INIT(process_list);
// 创建两个进程结构体并加入链表
struct task_struct process1 = {.pid=1, .comm="init"};
struct task_struct process2 = {.pid=2, .comm="bash"};
list_add(&process1.list, &process_list);
list_add(&process2.list, &process_list);
// 遍历链表并打印进程名
struct task_struct *p;
list_for_each_entry(p, &process_list, list) {
printk("Process: %s (PID %d)\n", p->comm, p->pid);
}
第五部分:扩展思考与面试高频问题
-
哈希表的扩容机制为什么是 2 倍?
- 便于位运算快速计算新索引(原索引或原索引 + 旧容量),减少重新哈希的计算量。
-
链表反转的算法实现(迭代法 vs 递归法)
- 迭代法:维护前驱、当前、后继三个指针,依次反转指针方向,时间复杂度 O (n),空间复杂度 O (1)。
- 递归法:通过递归到尾节点,逐层反转指针,空间复杂度 O (n)(递归栈深度)。
-
如何判断单向链表是否有环?
- 快慢指针法(Floyd 判圈算法):慢指针每次走 1 步,快指针每次走 2 步,若有环则两者会相遇;若无环则快指针先到 NULL。
-
哈希表与链表的时间复杂度在什么情况下退化为 O (n)?
- 哈希表:当哈希函数设计差(如所有键映射到同一个桶),链表退化为单链表,查找、插入、删除均为 O (n)。
- 链表:任何基于值的查找或按位置的访问(如获取第 n 个节点),均需遍历,时间复杂度 O (n)。
三、总结:从生活到技术的核心关联
- 哈希表:利用 “快递站智能分桶” 思想,通过哈希函数快速定位,用链表解决冲突,实现高效的键值对操作。
- 链表:像 “动态排队的糖葫芦”,适合频繁增删场景,Linux 内核通过
list_head
实现了与数据类型解耦的双向链表,成为驱动、文件系统等模块的基石。
理解这两个数据结构,不仅能掌握编程的核心工具,还能深入理解 Linux 内核、数据库、编程语言(如 Python 的字典、C++ 的unordered_map
)的底层实现逻辑。
形象比喻:用 “快递站取件” 理解哈希表与链表
1. 哈希表(Hash Table):快递站的 “智能货架”
想象你去快递站取件,快递站有一个大货架,每个格子上贴着编号(比如 “001”“002”)。
- 快递单号就是 “键(Key)”,你需要凭借它找到自己的快递。
- 快递站工作人员不会挨个翻找所有快递,而是用一个 “神奇公式”(哈希函数),把快递单号转换成货架编号。比如公式是 “快递单号后 3 位数字”,你的快递单号是 “20250507008”,后 3 位是 “008”,那你的快递就会放在编号 “008” 的格子里。
- 这个 “货架” 就是哈希表,本质上是一个数组,每个格子叫一个 “桶(Bucket)”。通过哈希函数,能快速定位到目标桶,就像直接走到 “008” 格子拿快递,不用逐个翻找。
但是遇到 “快递挤爆格子” 怎么办?(哈希冲突)
如果两个快递单号的后 3 位都是 “008”,它们就会被分到同一个格子里。这时候,快递站会在这个格子里挂一个 “小挂钩”,把同编号的快递按到来顺序排成一队(链表)。取件时,先找到 “008” 格子,再逐个核对快递单号,直到找到你的快递。
- 这种用 “链表” 解决哈希冲突的方法,就是哈希表中常见的链地址法(Separate Chaining)。
2. 链表(Linked List):排队的 “糖葫芦串”
链表就像一串糖葫芦:
- 每颗糖葫芦是一个 “节点(Node)”,包含两部分:“数据”(比如快递单号、收件人信息)和 “下一颗糖葫芦的位置”(指针,指向下一个节点)。
- 第一个节点叫 “头节点”,最后一个节点叫 “尾节点”(尾节点的指针指向 “空”,就像糖葫芦的最后一颗没有下一颗)。
- 如果是 “双向链表”,每个节点还会多一个指针,指向 “前一颗糖葫芦”(前驱节点),方便来回查找。
链表 vs 数组:两种 “排队” 方式的区别
- 数组:像教室的固定座位,每个位置编号固定(下标),必须提前预留足够的座位。插入或删除一个座位时,后面的人都要移动,很麻烦。
- 链表:像排队买奶茶,队伍可以随时插入新人(在两个节点间加一个新节点,改一下指针就行),也可以随时离开(删除节点,改前后节点的指针),不需要移动其他人,灵活但找位置时必须从头开始数。