数据结构与算法:从线性表、树到图算法的系统指南
用统一的视角理解 数据如何组织、操作成本如何衡量,以及算法如何借助结构降低复杂度。
阅读导航
- 基础方法:先明确问题规模、时间复杂度、空间复杂度与均摊分析。
- 线性结构:数组、链表、栈、队列与哈希表。
- 树结构:二叉树、BST、堆、哈夫曼树、Trie 与平衡搜索树。
- 图结构与算法:图存储、DFS/BFS、最小生成树、最短路径与拓扑排序。
⭐ 核心结论 / 面试高频
数据结构决定数据的组织方式与基本操作成本,算法决定如何组合这些操作解决问题。选型时不能只看大
1. 复杂度与选型方法
算法是完成一项任务或解决一个问题的明确、有限的步骤和流程。
数据结构描述数据的组织、存储与操作方式;合适的数据结构能够让算法更清晰、更高效。
💡 通俗理解:字典与查找
- 数据结构 像字典的排版、排序和索引。
- 算法 像查字典时采用的二分定位步骤。
同一算法换一种数据组织方式,成本可能完全不同。
- 数据结构:设计稳固的地基。
- 算法:规划最优的动线。
为了与标准库术语一致,后文 C/C++ 代码统一使用 c、cpp 或 python 代码块;其中部分片段聚焦核心算法,依赖前文定义的类型、常量和辅助函数。
1.1 时间复杂度与空间复杂度
- 时间复杂度:输入规模为
时,基本操作次数的渐近增长速度。 - 空间复杂度:算法额外占用空间随
的增长速度。 - 最坏复杂度:给出性能上界,工程与面试中最常使用。
- 均摊复杂度:把偶发昂贵操作分摊到一系列操作中,例如动态数组追加。
| 复杂度 | 典型含义 | 常见例子 |
|---|---|---|
| 常数级 | 数组按下标访问、栈顶操作 | |
| 每轮缩小问题规模 | 二分查找、平衡树查找 | |
| 遍历一次数据 | 链表查找、数组扫描 | |
| 分治或堆排序量级 | 归并排序、堆排序 | |
| 两层规模相关循环 | 朴素邻接矩阵遍历 |
⚡ 易混淆点:大
1.2 选型四问
- 是否需要随机访问?
- 插入、删除和查询的比例如何?
- 数据规模是否稳定,能否预估容量?
- 是否要求有序、去重、优先级或关系遍历?
2. 线性结构与哈希
2.1 数组
数组是一块连续的内存空间,用于存放一组相同类型的数据。
- 连续存储:像一排紧挨着的房间。
- 固定步长:每个房间大小完全一致。
- 下标访问:通过门牌号(Index)快速定位。
🔍 展开查看:代码实现
int main(){
// 声明并初始化数组
int a[] = {3, 1, 4, 1, 5};
// 计算元素个数
int n = sizeof(a) / sizeof(a[0]);
// 遍历打印
for(int i = 0 ; i < n; i ++){
printf("a[%d] = %d\n", i, a[i]);
}
}2
3
4
5
6
7
8
9
10
11
12
2.1.1 逻辑视角
随机访问(Random Access):指可以直接访问任意位置的数据,而不需要从头遍历。
寻址公式:
CPU只需要进行一次乘法和一次加法,就能算出任意元素的内存地址。
数组显然符合随机访问逻辑🤓
2.1.2 物理视角
数组在物理内存中的连续性,完美契合了现代CPU的缓存机制。
空间局部性原理:程序倾向于访问相邻的内存位置。
预取优势(Prefetching):CPU读取a[0]时, 会顺便把a[1]...a[k]一起加载到高速缓存(Cache🚀)中。
2.1.3 数组的增删改查
数组在读写方面是天才,但在增删方面略显笨拙。
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 读取(Access) | 按下标随机访问 | |
| 修改(Update) | 直接覆盖对应元素 | |
| 插入(Insert) | 最坏需移动 | |
| 删除(Delete) | 最坏需移动 |
⭐ 数组选型结论
如果场景以按下标访问和顺序扫描 为主,数组通常是首选;若频繁在中间插删,则需要评估元素搬移成本。
2.1.4 动态数组
动态数组(Dynamic Array)
动态数组解决了静态数组“大小固定”的痛点(如Java ArrayList,C++ Vector,Python List)
自动扩容与均摊分析
初始化:申请小空间(Capacity = 10)
存满时:触发扩容(Grow)
搬家:
- 申请更大的新空间(通常按几何倍数增长)
- 将旧数据复制或移动过去
- 释放旧空间
代码实现:
定义结构体:
🔍 展开查看:代码实现
typedef struct{
int *data; // 连续内存指针
size_t size; // 实际元素个数
size_t capacity; // 总容量
} DynamicArray;2
3
4
5
data:指向底层真正的静态数组。
size:住了多少人(Len)。
capacity:房子有多大(Cap)。
始终保持不变量:
。
追加元素(Push Back):
🔍 展开查看:代码实现
void push_back(DynamicArray *da, int val){
// 1. 检查容量
if(da->size == da->capacity){
grow(da); // 扩容
}
// 2. 放入新元素
da->data[da->size] = val;
// 3. 更新计数
da->size ++;
}2
3
4
5
6
7
8
9
10
11
12
- 容量充足时,单次追加为
。 - 扩容时需要重新分配并复制已有元素,单次为
。 - 采用几何倍增后,连续
次追加总成本为 ,因此追加的均摊复杂度 为 。
⚡ 避坑指南:扩容会使旧地址失效
动态数组扩容后底层内存地址可能改变,原有指针、引用或迭代器可能失效。不同语言和容器的失效规则不同,应查阅具体 API。
扩容(grow):
🔍 展开查看:代码实现
void grow(DynamicArray *da){
// 1. 计算新容量(通常翻倍)
int new_cap = da->capacity * 2;
int *new_data = malloc(new_cap * sizeof(int)); // 申请新空间
// 2. 搬家(复制数据)
for (int i = 0; i < da->size; ++ i){
new_data[i] = da->data[i];
}
// 3. 销毁旧房子,启用新房子
free(da->data);
da->data = new_data;
da->capacity = new_cap;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
- 申请内存:需要操作系统分配。
- 数据复制:
,元素越多,单次扩容成本越高。
2.2 链表
数组的困境:数组要求大家“整齐排队”(连续内存),但如果内存里没有足够大的连续空地怎么办。
链表(Linked List)化身为一张藏宝图:
数据分散在各个角落,每个节点即有奖励(Data),又有指向下一站的线索(Pointer)。
链表是一组零散的内存块(节点),通过指针串联在一起的数据结构。
- 节点(Node):既存数据,也存线索。
- 离散存储:无需连续内存,走到哪住到哪。
- 逻辑连续:通过指针“手拉手”形成链条。
2.2.1 定义节点与创建链表
代码实现:
1.定义节点:
🔍 展开查看:代码实现
typedef struct Node{
int data; // 数据域
struct Node* next; // 指针域
} Node;
// 创建新节点
Node* createNode(int value){
Node* newNode = (Node*)malloc(sizeof(Node));
newNode->data = value;
newNode->next = NULL;
return newNode;
}2
3
4
5
6
7
8
9
10
11
12
13
2.创建链表:
C++创建链表代码示例:
🔍 展开查看:代码实现
#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
typedef struct Node
{
int data;
struct Node* next;
}Node;
Node* createNode(int val)
{
Node* newNode = new Node;
newNode->data = val;
newNode->next = nullptr;
return newNode;
}
int main()
{
// 创建3个节点
Node* a = createNode(10);
Node* b = createNode(20);
Node* c = createNode(30);
// 将三个节点连接
a->next = b; b->next = c;
// 打印该链表
int j = 1;
for(Node* i = a; i != nullptr; i = i->next)
{
printf("节点%d的地址为%p, 节点%d的值为%d, 节点%d的next为%p\n",
j, (void*)i, j, i->data, j, (void*)i->next);
j++;
}
delete a; delete b; delete c;
return 0;
}
/*
结果:
节点1的地址为0x10, 节点1的值为10, 节点1的next为0x2A
节点2的地址为0x2A, 节点2的值为20, 节点2的next为0x5F
节点3的地址为0x5F, 节点3的值为30, 节点3的next为0x0
*/2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
在C中创建链表,开辟空间和释放空间方式有所不同:
🔍 展开查看:代码实现
#include<stdlib.h>
#include<stdio.h>
typedef struct Node
{
int data;
struct Node* next;
}Node;
Node* createNode(int val)
{
Node* newNode = (Node*)malloc(sizeof(Node));
newNode->data = val;
newNode->next = NULL;
return newNode;
}
int main()
{
// 创建3个节点
Node* a = createNode(10);
Node* b = createNode(20);
Node* c = createNode(30);
// 将三个节点连接
a->next = b; b->next = c;
// 打印该链表
int j = 1;
for(Node* i = a; i != NULL; i = i->next)
{
printf("节点%d的地址为%p, 节点%d的值为%d, 节点%d的next为%p\n",
j, (void*)i, j, i->data, j, (void*)i->next);
j++;
}
free(a); free(b); free(c);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
2.2.2 链表插入
相比数组的“搬家式”插入,链表在已经定位到前驱节点 时,只需修改指针即可完成。

- 关键两步:1. New指向C 2. B指向New
代码实现:
A -> New -> B
🔍 展开查看:代码实现
void insert(Node* A, Node* New){
// 1. 先连后方(New -> B)
New->next = A->next;
// 2. 再断前缘(A -> New)
A->next = New;
}2
3
4
5
6
7
2.2.3 链表删除
删除节点同样只需“修改指针”,让前驱直接指向后继。

- 关键一步:A 绕过 B,直接指向 C。
⚡ 避坑指南:删除必须处理所有权与边界
C/C++ 链表删除除了重连指针,还要释放被删节点,并处理删除头节点、尾节点、空链表以及外部悬空指针。
删除 Current 的后继节点
- 定位:标记要删的节点(toDelete)
- 旁路:让当前节点指向下下个节点
- 释放:归还内存(free)
代码实现:
🔍 展开查看:代码实现
void deleteNextNode(Node* currentNode){
if(currentNode->next != NULL){
// 1. 找到要删除的节点
Node* toDelete = currentNode->next;
// 2. 旁路:当前指向下下个
currentNode->next = toDelete->next;
// 3. 释放内存
free(toDelete);
}
}2
3
4
5
6
7
8
9
10
11
12
2.2.4 链表读取
只知道头节点,要想找到下标为5的节点。
因为内存不连续,无法通过公式计算地址,必须从头节点开始,顺着next指针跳5次。
走法:从head出发,沿next连跳5次。

代码实现:
🔍 展开查看:代码实现
Node* get(Node* head, int k){
int i = 0;
while(head && i < k){
head = head->next;
i ++;
}
return head;
}2
3
4
5
6
7
8
2.2.5 链表与数组
- 内存布局:连续,缓存局部性好。
- 随机访问:
。 - 中间插删:通常为
,需要移动元素。 - 适合场景:读多写少、频繁按下标访问。
2.2.6 链表总结
链表牺牲随机访问与缓存局部性,换取按需分配和已知位置上的常数级指针重连。
⭐ 链表核心结论
数组擅长随机访问与顺序扫描;链表擅长在已知节点位置进行局部插删。若必须先从头查找插入点,链表操作的总复杂度仍为
2.3 栈
栈是一种受限的线性表,只允许在同一端(栈顶)进行插入和删除操作。
💡 通俗理解:栈像叠盘子
只能从最上面放入或取走盘子,因此最后放入的盘子最先取出,这就是 LIFO。
- 栈顶(Top):允许操作的一端。
- 栈底(Bottom):封死的一端,最早进来的元素在最底下。

- Push(入栈)
- Pop(出栈)
2.3.1 栈的核心操作
无论底层使用数组还是链表,栈的抽象接口一致。合理实现下,push、pop、peek 通常为 push 为均摊
push(x)压栈:把元素x放到栈顶。pop()弹栈:移除并返回栈顶元素。peek()查看:只看一眼栈顶(不删除)。isEmpty()判空:检查栈里有没有元素。size()计数:返回当前元素个数。
约定:
- top指向当前栈顶元素。
- 栈空时
top = -1
代码实现:
🔍 展开查看:代码实现
typedef struct Stack{
int data[MAX];
int top;
} Stack;
void initStack(Stack *s){
s->top = -1;
}
bool push(Stack *s, int val){
if(s->top == MAX - 1) return false;
s->data[++ s->top] = val; // 栈顶上移并存值
return true;
}
bool pop(Stack *s, int *val){
if(isEmpty(s)) return false;
*val = s->data[s->top --]; // 弹栈并返回
return true;
}
bool peek(Stack *s, int *val){
if(isEmpty(s)) return false;
*val = s->data[s->top]; // 只读不移
return true;
}
bool isEmpty(Stack *s){
return s->top == -1;
}
int size(Stack *s){
return s->top + 1;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
2.3.2 括号匹配
代码实现:
🔍 展开查看:代码实现
// 判断左右括号是否匹配
bool isMatchingPair(char left, char right){
return (left == '(' && right == ')') ||
(left == '[' && right == ']') ||
(left == '{' && right == '}');
}
// 是否是合法括号
bool isValidBrackets(const char *str){
Stack s;
initStack(&s);
// 读取str
for(int i = 0; str[i] != '\0'; i ++){
char ch = str[i];
// 遇到左括号,入栈
if(ch == '(' || ch == '[' || ch == '{'){
push(&s, ch);
}
//遇到右括号,尝试匹配
else if(ch == ')' || ch == ']' || ch == '}'){
int topVal;
if(isEmpty(&s)) return false; // 右括号多余
pop(&s, &topVal);
if(!isMatchingPair((char)topVal, ch)){
return false; // 类型不匹配
}
}
}
// 最后栈必须为空
return isEmpty(&s);
}
int main(){
const char *str = "abc(d[e]f)[g{h}i]{jkl}";
bool valid = isValidBrackets(str);
printf("%-20s -> %s\n", str, valid ? "匹配成功" : "匹配失败");
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
2.3.3 后缀表达式求值
代码实现:
🔍 展开查看:代码实现
int applyOperator(int a, int b, char op){
switch(op){
case '+': return a + b;
case '-': return a - b;
case '*': return a * b;
case '/': return a / b;
}
return 0;
}
int evaluatePostfix(const char *expr){
Stack s;
initStack(&s);
for(int i = 0; expr[i] != '\0'; i ++){
char c = expr[i];
if(c == ' ') continue;
// 如果是数字
if(isdigit(c)){
push(&s, c - '0');
}
// 如果是运算符
else if(isOperator(c)){
int a, b;
pop(&s, &b); // 先出栈的是右操作数
pop(&s, &a); // 后出栈的是左操作数
int result = applyOperator(a, b, c);
push(&s, result);
}
}
int finalResult;
pop(&s, &finalResult);
return finalResult;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
2.3.4 中缀转后缀
代码实现:
🔍 展开查看:代码实现
// 辅助:获取优先级(*/ > +-)
int getPriority(char op){
if(op == '*' || op == '/') return 2;
if(op == '+' || op == '-') return 1;
return 0; // '('优先级最低
}
void infixToPostfix(const char* infix, char* postfix){
Stack s;
initStack(&s);
int j = 0; // postfix写入位置
for(int i = 0; infix[i] != '\0'; i ++){
char c = infix[i];
if(c == ' ') continue;
// 1. 数字:直接输出
if(isdigit(c)){
postfix[j ++] = c;
}
// 2. 左括号:入栈待命
else if(c == '('){
push(&s, c);
}
// 3. 右括号:弹栈直到'('
else if(c == ')'){
int topVal;
while(!isEmpty(&s)){
peek(&s, &topVal);
if(topVal == '(') break;
pop(&s, &topVal);
postfix[j ++] = (char)topVal;
}
if(!isEmpty(&s)) pop(&s, &topVal); // 丢弃'('
}
// 4.运算符:维护优先级单调性
else{
int topVal;
while(!isEmpty(&s)){
peek(&s, &topVal);
// 栈顶 < 当前:直接入栈
if(topVal =='(' || getPriority(topVal) < getPriority(c))
break;
// 栈顶 >= 当前:弹出栈顶
pop(&s, &topVal);
postfix[j ++] = (char)topVal;
}
push(&s, c);
}
}
// 5. 收尾:弹出剩余符号
while(!isEmpty(&s)){
int topVal;
pop(&s, &topVal);
postfix[j ++] = (char)topVal;
}
postfix[j] = '\0';
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
2.3.5 栈的应用
⭐ 栈的使用信号
只要问题出现“撤销最近操作”“保存路径后回退”或“先深入再返回”,就应优先考虑栈、递归或显式模拟调用栈。
算法应用:
- 深度优先搜索(DFS):利用栈“先走到最深,再按相反顺序回退”,逐层展开并回溯。
- 回溯算法:保存决策路径,失败时快速撤销最近一次选择。
- 递归显式实现:手动保存函数状态,模拟系统调用栈。
系统与工程应用:
- OS函数调用栈:保证函数按”后调用、先返回“顺序执行。
- 浏览器前进/后退:通过栈记录访问历史,实现逆序回退。
- 撤销操作(Undo):按相反顺序回访,优先撤销最近修改。
2.4 队列
- 入队(Enqueue):新来的只能排在队尾(Rear)。
- 出队(Dequeue):离开的永远是队头(Front)。
- 双端操作:一端只进,另一端只出。

💡 通俗理解:队列像排队办业务
新来的人站到队尾,最早到的人从队头先办理,因此遵循 FIFO。
2.4.1 队列的核心操作
队列的抽象接口统一。循环数组或维护头尾指针的链式实现可使入队、出队达到
enqueue(x):将元素x加入队尾dequeue(): 移除并返回队头元素peek()查看队头元素(不删除)。isEmpty()判空:检查队列里有没有元素。size()计数:获取队列当前元素个数。
2.4.2 循环队列
循环数组本质上是基于固定长度数组实现的。
通过取模运算,在逻辑上将数组的末尾与开头相连,形成一个环形结构,从而复用数组空间。
核心变量状态:
- Front(头指针):指向队列第一个有效元素的位置。
- Rear(尾指针):指向下一个即将插入数据的空位。
- Capacity(容量):数组的总长度。

循环队列的实现:
🔍 展开查看:代码实现
#define MAX_SIZE 128
typedef struct Queue{
int data[MAX_SIZE];
int front;
int rear;
} Queue;
bool enqueue(Queue *q, int val){
// 1. 判满(留空法)
if((q->rear + 1) % MAX_SIZE == q->front){
return false;
}
// 2. 存数据
q->data[q->rear] = val;
// 3. 移动指针(回绕)
q->rear = (q->rear + 1) % MAX_SIZE;
return true;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- 不需要size变量也能维护状态。
- Rear指向的是空位,所以先存数据,再移指针。
- 容量上限固定,适合内存敏感或负载可以预测的场景。
🔍 展开查看:代码实现
bool dequeue(Queue *q, int *val){
// 1. 判空
if(q->front == q->rear){
return false;
}
// 2. 取数据
*val = q->data[q->front];
// 3. 移动头指针(回绕)
q->front = (q->front + 1) % MAX_SIZE;
return true;
}2
3
4
5
6
7
8
9
10
11
2.4.3 链式队列
链表实现队列
如果无法预估计数据量,或者需要频繁扩容,链表(Linked List)是更好的选择。
- 动态容量:不要求预先分配连续大块空间,但仍受可用内存与分配失败限制。
- 头尾指针:
- front指向链表头节点(出队)。
- rear指向链表尾节点(入队)。

- 入队:队列为空front和rear指向新节点,否则只更新rear。
- 出队:当队列中只剩下一个元素时,手动将rear也设为NULL。
🔍 展开查看:代码实现
typedef struct Node{
int data;
struct Node *next;
}Node;
typedef struct LinkedQueue{
Node *front;
Node *rear;
}LinkedQueue;
void enqueue(LinkedQueue *q, int val){
Node *n = malloc(sizeof(Node));
n->data = val; n->next = NULL;
// 特判空队列
if(q->rear == NULL){
q->front = q->rear = n;
}
else{
q->rear->next = n;
q->rear = n;
}
}
bool dequeue(LinkedQueue *q, int *val){
if(!q->front) return false;
Node *t = q->front;
*val = t->data;
q->front = q->front->next;
// 特判队空,重置rear
if(!q->front) q->rear = NULL;
free(t);
return true;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
链表队列的使用场景:
- 连续内存,缓存局部性好,分配次数少。
- 容量固定时延迟可预测,适合环形缓冲区和高吞吐场景。
- 需要预估容量,或额外设计扩容与溢出策略。
2.4.4 队列的应用
广度优先搜索(BFS):
层序遍历的基础。比如地图导航找最近路线、社交网络找三度好友。
消息队列(MQ):
Kafka/RabbitMQ。用于削峰填谷和系统解耦。生产者发消息入队,消费者按能力出队处理。
操作系统调度:
CPU就绪队列。所有进程排队等待CPU时间片(Time Slice)。
2.5 栈与队列对比
- 入口和出口都在栈顶。
- 最近加入的元素最先处理。
- 常用于递归、回溯、撤销、表达式求值与 DFS。
2.6 哈希表
一种用键(Key)快速定位值(Value)的数据结构。

- Key:查找的标识(如学号)。
- Bucket:存储数据的容器(数组)。
- Hash Function:转换器(Key->整数)。
- 策略:用空间和哈希计算换取平均意义上的快速查询。
⚡ 易混淆点:哈希表不是严格
哈希表查找、插入通常是平均
2.6.1 哈希函数
将任意长度的输入,转换为固定长度的输出(下标)。
🔍 展开查看:代码实现
// 简单的字符串哈希
unsigned int simpleHash(char *key, int cap){
unsigned int hash = 0;
while(*key){
hash = 31 * hash + *key; // 31是质数
key ++;
}
return hash % cap;
}2
3
4
5
6
7
8
9
- 转整数:把字符串变成数字。
- 取模:%capacity确保落在数组范围内。
- 定位:拿到合法的数组下标。
2.6.2 哈希冲突
💡 通俗理解:哈希冲突与鸽巢原理
桶数量有限,而可能的键几乎无限,因此不同键映射到同一桶不可避免。哈希表设计的重点不是“消灭冲突”,而是让冲突分布均匀并高效处理。
拉链法
拉链法(Separate Chaining)
桶内不直接存数据,而是存一个链表头指针。

- 插入:计算下标 -> 找到链表头 -> 头插法/尾插法放入。
- 查找:计算下标 -> 遍历该位置的链表 -> 比对Key。
2.6.3 哈希表实现
定义结构体:
🔍 展开查看:代码实现
typedef struct Entry{
char *key;
int value;
struct Entry *next; // 链表指针
}Entry;
typedef struct HashTable{
// 指针数组:每个元素是链表头
Entry *buckets[TABLE_SIZE];
}HashTable;2
3
4
5
6
7
8
9
10
- Entry(节点):存储Key,Value和Next指针。
- HashTable:一个数组,数组存的是Entry*。
- 初始化时,要把所有buckets置为NULL。
put()函数:
🔍 展开查看:代码实现
void put(HashTable *table, char *key, int val){
int idx = hash(key) % TABLE_SIZE;
// 1. 遍历链表,检查Key是否已存在。
Entry *cur = table->buckets[idx];
while(cur){
if(strcmp(cur->key, key) == 0){
cur->value = val; // 更新
return ;
}
cur = cur->next;
}
// 2. 不存在,头插法插入新节点
Entry *new_node = malloc(sizeof(Entry));
new_node->key = strdup(key);
new_node->value = val;
new_node->next = table->buckets[idx]; //接旧头
table->buckets[idx] = new_node; //换新头
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- 计算
hash(key)得到数组下标i。 - 找到
table[i]对应的链表。 - 遍历链表:
- 如果发现Key已经存在,更新Value。
- 如果遍历完没找到,将新节点添加到链表中
get()函数:
🔍 展开查看:代码实现
int get(HashTable *table, char *key){
int idx = hash(key) % TABLE_SIZE;
Entry *cur = table->buckets[idx];
while(cur){
if(strcmp(cur->key, key) == 0){
return cur->value; // 找到了
}
cur = cur->next;
}
return -1; // 没找到
}2
3
4
5
6
7
8
9
10
11
12
13
14
2.6.4 哈希表的应用
- 缓存(Cache):Redis核心。键值对极速读写。
- 唯一性(Set):去重、判断是否存在。Set底层就是Map。
3. 树结构:层次关系与有序索引
3.1 树的基本概念
树:分治与递归的艺术

- 树是N个节点的有限集合。
- N = 0:空树。
- N > 0: 又一个特定的根(Root),其余节点分为M个互不相交的结合,每个集合又是一棵树(即子树Subtree)。
- 核心逻辑:一对多(One-to-Many)。
3.1.1 节点关系

- 父(Parent):若A有子树B,则A是B的父节点。
- 子(Child):B是A的子节点。
- 兄弟(SibLing):共享同一个父节点的节点们。
- 祖先(Ancestor):从根到该节点路径上的所有节点。
- 后代(Descendant):某节点子树中的所有节点。
3.1.2 节点的度与树的度

- 节点的度:一个节点拥有子树的个数(它分了几个叉)。
- 树的度:树中所有节点度的最大值。
- 二叉树中每个节点最多有两个有序子节点,即节点度不超过
。
3.1.3 层、深度与高度

层(Level):从根开始数,Root是第1层。
深度(Depth):从上往下数。
高度(Height):从下往上数。
起始值定义差异(1 vs 0):
- 节点派(Start = 1) 国内教材/考研/LeetCode。
- 边派(Start = 0) 国外经典教材(如CLRS)。
3.1.4 多叉树与二叉树
- 多叉树(Generic Tree): 每个节点可以有任意多个子节点。

- 应用:文件系统、目录、组织层级。
- 二叉树(Binary Tree):每个节点最多两个子节点(分左、右)。

- 应用:二分查找、判断题。
3.1.5 常见二叉树形态
- 完美二叉树(Perfect)
- 除了叶子,所有节点度为2。
- 所有叶子在同一层。
- 非常完美,但很少见。
- 完全二叉树(Complete)
- 最后一层叶子靠左排列。
- 适合数组存储(堆Heap)。
- 完满二叉树(Full)
- 节点要么没孩子,要么有2个,没有独生子女(Huffman树)。
- 高度平衡二叉树(以 AVL 条件为例)

- 任意节点左右高度差
。 - 这是 AVL 树的典型平衡条件,可避免退化为链表,并保证树高为
。
3.2 二叉树的存储
3.2.1 顺序存储
顺序存储(数组实现):

- 利用数组下标关系模拟树结构
- Left:2i + 1, Right:2i + 2
- Parent:[(i - 1) / 2]
适合:完全二叉树(如二叉堆)。
🔍 展开查看:代码实现
#define MAX_N 100
char tree[MAX_N]; // 0 表示空节点
// 1. 初始化
void initTree(){
for(int i = 0; i < MAX_N; i ++) tree[i] = 0;
}
// 2. 设置根节点
void setRoot(char val){
tree[0] = val;
}
// 3. 设置左孩子(2i + 1)
void setLeft(int parentIdx, char val){
if(tree[parentIdx] == 0) return ; // 父不存在
int idx = 2 * parentIdx + 1;
if(idx < MAX_N) tree[idx] = val;
}
// 4. 设置右孩子(2i + 2)
void setRight(int parentIdx, char val){
if(tree[parentIdx] == 0) return ; // 父不存在
int idx = 2 * parentIdx + 2;
if(idx < MAX_N) tree[idx] = val;
}
int main(){
initTree();
setRoot('A');
setLeft(0, 'B');
setRight(0, 'C');
setLeft(1, 'D');
setLeft(1, 'E');
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
3.2.2 链式存储
逻辑相邻物理不一定相邻,通过指针记录“下家”在哪里。

- 逻辑上是树,物理上是散落在内存各处的内存块。
🔍 展开查看:代码实现
#include<stdlib.h>
// 1. 定义节点结构
typedef struct TreeNode{
char val;
struct TreeNode *left;
struct TreeNode *right;
}Node;
// 2. 创建新节点
Node* createNode(char val){
Node* node = (Node*)malloc(sizeof(Node));
node->val = val;
node->left = NULL;
node->right = NULL;
return node;
}
// 3. 模拟构建过程( A -> B, C)
void buildTree(){
Node* root = createNode('A');
Node* nodeB = createNode('B');
Node* nodeC = createNode('C');
root->left = nodeB;
root->right = nodeC;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
3.3 二叉树遍历
⭐ 树遍历记忆法
- 前序:先处理根,适合复制或序列化结构。
- 中序:根在中间,BST 中序结果有序。
- 后序:最后处理根,适合释放子树或计算目录大小。
- 层序:借助队列逐层访问。

3.3.1 深度优先遍历
前序遍历:
根-左-右
1.访问根节点
2.遍历左子树
3.遍历右子树
”进门先办事“(如:目录复制)
🔍 展开查看:代码实现
void preOrder(Node* root){
// 1. 递归终止条件
if(root == NULL){
return;
}
// 2. 访问当前节点(根)
printf("%c ", root->val);
// 3. 递归遍历左子树
preOrder(root->left);
// 4. 递归遍历右子树
preOrder(root->right);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
模拟系统
中序遍历:
左-根-右
”夹在中间办“(如:BST排序)
🔍 展开查看:代码实现
void inOrder(Node* root){
if(!root) return ;
// 1. 先递归左子树
inOrder(root->left);
// 2. 访问当前节点(根)
printf("%c ", root->val);
// 3. 再递归右子树
inOrder(root->right);
}2
3
4
5
6
7
8
9
10
11
12
后序遍历:
左-右-根
”最后再收尾“(如:删除目录)
🔍 展开查看:代码实现
void postOrder(Node* root){
if(!root) return;
postOrder(root->left);
postOrder(root->right);
printf("%c ", root->val);
}2
3
4
5
6
7
8
3.3.2 广度优先遍历
BFS层序遍历(Queue实现)
- 上一层节点出队时,顺手把下一层的孩子(左右带入队)。
🔍 展开查看:代码实现
void levelOrder(Node* root){
if(!root) return ;
Node* queue[100];
int front = 0, rear = 0;
queue[rear ++] = root;
while(front < rear){
Node* curr = queue[front ++];
printf("%c ", curr->val);
if(curr->left)
queue[rear ++] = curr->left;
if(curr->right)
queue[rear ++] = curr->right;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
- 节点数N小于队列MAX_SIZE(数组空间用不完),就直接剩去循环数组公式
3.4 二叉搜索树(BST)
二叉搜索树(BST)
将二分查找的决策路径“固化”为一种数据结构。

对于树中的任意节点:
- 左子树所有键小于当前键。
- 右子树所有键大于当前键。
⚡ 重复键策略必须明确
BST 可以禁止重复键,也可以将重复计数存入节点,或统一放到某一侧。策略不同会影响中序遍历、删除和树高。本文示例采用“不插入重复键”。
🔍 展开查看:代码实现
typedef struct TreeNode{
int val;
TreeNode *left;
TreeNode *right;
} TreeNode;
bool search(TreeNode* node, int target){
if(!node) return false;
if(target == node->val) return true;
if(target < node->val)
return search(node->left, target); // 目标在左子树
else
return search(node->right, target); // 目标在右子树
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
3.4.1 BST 的递归原理
1.向下甩锅(Boss思维)
- 判断方向:
- 比我小 -> 命令左下属去办
- 比我大 -> 命令右下属去办
- 信任机制:任务派发后(递归调用),无条件信任下属能把事情办好。
2.向上汇报(指针重连)
- 汇报身份(返回值):递归返回值代表——“任务执行完后,这块地盘现在的Leader是谁”。
- 更新连接:Boss听到汇报后,必须执行“指针重连”:
root.left = insert(...)
3.4.2 BST 插入
分情况讨论:
- 比当前节点小(插左子树)
- 比当前节点大(插右子树)
- 空树
🔍 展开查看:代码实现
TreeNode* insert(TreeNode* node, int val){
if(node == NULL) return createNode(val);
if(val < node->val){
node->left = insert(node->left, val);
}else if(val > node->val){
node->right = insert(node->right, val);
}
return node;
}2
3
4
5
6
7
8
9
10
11
3.4.3 BST 删除
分情况讨论:
- 叶子节点(Leaf)直接删除。
- 只有一个孩子(父节点指向孙子)。
- 有两个孩子,找替罪羊(Successor)顶包:
- 右子树中最小的节点(或左子树最大的节点)。
- 值覆盖当前节点,然后删除那个替罪羊。
🔍 展开查看:代码实现
TreeNode* deleteNode(TreeNode* node, int key){
if(!node) return NULL;
if(key < node-> val)
node->left = deleteNode(node->left, key);
else if(key > node->val)
node->right = deleteNode(node->right, key);
else {
// Case 1 & 2: 0或1个孩子
if(!node->left){
TreeNode* right = node->right;
free(node);
return right;
}
if(!node->right){
TreeNode* left = node->left;
free(node);
return left;
}
// Case 3: 两个孩子
TreeNode* min = node->right;
while(min->left) min = min->left; // 找最小
node->val = min->val; // 值覆盖
node->right = deleteNode(node->right, min->val);
}
return node;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
3.4.4 BST 与中序遍历
在不允许重复键的约定下,对 BST 做中序遍历可得到严格递增序列;若允许重复键,则需明确重复键放置策略,结果通常为非递减序列。
3.5 堆与优先队列
- 结构性:完全二叉树
- 有序性:堆序只约束父子关系;小根堆满足父节点
子节点,大根堆满足父节点 子节点。 - 大根堆(Max Heap):根最大(Parent
Child)。 - 小根堆(Min Heap):根最小(Parent
Child)。
- 大根堆(Max Heap):根最大(Parent

⭐ 堆的核心不变量
堆只保证堆顶是全局极值以及父子满足堆序,不保证数组整体有序。因此堆适合反复取极值,不适合查找任意元素。

3.5.1 上浮与下沉
上浮(Sift Up/Swim):小根堆中某个节点“太小了”,比父亲还小,应该往上爬,直到不再比父亲小。
实现插入操作:新元素先放在数组某尾/树的最后一个位置。
下沉(Sift Down/Sink):小根堆中某个节点“太大了”,比孩子还要大,应该往下沉,直到不比孩子大。
实现删除堆顶:把最后一个元素搬到根,根变大了,需要往下沉。
插入(Push):
公式:Append+Swim
- Append:将新元素放到数组末尾。
- Swim:执行上浮操作恢复堆序。
时间复杂度:
大根堆插入示例:
🔍 展开查看:代码实现
// 辅助宏定义
#define PARENT(i) ((i - 1) / 2)
// 插入操作:修改size需要传指针
void push(int *heap, int *size, int val){
// 1. 放到莫为(先赋值再 +1)
heap[(*size) ++] = val;
// 2. 上浮恢复堆序
swim(heap, *size - 1);
}
// 上浮:只是一个原子操作
void swim(int *heap, int cur){
while(cur > 0 && heap[cur] > heap[PARENT(cur)]){
swap(&heap[cur], &heap[PARENT(cur)]);
cur = PARENT(cur);
}
}
void swap(int *a, int *b){
int t = *a;
*a = *b;
*b = t;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
删除堆顶(Pop)
公式:Replace + Sink
- Remove:移除堆顶(拿到最大值)。
- Replace:用末尾元素补到堆顶。
- Sink:执行下沉操作恢复堆序。
时间复杂度:
大根堆删除堆顶(取出最大值):
🔍 展开查看:代码实现
// 辅助宏定义
#define LEFT(i) (2 * i + 1)
int pop(int *heap, int *size){
int ret = heap[0];
// 1. 末尾补到堆顶
heap[0] = heap[--(*size)];
// 2. 下沉恢复堆序
sink(heap, *size, 0);
return ret;
}
void sink(int *heap, int n, int i){
while(LEFT(i) < n){
int child = LEFT(i);
// 选较大的孩子
if(child + 1 < n && heap[child + 1] > heap[child]) child ++;
if(heap[i] >= heap[child]) break;
swap(&heap[i], &heap[child]);
i = child;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
3.5.2 建堆
方案A:逐个插入(Swim)
从空堆开始,依次对N个元素执行Push操作
- 新元素加在末尾,需要一路“上浮”到根。
- 大部份节点(叶子)都要经历漫长的上浮路径。
时间复杂度:
方案B:批量下沉(Sink)
原地调整:从最后一个非叶子节点开始,倒序遍历执行Sink
- 利用特性:叶子节点(占N/2)天然有序,无需操作。
- 大多数叶子节点无需下沉;越靠近根的节点虽然下沉距离更长,但数量按层指数减少。对各层工作量求和可得总复杂度
。
时间复杂度:
批量下沉法实现大根堆建堆:
从最后一个非叶子节点开始,倒序执行下沉
- 起点:
i = n / 2 - 1 - 方向:i递减到0
- 操作:对每个i执行
sink(heap, n, i)
🔍 展开查看:代码实现
// 下沉操作
void sink(int *heap, int n, int i){
while(LEFT(i) < n){
int child = LEFT(i);
// 选较大的孩子
if(child + 1 < n && heap[child + 1] > heap[child]) child ++;
if(heap[i] >= heap[child]) break;
swap(&heap[i], &heap[child]);
i = child;
}
}
// 建堆
void build_heap(int *heap, int n){
// 从最后一个非叶子节点开始
for(int i = n / 2 - 1; i >= 0; i --){
sink(heap, n, i);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
递归建堆:
🔍 展开查看:代码实现
// 递归下沉操作
void heapify(int *heap, int n, int i){
int largest = i;
int left = 2 * i + 1;
int right = 2 * i + 2;
// 找出最大节点
if(left < n && heap[left] > heap[i])
largest = left;
if(right < n && heap[right] > heap[largest])
largest = right;
// 如果最大不是当前节点,交换并递归
if(largest != i){
swap(&heap[i], &heap[largest]);
heapify(heap, n, largest);
}
}
// 建堆
void build_heap(int *heap, int n){
// 从最后一个非叶子节点开始
for(int i = n / 2 - 1; i >= 0; i --){
heapify(heap, n, i);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
3.5.3 堆排序
堆排序(Heap Sort)
- 建堆(Build):先将数组转化为大顶堆(
)。 - 交换(Swap):将堆顶与末尾呼唤 -> 最大值归位
- 下沉(Sink):堆大小减1,剩余部分执行下沉恢复堆序。
- 循环:重复2-3,直到堆为空。
时间复杂度:
🔍 展开查看:代码实现
void heap_sort(int *arr, int n){
// 1. 建堆(变成大根堆)
for(int i = n / 2 - 1; i >= 0; i --)
sink(arr, n, i);
// 2. 排序(Swap + Sink)
for(int i = n - 1; i > 0; i --){
// 把最大值(堆顶换到末尾)
swap(&arr[0], &arr[i]);
// 剩余元素(大小为i),恢复堆序
sink(arr, i, 0);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
3.5.4 数据流的中位数
问题:动态添加数字,随时返回中位数。
双堆法思路:
维护两个堆,各存一半数据:
- 大根堆(Max Heap):存较小的一半数。
- 小根堆(Min Heap):存较大的一半数。
中位数 = 两个堆顶的平均值(或元素多的那个堆顶)。
具体设计:
把数据流切成两半,保持有序:
- 左边(Max Heap):存较小的一半。
- 右边(Min heap):存较大的一半。
- 规定:左边数量
右边(最多多1个)。
如果不比较直接插入,可能会把“大数”错放到左边。
策略:新来的先去左边“过一下”,选出最大的送去右边;如果右边人多了,再匀一个回左边。
🔍 展开查看:代码实现
#include<stdio.h>
#include<stdlib.h>
typedef struct{
int *data;
int size;
int capacity;
int isMinHeap; // 1表示小根堆,0表示大根堆
}Heap;
// 创建堆
Heap* createHeap(int capacity, int isMinHeap){
Heap* obj = (Heap*)malloc(sizeof(Heap));
obj->data = (int*)malloc(sizeof(int) * capacity);
obj->size = 0;
obj->capacity = capacity;
obj->isMinHeap = isMinHeap;
return obj;
}
// 交换函数
void swap(int *a, int *b){
int t = *a;
*a = *b;
*b = t;
}
// 比较函数:根据堆类型判断a是否应该排在b前面
int compare(int a, int b, int isMinHeap){
if(isMinHeap) return a < b; // 小根堆:小的在前
else return a > b; // 大根堆:大的在前
}
// 上浮
void swim(Heap *obj, int k){
while(k > 0){
int parent = (k - 1) / 2;
if(compare(obj->data[k], obj->data[parent], obj->isMinHeap)){
swap(&obj->data[k], &obj->data[parent]);
k = parent;
}else {
break;
}
}
}
// 下沉
void sink(Heap *obj, int k){
while(1){
int left = 2 * k + 1;
int right = 2* k + 2;
int target = k; // 目标位置(最应该在上层的)
if(left < obj->size && compare(obj->data[left], obj->data[target], obj->isMinHeap))
target = left;
if(right < obj->size && compare(obj->data[right], obj->data[target], obj->isMinHeap))
target = right;
if(target != k){
swap(&obj->data[k], &obj->data[target]);
k = target;
}else {
break;
}
}
}
// 入堆(Push)
void heapPush(Heap *obj, int val){
obj->data[obj->size] = val;
swim(obj, obj->size); // 上浮新加入的元素
obj->size ++;
}
// 出堆(Pop)
int heapPop(Heap * obj){
if(obj->size == 0) return -1;
int top = obj->data[0];
swap(&obj->data[0], &obj->data[obj->size - 1]); // 交换堆顶和最后一个元素
obj->size --;
sink(obj, 0); // 下沉新的堆顶
return top;
}
// 获取堆顶
int heapPeek(Heap *obj){
if(obj->size == 0) return -1;
return obj->data[0];
}
// 释放堆内存
void freeHeap(Heap *obj){
if(obj){
free(obj->data);
free(obj);
}
}
// MedianFinder的定义
typedef struct {
Heap *minHeap; // 存较大的一半
Heap *maxHeap; // 存较小的一半
}MedianFinder;
// 初始化
MedianFinder* medianFinderCreate(){
MedianFinder* obj = (MedianFinder*)malloc(sizeof(MedianFinder));
// 题目最多调用50000次,每个堆分配30000足够
int capacity = 30000;
obj->minHeap = createHeap(capacity, 1); // 1:MinHeap
obj->maxHeap = createHeap(capacity, 0); // 0:MaxHeap
return obj;
}
// 添加数字
void medianFinderAddNum(MedianFinder* obj, int num){
// 1. 先放大根堆(左边)
heapPush(obj->maxHeap, num);
// 2. 将大根堆中最大的移到小根堆(右边),保证右边的数永远大于左边
int maxTop = heapPop(obj->maxHeap);
heapPush(obj->minHeap, maxTop);
// 3. 平衡大小:保证maxHeap的数量 >= minHeap的数量
// 如果minHeap太多了,就把嘴笑的拿回maxHeap
if(obj->minHeap->size > obj->maxHeap->size){
int minTop = heapPop(obj->minHeap);
heapPush(obj->maxHeap, minTop);
}
}
// 查找中位数
double medianFinderFindMedian(MedianFinder* obj){
// 如果大根堆元素多,说明总数是奇数,中位数在大根堆堆顶
if(obj->maxHeap->size > obj->minHeap->size){
return (double)heapPeek(obj->maxHeap);
}
//否则说明总数是偶数,取平均
return (heapPeek(obj->maxHeap) + heapPeek(obj->minHeap)) / 2.0;
}
// 销毁对象
void medianFinderFree(MedianFinder *obj){
if(obj){
freeHeap(obj->minHeap);
freeHeap(obj->maxHeap);
free(obj);
}
}
int main(){
MedianFinder *obj = medianFinderCreate();
printf("添加 10 :");
medianFinderAddNum(obj, 10);
printf("中位数 = %.2f\n", medianFinderFindMedian(obj));
printf("添加 27 :");
medianFinderAddNum(obj, 27);
printf("中位数 = %.2f\n", medianFinderFindMedian(obj));
printf("添加 13 :");
medianFinderAddNum(obj, 13);
printf("中位数 = %.2f\n", medianFinderFindMedian(obj));
printf("添加 49 :");
medianFinderAddNum(obj, 49);
printf("中位数 = %.2f\n", medianFinderFindMedian(obj));
printf("添加 35 :");
medianFinderAddNum(obj, 35);
printf("中位数 = %.2f\n", medianFinderFindMedian(obj));
medianFinderFree(obj);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
3.5.5 堆的应用
优先队列:普通队列按 FIFO 出队,优先队列按优先级选择下一个元素。
- 谁优先级高,谁先出队。
- 插入
,删除最高优先级元素 ,查看最高优先级元素 。
Top-K问题:从N个元素中找到前K大/小的元素
- 排序法:
。 - 堆解法:维护大小为
的堆,复杂度为 。
- 排序法:
图算法加速:在图论算法中,利用堆快速获取“当前距离最近”的节点。
- Dijkstra:寻找最短路径。
- 性能提升:查看堆顶为
,取出最小元素并恢复堆序为 ;从而避免每轮线性扫描所有候选点。
3.6 堆与 BST 对比
- 核心目标:快速访问极值,堆顶查看
,插入和删除堆顶 。 - 任意查找:通常为
。 - 有序性:只保证父子间的堆序,不保证全局排序。
- 场景:优先队列、Top-K、调度、双堆中位数。
3.7 哈夫曼树
哈夫曼树引入场景:发送电报
假设我们要发送一段文字:“BAD CAD FEED”
传统方法:定长编码
- 每个字符用固定长度的二进制表示,如
A:000, B:001, C:010...
变长编码思路:
- 高频字符 -> 短编码(如'e' -> 10)
- 低频字符 -> 长编码(如'z' -> 110011)
为了实现变长编码,通常借助二叉树结构:
- 编码长度 -> 节点深度(路径长度)
- 字符频率 -> 节点权重
- 目标:总长度(
)最小。
核心指标:带权路径长度(Weighted Path Length,WPL)
哈夫曼树定义:WPL最小的二叉树

- 核心思想:权值大的靠上,权值小的靠下
- 这样可以保证高频字符编码短,总长度最小。
生成规则:
- 左分支标0、右分支标1
- 路径即编码:从根到叶子路径上的01序列
前缀特性(Prefix Property):
- 任何一个字符的编码都不是另一个字符编码的前缀。
- 例如:如果A是0,那么B绝不可能是01.因为字符都在叶子节点,不会出现在路径中间。保证了解码的唯一性,无需分隔符。

哈夫曼树构造:贪心策略
- 构成森林:将每个字符看作一棵单节点树。
- 选两最小:从森林中选出根权值最小两棵树。
- 合并新树:
- 创造新父节点,权值为两子之和。
- 两棵小树作为左右子树。
- 删旧添新:从森林中删除选中的两棵树,加入新树。
- 重复:直到森林只剩一棵树。
🔍 展开查看:代码实现
typedef struct TreeNode{
int weight;
char val; // 仅叶子节点有效
struct TreeNode *left, *right;
}TreeNode;
// 优先队列节点(存指针)(直接用STL中的优先队列)
PriorityQueue<TreeNode*> minHeap;
TreeNode* buildHuffmanTree(int *weights, char *chars, int n){
// 1. 将所有字符作为节点放入小根堆
PriorityQueue pq = createMinHeap();
for(int i = 0; i < n; i ++)
push(pq, newNode(weights[i], chars[i]));
// 2. 循环合并
while(size(pq) > 1) {
// 取出最小的两个
TreeNode* left = pop(pq);
TreeNode* right = pop(pq);
// 合并
TreeNode* parent = newNode(left->weight + right->weight, 0);
parent->left = left;
parent->right = right;
// 放回
push(pq,parent);
}
return pop(pq); // 返回根节点
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
哈夫曼树不是:”滚雪球“,而是“森林进化”:
- 新生成的子树(节点)权值可能很大。
- 贪心策略要求:每一刻都必须选全局最小的两个,无论它是原来的叶子,还是新生成的子树。
3.8 Trie 字典树
Trie(名称源自 retrieval,也称前缀树或字典树)
核心定义:
- 多叉树结构:每个节点代表一个字符。
- 公共前缀:同前缀单词共享路径。
- 根节点:空节点,不包含字符。
- 路径:根到节点的路径字符=字符串
空间换时间
- 利用字符串的公共前缀来减少查询时间,最大限度地减少无谓的字符串比较。

结构体定义要素:
- Next指针数组:指向子节点(如果是全小写字母,大小为26)。
- 结束标记(isEnd):布尔值(标记当前节点是否为一个单词的结尾)。
🔍 展开查看:代码实现
#define ALPHABET_SIZE 26
typedef struct TrieNode{
// 指向子节点的指针数组
// index 0->'a', 1->'b'...
struct TrieNode *children[ALPHABET_SIZE];
// 标记是否是单词结尾
// true 表示从根到此的路径构成了一个完整单词
bool isEndOfWord;
} TrieNode;2
3
4
5
6
7
8
9
10
11
流程:
例如:依次插入“cat”和“car”
- 从根节点触发,遍历单词字符。
- 若子节点存在:移动到下一层。
- 若不存在:新建节点,链接上去。
- 单词结束时,标记
isEnd = true
🔍 展开查看:代码实现
TrieNode* getNode() {
// 1. 分配内存(使用 calloc 可以同时清零)
TrieNode* newNode = (TrieNode*)calloc(1, sizeof(TrieNode));
// 2. 显式初始化(如果使用 malloc 则需要这一步)
// 由于使用了 calloc,这一步其实可以省略,因为 calloc 已经把内存置为 0
// 但为了代码清晰,通常保留逻辑或直接使用 calloc
/*
for (int i = 0; i < ALPHABET_SIZE; i++) {
newNode->children[i] = NULL;
}
newNode->isEndOfWord = false;
*/
return newNode;
}
void insert(TrieNode *root, const char *key){
TrieNode *pCrawl = root;
for(int i = 0; key[i] != '\0'; i ++){
// 计算下标:'a'->0, 'b'->1 ...
int index = key[i] - 'a';
// 如果路径不存在,创建新节点
if(!pCrawl->children[index])
pCrawl->children[index] = getNode();
// 移动指针到下一层
pCrawl = pCrawl->children[index];
}
// 标记单词结束
pCrawl->isEndOfWord = true;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
- 查单词(Whole Word):必须路径存在且
isEnd == true。 - 查前缀(StartsWith):只要路径存在即可。
🔍 展开查看:代码实现
bool search(TrieNode *root, const char *key){
TrieNode *pCrawl = root;
for(int i = 0; key[i] != '\0'; i ++){
int index = key[i] - 'a';
// 路径断了 -> 不存在
if(!pCrawl->children[index])
return false;
pCrawl = pCrawl->children[index];
}
// 必须同时满足:路径走通且标记了结束
return (pCrawl != NULL && pCrawl->isEndOfWord);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
实战:LeetCode 820:单词的压缩编码
目标:构造最短字符串S,包含所有单词(以#结尾)。
规则:如果A是B的后缀,A可以“藏”在B里,不用单独写。
例如["time", "me", "bell"]
1."me"是"time"后缀 -> 合并为"time#"
2."bell"无后缀关系 -> 单独"bell#"
最终长度:len("time#") + len("bell#") = 10
思路:Trie擅长处理前缀,反转单词,让"em"变成"emit"的前缀。
构造Trie,然后遍历Trie,答案为每个最长单词+1的和。
解题步骤:
1.单词全反转:"emit","em","lleb"
2.构建Trie树:
3.关键:如果一个词是另一个词的前缀,则它不需要独立编码(被包含)
4.结构:所有叶子节点对应单词长度+1
🔍 展开查看:代码实现
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#define ALPHABET_SIZE 26
typedef struct TrieNode {
struct TrieNode *children[ALPHABET_SIZE];
bool isEndOfWord;
} TrieNode;
// 创建新节点(使用 calloc 自动初始化)
TrieNode* getNode() {
return (TrieNode*)calloc(1, sizeof(TrieNode));
}
// 反转字符串函数
void reverseStr(char *str) {
int len = strlen(str);
for (int i = 0; i < len / 2; i++) {
char temp = str[i];
str[i] = str[len - 1 - i];
str[len - 1 - i] = temp;
}
}
// 插入单词(已反转)
void insert(TrieNode *root, const char *key) {
TrieNode *pCrawl = root;
for (int i = 0; key[i] != '\0'; i++) {
int index = key[i] - 'a';
if (!pCrawl->children[index])
pCrawl->children[index] = getNode();
pCrawl = pCrawl->children[index];
}
pCrawl->isEndOfWord = true;
}
// 深度优先遍历,统计叶子节点路径长度
void dfs(TrieNode* node, int depth, int* ans) {
bool isLeaf = true;
for (int i = 0; i < ALPHABET_SIZE; i++) {
if (node->children[i]) {
isLeaf = false;
dfs(node->children[i], depth + 1, ans);
}
}
// 是叶子节点 -> 累加长度(depth + 1个#号)
if (isLeaf && depth > 0) {
*ans += (depth + 1); // 修正此处语法
}
}
// 计算最小长度编码
int minimumLengthEncoding(char** words, int size) {
TrieNode* root = getNode();
for (int i = 0; i < size; i++) {
reverseStr(words[i]); // 反转单词
insert(root, words[i]);
}
int ans = 0;
dfs(root, 0, &ans);
return ans;
}
// 释放 Trie 树内存
void freeTrie(TrieNode* node) {
if (!node) return;
for (int i = 0; i < ALPHABET_SIZE; i++) {
if (node->children[i]) {
freeTrie(node->children[i]);
}
}
free(node);
}
int main() {
// 示例单词列表
char* words[] = {"time", "me", "bell"};
int size = sizeof(words) / sizeof(words[0]);
// 计算最小编码长度
int result = minimumLengthEncoding(words, size);
printf("Minimum Length Encoding: %d\n", result); // 应输出 10 ("time#bell#")
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
3.9 Trie 与哈希表对比
- 查找、插入复杂度通常为
,其中 是字符串长度。 - 天然支持前缀查询、自动补全和字典序遍历。
- 朴素子指针数组可能浪费空间,可用压缩 Trie、哈希子节点或基数树优化。
3.10 AVL 树
AVL树:平衡的二叉搜索树,解决BST退化问题。
理想的BST(Balanced):
- 插入顺序:50,20,80,10,30(随机)
- 结构:像一棵茂盛的树,左右均匀。
- 高度:
- 查找效率:快(只找几层)
极端的BST(Skewed):
- 插入顺序:10,20,30,40,50(有序)
- 结构:退化成链表。
- 高度:
- 查找效率:慢(退回到线性扫描)。

AVL树(Adelson-Velsky and Landis Tree)是最早发明的自平衡二叉搜索树,树中任意一个节点的左子树和右子树的高度差的绝对值$\le$1。
平衡因子:BF
铁律:任意节点的
一旦某节点

插入操作:后序处理
- 标准插入:像普通BST一样找到空位插入。
- 回溯(BackTracking):递归函数返回,沿着路径向上走。
- 更新高度:
- 计算BF:
- 检查失衡:
- BF > 1(左重):看左孩子BF。
- 左孩子
:LL(右旋)。 - 左孩子 < 0:LR(先左旋后右旋)。
- 左孩子
- BF < -1(右重):看右孩子BF。
- 右孩子
:RR(左旋)。 - 右孩子 > 0:RL(先右旋后左旋)。
- 右孩子
- BF > 1(左重):看左孩子BF。
3.11 红黑树
AVL树vs红黑树
AVL树:
- 规则:左右高度差
。 - 优点:查找路径长度稳定在
。 - 痛点:插入/删除时,为了维持这种“绝对平衡”,需要频繁旋转。
红黑树(RB Tree):
- 规则:引入颜色位,通过5条性质约束。
- 效果:没有 AVL 那么严格,但从根到叶子的最长简单路径不超过最短简单路径的约
倍,因此高度仍为 。 - 收益:插入/删除时,旋转次数更少(常数级),大量使用变色(开销小)。

红黑树的5条铁律:
颜色属性:每个节点要么是红色,要么是黑色。
根属性:根节点必须是黑色。
叶子属性:所有叶子节点(NIL)都是黑色。
红属性(关键):不能有两个连续的红色节点。
黑高属性(关键):从任一节点到其所有后代叶子的路径上,包含相同数量的黑色节点。
口诀:根叶黑,不红红,黑路同。

插入操作:默认为红
插入为黑色:
- 后果:所在路径黑高+1
- 问题:必然破坏“黑高属性”。
- 修复难度:全局性破坏,很难修。
插入为红色:
- 后果:如果父节点是黑,完美!无需修复。
- 风险:如果父节点是红,破坏“红红不相邻”。
- 优势:局部性破坏,好修。
修复三板斧
目标:消除双红,且不引入新的违规。
变色(Recolor):红变黑,黑变红。代价最小。
旋转(Rotate):调整结构(同AVL)。
看“叔叔”的脸色行事:
- 叔叔是红色 -> 变色。
- 叔叔是黑色 -> 旋转。
策略1:叔叔是红色 -> 变色
- 父亲(P)变黑:消除与新节点(New)的双红冲突。
- 叔叔(U)变黑:为了保持黑高平衡,叔叔也必须增加一个黑色。
- 爷爷(GP)变红:因为左右子树都各加了一个黑,所以根必须减一个黑(变红),维持整体黑高不变。
- 注意:爷爷变红后,可能会与其父节点产生新的双红冲突,需要向上递归处理。

策略2:叔叔是黑(LL型) -> 右旋
- 特征:三点一线(Left-Left),叔叔是黑色或NIL。
- 变色:父亲(P)变黑,爷爷(GP)变红。
- 旋转:以父亲(P)为轴,对爷爷(GP)进行右旋。
- 结构:父节点P上位称为子树根节点,完美平衡,无需继续递归。

策略3:叔叔是黑(RR型) -> 左旋
- 特征:三点一线(Right-Right),与LL镜像对称。
- 变色:父亲(P)变黑,爷爷(GP)变红。
- 旋转:以父亲(P)为轴,对爷爷(GP)进行左旋。
- 结果:父节点P上位,完美平衡,同样无需递归。

插入修复操作速查表
| 场景 | 叔叔颜色 | 结构特征 | 核心操作 | 后续 |
|---|---|---|---|---|
| Case 1 | 红色 | 任意 | P, U → 黑 GP → 红 | 递归 (查 GP) |
| Case 2 (LL) | 黑色 | GP-P-N (左直线) | 右旋 GP + 变色 | 结束 |
| Case 3 (RR) | 黑色 | GP-P-N (右直线) | 左旋 GP + 变色 | 结束 |
| Case 4 (LR) | 黑色 | 左-右 (折线) | 左旋 P → 变成 LL | 转 Case 2 |
| Case 5 (RL) | 黑色 | 右-左 (折线) | 右旋 P → 变成 RR | 转 Case 3 |
- 口诀:叔红变色看祖父,叔黑直旋折变直。
3.12 AVL 树与红黑树对比
- 严格维护每个节点的平衡因子
。 - 树高更低,查找通常更稳定。
- 插入和删除可能触发更多高度更新与旋转。
- 适合查找密集、更新相对较少的场景。
3.13 B 树
B 树是一种面向磁盘与页式存储的多路平衡搜索树。与二叉搜索树不同,一个节点可以保存多个有序关键字和多个子指针,从而显著降低树高与随机 I/O 次数。
- 所有叶子位于同一层。
- 节点内关键字有序,并将值域划分给多个子树。
- 插入时节点溢出会分裂;删除时节点过少会借位或合并。
- 查找复杂度为
层,其中 是分支因子;实际性能更关注访问了多少磁盘页。
💡 通俗理解:为什么数据库喜欢“矮胖”的树?
一次磁盘或 SSD 页访问远比内存比较昂贵。让一个节点装下大量关键字,相当于一次 I/O 就排除大量数据,因此 B 树会尽量降低高度。
3.14 B+ 树
B+ 树是数据库索引更常见的变体:内部节点主要保存分隔键和子指针,完整记录或记录指针集中在叶子节点;叶子节点通常还按键值顺序串联。
- 内部节点和叶子节点都可以存放记录。
- 命中内部节点时可提前结束查询。
- 范围扫描需要在树结构中继续导航。
⚡ 易混淆点:B 树不是二叉树
名称中的 “B” 不代表 Binary。B 树和 B+ 树都是多路平衡搜索树,节点容量通常按存储页大小设计。
4. 图结构与经典算法
图的应用:
- 社交网络:利用连通性做推荐,用BFS算六度分隔。
- 地图导航:将路口建模为定点,用Dijkstra算最短路径。
- 任务调度:解析依赖关系,用拓扑排序决定执行顺序。
- 互联网(Web):网页是顶点,链接是边,用PageRank算权重。
- 知识图谱:实体间建立语义关联,支持复杂推理。
- 网络拓扑:用最小生成树优化布线成本或路由广播。
图由顶点集合
- 图适合表达多对多(Many-to-Many)关系。

4.1 图论基础
顶点、边、度、权重、方向
4.1.1 无向图与有向图

4.1.2 顶点的度
度(Degree)

4.1.3 边的权重
定义:赋予边的数值,代表两个顶点之间关系的强度或成本。
常见含义:
- 地图:距离(km)、时间(min)。
- 网络:带宽(Mbps)、延迟(ms)。
- 社交:亲密度、互动频率。
带权图(Weighted Graph)又称为网(Network)。

4.1.4 路径与环
路径(Path):从一个顶点到另一个顶点,途径的顶点序列。
简单路径:不重复经过顶点的路径。
环(Cycle):起点和终点相同的路径。

4.1.5 连通性
- 连通(Connected):顶点之间存在路径即为连通。
- 连通图(Undirected):任意两点连通,若不连通则包含多个“连通分量”。
- 强连通(Directed):有向图中,任意两个顶点之间都可以互相到达。

4.2 图的存储
邻接矩阵 & 邻接表
4.2.1 邻接矩阵
邻接矩阵:有向图(Directed)
定义:使用一个二维数组G[N][N]来表示顶点之间的连接关系。
- 若顶点i和j之间有边,则
G[i][j] = 1(或权重)。 - 若无边,则
G[i][j] = 0(或)。
特点:
- ✅ 查边快:
判断两点是否相连。 - ✅算度快:行和 = 出度, 列和 = 入度。
- ⚠️耗空间:固定
,适合稠密图。

邻接矩阵:无向图(Undirected)
对称性(Symmetry):无向图的边没有方向,(i, j)等同于(j, i)。
- 矩阵关于主对角线对称。
G[i][j] == G[j][i]- 存储时可以只存上三角或下三角以节省空间。

邻接矩阵:代码实现
🔍 展开查看:代码实现
#define MAX_V 100
#define INF 65535 // 表示无穷大(无边)
typedef struct{
char V[MAX_V]; // 顶点表
int Edge[MAX_V][MAX_V]; // 邻接矩阵(边表)
int numV, numE; // 当前顶点数和边数
}MGraph;
void CreateMGraph(MGraph *G){
// 1. 输入顶点数和边数
printf("输入顶点数和边数:");
scanf("%d %d", &G->numV, &G->numE);
// 2. 初始化邻接矩阵
for(int i = 0; i < G->numV; i ++){
for(int j = 0; j < G->numV; j ++){
if(i == j) G->Edge[i][j] = 0;
else G->Edge[i][j] = INF;
}
}
// 3. 建立边(读入边的数据)
int a, b, w;
for(int k = 0; k < G->numE; k ++){
printf("输入边(vi,vj)的下标i, j和权重w:");
scanf("%d,%d,%d", &a, &b, &w);
G->Edge[a][b] = w;
G->Edge[b][a] = w; // 无向图需对称赋值
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
4.2.2 邻接表
邻接表(Adjacency List)
定义:数组与链表的结合。每个顶点建立一个单链表,链表中存储该顶点的所有邻居。
- 顶点数组
AdjList[N]存储表头。 - 链表节点存储邻接点下标adjvex和权重weight。
特点:
- ✅节省空间,只存实际存在的边。
- ✅ 空间复杂度为
。 - ⚠️无向图中每条边存储两次(A -> B, B -> A)。

邻接表:无向图(Undirected)
冗余存储:无向图的边(i, j)没有方向,需要在两个顶点的链表中分别存储。
- 在
AdjList[i]中插入节点j。 - 在
AdjList[j]中插入节点i
影响:
- ⚠️边表节点总数是边数的2倍。
- ✅容易求顶点的度(链表长度即为度)。

邻接表:代码实现
关键点解析:
- 结构体嵌套:EdgeNode(边)挂在VertexNode(点)下。
- 头插法:新边插入链表头部,复杂度为
,但邻居顺序与输入顺序相反。 - 出度容易,入度难:求入度需要遍历整个图(或者使用逆邻接表)。
🔍 展开查看:代码实现
typedef struct EdgeNode{
int adjvex; // 邻接点下标
int weight; // 权重
struct EdgeNode *next; // 指向下一个邻接点
}EdgeNode;
typedef struct VertexNode{
char data; // 顶点信息
EdgeNode *firstedge; // 边表头指针
}VertexNode, AdjList[MAX_V];
typedef struct{
AdjList adjList;
int numV, numE;
}GraphAdjList;
void CreateALGraph(GraphAdjList *G){
int i, j, k;
EdgeNode *e;
scanf("%d,%d", &G->numV, &G->numE);
// 初始化顶点表
for(i = 0; i < G->numV; i ++){
scanf("%c", &G->adjList[i].data);
G->adjList[i].firstedge = NULL;
}
// 头插法建立边表
for(k = 0; k < G->numE; k ++){
scanf("%d,%d", &i, &j);
e = (EdgeNode *)malloc(sizeof(EdgeNode));
e->adjvex = j;
e->next = G->adjList[i].firstedge;
G->adjList[i].firstedge = e;
// 无向图重复一次(j -> i)
e = (EdgeNode *)malloc(sizeof(EdgeNode));
e->adjvex = i;
e->next = G->adjList[j].firstedge;
G->adjList[j].firstedge = e;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
4.2.3 邻接矩阵与邻接表对比
- 空间复杂度为
。 - 判断边是否存在为
,遍历一个顶点的邻居为 。 - 结构紧凑、实现简单,适合稠密图或频繁查询任意两点是否相连。
4.3 图的遍历
图的遍历:从图中某一顶点出发,按照某种搜索方法,对图中所有可达顶点访问一次且仅一次。
核心难点:
- 图可能有回路(环),如何避免兜圈子。
- 图可能不连通,如何访问孤立点。
- 解决方案:设置
visited[N]数组记录访问状态。
💡 通俗理解:DFS 与 BFS
- DFS 像走迷宫:沿一条路深入,走不通再回退。
- BFS 像水波扩散:距离起点一层的节点先于下一层被访问。
4.3.1 DFS
算法思想:
- 访问起始点v。
- 若v有未被访问的邻接点w,则从w出发继续深度遍历。
- 若所有邻接点都被访问过,则回退(Backtrack)到前一个顶点。
- 重复上述过程,直到所有通达节点都被访问。

实现机制:
- 栈(Stack):后进先出(LIFO)。通常使用递归隐式使用系统栈。
代码解析:
- 递归基:隐式包含在循环条件中(无未访问邻居时函数结束,自动回溯)。
visited[]:至关重要,防止在环路中死循环。- 非连通图:DFSTraverse循环确保所有连通分量都被访问。
复杂度分析:
- 邻接矩阵:
————每次都要遍历一行找邻居。 - 邻接表:
————只遍历实际存在的边。
邻接矩阵DFS实现:
🔍 展开查看:代码实现
int visited[MAX_V]; // 访问标记数组
// 邻接矩阵的DFS递归算法
void DFS(MGraph G, int i){
int j;
// 1. 访问当前节点并标记
visited[i] = 1;
printf("%c ", G.vexs[i]);
// 2. 遍历所有邻接点
for(j = 0; j < G.numVertexes; j ++){
// 若有边且未被访问
if(G.arc[i][j] == 1 && !visited[j]){
DFS(G, j); // 递归调用
}
}
}
// 遍历整个图(处理非连通图)
void DFSTraverse(MGraph G){
int i;
for(i = 0; i < G.numVertexes; i ++)
visited[i] = 0; // 初始化
for(i = 0; i < G.numVertexes; i ++)
if(!visited[i])
DFS(G, i); // 对未访问的顶点调用DFS
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
邻接表DFS实现:
4.3.2 BFS
算法思想:
- 访问起始点v。
- 依次访问v的所有未被访问的邻接点w1,w2...。
- 再依次访问w1,w2...的所有邻接点。
- 像波纹一样一层层向外扩展。
实现机制:
- 队列(Queue):先进先出(FIFO)。保证先被访问节点的邻居先得到访问。
关键逻辑:
- 入队时机:发现未访问邻居时立即入队并标记
visited[i]。 - 出队时机:处理完当前节点后出队,转而处理队列头部节点的邻居。
- 循环结构:外层循环处理非连通图,内层while循环处理当前连通分量。
🔍 展开查看:代码实现
void BFSTraverse(MGraph G){
int i, j;
Queue Q;
for(i = 0; i < G.numVertexes; i ++)
visited[i] = 0;
InitQueue(&Q); // 初始化辅助队列
for(i = 0; i < G.numVertexes; i ++){
if(!visited[i]){
visited[i] = 1;
printf("%c ", G.vexs[i]);
EnQueue(&Q, i); // 入队
while(!QueueEmpty(Q)){
int u;
DeQueue(&Q, &u);
// 找顶点u的所有邻接点
for(j = 0; j < G.numVertexes; j ++){
if(G.arc[u][j] == 1 && !visited[j]){
visited[j] = 1;
printf("%c ", G.vexs[j]);
EnQueue(&Q, j);
}
}
}
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
4.3.3 DFS 与 BFS 对比
- 使用递归调用栈或显式栈,优先深入未访问分支。
- 适合连通性、环检测、拓扑排序、回溯与枚举路径。
- 不保证先找到边数最少的路径。
- DFS典型应用:全排列、寻找所有路径、拓扑排序、判断环、连通性检测。
- BFS典型应用:最短路径(迷宫/地图)、社交网络(几度好友)、Web爬虫、垃圾回收(GC)。
4.4 最小生成树
什么是最小生成树(MST)?
💡 通俗理解:最小生成树就是最低成本联网
把城市看作顶点、线路看作带权边。目标是在不形成冗余环路的前提下,用

定义:MST
- 生成树:包含图中所有顶点,且没有环的子图(V个顶点,V-1条边)。
- 最小:边的权值之和最小。
4.4.1 Prim
核心思想:贪心(每次都找局部的最优解,最后得到全局的最优解)
- 从任意一个顶点出发,将其视为“已生成的树”。
- 每次从“树外”的节点中,挑选一个距离“树”最近的节点,加入到树中。

关键数据结构:
adjvex[i]:记录节点i依附在树上的哪个节点(即父节点)。lowcost[i]:记录节点i到当前生成树集合的最小距离。- 若
lowcost[i] == 0,表示节点i已在树中。
- 若
复杂度分析:
- 时间复杂度:
。 - 双重循环:外层循环V次,内层查找最小值+更新数组也是V次。
- 适用场景:稠密图(边数非常多时),因为其复杂度与边数无关。
关键逻辑:
- 核心在于
lowcost[j] = 0这一句,它标记了节点j已经“入伙”。 - 每次新节点入伙,都要用它去“更新”周围邻居到组织的距离。
🔍 展开查看:代码实现
void MiniSpanTree_Prim(MGraph G){
int min, i, j, k;
int adjvex[MAXV]; // 保存相关顶点下标(构建MST)
int lowcost[MAXV]; // 保存相关顶点间边的权值
// 1. 初始化:从顶点0开始构建
lowcost[0] = 0; // lowcost[i] = 0 表示i已经加入MST
adjvex[0] = 0;
for(i = 1; i < G.numV; i ++){
lowcost[i] = G.arc[0][i]; // 将与0相连的边权值存入
adjvex[i] = 0;
}
// 2. 循环 n-1 次,找剩余的 n-1 个顶点
for(i = 1; i < G.numV; i ++){
min = INF;
j = 1; // 当前树的顶点数
k = 0; // 当前最小边下标
// 寻找离当前MST最近的顶点k
while(j < G.numV){
if(lowcost[j] != 0 && lowcost[j] < min){
min = lowcost[j];
k = j; // k记录最小值的下标
}
j ++;
}
printf("(%d, %d) ", adjvex[k], k); // 打印当前最小边
lowcost[k] = 0; // 将k加入MST(标记为0)
// 3. 更新lowcost 数组(更新邻居距离)
for(j = 1; j < G.numV; j ++){
if(lowcost[j] != 0 && G.arc[k][j] < lowcost[j]){
lowcost[j] = G.arc[k][j]; // 发现通过k到j的距离更近
adjvex[j] = k; // 更新j的父节点为k
}
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
4.4.2 Kruskal
Kruskal算法:加边法
核心思想:
- 不属于任何一个点,而是着眼于边。
- 将所有边按权值从小到大排序,依次尝试加入。
- 如果加入某条边会形成环,则丢弃;否则保留。

关键技术:并查集
- 判环:如何快速判断两个点是否已经连通?
Find(x):查找x所属集合的根节点。Union(x, y):合并两个集合。- 若
Find(u) == Find(v),说明u,v已连通,加边必成环 --> 跳过。
复杂度分析:
- 时间复杂度:
。 - 主要耗时是边排序;使用路径压缩与按秩合并后,并查集单次操作的均摊复杂度为
,其中 是增长极慢的反阿克曼函数。 - 适用场景:稀疏图(边数较少时),因为只与边数E有关。
实现细节:
parent[]数组初始化为0。- Find函数通过不断向上找父节点直到为0来找到根。
- 路径压缩(Path Compression)可进一步优化查找效率。
🔍 展开查看:代码实现
typedef struct{
int begin, end, weight;
}Edge;
// 查找集合根节点
int Find(int *parent, int f){
while(parent[f] > 0) f = parent[f];
return f;
}
void MiniSpanTree_Kruskal(MGraph G){
int i, n, m;
Edge edges[MAXE]; // 边集数组
int parent[MAXV]; // 并查集数组(判断环)
sort(edges); // 1. 核心:将边按权值从小到大排序
for(i = 0; i < G.numV; i ++) parent[i] = 0; // 初始化并查集
for(i = 0; i < G.numE; i ++){
n = Find(parent, edges[i].begin); // 查找起点所属集合
m = Find(parent, edges[i].end); // 查找终点所属集合
// 2. 若不属于同一集合(说明无环)
if(n != m){
parent[n] = m; // 合并两个集合
printf("(%d, %d) ", edges[i].begin, edges[i].end);
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
⚡ 并查集实现要统一根节点约定
前一版使用 parent[root] == 0 表示根;路径压缩版改用 parent[root] == root。两套约定不能混用。生产实现还应加入按秩或按大小合并。
路径压缩实现:
🔍 展开查看:代码实现
// 初始化方式改为:
for(int i = 0; i < G.numV; i ++) parent[i] = i;
// find()函数为:
int find(int *parent, int f){
if(parent[f] != f) parent[f] = find(parent, parent[f]); // 在find()的过程,父节点会改成根节点,压缩寻找根节点的路径。
return parent[f];
}2
3
4
5
6
7
8
4.4.3 Prim 与 Kruskal 对比
- 从一个顶点出发,不断把连接当前生成树的最小安全边加入。
- 邻接矩阵朴素实现为
;邻接表加堆可达 。 - 常适合稠密图或图以邻接结构提供的场景。
- 两者都是贪心算法(Greedy)的经典应用,都基于切分定理。
4.5 最短路径
定义:
- 在带权图中,寻找从起点(Source)到终点(Destination)的路径,使得路径上所有边的权值之和最小。

4.5.1 Dijkstra
Dijkstra适用场景:
- 单源最短路径(One-to-All):求图中某一顶点到其他所有顶点的最短路径。
- 限制条件:不适用于包含负权边的图。
⭐ Dijkstra 的核心动作
每轮选择当前 dist 最小的未确定顶点,将其锁定,再用它松弛所有出边。非负边权 保证锁定后的距离不会被未来路径改小。
- 选最近:每次都在还没确定的点里,挑一个离起点最近的,直接把它的最短路“锁死”。
- 做跳板:利用这个刚“锁死”的点作为中转站,去问问它的邻居们“从我这走,会不会比你们原来的路更近?”
- Dijkstra算法就是从起点开始,像水波纹一样向外扩散,每次锁定一个离波纹中心最近的点,利用这个点作为新的波源,去触达更远的地方,直到覆盖所有可达顶点。
关键数组:
dist[i]:起点到顶点i的当前最短距离。pre[i]:记录顶点i在最短路径上的前驱节点(用于还原路径)。visited[i]:标记顶点i的最短路径是否已确定。
复杂度分析:
- 时间复杂度:
。 - 若使用优先队列(堆优化),可降至
。
注意事项:
- ⚠️Dijkstra算法不适用于包含负权边的图。如果有负权边,需要使用Bellman-Ford算法。
🔍 展开查看:代码实现
void dijkstra(MGraph G, int v0, int pre[], int dist[]){
// 循环变量及最小值临时变量
int i, j, u, min;
// 标记数组,visited[i]=1表示已确定
int visited[MAXV];
// 1. 初始化所有节点状态
for(i = 0; i < G.numVertexes; i ++){
// 初始化所有节点均未被访问
visited[i] = 0;
// 将所有节点的距离初始化为无穷大
dist[i] = INF;
// 初始化前驱节点为-1(无前驱)
pre[i] = -1;
}
// 起点到自身的距离为0
dist[v0] = 0;
// 2. 主循环:每次确定一个节点的最短路径
for(i = 0; i < G.numVertexes; i ++){
// 初始化当前最小距离为无穷大
min = INF;
// 初始化最近节点索引为无效值
u = -1;
// 2. 贪心选择:在未标记节点中寻找距离最近的节点u
for(j = 0; j < G.numVertexes; j ++){
// 如果节点j未被标记且距离小于当前最小值
if(!visited[j] && dist[j] < min){
// 更新最小值
min = dist[j];
// 记录该节点索引
u = j;
}
}
// 如果找不到可达节点,跳出循环
if(u == -1) break;
// 将找到的最近节点u标记已确定
visited[u] = 1;
// 2.2 松弛操作:更新节点u的所有邻居j
for(j = 0; j < G.numVertexes; j ++){
// 如果节点j未被标记且u到j之间有边相连
if(!visited[j] && G.arc[u][j] < INF)
// 核心判断:经过u到达j的距离是否比原来更短?
if(dist[u] + G.arc[u][j] < dist[j]){
// 更新j的最短距离
dist[j] = dist[u] + G.arc[u][j];
// 更新j的前驱节点为u
pre[j] = u;
}
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
什么是“贪心”?
- 总是做出在当前看来最好的选择。
- 不考虑长远的整体规划,而是通过每一步的局部最优,试图达成全局最优。
Dijkstra的“贪心时刻”:
- 选最近:在所有未确定的节点中,选离起点最近的那个。
- 不回头:一旦选定,就认为“这就是最短路了”,标记为已确定,不再更改。
为什么“锁定当前最近点”是正确的?
设 dist 最小者。任何试图通过另一个未确定顶点
⚡ Dijkstra 的硬性前提
只要存在可达负权边,上述“锁定后不再变化”的证明就失效。负权图应考虑 Bellman–Ford、SPFA 的受控实现或 Johnson 等算法,并同时关注负环检测。

4.5.2 Floyd–Warshall
定义:
- 寻找图中任意两个顶点之间的最短路径。
- 不同于Dijkstra(单源),Floyd–Warshall 算法一次性求出所有顶点对之间的最短距离。
Floyd适用场景:
- 多源最短路径(All-Pairs):需要知道任意两点间距离,不仅是起点到其他点。
适用范围:
- ✅允许负权边。
- ❎不允许负权回路。

动态规划思想:
- 每次引入一个新的中间点
,检查是否能通过 缩短任意两点 间的路径。 - 最优子结构:
- 原问题的最优解包含子问题的最优解。
- 如果最短路径经过
,那么分段路径 和 也必须分别最优。
- 算法本质:
- 通过动态规划逐步扩大允许作为中间点的集合。
- 它与 Dijkstra 解决的问题和适用前提不同,不能简单视为并行执行
次 Dijkstra。 - 两者都依赖“松弛”(Relaxation)思想。
- 状态转移方程:

🔍 展开查看:代码实现
void Floyd(MGraph G){
int i, j, k;
int D[MAXV][MAXV];
int P[MAXV][MAXV]; // 路径矩阵
// 1. 初始化
for(i = 0; i < G.numVertexes; ++ i){
for(j = 0; j < G.numVertexes; ++ j){
D[i][j] = G.arc[i][j];
P[i][j] = j; // 初始化路径
}
}
// 2. 三重循环核心算法
// k:中转节点(必须在最外层)
for(k = 0; k < G.numVertexes; ++ k){
// i:起点
for(i = 0; i < G.numVertexes; ++ i){
// j:终点
for(j = 0; j < G.numVertexes; ++ j){
// 如果经过k能缩短到i到j的距离
if(D[i][k] < INF && D[k][j] < INF &&
D[i][k] + D[k][j] < D[i][j]){
// 更新距离
D[i][j] = D[i][k] + D[k][j];
// 更新路径前驱
P[i][j] = P[i][k];
}
}
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
复杂度分析:
- 时间复杂度:
。 - 空间复杂度:
(存储二维矩阵)。 - 虽然复杂度高,但常数小,且代码极其简单,适合节点数较少(
)的图。
⚡ Floyd–Warshall 易错点
- 中转点
必须在最外层,因为它定义动态规划阶段。 - 使用有限
INF时,加法前要确认两段路径均可达,避免溢出或把“无穷大 + 负数”误判为更短。 - 算法结束后若存在
,说明顶点 可达负权环。
4.5.3 Dijkstra 与 Floyd–Warshall 对比
贪心vs动态规划:
- Dijkstra(贪心):
- 每次“贪婪”地选择当前距离最近的未知节点。
- 像走迷宫,每次只走确定的最短一步,不回头。
- Floyd(动态规划):
- 逐步扩大“允许经过的中转点集合”。
- 像拼图,先拼好局部(只经过0),再拼更大局部(经过0,1)...
核心区别:
- Dijkstra关注“点”的扩展(从源点向外辐射)。
- Floyd关注“路径”的优化(通过中转点松弛)。
- 解决非负权图的单源最短路径。
- 朴素实现为
;邻接表加二叉堆通常为 。 - 可通过前驱数组恢复从源点到各顶点的路径。
4.6 DAG、拓扑排序与关键路径
4.6.1 拓扑排序
定义:
- 拓扑排序(Topological Sort)是针对有向无环图(DAG)的一种节点线性排序方法。
⭐ 拓扑排序的两个结论
- 只有 DAG 才存在拓扑序。
- 若最终输出顶点数少于
,图中存在有向环。
零入度容器可以使用栈、队列或优先队列;选择不同,得到的合法拓扑序可能不同。
基本思想:
- “每次找到一个没有前驱(入度为0)的顶点,将其输出,并删去该顶点及其发出的所有边。”
详细步骤:
- 统计入度:遍历图,计算每个顶点的入度(Indegree)。
- 入栈:将所有入度为0的顶点压入栈(Stack)。
- 循环处理:
- 弹出栈顶的顶点V,输出V。
- 遍历V的所有邻接点W,将W的入度减1.
- 如果W的入度变为0,则将W入栈。

关键点解析:
- 邻接表结构:方便查找每个顶点的邻接点(出边)。
- 入度数组:需预先计算好,存储在
adjList[i].in中。 - 辅助数据结构:使用栈(Stack)来存储当前入度为0的顶点。
复杂度分析:
- 时间复杂度:
。每个顶点和每条边都只处理常数次。 - 空间复杂度:
,用于维护入度数组和零入度容器。
🔍 展开查看:代码实现
// 边表结点
typedef struct EdgeNode{
int adjvex; // 顶点对应的下标
struct EdgeNode *next; // 指向下一个临界点
}EdgeNode;
// 顶点表结点
typedef struct VertexNode{
int in; // 顶点入度
int data; // 顶点数据
EdgeNode *firstedge; // 边表头指针
}VertexNode, AdjList[MAXVEX];
// 图结构
typedef struct{
AdjList adjList;
int numV, numE;
}GraphAdjList;
Status TopologicalSort(GraphAdjList GL){
EdgeNode *e;
int i, k, current;
int top = 0;
int count = 0;
int *stack = (int *)malloc(GL.numV * sizeof(int));
// 1. 初始化:遍历所有顶点,将入度为0的顶点入栈
for(i = 0; i < GL.numV; i ++)
if(GL.adjList[i].in == 0)
// 入栈操作
stack[top ++] = i;
// 2. 主循环:只要栈不为空,就继续处理
while(top > 0){
// 出栈,获取当前处理的顶点下标
current = stack[-- top];
printf("%d -> ", GL.adjList[current].data);
count ++;
// 遍历当前顶点的所有邻接点(出边)
// 获取第一条边
e = GL.adjList[current].firstedge;
while(e){
// k是current的邻接点下标
k = e->adjvex;
// 入度减1
if(!(-- GL.adjList[k].in))
// 入度为0的顶点入栈
stack[++ top] = k;
// 指向下一条边
e = e->next;
}
}
// 3. 环检测:如果输出的顶点数少于总顶点数,说明图中有环
free(stack);
return count < GL.numV ? ERROR : OK;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
4.6.2 AOE 网与关键路径
核心定义:
- 在一个表示工程的有向无环图(DAG)中:
- 顶点(vertex):表示事件(Event)。如“食材备好”、“可以开饭”。
- 有向边(Edge):表示活动(Activity)。如“打鸡蛋”、“炒菜”。
- 权值(Weight):表示活动持续的时间。
性质:只有在某顶点代表的事件发生后,从该顶点出发的活动才能开始。

工程的总工期取决于最长的那条路径长度,这条路径被称为关键路径(Critical Path)。
关键参数解析:
- 事件(顶点)参数:
ve(i):最早发生时间(Earliest),事件i最早可以在什么时候发生?取决于所有前驱活动都完成的时间点。vl(i):最晚发生时间(Latest),事件i最晚必须在什么时候发生?为了不拖延整个工期,最迟不能晚于这个时间点。
- 活动(边)参数:
e(k):最早开始事件(Earliest),活动k最早什么时候能开工?等于其起点事件的最早发生时间ve(i)。l(k):最晚开始事件(Latest),活动k最晚必须什么时候开工?等于终点最晚事件减去耗时:vl(j) - weight。
⭐ 关键路径核心结论
AOE 网中的关键路径是从源点到汇点的最长工期路径。关键活动满足最早开始时间与最晚开始时间相等,即活动时差为
计算步骤:
- 按拓扑序正向计算事件最早发生时间:
- 以汇点总工期初始化,按逆拓扑序计算事件最晚发生时间:
- 对活动
计算:
- 若
,该活动为关键活动;关键活动连接形成关键路径。
⚡ 避坑指南:关键路径可能不唯一
只要存在多条同长度的最长路径,就会有多条关键路径。任何一条关键活动延期都可能拖延总工期;缩短非关键活动不一定能缩短总工期。
