数据结构
数据结构
N0orange数据结构
第一章 绪论
1.1 数据结构基本概念
| 术语 | 定义 | 举例 |
|---|---|---|
| 数据 | 信息的载体,能被计算机识别、存储和加工处理 | 整数、字符、图像 |
| 数据元素 | 数据的基本单位,由若干数据项组成 | 一条学生记录 |
| 数据项 | 数据的最小不可分割单位 | 姓名、年龄、学号 |
| 数据对象 | 性质相同的数据元素的集合 | 所有学生的集合 |
| 数据结构 | 相互之间存在特定关系的数据元素的集合 | 线性表、树、图 |
易混淆点:
- 数据元素是数据的基本单位,数据项是最小单位
- 数据结构强调的是元素之间的关系,不是元素本身
- 数据对象是数据的子集,数据结构包含数据对象+关系+操作
1.2 数据结构三要素
逻辑结构
数据元素之间的逻辑关系,与存储无关。
| 结构 | 关系特点 | 典型示例 |
|---|---|---|
| 集合 | 元素间无任何关系,仅同属一个集合 | 整数集合 |
| 线性结构 | 一对一,有前驱后继关系 | 线性表、栈、队列、串 |
| 树形结构 | 一对多,有层次关系 | 二叉树、B树、堆 |
| 图状/网状结构 | 多对多,任意两个元素都可能有关系 | 社交网络、交通图 |
注意:集合、线性、树形、图状是四大逻辑结构,从上到下关系越来越复杂。
存储结构(物理结构)
数据在计算机内存中的实际存储方式。
| 存储方式 | 核心思想 | 优点 | 缺点 |
|---|---|---|---|
| 顺序存储 | 逻辑相邻→物理也相邻,用数组 | 随机访问 $O(1)$,存储密度高 | 插入删除需移动元素,大小固定 |
| 链式存储 | 逻辑相邻→物理不一定相邻,用指针 | 插入删除 $O(1)$,动态分配 | 不能随机访问,指针额外开销 |
| 索引存储 | 建立索引表(关键字→地址) | 查找速度快 | 索引表占额外空间 |
| 散列存储 | 根据关键字直接计算地址 | 查找 $O(1)$ | 冲突处理复杂,空间利用率低 |
重要:同一种逻辑结构可以有不同的存储结构。例如线性表可以用顺序存储(顺序表)也可以用链式存储(链表)。
数据的运算
- 运算的定义:针对逻辑结构,指出”做什么”(如查找、插入、删除)
- 运算的实现:针对存储结构,指出”怎么做”(具体算法)
1.3 算法与算法评价
算法的特性
| 特性 | 含义 |
|---|---|
| 有穷性 | 必须在有穷步内结束,每步在有穷时间内完成 |
| 确定性 | 每条指令含义明确,相同输入相同输出 |
| 可行性 | 每一步都可以通过基本运算执行有限次实现 |
| 输入 | 有零个或多个输入(可以没有输入) |
| 输出 | 有一个或多个输出(至少一个) |
注意:有穷性要求算法在有限步骤内结束,但程序可以是无穷的(如操作系统)。
时间复杂度
表示算法执行时间与问题规模 $n$ 的增长关系。
$$T(n) = O(f(n))$$
计算规则:
- 只保留最高阶项
- 去掉最高阶项的系数
- 常数语句视为 $O(1)$
- 顺序执行:取最大(加法规则)
- 嵌套循环:相乘(乘法规则)
常见复杂度排序(必须记住):
$$O(1) < O(\log_2 n) < O(n) < O(n\log_2 n) < O(n^2) < O(n^3) < O(2^n) < O(n!) < O(n^n)$$
典型例子:
| 代码结构 | 时间复杂度 |
|---|---|
单层循环 for(i=0;i<n;i++) |
$O(n)$ |
双层嵌套 for+for 各n次 |
$O(n^2)$ |
i*=2 或 i/=2 的循环 |
$O(\log_2 n)$ |
| 外层 $O(\log n)$ 内层 $O(n)$ | $O(n\log n)$ |
| 三个顺序循环各 $n$ 次 | $O(n)$(取最大,非 $3n$) |
最好/最坏/平均时间复杂度:
- 最好:输入恰好使算法执行最少操作(如顺序表查找第一个就是)
- 最坏:输入使算法执行最多操作(如查找最后一个或不存在)
- 平均:所有可能输入的期望值(等概率假设下)
常考递推式:
| 递推式 | 时间复杂度 | 例子 |
|---|---|---|
| $T(n) = T(n-1) + O(1)$ | $O(n)$ | 线性递归 |
| $T(n) = T(n/2) + O(1)$ | $O(\log n)$ | 二分查找 |
| $T(n) = 2T(n/2) + O(n)$ | $O(n\log n)$ | 归并排序 |
| $T(n) = 2T(n/2) + O(1)$ | $O(n)$ | 遍历二叉树 |
| $T(n) = T(n-1) + O(n)$ | $O(n^2)$ | 冒泡排序 |
空间复杂度
$$S(n) = O(g(n))$$
- 原地工作:算法所需辅助空间为 $O(1)$
- 递归算法的空间复杂度 = 递归深度 × 每层辅助空间
第二章 线性表
2.1 顺序表(SeqList)
定义与特点
用一段地址连续的存储单元依次存储线性表的数据元素。
关键特征:
- 逻辑上相邻的元素,物理上也相邻
- 支持随机访问(按下标 $O(1)$ 访问)
- 存储密度高(只存数据,无额外指针开销)
顺序表结构定义
1 |
|
初始化:L.length = 0
判空:L.length == 0
判满:L.length == MaxSize
基本操作
按位查找(随机访问):$O(1)$
1 | int GetElem(SqList L, int i) { |
按值查找:$O(n)$
1 | int LocateElem(SqList L, int e) { |
插入操作:在第 $i$ 个位置插入元素 $e$
1 | bool ListInsert(SqList *L, int i, int e) { |
- 移动次数:最好 $0$ 次(插在末尾),最坏 $n$ 次(插在开头),平均 $n/2$ 次
- 时间复杂度:$O(n)$
删除操作:删除第 $i$ 个位置的元素
1 | bool ListDelete(SqList *L, int i, int *e) { |
- 移动次数:最好 $0$ 次(删末尾),最坏 $n-1$ 次(删开头),平均 $(n-1)/2$ 次
- 时间复杂度:$O(n)$
2.2 链表(LinkedList)
单链表
1 | typedef struct LNode { |
两种定义方式的区别:
LNode:结点类型,声明结点变量LNode node;LinkList:头指针类型,声明链表LinkList L;(等价于LNode *L)
头结点 vs 无头结点
| 对比项 | 有头结点 | 无头结点 |
|---|---|---|
| 头指针指向 | 头结点(不存数据) | 第一个数据结点 |
| 空表判断 | L->next == NULL |
L == NULL |
| 插入/删除 | 第一个结点无需特殊处理 | 第一个结点需特殊处理 |
| 推荐度 | ✅ 推荐 | 较麻烦 |
按位序插入(带头结点)
在第 $i$ 个位置插入元素 $e$:
1 | bool ListInsert(LinkList L, int i, int e) { |
⚠️ 顺序不能反! 如果先 p->next = s,则 p->next 原来的值丢失,无法赋给 s->next。
时间复杂度:
- 最好 $O(1)$(插在第1个位置,只找0次)
- 最坏 $O(n)$(插在第 $n+1$ 个位置)
- 平均 $O(n)$(平均移动 $n/2$ 个指针)
删除结点
1 | bool ListDelete(LinkList L, int i, int *e) { |
易错:while 循环条件是 p->next 而非 p,因为要找的是待删结点的前驱。
头插法建表
1 | LinkList HeadInsert(LinkList L) { |
特点:输入顺序与存储顺序相反(逆序)
应用:链表逆置可以用头插法思想实现
尾插法建表
1 | LinkList TailInsert(LinkList L) { |
特点:输入顺序与存储顺序相同(正序)
注意:最后必须 r->next = NULL,否则尾结点指向不确定的地址。
按值查找
1 | LNode *LocateElem(LinkList L, int e) { |
求表长
1 | int Length(LinkList L) { |
2.3 双链表
1 | typedef struct DNode { |
优势:可以 $O(1)$ 找到前驱(单链表需要 $O(n)$)
插入操作(四步,顺序重要)
在结点 $p$ 之后插入结点 $s$:
1 | s->next = p->next; // ① s 的后继指向 p 的后继 |
如果 $p$ 是最后一个结点,第②步不能执行(p->next 为 NULL),需要特判。
删除操作
删除结点 $p->next$(即 $q$):
1 | p->next = q->next; |
2.4 循环链表
循环单链表
- 最后一个结点的
next指向头结点(不是 NULL) - 判空:
L->next == L - 判尾:
p->next == L - 优势:从任一结点出发可遍历整个链表(单链表只能从头开始)
实际应用:将头指针改为尾指针,两个循环单链表合并只需 $O(1)$:
1 | // 将表 B 接到表 A 后面 |
循环双链表
- 尾结点
next指向头结点,头结点prior指向尾结点 - 判空:
L->next == L && L->prior == L
2.5 静态链表
用数组模拟链表,数组元素包含 data 和 next(游标/下标)。
1 |
|
特点:
- 分配固定大小的数组空间
next存的是数组下标,不是内存地址- 适用于没有指针的语言(如 Basic、Fortran)
- 插入删除不需要移动元素,只需修改游标
优缺点:
- 优点:不需要动态分配,操作不涉及指针
- 缺点:容量固定,不能动态增长
2.6 顺序表 vs 链表对比
| 比较维度 | 顺序表 | 链表 |
|---|---|---|
| 随机访问 | $O(1)$ ✅ | $O(n)$ ❌ |
| 插入/删除(已知位置) | $O(n)$(移动元素) | $O(1)$(改指针)✅ |
| 存储密度 | 高 ✅ | 低(指针额外开销) |
| 空间分配 | 静态/预分配,可能浪费 | 动态分配,按需申请 ✅ |
| 适用场景 | 频繁查找、很少增删 | 频繁增删、很少随机访问 |
| 缓存友好性 | ✅ 连续内存,缓存命中率高 | ❌ 离散内存,缓存命中率低 |
2.7 链表高频考点
1. 链表逆置(头插法思想):
1 | void Reverse(LinkList L) { |
2. 查找倒数第 $k$ 个结点(双指针法):
1 | // p 先走 k 步,然后 p、q 一起走,p 到末尾时 q 就是倒数第 k 个 |
3. 查找中间结点(快慢指针):
1 | // slow 走一步,fast 走两步,fast 到末尾时 slow 在中间 |
4. 判断链表是否有环(快慢指针):
1 | bool HasCycle(LinkList L) { |
第三章 栈、队列和数组
3.1 栈(Stack)
定义与特性
栈:只允许在一端(栈顶)进行插入和删除的线性表。
特性:后进先出(LIFO, Last In First Out)
基本操作:
Push:入栈(栈顶插入)Pop:出栈(栈顶删除)GetTop:取栈顶元素(不删除)StackEmpty:判空
卡特兰数:$n$ 个不同元素进栈,出栈序列个数为 $\frac{1}{n+1}C_{2n}^{n}$
顺序栈
1 |
|
初始化:S.top = -1(空栈)
判空:S.top == -1
判满:S.top == MaxSize - 1
栈的元素个数:S.top + 1
入栈:
1 | bool Push(SqStack *S, int x) { |
出栈:
1 | bool Pop(SqStack *S, int *x) { |
⚠️ 易错:S->data[++S->top] 和 S->data[S->top++] 的区别!前者先移后存,后者先存后移。
另一种初始化方式:S.top = 0(top 指向栈顶元素的下一个位置)
- 判空:
S.top == 0 - 入栈:
S->data[S->top++] = x - 出栈:
*x = S->data[--S->top]
共享栈(两栈共享空间)
两个栈从数组两端向中间生长,栈1从左往右,栈2从右往左。
1 | typedef struct { |
- 栈1空:
top1 == -1 - 栈2空:
top2 == MaxSize - 栈满:
top1 + 1 == top2
优势:空间互补利用,一个栈用得少时另一个可以用更多空间。
链栈
1 | typedef struct StackNode { |
- 栈顶在链头
- 无头结点更方便(直接操作第一个结点)
- 不存在栈满问题(动态分配)
- 入栈出栈都是 $O(1)$
3.2 队列(Queue)
定义与特性
队列:只允许在一端(队尾)插入、另一端(队头)删除的线性表。
特性:先进先出(FIFO, First In First Out)
基本操作:
EnQueue:入队(队尾插入)DeQueue:出队(队头删除)GetHead:取队头元素QueueEmpty:判空
顺序队列(循环队列)
普通顺序队列会出现”假溢出”(前面有空位但 rear 已到末尾),用循环队列解决。
1 |
|
初始化:Q.front = Q.rear = 0
入队:
1 | bool EnQueue(SqQueue *Q, int x) { |
出队:
1 | bool DeQueue(SqQueue *Q, int *x) { |
关键公式(牺牲一个存储单元区分队空队满):
| 公式 | 表达式 |
|---|---|
| 队空 | Q.front == Q.rear |
| 队满 | (Q.rear + 1) % MaxSize == Q.front |
| 元素个数 | (Q.rear - Q.front + MaxSize) % MaxSize |
| 队头元素 | Q.data[Q.front] |
其他判满方式(不牺牲空间):
增加
size变量:- 入队时
size++,出队时size-- - 队满:
size == MaxSize
- 入队时
增加
tag变量:tag = 0:最近操作是删除(队空可能)tag = 1:最近操作是插入(队满可能)- 队满:
Q.front == Q.rear && tag == 1 - 队空:
Q.front == Q.rear && tag == 0
链式队列
1 | typedef struct QNode { |
- 带头结点:
front指向头结点,rear指向尾结点 - 队空:
Q.front == Q.rear(都指向头结点) - 出队:修改
front->next,若删的是最后一个元素,还要更新rear
1 | bool DeQueue(LinkQueue *Q, int *x) { |
双端队列
两端都可以进行入队和出队操作。
- 输入受限的双端队列:只有一端能入队,两端都能出队
- 输出受限的双端队列:两端都能入队,只有一端能出队
3.3 栈和队列的应用
栈的应用
1. 括号匹配:
1 | 算法: |
2. 表达式求值:
中缀表达式:运算符在两个操作数之间,如 a + b * c
后缀表达式(逆波兰):运算符在两个操作数之后,如 a b c * +
前缀表达式(波兰):运算符在两个操作数之前,如 + a * b c
中缀 → 后缀(调度场算法):
1 | 1. 初始化运算符栈 |
运算符优先级(从高到低):
| 优先级 | 运算符 |
|---|---|
| 3 | () |
| 2 | *, / |
| 1 | +, - |
后缀表达式求值:
1 | 1. 初始化操作数栈 |
示例:a b c * + 对应 a + b * c
- 扫描到
a:入栈 → 栈:[a] - 扫描到
b:入栈 → 栈:[a, b] - 扫描到
c:入栈 → 栈:[a, b, c] - 扫描到
*:出c, b,算b*c入栈 → 栈:[a, b*c] - 扫描到
+:出b*c, a,算a+b*c入栈 → 栈:[a+b*c]
3. 递归转非递归:
用栈模拟递归过程中的函数调用栈,保存每层的局部变量和返回地址。
4. 进制转换(十进制转其他进制):
1 | void Convert(int n, int base) { |
队列的应用
1. 层次遍历(二叉树、图的 BFS)
2. 操作系统:
- 进程就绪队列
- 打印任务队列
- 消息队列
3. 缓冲区:生产者-消费者模型(队列作为缓冲)
4. 脱机输入输出:用队列缓冲输入输出速度差异
3.4 数组与特殊矩阵
数组的存储
一维数组:
$$LOC(a_i) = LOC(a_0) + i \times L$$
$L$ 为每个元素占用的字节数。
二维数组($m$ 行 $n$ 列,$L$ 为每个元素字节数):
行优先(C/C++ 默认):
$$LOC(a_{i,j}) = LOC(a_{0,0}) + (i \times n + j) \times L$$
列优先(Fortran 默认):
$$LOC(a_{i,j}) = LOC(a_{0,0}) + (j \times m + i) \times L$$
易错:注意 $m$ 和 $n$ 的位置!行优先时 $j$ 在右边,列优先时 $i$ 在右边。
特殊矩阵压缩存储
将对称的或有规律的矩阵压缩到一维数组中,节省空间。
1. 对称矩阵($A[i][j] = A[j][i]$)
只存下三角(含主对角线),共 $n(n+1)/2$ 个元素。
下三角映射(行优先,$i \geq j$):
$$k = \frac{i(i-1)}{2} + j - 1 \quad (i, j \text{ 从 1 开始})$$
或
$$k = \frac{i(i+1)}{2} + j \quad (i, j \text{ 从 0 开始})$$
上三角($i < j$):交换 $i$ 和 $j$ 后代入下三角公式。
2. 三角矩阵
- 下三角矩阵:上三角全为常量 $c$,存下三角 + 一个常量 $= n(n+1)/2 + 1$
- 上三角矩阵:下三角全为常量 $c$,存上三角 + 一个常量
3. 三对角矩阵(带状矩阵)
只有主对角线及上下各一条对角线有非零元素。
- 元素个数:$3n - 2$
- 映射公式(行优先):$k = 2i + j - 2$($i, j$ 从 0 开始)
或 $k = 2i + j - 3$($i, j$ 从 1 开始)
4. 稀疏矩阵
非零元素个数远少于总元素个数。
三元组表示:每个非零元素用 (行, 列, 值) 表示。
1 | typedef struct { |
注意:三元组压缩存储后,矩阵的转置、乘法等运算变得复杂。转置时需要按列序重排三元组。
十字链表:同时存储行指针和列指针,适合稀疏矩阵的加法运算。
第四章 串
4.1 串的定义和基本概念
串(String):由零个或多个字符组成的有限序列。
$$S = “a_1 a_2 \cdots a_n”$$
| 术语 | 定义 | 示例 |
|---|---|---|
| 空串 | 长度为 0 的串,用 $\varnothing$ 表示 | "" |
| 空格串 | 由空格组成的串 | " "(长度为3) |
| 子串 | 串中任意连续字符组成的子序列 | "lo" 是 "hello" 的子串 |
| 主串 | 包含子串的串 | "hello" |
| 子串位置 | 子串在主串中第一次出现时,第一个字符的位置 | "lo" 在 "hello" 中位置为 4 |
串的存储:
- 定长顺序存储:固定大小的
char数组,data[0]存长度 - 堆分配存储:动态分配
char数组 - 块链存储:每个结点存多个字符(块)
4.2 朴素模式匹配(BF算法)
Brute-Force:暴力匹配。
思想:从主串的每个位置开始,逐字符与模式串比较。不匹配时,主串指针回退,模式串指针归 1。
1 | int Index(SString S, SString T) { |
时间复杂度:
- 最好:$O(m)$(第一次就匹配成功,$m$ 为模式串长度)
- 最坏:$O(m \times n)$($m$ 为主串长度,$n$ 为模式串长度)
- 平均:$O(m + n)$
最坏情况举例:主串 "aaaaaab",模式串 "aaab",每次比较到最后一个字符才失败。
4.3 KMP算法
核心思想
利用已匹配的信息,避免主串指针回退。当匹配失败时,根据模式串本身的结构,决定模式串向右滑动多远。
关键区别:
- BF:主串指针 $i$ 回退,模式串指针 $j$ 归 1
- KMP:主串指针 $i$ 不回退,只调整模式串指针 $j$
next数组
定义:$next[j]$ 表示当模式串第 $j$ 个字符匹配失败时,应该用模式串的第 $next[j]$ 个字符继续与主串当前位置比较。
物理含义:$next[j]$ = 模式串中前 $j-1$ 个字符组成的子串中,最长相等前后缀的长度 + 1
前缀和后缀:
- 前缀:包含第一个字符但不包含最后一个字符的子串
- 后缀:包含最后一个字符但不包含第一个字符的子串
示例:模式串 "abaabcac"
| $j$ | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| 模式 | a | b | a | a | b | c | a | c |
| $next[j]$ | 0 | 1 | 1 | 2 | 2 | 3 | 1 | 2 |
计算过程:
- $j=1$:约定 $next[1] = 0$
- $j=2$:前缀子串
"a",无相等前后缀,$next[2] = 1$ - $j=3$:前缀子串
"ab",无相等前后缀,$next[3] = 1$ - $j=4$:前缀子串
"aba",最长相等前后缀"a"长度为 1,$next[4] = 2$ - $j=5$:前缀子串
"abaa",最长相等前后缀"a"长度为 1,$next[5] = 2$ - $j=6$:前缀子串
"abaab",最长相等前后缀"ab"长度为 2,$next[6] = 3$ - $j=7$:前缀子串
"abaabc",无相等前后缀,$next[7] = 1$ - $j=8$:前缀子串
"abaabca",最长相等前后缀"a"长度为 1,$next[8] = 2$
next数组求解代码
1 | void GetNext(char *T, int *next) { |
理解要点:
j代表已匹配的前缀长度T[i] == T[j]时,说明可以扩展已匹配前缀,$next[i+1] = j+1$T[i] != T[j]时,$j$ 回退到 $next[j]$,尝试更短的前缀
KMP匹配算法
1 | int KMP(char *S, char *T, int *next) { |
时间复杂度:$O(m + n)$,其中 $m$ 为主串长度,$n$ 为模式串长度。
nextval数组(改进的next)
问题:当 T[next[j]] == T[j] 时,回退到 next[j] 后仍然会失配,多做了一次无用比较。
改进:如果 T[next[j]] == T[j]],则继续回退到 next[next[j]]。
1 | void GetNextval(char *T, int *nextval) { |
示例对比:模式串 "aaaab"
| $j$ | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| 模式 | a | a | a | a | b |
| $next[j]$ | 0 | 1 | 2 | 3 | 4 |
| $nextval[j]$ | 0 | 0 | 0 | 0 | 4 |
KMP易错总结
next[1] = 0是约定,当 $j=1$ 匹配失败时,$i$ 前进,$j$ 不动next[2] = 1总是为 1(长度为 1 的子串没有前后缀)- next 数组的值与具体模式串有关,不同模式串的 next 数组不同
- KMP 不适用于模式串比主串长的情况
- 改进的 nextval 可以减少无效比较,但不影响时间复杂度数量级
第五章 树与二叉树
5.1 树的基本概念
树的定义
树是 $n$ ($n \geq 0$) 个结点的有限集合:
- $n = 0$ 时为空树
- 有且仅有一个特定的根结点
- 其余结点分为 $m$ ($m \geq 0$) 个互不相交的有限集合,每个集合本身又是一棵树(子树)
递归定义:树的定义本身就是递归的。
基本术语
| 术语 | 定义 | 图示含义 |
|---|---|---|
| 结点的度 | 结点的子树个数 | 分支数 |
| 树的度 | 所有结点度的最大值 | 最大的分支数 |
| 叶子结点(终端结点) | 度为 0 的结点 | 最末端的结点 |
| 分支结点(非终端结点) | 度 > 0 的结点 | 有孩子的结点 |
| 孩子/双亲 | 结点的子树根 / 孩子的父结点 | — |
| 兄弟 | 同一双亲的孩子 | — |
| 祖先 | 从根到该结点路径上的所有结点 | — |
| 子孙 | 该结点所有子树中的结点 | — |
| 层次 | 根为第 1 层,孩子为第 2 层… | — |
| 树的高度(深度) | 最大层次 | — |
| 有序树 | 子树有先后顺序 | — |
| 无序树 | 子树无先后顺序 | — |
| 森林 | $m$ ($m \geq 0$) 棵互不相交的树的集合 | — |
树的性质(常考)
- 结点数 = 总度数 + 1:每个结点贡献一条边(指向它),只有根没有被指向
- $m$ 叉树第 $i$ 层最多 $m^{i-1}$ 个结点($i \geq 1$)
- 高度为 $h$ 的 $m$ 叉树最多 $\frac{m^h - 1}{m - 1}$ 个结点
- $n$ 个结点的 $m$ 叉树最小高度:$h_{min} = \lceil \log_m(n(m-1)+1) \rceil$
5.2 二叉树
定义
二叉树是 $n$ ($n \geq 0$) 个结点的有限集合:
- 或为空二叉树 ($n = 0$)
- 或由一个根结点和两棵互不相交的左子树和右子树组成
⚠️ 二叉树 ≠ 度为 2 的树:二叉树的子树有左右之分,即使只有一棵子树也要区分是左还是右。
五种基本形态
- 空二叉树
- 只有根结点
- 根 + 左子树
- 根 + 右子树
- 根 + 左右子树
特殊二叉树
| 类型 | 定义 | 特点 |
|---|---|---|
| 满二叉树 | 每层都填满 | 高度 $h$ 有 $2^h - 1$ 个结点 |
| 完全二叉树 | 叶子只在最后两层,最后一层靠左 | 编号与满二叉树一一对应 |
| 二叉排序树(BST) | 左子树 < 根 < 右子树 | 中序遍历有序 |
| 平衡二叉树(AVL) | 任意结点 $ | h_L - h_R |
完全二叉树的重要性质:
- 若 $i \leq \lfloor n/2 \rfloor$,则结点 $i$ 是分支结点(有孩子)
- 若 $i > \lfloor n/2 \rfloor$,则结点 $i$ 是叶子结点
- 若 $n$ 为奇数,则每个分支结点都有两个孩子
- 若 $n$ 为偶数,则编号最大的分支结点($\lfloor n/2 \rfloor$)只有左孩子
二叉树的五条性质(必须背)
$n_0 = n_2 + 1$:叶子结点数 = 度为 2 的结点数 + 1
推导:$n = n_0 + n_1 + n_2$,$n = 0 \times n_0 + 1 \times n_1 + 2 \times n_2 + 1$(边数 = 总度数 = 结点数 - 1),两式相减得 $n_0 = n_2 + 1$。
第 $i$ 层最多 $2^{i-1}$ 个结点($i \geq 1$)
高度为 $h$ 的二叉树最多 $2^h - 1$ 个结点
完全二叉树 $n$ 个结点,高度 $h = \lfloor \log_2 n \rfloor + 1$
完全二叉树的结点编号关系:
- $i$ 的父结点:$\lfloor i/2 \rfloor$($i=1$ 时无父结点)
- $i$ 的左孩子:$2i$($2i > n$ 时无左孩子)
- $i$ 的右孩子:$2i + 1$($2i+1 > n$ 时无右孩子)
5.3 二叉树的存储
顺序存储
用数组存储,结点 $i$ 存在 data[i]。
- 适合完全二叉树,不浪费空间
- 一般二叉树需要补全为完全二叉树,浪费空间
- 最坏情况:只有右孩子的单支树,需要 $2^n$ 个存储单元
链式存储(二叉链表)
1 | typedef struct BiTNode { |
重要结论:$n$ 个结点的二叉链表有 $n+1$ 个空指针域。
推导:$n$ 个结点共 $2n$ 个指针域,$n-1$ 个非空($n$ 个结点有 $n-1$ 条边),所以空指针 = $2n - (n-1) = n+1$。
三叉链表(增加父指针)
1 | typedef struct BiTNode { |
5.4 二叉树的遍历
四种遍历方式
| 遍历方式 | 顺序 | 递归定义 |
|---|---|---|
| 先序遍历 | 根 → 左 → 右 | 根 → 先序(左) → 先序(右) |
| 中序遍历 | 左 → 根 → 右 | 中序(左) → 根 → 中序(右) |
| 后序遍历 | 左 → 右 → 根 | 后序(左) → 后序(右) → 根 |
| 层序遍历 | 逐层从左到右 | 借助队列 |
先序遍历
1 | void PreOrder(BiTree T) { |
中序遍历
1 | void InOrder(BiTree T) { |
后序遍历
1 | void PostOrder(BiTree T) { |
层序遍历
1 | void LevelOrder(BiTree T) { |
中序遍历的非递归实现(重要!)
1 | void InOrder2(BiTree T) { |
理解:
- 沿左子树一路入栈,直到最左
- 出栈访问(此时左子树已访问完,该访问根了)
- 转向右子树,重复
先序非递归:只需把 visit(p) 移到 Push 之后。
后序非递归:需要记录上一次访问的结点,判断是从左子树返回还是右子树返回。
由遍历序列构造二叉树
| 已知序列 | 能否唯一确定 |
|---|---|
| 先序 + 中序 | ✅ 能 |
| 后序 + 中序 | ✅ 能 |
| 先序 + 后序 | ❌ 不能 |
| 层序 + 中序 | ✅ 能 |
构造方法:
- 先序/后序确定根
- 在中序中找到根的位置,划分左子树和右子树
- 递归处理左右子树
示例:
- 先序:
ABDECFG - 中序:
DBEAFCG
步骤:
- 先序第一个
A是根 - 中序中
A左边DBE是左子树,右边FCG是右子树 - 左子树先序
BDE,中序DBE→ 递归 - 右子树先序
CFG,中序FCG→ 递归
5.5 线索二叉树
目的
$n$ 个结点的二叉链表有 $n+1$ 个空指针域,利用这些空指针存放前驱和后继信息,方便遍历。
结构定义
1 | typedef struct ThreadNode { |
中序线索化
1 | ThreadNode *pre = NULL; // 全局变量,指向前驱 |
遍历中序线索二叉树
1 | // 找中序下的第一个结点(最左下) |
优势:不需要栈,不需要递归,空间 $O(1)$。
5.6 树和森林
树的存储结构
1. 双亲表示法:
1 | typedef struct { |
- 求父结点:$O(1)$ ✅
- 求子结点:需遍历整个数组 $O(n)$ ❌
2. 孩子表示法:每个结点一个链表存所有孩子。
- 求子结点:$O(1)$(直接遍历孩子链表)✅
- 求父结点:需遍历 $O(n)$ ❌
3. 孩子兄弟表示法(二叉链表表示法,最重要!):
1 | typedef struct CSNode { |
核心思想:左孩子右兄弟。每个结点只存两个指针:第一个孩子和下一个兄弟。
树 ↔ 二叉树 转换
树 → 二叉树(孩子兄弟法):
- 连线:同一父结点的孩子之间从左到右连线
- 删线:只保留每个结点与第一个孩子的连线
- 旋转:以根为轴心,顺时针旋转 45°
二叉树 → 树:逆过程。
关键:树的先根遍历 = 二叉树的先序遍历;树的后根遍历 = 二叉树的中序遍历。
森林 ↔ 二叉树 转换
森林 → 二叉树:
- 每棵树转换为二叉树
- 第一棵树的根作为二叉树的根
- 后面每棵树的根作为前一棵树根的右孩子
二叉树 → 森林:逆过程。
遍历对应关系
| 树 | 森林 | 二叉树 |
|---|---|---|
| 先根遍历 | 先序遍历 | 先序遍历 |
| 后根遍历 | 中序遍历 | 中序遍历 |
注意:树没有”中根遍历”的概念(因为不止两棵子树),但有先根和后根。
5.7 哈夫曼树与哈夫曼编码
基本概念
| 术语 | 定义 |
|---|---|
| 路径长度 | 从一个结点到另一个结点的边数 |
| 树的路径长度 | 从根到所有结点的路径长度之和 |
| 带权路径长度(WPL) | $\sum_{i=1}^{n} w_i \times l_i$($w_i$ 为叶子权值,$l_i$ 为叶子到根的路径长度) |
| 哈夫曼树 | WPL 最小的二叉树(最优二叉树) |
哈夫曼树构造算法
步骤:
- 将 $n$ 个权值作为 $n$ 棵只有根结点的树,构成森林 $F$
- 在 $F$ 中选取两棵权值最小的树作为新结点的左右子树,新根权值 = 左 + 右
- 从 $F$ 中删除这两棵树,将新树加入 $F$
- 重复 2-3 直到 $F$ 中只剩一棵树
示例:权值集合 ${7, 5, 2, 4}$
1 | 初始: (7) (5) (2) (4) |
哈夫曼树的特点:
- 没有度为 1 的结点(因为每次合并产生一个新结点,度为 2)
- $n$ 个叶子结点的哈夫曼树共有 $2n - 1$ 个结点
- 每次选最小的两个合并 → 贪心算法
- 权值越大(越常用)的叶子离根越近 → 编码越短
哈夫曼编码
前缀编码:任何字符的编码都不是另一个字符编码的前缀。
构造:
- 以字符出现频率(权值)构建哈夫曼树
- 左分支编码
0,右分支编码1 - 从根到叶子的路径即为该字符的编码
示例:
| 字符 | 频率 | 哈夫曼编码 |
|---|---|---|
| A | 7 | 0 |
| B | 5 | 10 |
| C | 2 | 110 |
| D | 4 | 111 |
为什么哈夫曼编码是前缀编码:因为所有字符编码都在叶子结点上,没有一个编码是另一个编码的路径前缀。
第六章 图
6.1 图的基本概念
图的定义
图 $G$ 由顶点集 $V$ 和边集 $E$ 组成:$G = (V, E)$
| 术语 | 定义 | 数量关系 |
|---|---|---|
| 有向图 | 边有方向,用 $<v_i, v_j>$ 表示 | 最多 $n(n-1)$ 条弧 |
| 无向图 | 边无方向,用 $(v_i, v_j)$ 表示 | 最多 $n(n-1)/2$ 条边 |
| 完全图 | 任意两点间都有边 | 无向 $n(n-1)/2$,有向 $n(n-1)$ |
| 稀疏图 | 边数很少 $e < n\log n$ | — |
| 稠密图 | 边数很多 | — |
| 度 | 与顶点关联的边数 | 无向图 $\deg(v_i)$ |
| 入度/出度 | 有向图中指向/离开顶点的弧数 | $ID(v_i)$ / $OD(v_i)$ |
| 路径 | 顶点序列 $v_1, v_2, \ldots, v_k$ | — |
| 路径长度 | 路径上边的数目 | — |
| 回路(环) | 起点 = 终点的路径 | — |
| 简单路径 | 顶点不重复的路径 | — |
| 连通图 | 无向图中任意两点连通 | — |
| 强连通图 | 有向图中任意两点互相可达 | — |
| 连通分量 | 无向图的极大连通子图 | — |
| 强连通分量 | 有向图的极大强连通子图 | — |
| 生成树 | 连通图的极小连通子图,$n$ 个顶点 $n-1$ 条边 | — |
| 生成森林 | 非连通图的各连通分量的生成树构成 | — |
| 权/网 | 边带权值的图 | — |
重要公式:
无向图:$\sum_{i=1}^{n} \deg(v_i) = 2|E|$(每条边贡献 2 度)
有向图:$\sum_{i=1}^{n} ID(v_i) = \sum_{i=1}^{n} OD(v_i) = |E|$(每条弧贡献 1 入度 + 1 出度)
特殊图:
| 图类型 | 条件 |
|---|---|
| 连通图 | 无向图中任意两点间存在路径,至少 $n-1$ 条边 |
| 强连通图 | 有向图中任意两点互相可达,至少 $n$ 条边(构成回路) |
| 连通分量 | 无向图的极大连通子图 |
| 生成树 | 连通图的极小连通子图,$n$ 个顶点 $n-1$ 条边 |
6.2 图的存储
邻接矩阵
1 |
|
无权图:
edge[i][j] = 1表示有边,0表示无边
带权图(网):
edge[i][j] = 权值表示有边edge[i][j] = ∞表示无边
特点:
- 空间复杂度:$O(n^2)$,适合稠密图
- 无向图的邻接矩阵是对称矩阵(可以只存上三角)
- 顶点 $v_i$ 的度 = 第 $i$ 行(或列)非零元素个数
- 判断 $v_i$ 和 $v_j$ 是否有边:$O(1)$
- 找某个顶点的所有邻接点:$O(n)$
邻接表
1 | typedef struct ArcNode { |
特点:
- 空间复杂度:$O(n + e)$,适合稀疏图
- 无向图:顶点 $v_i$ 的度 = 邻接表中第 $i$ 个链表的结点数
- 有向图:
- 出度 = 出边表结点数(容易求)
- 入度 = 需要遍历所有边表(或建立逆邻接表)
- 判断 $v_i$ 和 $v_j$ 是否有边:$O(\deg(v_i))$
- 找某个顶点的所有邻接点:$O(\deg(v_i))$
邻接矩阵 vs 邻接表:
| 对比项 | 邻接矩阵 | 邻接表 |
|---|---|---|
| 空间 | $O(n^2)$ | $O(n+e)$ |
| 适合 | 稠密图 | 稀疏图 |
| 判边存在 | $O(1)$ | $O(\deg)$ |
| 求度 | $O(n)$ | $O(\deg)$ |
| 有向图求入度 | $O(n)$ | $O(n+e)$ |
十字链表(有向图)
结合邻接表和逆邻接表。每条弧有一个结点,包含:
tailvex:弧尾下标headvex:弧头下标taillink:弧尾相同的下一条弧headlink:弧头相同的下一条弧
每个顶点有一个结点,包含 firstin(入边表头)和 firstout(出边表头)。
邻接多重表(无向图)
每条边只用一个结点表示,避免邻接表中无向边存两次的问题。
6.3 图的遍历
广度优先搜索(BFS)
类似层序遍历,用队列。
1 | bool visited[MaxVertexNum]; |
时间复杂度:
- 邻接矩阵:$O(n^2)$
- 邻接表:$O(n + e)$
空间复杂度:$O(n)$(辅助队列)
BFS 的应用:
- 求无权图的单源最短路径
- 求连通分量
- 判断图是否连通
深度优先搜索(DFS)
类似先序遍历,用递归(隐式栈)。
1 | void DFS(Graph G, int v) { |
时间复杂度:
- 邻接矩阵:$O(n^2)$
- 邻接表:$O(n + e)$
空间复杂度:$O(n)$(递归栈深度)
BFS 与 DFS 对比:
| 对比 | BFS | DFS |
|---|---|---|
| 数据结构 | 队列 | 栈(递归) |
| 搜索方式 | 逐层扩展 | 一路深入 |
| 最短路径 | ✅ 无权图 | ❌ 不保证 |
| 空间 | $O(n)$ | $O(n)$ |
遍历序列唯一性
- 给定图和起始点,BFS/DFS 序列不唯一(邻接点的访问顺序不确定)
- 给定图的邻接矩阵,遍历序列唯一(邻接点顺序由矩阵确定)
- 给定图的邻接表,遍历序列不唯一(邻接表建表顺序不确定)
图的连通性
- 无向图:一次 BFS/DFS 调用访问的顶点 = 一个连通分量
- 有向图:
- 从某顶点出发 BFS/DFS 能访问所有顶点 ≠ 强连通
- 需要验证正向和反向都能到达
6.4 图的应用
最小生成树(MST)
连通无向带权图中,总权值最小的生成树。
性质(重要!):
- 最小生成树不唯一(权值有相同时)
- 最小生成树的边数 = $n - 1$
- 切割性质:将顶点集分成两个集合,连接两个集合的最小权边一定在 MST 中
Prim 算法(加点法):
1 | 1. 从任意顶点开始,加入已选集合 U |
- 时间:$O(n^2)$
- 适合稠密图
- 用邻接矩阵实现
Kruskal 算法(加边法):
1 | 1. 将所有边按权值从小到大排序 |
- 时间:$O(e\log e)$
- 适合稀疏图
- 判断两点是否在同一连通分量:并查集($O(\alpha(n))$)
Prim vs Kruskal 对比:
| 对比 | Prim | Kruskal |
|---|---|---|
| 策略 | 加点 | 加边 |
| 时间 | $O(n^2)$ | $O(e\log e)$ |
| 适合 | 稠密图 | 稀疏图 |
| 数据结构 | 数组 | 并查集 |
最短路径
Dijkstra 算法(单源最短路,无负权边):
1 | 初始化:dist[i] = ∞, dist[src] = 0 |
- 时间:$O(n^2)$(朴素),$O((n+e)\log n)$(优先队列优化)
- 不适用于负权边!(贪心策略被破坏)
- 不能处理负权回路!
Floyd 算法(多源最短路):
1 | 初始化:D = 邻接矩阵 |
1 | void Floyd(MGraph *G) { |
- 时间:$O(n^3)$
- 可处理负权边(但不能有负权回路)
- 可求任意两点间最短路径
- 不能用于负权回路(最短路径无意义)
Dijkstra vs Floyd 对比:
| 对比 | Dijkstra | Floyd |
|---|---|---|
| 功能 | 单源最短路 | 多源最短路 |
| 时间 | $O(n^2)$ | $O(n^3)$ |
| 负权边 | ❌ 不支持 | ✅ 支持 |
| 负权回路 | ❌ | ❌ |
拓扑排序(AOV网)
AOV网:用顶点表示活动、边表示先后关系的有向图。
拓扑序列:满足所有先后关系的顶点线性序列。
算法:
- 选一个入度为 0 的顶点输出
- 删除该顶点及其所有出边(入度 -1)
- 重复 1-2
结果:
- 成功输出所有顶点 → 拓扑序列(可能不唯一)
- 中途找不到入度为 0 的顶点 → 有环
时间:$O(n + e)$
应用:
- 判断有向图是否有环
- 任务调度顺序
- 编译顺序(课程先修关系)
关键路径(AOE网)
AOE网:用边表示活动、顶点表示事件的有向带权图。
| 术语 | 定义 | 计算 |
|---|---|---|
| 事件最早发生时间 $ve$ | 从源点出发的最长路径 | $ve[j] = \max{ve[i] + w_{i,j}}$ |
| 事件最迟发生时间 $vl$ | 不推迟工期的最晚时间 | $vl[i] = \min{vl[j] - w_{i,j}}$ |
| 活动最早开始时间 $e$ | 弧尾事件的 $ve$ | $e(a_k) = ve(i)$ |
| 活动最迟开始时间 $l$ | 弧头事件 $vl$ 减弧长 | $l(a_k) = vl(j) - w_{i,j}$ |
| 关键活动 | $e = l$ 的活动 | $l - e = 0$ |
关键路径 = 关键活动构成的路径(从源点到汇点的最长路径)
⚠️ 易错:
- 缩短关键活动不一定能缩短工期(可能有新的关键路径出现)
- 关键路径可能不止一条
- 关键活动的 $l - e = 0$,不能为负
第七章 查找
7.1 顺序查找
从头到尾逐个比较。
1 | int SeqSearch(int A[], int n, int key) { |
平均查找长度(ASL):
$$ASL_{成功} = \sum_{i=1}^{n} P_i \times C_i = \frac{1}{n}\sum_{i=1}^{n} i = \frac{n+1}{2}$$
$$ASL_{失败} = n + 1$$
哨兵优化:将 A[0] = key(哨兵),从后往前找,省去判空。
1 | int SeqSearch_Sentinel(int A[], int n, int key) { |
7.2 折半查找(二分查找)
前提:有序顺序表(不能是链表!因为需要随机访问)
1 | int BinarySearch(int A[], int n, int key) { |
判定树:
- $n$ 个结点的折半查找判定树是一棵平衡二叉树
- 根结点为
mid = (low + high) / 2 - 高度 $h = \lfloor \log_2 n \rfloor + 1$
- 最多比较 $h$ 次
ASL:
$$ASL_{成功} = \frac{1}{n}\sum_{i=1}^{h} i \times (\text{第 } i \text{ 层结点数})$$
注意:
- 只适用于顺序存储的有序表
- 不能用于链表(无法随机访问 mid)
- 每次比较后,搜索范围减半
7.3 分块查找(索引顺序查找)
要求:块内无序,块间有序(第 $i$ 块最大值 < 第 $i+1$ 块最小值)
步骤:
- 在索引表中折半/顺序查找确定所在块
- 在块内顺序查找
ASL:介于顺序查找和折半查找之间。
设 $n$ 个元素分成 $b$ 块,每块 $s$ 个元素:
$$ASL = \frac{b+1}{2} + \frac{s+1}{2} = \frac{n/s + s}{2} + 1$$
当 $s = \sqrt{n}$ 时,ASL 最小。
7.4 二叉排序树(BST)
定义
- 左子树所有结点值 < 根结点值
- 右子树所有结点值 > 根结点值
- 左右子树也分别是 BST
中序遍历 BST 得到递增有序序列。
查找
1 | BSTNode *BST_Search(BiTree T, int key) { |
递归版本:
1 | BSTNode *BST_Search_Recursive(BiTree T, int key) { |
插入
1 | bool BST_Insert(BiTree *T, int key) { |
新插入的结点一定是叶子结点。
删除
| 情况 | 操作 |
|---|---|
| 叶子结点 | 直接删除 |
| 只有左子树 | 用左子树替代 |
| 只有右子树 | 用右子树替代 |
| 左右子树都有 | 用中序前驱(左子树最右)或中序后继(右子树最左)替代,然后删除那个结点 |
注意:删除操作不能简单地用子树替代,必须保持 BST 性质。
性能
| 情况 | ASL |
|---|---|
| 平衡(完全BST) | $O(\log_2 n)$ |
| 最坏(退化为链表) | $O(n)$ |
| 平均 | $O(\log_2 n)$(等概率插入) |
7.5 平衡二叉树(AVL树)
定义
平衡因子:$BF = h_L - h_R$(左子树高度 - 右子树高度)
AVL树:任意结点的 $|BF| \leq 1$。
四种旋转
1. LL 型(右旋):在左孩子的左子树插入
1 | A B |
2. RR 型(左旋):在右孩子的右子树插入
1 | A B |
3. LR 型(先左旋再右旋):在左孩子的右子树插入
1 | A C |
4. RL 型(先右旋再左旋):在右孩子的左子树插入
1 | A C |
判断方法:
- 看插入位置相对于最近失衡结点的关系
- LL:插入在左子树的左子树 → 右旋
- RR:插入在右子树的右子树 → 左旋
- LR:插入在左子树的右子树 → 先左旋左孩子,再右旋
- RL:插入在右子树的左子树 → 先右旋右孩子,再左旋
最少结点数
高度为 $h$ 的 AVL 树最少结点数:
$$n_h = n_{h-1} + n_{h-2} + 1$$
- $n_0 = 0$
- $n_1 = 1$
- $n_2 = 2$
- $n_3 = 4$
- $n_4 = 7$
这类似于 Fibonacci 数列。
反推:$n$ 个结点的 AVL 树最大高度约 $1.44\log_2(n+2) - 0.328$。
查找性能
- 时间 $O(\log_2 n)$
- 比 BST 稳定,不会退化到 $O(n)$
7.6 B树和B+树
B树(m阶)
定义:
- 每个结点最多 $m$ 棵子树,$m-1$ 个关键字
- 每个结点(除根)至少 $\lceil m/2 \rceil$ 棵子树
- 根结点至少 2 棵子树(除非只有根)
- 所有叶子结点在同一层(查找失败结点)
- 关键字递增排列:$K_1 < K_2 < \cdots < K_{m-1}$
B树的高度:
$$\log_m(n+1) \leq h \leq \log_{\lceil m/2 \rceil}\frac{n+1}{2} + 1$$
查找:
- 在结点内顺序/折半查找关键字
- 若找到则返回
- 若没找到,沿指针到子树继续查找
- 时间 $O(\log n)$
插入:
- 找到叶子结点位置插入
- 若关键字数超过 $m-1$,取中间关键字上提到父结点,左右分裂
- 若父结点也溢出,继续分裂(可能直到根,树增高)
删除:
- 非叶子结点:用中序前驱/后继替代后,在叶子中删除
- 叶子结点:
- 关键字够多:直接删
- 关键字太少:向兄弟借(旋转)或与兄弟合并
- 合并后父结点关键字减少,可能继续调整
B+树
定义(m阶):
- 非叶子结点只存索引(关键字),不存数据
- 叶子结点存所有关键字和数据指针,用链表相连
- 非叶子结点的每个关键字是其子树中最大(或最小)关键字的副本
- 叶子包含全部关键字信息
B树 vs B+树:
| 对比 | B树 | B+树 |
|---|---|---|
| 数据位置 | 所有结点 | 只有叶子 |
| 叶子链表 | 无 | 有 |
| 查找性能 | 不稳定(可能在任意层找到) | 稳定(一定到叶子) |
| 范围查询 | 需中序遍历 | 叶子链表直接遍历 ✅ |
| 结点利用率 | 较低 | 较高(非叶只存索引) |
| 应用场景 | 文件系统 | 数据库索引 |
B+树更适合数据库的原因:
- 范围查询高效(叶子链表)
- 查找稳定(都到叶子)
- 非叶子结点能存更多索引,树更矮
7.7 散列表(Hash Table)
基本概念
散列函数:$H(key) = $ 存储地址
冲突:不同关键字映射到同一地址 $H(key_1) = H(key_2)$
同义词:发生冲突的关键字
装填因子:$\alpha = \frac{n}{m}$($n$ 为已存元素数,$m$ 为表长)
散列函数构造
1. 除留余数法(最常用):
$$H(key) = key \ % \ p$$
$p$ 取不大于表长 $m$ 的最大素数。
2. 直接定址法:
$$H(key) = a \times key + b$$
适用于关键字分布连续的情况。
3. 数字分析法:取关键字中分布均匀的若干位。
4. 平方取中法:取关键字平方后的中间几位。
冲突处理
1. 开放定址法:
$$H_i = (H(key) + d_i) \ % \ m$$
| 方法 | $d_i$ 序列 | 特点 |
|---|---|---|
| 线性探测 | $1, 2, 3, \ldots$ | 简单,但会产生堆积 |
| 二次探测 | $1^2, -1^2, 2^2, -2^2, \ldots$ | 减少堆积,但可能有空间浪费 |
| 伪随机探测 | 伪随机序列 | 避免堆积 |
堆积 vs 聚集:
- 一次聚集:线性探测中,冲突的元素聚集成片
- 二次聚集:二次探测中,映射到同一初始位置的元素聚集
2. 链地址法(拉链法):
相同散列地址的元素用链表串起来。
优点:
- 无堆积问题
- 删除操作方便
- 表长可以小于元素数
缺点:指针需要额外空间
3. 再散列法:使用多个散列函数,第一个冲突用第二个,依次类推。
查找效率分析
装填因子 $\alpha$ 的影响:
| 冲突处理 | ASL(成功) | ASL(失败) |
|---|---|---|
| 线性探测 | $\frac{1}{2}(1 + \frac{1}{1-\alpha})$ | $\frac{1}{2}(1 + \frac{1}{(1-\alpha)^2})$ |
| 链地址法 | $1 + \alpha/2$ | $\alpha$ |
结论:
- 散列表的查找效率取决于:散列函数、冲突处理、装填因子
- $\alpha$ 越大,冲突越多,效率越低
- 一般取 $\alpha \leq 0.75$(开放定址法)或 $\alpha \leq 1$(链地址法)
散列表 vs 其他查找:
- 有序顺序查找 $O(n)$
- 折半查找 $O(\log n)$
- BST/AVL $O(\log n)$
- 散列表 $O(1)$(平均),$O(n)$(最坏)
第八章 排序
8.1 排序分类
按存储分类
| 分类 | 说明 | 算法 |
|---|---|---|
| 内部排序 | 数据全部在内存中 | 快排、堆排、归并等 |
| 外部排序 | 数据太大需要磁盘辅助 | 多路归并 |
按策略分类
| 分类 | 算法 |
|---|---|
| 插入排序 | 直接插入、折半插入、希尔排序 |
| 交换排序 | 冒泡排序、快速排序 |
| 选择排序 | 简单选择、堆排序 |
| 归并排序 | 二路归并 |
| 基数排序 | 最高位/最低位优先 |
稳定性
稳定排序:相等元素排序后相对位置不变。
| 稳定 ✅ | 不稳定 ❌ |
|---|---|
| 冒泡排序 | 简单选择排序 |
| 直接插入排序 | 快速排序 |
| 基数排序 | 希尔排序 |
| 归并排序 | 堆排序 |
记忆口诀:“归插冒基稳,快选希堆不稳”
不稳定举例:
- 选择排序:
{5a, 5b, 3}→ 第一趟3和5a交换 →{3, 5b, 5a},5a和5b相对位置变了 - 快速排序:
{3, 3, 3}→ Partition 可能交换相等元素 - 堆排序:建堆过程可能交换相等元素
8.2 插入排序
直接插入排序
思想:将待排序元素插入到已排序序列的正确位置。
1 | void InsertSort(int A[], int n) { |
复杂度:
| 情况 | 比较次数 | 移动次数 | 时间 |
|---|---|---|---|
| 最好(已有序) | $n-1$ | 0 | $O(n)$ |
| 最坏(逆序) | $\sum_{i=1}^{n-1} i = \frac{n(n-1)}{2}$ | $\sum_{i=1}^{n-1}(i+1) = \frac{(n+2)(n-1)}{2}$ | $O(n^2)$ |
| 平均 | $\frac{n^2}{4}$ | $\frac{n^2}{4}$ | $O(n^2)$ |
- 空间:$O(1)$
- 稳定 ✅
- 适用于基本有序的数据(最好 $O(n)$)
折半插入排序
改进:查找插入位置用折半查找,减少比较次数。
1 | void BinaryInsertSort(int A[], int n) { |
- 比较次数减少为 $O(n\log n)$
- 移动次数不变,仍为 $O(n^2)$
- 总时间仍为 $O(n^2)$
- 稳定 ✅
希尔排序
思想:按增量序列分组,组内直接插入排序,逐步缩小增量至 1。
1 | void ShellSort(int A[], int n) { |
特点:
- 时间:$O(n^{1.3})$(与增量序列有关)
- 空间:$O(1)$
- 不稳定 ❌(分组排序可能改变相等元素相对位置)
- 最后一趟增量为 1,等价于直接插入排序
8.3 交换排序
冒泡排序
思想:相邻元素比较交换,每趟将最大(或最小)元素”冒泡”到末尾。
1 | void BubbleSort(int A[], int n) { |
复杂度:
| 情况 | 比较次数 | 交换次数 | 时间 |
|---|---|---|---|
| 最好(已有序) | $n-1$ | 0 | $O(n)$ |
| 最坏(逆序) | $n(n-1)/2$ | $n(n-1)/2$ | $O(n^2)$ |
| 平均 | $n(n-1)/2$ | $n^2/4$ | $O(n^2)$ |
- 空间:$O(1)$
- 稳定 ✅
快速排序(重点中的重点!)
思想:选一个基准元素(pivot),将序列分成两部分,左边都 ≤ pivot,右边都 ≥ pivot,递归处理两边。
1 | int Partition(int A[], int low, int high) { |
复杂度:
| 情况 | 时间 | 空间 |
|---|---|---|
| 最好(每次均匀划分) | $O(n\log n)$ | $O(\log n)$ |
| 最坏(已有序/逆序) | $O(n^2)$ | $O(n)$ |
| 平均 | $O(n\log n)$ | $O(\log n)$ |
- 不稳定 ❌
- 内部排序中平均性能最好
- 每趟 Partition 确定一个元素的最终位置
优化策略:
- 随机选基准:避免最坏情况
- 三数取中:取首、中、尾的中位数作为基准
- 小规模切换:$n$ 小时切换为直接插入排序
快排的递归深度:最坏 $O(n)$(退化为链式递归),最好 $O(\log n)$(平衡递归)。
8.4 选择排序
简单选择排序
思想:每趟从未排序部分选最小的,放到已排序部分末尾。
1 | void SelectSort(int A[], int n) { |
复杂度:
- 比较次数:固定 $n(n-1)/2$(与初始状态无关)
- 移动次数:最好 0,最坏 $3(n-1)$
- 时间:$O(n^2)$
- 不稳定 ❌
为什么不稳定:例如 {5a, 5b, 3},第一趟选最小 3 与 5a 交换 → {3, 5b, 5a}。
堆排序
堆的定义:
- 大根堆:每个结点 ≥ 其孩子,堆顶最大
- 小根堆:每个结点 ≤ 其孩子,堆顶最小
存储:完全二叉树 + 数组,结点 $i$ 的左孩子 $2i$,右孩子 $2i+1$。
向下调整(大根堆):
1 | void HeadAdjust(int A[], int k, int len) { |
建堆:从最后一个非叶子结点 $\lfloor n/2 \rfloor$ 开始,依次向下调整。
1 | void BuildMaxHeap(int A[], int len) { |
建堆时间:$O(n)$(不是 $O(n\log n)$!)
堆排序:
1 | void HeapSort(int A[], int len) { |
复杂度:
- 建堆:$O(n)$
- 排序:$O(n\log n)$
- 总时间:$O(n\log n)$(最好最坏平均都是)
- 空间:$O(1)$
- 不稳定 ❌
堆的插入:新元素放在末尾,向上调整(与父比较交换)。
堆的删除:用末尾元素替代堆顶,向下调整。
向上调整(插入用):
1 | void HeapInsert(int A[], int k) { |
8.5 归并排序
思想:将两个有序表合并为一个有序表。
二路归并:
1 | int *B; // 辅助数组 |
归并排序:
1 | void MergeSort(int A[], int low, int high) { |
复杂度:
- 时间:$O(n\log n)$(最好最坏平均都是)
- 空间:$O(n)$(辅助数组)
- 稳定 ✅
归并排序的特点:
- 唯一最好最坏都是 $O(n\log n)$ 且稳定的排序算法
- 空间 $O(n)$ 是主要缺点
- 外部排序的基础
$k$ 路归并:每趟从 $k$ 个有序段中选最小元素,需要 $\lceil \log_k n \rceil$ 趟。
8.6 基数排序
思想:按关键字各位分配收集,从最低位到最高位(LSD)。
过程(以三位数为例):
- 按个位分配到 10 个桶,依次收集
- 按十位分配到 10 个桶,依次收集
- 按百位分配到 10 个桶,依次收集
复杂度:
- 时间:$O(d(n+r))$,$d$ 为位数,$r$ 为基数(桶的个数)
- 空间:$O(r)$(桶)
- 稳定 ✅
- 不基于比较的排序算法
特点:
- 适用于关键字位数少且每一位取值范围不大的情况
- 与比较类排序不同,不受 $O(n\log n)$ 下界约束
8.7 排序算法总结
| 算法 | 平均时间 | 最好 | 最坏 | 空间 | 稳定 | 适用场景 |
|---|---|---|---|---|---|---|
| 直接插入 | $O(n^2)$ | $O(n)$ | $O(n^2)$ | $O(1)$ | ✅ | 基本有序 |
| 折半插入 | $O(n^2)$ | $O(n\log n)$ | $O(n^2)$ | $O(1)$ | ✅ | 比较多时 |
| 希尔 | $O(n^{1.3})$ | — | — | $O(1)$ | ❌ | 中等规模 |
| 冒泡 | $O(n^2)$ | $O(n)$ | $O(n^2)$ | $O(1)$ | ✅ | 基本有序 |
| 快速 | $O(n\log n)$ | $O(n\log n)$ | $O(n^2)$ | $O(\log n)$ | ❌ | 通用,平均最优 |
| 简单选择 | $O(n^2)$ | $O(n^2)$ | $O(n^2)$ | $O(1)$ | ❌ | 移动代价大时 |
| 堆 | $O(n\log n)$ | $O(n\log n)$ | $O(n\log n)$ | $O(1)$ | ❌ | 大量数据 |
| 归并 | $O(n\log n)$ | $O(n\log n)$ | $O(n\log n)$ | $O(n)$ | ✅ | 稳定+高效 |
| 基数 | $O(d(n+r))$ | — | — | $O(r)$ | ✅ | 关键字位数少 |
重要结论
- 基于比较的排序,时间下界为 $O(n\log n)$(决策树证明)
- 快速排序是内部排序中平均性能最好的
- 归并排序是唯一最好最坏都是 $O(n\log n)$ 且稳定的
- 堆排序最好最坏都是 $O(n\log n)$ 且空间 $O(1)$
- $n$ 较小($n \leq 50$):直接插入或简单选择
- $n$ 较大:快排/堆排/归并
- 关键字基本有序:直接插入或冒泡
- 关键字范围不大:基数排序
排序过程手算技巧
快速排序:每趟 Partition 确定一个元素的最终位置,手算时跟踪 pivot 的位置变化。
堆排序:手算时画出完全二叉树,从最后一个非叶子开始调整。每趟取出堆顶后,将末尾元素放到堆顶,重新调整。
归并排序:手算时画出递归分解过程,然后从底向上合并。
冒泡排序:每趟从一端开始,相邻比较交换,一趟确定一个最值的最终位置。
