数据结构

数据结构

第一章 绪论

1.1 数据结构基本概念

术语 定义 举例
数据 信息的载体,能被计算机识别、存储和加工处理 整数、字符、图像
数据元素 数据的基本单位,由若干数据项组成 一条学生记录
数据项 数据的最小不可分割单位 姓名、年龄、学号
数据对象 性质相同的数据元素的集合 所有学生的集合
数据结构 相互之间存在特定关系的数据元素的集合 线性表、树、图

易混淆点

  • 数据元素是数据的基本单位,数据项是最小单位
  • 数据结构强调的是元素之间的关系,不是元素本身
  • 数据对象是数据的子集,数据结构包含数据对象+关系+操作

1.2 数据结构三要素

逻辑结构

数据元素之间的逻辑关系,与存储无关。

结构 关系特点 典型示例
集合 元素间无任何关系,仅同属一个集合 整数集合
线性结构 一对一,有前驱后继关系 线性表、栈、队列、串
树形结构 一对多,有层次关系 二叉树、B树、堆
图状/网状结构 多对多,任意两个元素都可能有关系 社交网络、交通图

注意:集合、线性、树形、图状是四大逻辑结构,从上到下关系越来越复杂。

存储结构(物理结构)

数据在计算机内存中的实际存储方式。

存储方式 核心思想 优点 缺点
顺序存储 逻辑相邻→物理也相邻,用数组 随机访问 $O(1)$,存储密度高 插入删除需移动元素,大小固定
链式存储 逻辑相邻→物理不一定相邻,用指针 插入删除 $O(1)$,动态分配 不能随机访问,指针额外开销
索引存储 建立索引表(关键字→地址) 查找速度快 索引表占额外空间
散列存储 根据关键字直接计算地址 查找 $O(1)$ 冲突处理复杂,空间利用率低

重要:同一种逻辑结构可以有不同的存储结构。例如线性表可以用顺序存储(顺序表)也可以用链式存储(链表)。

数据的运算

  • 运算的定义:针对逻辑结构,指出”做什么”(如查找、插入、删除)
  • 运算的实现:针对存储结构,指出”怎么做”(具体算法)

1.3 算法与算法评价

算法的特性

特性 含义
有穷性 必须在有穷步内结束,每步在有穷时间内完成
确定性 每条指令含义明确,相同输入相同输出
可行性 每一步都可以通过基本运算执行有限次实现
输入 有零个或多个输入(可以没有输入)
输出 有一个或多个输出(至少一个)

注意:有穷性要求算法在有限步骤内结束,但程序可以是无穷的(如操作系统)。

时间复杂度

表示算法执行时间与问题规模 $n$ 的增长关系。

$$T(n) = O(f(n))$$

计算规则

  1. 只保留最高阶项
  2. 去掉最高阶项的系数
  3. 常数语句视为 $O(1)$
  4. 顺序执行:取最大(加法规则)
  5. 嵌套循环:相乘(乘法规则)

常见复杂度排序(必须记住):

$$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*=2i/=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
2
3
4
5
#define MaxSize 50
typedef struct {
int data[MaxSize]; // 存放数据元素
int length; // 当前长度
} SqList;

初始化L.length = 0

判空L.length == 0

判满L.length == MaxSize

基本操作

按位查找(随机访问):$O(1)$

1
2
3
int GetElem(SqList L, int i) {
return L.data[i-1]; // 位序从1开始,数组下标从0开始
}

按值查找:$O(n)$

1
2
3
4
5
int LocateElem(SqList L, int e) {
for (int i = 0; i < L.length; i++)
if (L.data[i] == e) return i + 1; // 返回位序
return 0;
}

插入操作:在第 $i$ 个位置插入元素 $e$

1
2
3
4
5
6
7
8
9
bool ListInsert(SqList *L, int i, int e) {
if (i < 1 || i > L->length + 1) return false; // 位置合法性
if (L->length >= MaxSize) return false; // 表满
for (int j = L->length; j >= i; j--)
L->data[j] = L->data[j-1]; // 从后往前逐个后移
L->data[i-1] = e; // 放入新元素
L->length++;
return true;
}
  • 移动次数:最好 $0$ 次(插在末尾),最坏 $n$ 次(插在开头),平均 $n/2$ 次
  • 时间复杂度:$O(n)$

删除操作:删除第 $i$ 个位置的元素

1
2
3
4
5
6
7
8
bool ListDelete(SqList *L, int i, int *e) {
if (i < 1 || i > L->length) return false;
*e = L->data[i-1]; // 取出被删元素
for (int j = i; j < L->length; j++)
L->data[j-1] = L->data[j]; // 从前往后逐个前移
L->length--;
return true;
}
  • 移动次数:最好 $0$ 次(删末尾),最坏 $n-1$ 次(删开头),平均 $(n-1)/2$ 次
  • 时间复杂度:$O(n)$

2.2 链表(LinkedList)

单链表

1
2
3
4
typedef struct LNode {
int data; // 数据域
struct LNode *next; // 指针域
} LNode, *LinkList;

两种定义方式的区别

  • LNode:结点类型,声明结点变量 LNode node;
  • LinkList:头指针类型,声明链表 LinkList L;(等价于 LNode *L

头结点 vs 无头结点

对比项 有头结点 无头结点
头指针指向 头结点(不存数据) 第一个数据结点
空表判断 L->next == NULL L == NULL
插入/删除 第一个结点无需特殊处理 第一个结点需特殊处理
推荐度 ✅ 推荐 较麻烦

按位序插入(带头结点)

在第 $i$ 个位置插入元素 $e$:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
bool ListInsert(LinkList L, int i, int e) {
LNode *p = L; // p 指向头结点
int j = 0; // j 记录当前位序
// 找到第 i-1 个结点
while (p && j < i - 1) {
p = p->next;
j++;
}
if (!p || j > i - 1) return false; // i 不合法
// 插入新结点
LNode *s = (LNode *)malloc(sizeof(LNode));
s->data = e;
s->next = p->next; // ① 先连后(s 指向 p 的后继)
p->next = s; // ② 再连前(p 指向 s)
return true;
}

⚠️ 顺序不能反! 如果先 p->next = s,则 p->next 原来的值丢失,无法赋给 s->next

时间复杂度

  • 最好 $O(1)$(插在第1个位置,只找0次)
  • 最坏 $O(n)$(插在第 $n+1$ 个位置)
  • 平均 $O(n)$(平均移动 $n/2$ 个指针)

删除结点

1
2
3
4
5
6
7
8
9
10
11
12
13
bool ListDelete(LinkList L, int i, int *e) {
LNode *p = L;
int j = 0;
while (p->next && j < i - 1) { // 注意是 p->next
p = p->next; j++;
}
if (!(p->next) || j > i - 1) return false;
LNode *q = p->next; // q 指向待删结点
*e = q->data; // 取出数据
p->next = q->next; // 绕过 q
free(q); // 释放 q
return true;
}

易错:while 循环条件是 p->next 而非 p,因为要找的是待删结点的前驱

头插法建表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
LinkList HeadInsert(LinkList L) {
LNode *s; int x;
L = (LinkList)malloc(sizeof(LNode)); // 创建头结点
L->next = NULL;
scanf("%d", &x);
while (x != 9999) { // 9999 为结束标志
s = (LNode *)malloc(sizeof(LNode));
s->data = x;
s->next = L->next; // s 指向原第一个结点
L->next = s; // 头结点指向 s
scanf("%d", &x);
}
return L;
}

特点:输入顺序与存储顺序相反(逆序)

应用:链表逆置可以用头插法思想实现

尾插法建表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
LinkList TailInsert(LinkList L) {
LNode *s, *r; int x;
L = (LinkList)malloc(sizeof(LNode));
r = L; // r 始终指向尾结点
scanf("%d", &x);
while (x != 9999) {
s = (LNode *)malloc(sizeof(LNode));
s->data = x;
r->next = s; // 尾结点指向新结点
r = s; // r 更新为新的尾结点
scanf("%d", &x);
}
r->next = NULL; // 尾结点指针置空
return L;
}

特点:输入顺序与存储顺序相同(正序)

注意:最后必须 r->next = NULL,否则尾结点指向不确定的地址。

按值查找

1
2
3
4
5
6
LNode *LocateElem(LinkList L, int e) {
LNode *p = L->next; // 从第一个数据结点开始
while (p && p->data != e)
p = p->next;
return p; // 找到返回结点指针,没找到返回 NULL
}

求表长

1
2
3
4
5
6
int Length(LinkList L) {
int len = 0;
LNode *p = L->next;
while (p) { len++; p = p->next; }
return len;
}

2.3 双链表

1
2
3
4
typedef struct DNode {
int data;
struct DNode *prior, *next; // 前驱和后继指针
} DNode, *DLinkList;

优势:可以 $O(1)$ 找到前驱(单链表需要 $O(n)$)

插入操作(四步,顺序重要)

在结点 $p$ 之后插入结点 $s$:

1
2
3
4
s->next = p->next;       // ① s 的后继指向 p 的后继
p->next->prior = s; // ② p 的后继的前驱指向 s(若非尾结点)
s->prior = p; // ③ s 的前驱指向 p
p->next = s; // ④ p 的后继指向 s

如果 $p$ 是最后一个结点,第②步不能执行(p->next 为 NULL),需要特判。

删除操作

删除结点 $p->next$(即 $q$):

1
2
3
4
p->next = q->next;
if (q->next != NULL) // 若 q 不是最后一个结点
q->next->prior = p;
free(q);

2.4 循环链表

循环单链表

  • 最后一个结点的 next 指向头结点(不是 NULL)
  • 判空L->next == L
  • 判尾p->next == L
  • 优势:从任一结点出发可遍历整个链表(单链表只能从头开始)

实际应用:将头指针改为尾指针,两个循环单链表合并只需 $O(1)$:

1
2
3
4
// 将表 B 接到表 A 后面
s = B->next->next; // B 的第一个数据结点
B->next = A->next; // B 的尾指向 A 的头
A->next = s; // A 的尾指向 B 的第一个数据结点

循环双链表

  • 尾结点 next 指向头结点,头结点 prior 指向尾结点
  • 判空L->next == L && L->prior == L

2.5 静态链表

数组模拟链表,数组元素包含 datanext(游标/下标)。

1
2
3
4
5
#define MaxSize 50
typedef struct {
int data;
int next; // 下一个元素的数组下标(不是指针)
} SLinkList[MaxSize];

特点

  • 分配固定大小的数组空间
  • next 存的是数组下标,不是内存地址
  • 适用于没有指针的语言(如 Basic、Fortran)
  • 插入删除不需要移动元素,只需修改游标

优缺点

  • 优点:不需要动态分配,操作不涉及指针
  • 缺点:容量固定,不能动态增长

2.6 顺序表 vs 链表对比

比较维度 顺序表 链表
随机访问 $O(1)$ ✅ $O(n)$ ❌
插入/删除(已知位置) $O(n)$(移动元素) $O(1)$(改指针)✅
存储密度 高 ✅ 低(指针额外开销)
空间分配 静态/预分配,可能浪费 动态分配,按需申请 ✅
适用场景 频繁查找、很少增删 频繁增删、很少随机访问
缓存友好性 ✅ 连续内存,缓存命中率高 ❌ 离散内存,缓存命中率低

2.7 链表高频考点

1. 链表逆置(头插法思想):

1
2
3
4
5
6
7
8
9
10
void Reverse(LinkList L) {
LNode *p = L->next, *q;
L->next = NULL;
while (p) {
q = p->next;
p->next = L->next; // 头插
L->next = p;
p = q;
}
}

2. 查找倒数第 $k$ 个结点(双指针法):

1
2
3
4
5
6
7
// p 先走 k 步,然后 p、q 一起走,p 到末尾时 q 就是倒数第 k 个
LNode *FindKth(LinkList L, int k) {
LNode *p = L->next, *q = L->next;
for (int i = 0; i < k; i++) p = p->next;
while (p) { p = p->next; q = q->next; }
return q;
}

3. 查找中间结点(快慢指针):

1
2
3
4
5
6
7
8
9
// slow 走一步,fast 走两步,fast 到末尾时 slow 在中间
LNode *FindMid(LinkList L) {
LNode *slow = L->next, *fast = L->next;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
}
return slow;
}

4. 判断链表是否有环(快慢指针):

1
2
3
4
5
6
7
8
9
bool HasCycle(LinkList L) {
LNode *slow = L->next, *fast = L->next;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) return true; // 相遇则有环
}
return false;
}

第三章 栈、队列和数组

3.1 栈(Stack)

定义与特性

:只允许在一端(栈顶)进行插入和删除的线性表。

特性:后进先出(LIFO, Last In First Out)

基本操作

  • Push:入栈(栈顶插入)
  • Pop:出栈(栈顶删除)
  • GetTop:取栈顶元素(不删除)
  • StackEmpty:判空

卡特兰数:$n$ 个不同元素进栈,出栈序列个数为 $\frac{1}{n+1}C_{2n}^{n}$

顺序栈

1
2
3
4
5
#define MaxSize 50
typedef struct {
int data[MaxSize];
int top; // 栈顶指针,指向当前栈顶元素的位置
} SqStack;

初始化S.top = -1(空栈)

判空S.top == -1

判满S.top == MaxSize - 1

栈的元素个数S.top + 1

入栈

1
2
3
4
5
bool Push(SqStack *S, int x) {
if (S->top == MaxSize - 1) return false; // 栈满
S->data[++S->top] = x; // 先 top++ 再赋值
return true;
}

出栈

1
2
3
4
5
bool Pop(SqStack *S, int *x) {
if (S->top == -1) return false; // 栈空
*x = S->data[S->top--]; // 先取值再 top--
return true;
}

⚠️ 易错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
2
3
4
5
typedef struct {
int data[MaxSize];
int top1; // 栈1栈顶(从 -1 开始)
int top2; // 栈2栈顶(从 MaxSize 开始)
} ShStack;
  • 栈1空top1 == -1
  • 栈2空top2 == MaxSize
  • 栈满top1 + 1 == top2

优势:空间互补利用,一个栈用得少时另一个可以用更多空间。

链栈

1
2
3
4
typedef struct StackNode {
int data;
struct StackNode *next;
} StackNode, *LinkStack;
  • 栈顶在链头
  • 无头结点更方便(直接操作第一个结点)
  • 不存在栈满问题(动态分配)
  • 入栈出栈都是 $O(1)$

3.2 队列(Queue)

定义与特性

队列:只允许在一端(队尾)插入、另一端(队头)删除的线性表。

特性:先进先出(FIFO, First In First Out)

基本操作

  • EnQueue:入队(队尾插入)
  • DeQueue:出队(队头删除)
  • GetHead:取队头元素
  • QueueEmpty:判空

顺序队列(循环队列)

普通顺序队列会出现”假溢出”(前面有空位但 rear 已到末尾),用循环队列解决。

1
2
3
4
5
#define MaxSize 50
typedef struct {
int data[MaxSize];
int front, rear; // 队头和队尾指针
} SqQueue;

初始化Q.front = Q.rear = 0

入队

1
2
3
4
5
6
bool EnQueue(SqQueue *Q, int x) {
if ((Q->rear + 1) % MaxSize == Q->front) return false; // 队满
Q->data[Q->rear] = x;
Q->rear = (Q->rear + 1) % MaxSize;
return true;
}

出队

1
2
3
4
5
6
bool DeQueue(SqQueue *Q, int *x) {
if (Q->front == Q->rear) return false; // 队空
*x = Q->data[Q->front];
Q->front = (Q->front + 1) % MaxSize;
return true;
}

关键公式(牺牲一个存储单元区分队空队满):

公式 表达式
队空 Q.front == Q.rear
队满 (Q.rear + 1) % MaxSize == Q.front
元素个数 (Q.rear - Q.front + MaxSize) % MaxSize
队头元素 Q.data[Q.front]

其他判满方式(不牺牲空间):

  1. 增加 size 变量

    • 入队时 size++,出队时 size--
    • 队满:size == MaxSize
  2. 增加 tag 变量

    • tag = 0:最近操作是删除(队空可能)
    • tag = 1:最近操作是插入(队满可能)
    • 队满:Q.front == Q.rear && tag == 1
    • 队空:Q.front == Q.rear && tag == 0

链式队列

1
2
3
4
5
6
7
8
typedef struct QNode {
int data;
struct QNode *next;
} QNode;

typedef struct {
QNode *front, *rear; // 队头和队尾指针
} LinkQueue;
  • 带头结点:front 指向头结点,rear 指向尾结点
  • 队空Q.front == Q.rear(都指向头结点)
  • 出队:修改 front->next,若删的是最后一个元素,还要更新 rear
1
2
3
4
5
6
7
8
9
10
bool DeQueue(LinkQueue *Q, int *x) {
if (Q->front == Q->rear) return false;
QNode *p = Q->front->next;
*x = p->data;
Q->front->next = p->next;
if (Q->rear == p) // 若删的是最后一个元素
Q->rear = Q->front;
free(p);
return true;
}

双端队列

两端都可以进行入队和出队操作。

  • 输入受限的双端队列:只有一端能入队,两端都能出队
  • 输出受限的双端队列:两端都能入队,只有一端能出队

3.3 栈和队列的应用

栈的应用

1. 括号匹配

1
2
3
4
5
6
7
8
9
10
算法:
1. 初始化一个空栈
2. 从左到右扫描
3. 遇到左括号 → 入栈
4. 遇到右括号 →
a. 栈空 → 匹配失败(右括号多余)
b. 栈非空 → 出栈,检查是否匹配
5. 扫描结束 →
a. 栈空 → 匹配成功
b. 栈非空 → 匹配失败(左括号多余)

2. 表达式求值

中缀表达式:运算符在两个操作数之间,如 a + b * c

后缀表达式(逆波兰):运算符在两个操作数之后,如 a b c * +

前缀表达式(波兰):运算符在两个操作数之前,如 + a * b c

中缀 → 后缀(调度场算法)

1
2
3
4
5
6
7
1. 初始化运算符栈
2. 从左到右扫描中缀表达式:
a. 操作数 → 直接输出
b. 左括号 → 入栈
c. 右括号 → 弹栈输出直到遇到左括号(左括号弹出但不输出)
d. 运算符 → 弹栈输出所有优先级 ≥ 自己的,然后入栈
3. 扫描结束 → 弹栈输出所有剩余运算符

运算符优先级(从高到低):

优先级 运算符
3 ()
2 *, /
1 +, -

后缀表达式求值

1
2
3
4
5
1. 初始化操作数栈
2. 从左到右扫描后缀表达式:
a. 操作数 → 入栈
b. 运算符 → 连续出栈两个操作数(先出的为右操作数),计算后入栈
3. 栈中唯一元素即为结果

示例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
2
3
4
5
6
7
8
9
10
11
void Convert(int n, int base) {
SqStack S; InitStack(&S);
while (n) {
Push(&S, n % base);
n /= base;
}
while (!StackEmpty(S)) {
Pop(&S, &n);
printf("%d", n);
}
}

队列的应用

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
2
3
4
5
6
7
8
9
typedef struct {
int i, j; // 行号、列号
int value; // 值
} Triple;

typedef struct {
Triple data[MaxSize];
int rows, cols, nums; // 行数、列数、非零元素个数
} TSMatrix;

注意:三元组压缩存储后,矩阵的转置、乘法等运算变得复杂。转置时需要按列序重排三元组。

十字链表:同时存储行指针和列指针,适合稀疏矩阵的加法运算。


第四章 串

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
2
3
4
5
6
7
8
9
10
11
12
13
int Index(SString S, SString T) {
int i = 1, j = 1; // S 和 T 的下标从 1 开始
while (i <= S.length && j <= T.length) {
if (S.ch[i] == T.ch[j]) {
i++; j++; // 匹配则继续比较下一个
} else {
i = i - j + 2; // 主串指针回退到本趟开始的下一个位置
j = 1; // 模式串指针归 1
}
}
if (j > T.length) return i - T.length; // 匹配成功
return 0; // 匹配失败
}

时间复杂度

  • 最好:$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
2
3
4
5
6
7
8
9
10
11
12
void GetNext(char *T, int *next) {
int i = 1, j = 0;
next[1] = 0;
while (i < T[0]) { // T[0] 存储串长度
if (j == 0 || T[i] == T[j]) {
i++; j++;
next[i] = j;
} else {
j = next[j]; // j 回退
}
}
}

理解要点

  • j 代表已匹配的前缀长度
  • T[i] == T[j] 时,说明可以扩展已匹配前缀,$next[i+1] = j+1$
  • T[i] != T[j] 时,$j$ 回退到 $next[j]$,尝试更短的前缀

KMP匹配算法

1
2
3
4
5
6
7
8
9
10
11
12
int KMP(char *S, char *T, int *next) {
int i = 1, j = 1;
while (i <= S[0] && j <= T[0]) {
if (j == 0 || S[i] == T[j]) {
i++; j++; // 匹配或 j 回退到 0 时,都前进
} else {
j = next[j]; // 主串 i 不动,模式串 j 回退
}
}
if (j > T[0]) return i - T[0]; // 匹配成功
return 0;
}

时间复杂度:$O(m + n)$,其中 $m$ 为主串长度,$n$ 为模式串长度。

nextval数组(改进的next)

问题:当 T[next[j]] == T[j] 时,回退到 next[j] 后仍然会失配,多做了一次无用比较。

改进:如果 T[next[j]] == T[j]],则继续回退到 next[next[j]]

1
2
3
4
5
6
7
8
9
10
11
12
13
void GetNextval(char *T, int *nextval) {
int i = 1, j = 0;
nextval[1] = 0;
while (i < T[0]) {
if (j == 0 || T[i] == T[j]) {
i++; j++;
if (T[i] != T[j]) nextval[i] = j;
else nextval[i] = nextval[j]; // 关键改进
} else {
j = nextval[j];
}
}
}

示例对比:模式串 "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易错总结

  1. next[1] = 0 是约定,当 $j=1$ 匹配失败时,$i$ 前进,$j$ 不动
  2. next[2] = 1 总是为 1(长度为 1 的子串没有前后缀)
  3. next 数组的值与具体模式串有关,不同模式串的 next 数组不同
  4. KMP 不适用于模式串比主串长的情况
  5. 改进的 nextval 可以减少无效比较,但不影响时间复杂度数量级

第五章 树与二叉树

5.1 树的基本概念

树的定义

树是 $n$ ($n \geq 0$) 个结点的有限集合:

  • $n = 0$ 时为空树
  • 有且仅有一个特定的根结点
  • 其余结点分为 $m$ ($m \geq 0$) 个互不相交的有限集合,每个集合本身又是一棵树(子树)

递归定义:树的定义本身就是递归的。

基本术语

术语 定义 图示含义
结点的度 结点的子树个数 分支数
树的度 所有结点度的最大值 最大的分支数
叶子结点(终端结点) 度为 0 的结点 最末端的结点
分支结点(非终端结点) 度 > 0 的结点 有孩子的结点
孩子/双亲 结点的子树根 / 孩子的父结点
兄弟 同一双亲的孩子
祖先 从根到该结点路径上的所有结点
子孙 该结点所有子树中的结点
层次 根为第 1 层,孩子为第 2 层…
树的高度(深度) 最大层次
有序树 子树有先后顺序
无序树 子树无先后顺序
森林 $m$ ($m \geq 0$) 棵互不相交的树的集合

树的性质(常考)

  1. 结点数 = 总度数 + 1:每个结点贡献一条边(指向它),只有根没有被指向
  2. $m$ 叉树第 $i$ 层最多 $m^{i-1}$ 个结点($i \geq 1$)
  3. 高度为 $h$ 的 $m$ 叉树最多 $\frac{m^h - 1}{m - 1}$ 个结点
  4. $n$ 个结点的 $m$ 叉树最小高度:$h_{min} = \lceil \log_m(n(m-1)+1) \rceil$

5.2 二叉树

定义

二叉树是 $n$ ($n \geq 0$) 个结点的有限集合:

  • 或为空二叉树 ($n = 0$)
  • 或由一个根结点和两棵互不相交的左子树和右子树组成

⚠️ 二叉树 ≠ 度为 2 的树:二叉树的子树有左右之分,即使只有一棵子树也要区分是左还是右。

五种基本形态

  1. 空二叉树
  2. 只有根结点
  3. 根 + 左子树
  4. 根 + 右子树
  5. 根 + 左右子树

特殊二叉树

类型 定义 特点
满二叉树 每层都填满 高度 $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$)只有左孩子

二叉树的五条性质(必须背)

  1. $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$。

  2. 第 $i$ 层最多 $2^{i-1}$ 个结点($i \geq 1$)

  3. 高度为 $h$ 的二叉树最多 $2^h - 1$ 个结点

  4. 完全二叉树 $n$ 个结点,高度 $h = \lfloor \log_2 n \rfloor + 1$

  5. 完全二叉树的结点编号关系:

    • $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
2
3
4
typedef struct BiTNode {
int data;
struct BiTNode *lchild, *rchild; // 左右孩子指针
} BiTNode, *BiTree;

重要结论:$n$ 个结点的二叉链表有 $n+1$ 个空指针域。

推导:$n$ 个结点共 $2n$ 个指针域,$n-1$ 个非空($n$ 个结点有 $n-1$ 条边),所以空指针 = $2n - (n-1) = n+1$。

三叉链表(增加父指针)

1
2
3
4
typedef struct BiTNode {
int data;
struct BiTNode *lchild, *rchild, *parent;
} BiTNode, *BiTree;

5.4 二叉树的遍历

四种遍历方式

遍历方式 顺序 递归定义
先序遍历 根 → 左 → 右 根 → 先序(左) → 先序(右)
中序遍历 左 → 根 → 右 中序(左) → 根 → 中序(右)
后序遍历 左 → 右 → 根 后序(左) → 后序(右) → 根
层序遍历 逐层从左到右 借助队列

先序遍历

1
2
3
4
5
6
7
void PreOrder(BiTree T) {
if (T) {
visit(T); // 访问根
PreOrder(T->lchild); // 遍历左子树
PreOrder(T->rchild); // 遍历右子树
}
}

中序遍历

1
2
3
4
5
6
7
void InOrder(BiTree T) {
if (T) {
InOrder(T->lchild); // 遍历左子树
visit(T); // 访问根
InOrder(T->rchild); // 遍历右子树
}
}

后序遍历

1
2
3
4
5
6
7
void PostOrder(BiTree T) {
if (T) {
PostOrder(T->lchild); // 遍历左子树
PostOrder(T->rchild); // 遍历右子树
visit(T); // 访问根
}
}

层序遍历

1
2
3
4
5
6
7
8
9
10
11
void LevelOrder(BiTree T) {
Queue Q;
InitQueue(&Q);
EnQueue(&Q, T);
while (!IsEmpty(Q)) {
DeQueue(&Q, &p);
visit(p);
if (p->lchild) EnQueue(&Q, p->lchild);
if (p->rchild) EnQueue(&Q, p->rchild);
}
}

中序遍历的非递归实现(重要!)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void InOrder2(BiTree T) {
SqStack S; InitStack(&S);
BiTree p = T;
while (p || !StackEmpty(S)) {
if (p) {
Push(&S, p); // 入栈
p = p->lchild; // 一路向左
} else {
Pop(&S, &p); // 出栈
visit(p); // 访问
p = p->rchild; // 转向右子树
}
}
}

理解

  1. 沿左子树一路入栈,直到最左
  2. 出栈访问(此时左子树已访问完,该访问根了)
  3. 转向右子树,重复

先序非递归:只需把 visit(p) 移到 Push 之后。

后序非递归:需要记录上一次访问的结点,判断是从左子树返回还是右子树返回。

由遍历序列构造二叉树

已知序列 能否唯一确定
先序 + 中序 ✅ 能
后序 + 中序 ✅ 能
先序 + 后序 ❌ 不能
层序 + 中序 ✅ 能

构造方法

  1. 先序/后序确定
  2. 在中序中找到根的位置,划分左子树右子树
  3. 递归处理左右子树

示例

  • 先序:ABDECFG
  • 中序:DBEAFCG

步骤:

  1. 先序第一个 A 是根
  2. 中序中 A 左边 DBE 是左子树,右边 FCG 是右子树
  3. 左子树先序 BDE,中序 DBE → 递归
  4. 右子树先序 CFG,中序 FCG → 递归

5.5 线索二叉树

目的

$n$ 个结点的二叉链表有 $n+1$ 个空指针域,利用这些空指针存放前驱和后继信息,方便遍历。

结构定义

1
2
3
4
5
typedef struct ThreadNode {
int data;
struct ThreadNode *lchild, *rchild;
int ltag, rtag; // 0: 指向孩子 1: 指向前驱/后继(线索)
} ThreadNode, *ThreadTree;

中序线索化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ThreadNode *pre = NULL;  // 全局变量,指向前驱

void InThread(ThreadTree T) {
if (T) {
InThread(T->lchild); // 线索化左子树
// 处理当前结点
if (!T->lchild) { // 左子树为空
T->ltag = 1; // 标记为线索
T->lchild = pre; // 指向前驱
}
if (pre && !pre->rchild) { // 前驱的右子树为空
pre->rtag = 1; // 标记为线索
pre->rchild = T; // 前驱指向当前(后继)
}
pre = T; // 更新前驱
InThread(T->rchild); // 线索化右子树
}
}

遍历中序线索二叉树

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 找中序下的第一个结点(最左下)
ThreadNode *FirstNode(ThreadNode *p) {
while (p->ltag == 0) p = p->lchild;
return p;
}

// 找中序下的后继
ThreadNode *NextNode(ThreadNode *p) {
if (p->rtag == 1) return p->rchild; // 有线索直接用
return FirstNode(p->rchild); // 否则找右子树的最左
}

// 遍历
void InOrderThread(ThreadTree T) {
for (ThreadNode *p = FirstNode(T); p; p = NextNode(p))
visit(p);
}

优势:不需要栈,不需要递归,空间 $O(1)$。

5.6 树和森林

树的存储结构

1. 双亲表示法

1
2
3
4
5
6
7
8
9
typedef struct {
int data;
int parent; // 父结点下标,-1 表示无父结点
} PTNode;

typedef struct {
PTNode nodes[MaxSize];
int n; // 结点数
} PTree;
  • 求父结点:$O(1)$ ✅
  • 求子结点:需遍历整个数组 $O(n)$ ❌

2. 孩子表示法:每个结点一个链表存所有孩子。

  • 求子结点:$O(1)$(直接遍历孩子链表)✅
  • 求父结点:需遍历 $O(n)$ ❌

3. 孩子兄弟表示法(二叉链表表示法,最重要!)

1
2
3
4
5
typedef struct CSNode {
int data;
struct CSNode *firstchild; // 第一个孩子
struct CSNode *nextsibling; // 下一个兄弟
} CSNode, *CSTree;

核心思想:左孩子右兄弟。每个结点只存两个指针:第一个孩子和下一个兄弟。

树 ↔ 二叉树 转换

树 → 二叉树(孩子兄弟法):

  1. 连线:同一父结点的孩子之间从左到右连线
  2. 删线:只保留每个结点与第一个孩子的连线
  3. 旋转:以根为轴心,顺时针旋转 45°

二叉树 → 树:逆过程。

关键:树的先根遍历 = 二叉树的先序遍历;树的后根遍历 = 二叉树的中序遍历。

森林 ↔ 二叉树 转换

森林 → 二叉树

  1. 每棵树转换为二叉树
  2. 第一棵树的根作为二叉树的根
  3. 后面每棵树的根作为前一棵树根的右孩子

二叉树 → 森林:逆过程。

遍历对应关系

森林 二叉树
先根遍历 先序遍历 先序遍历
后根遍历 中序遍历 中序遍历

注意:树没有”中根遍历”的概念(因为不止两棵子树),但有先根和后根。

5.7 哈夫曼树与哈夫曼编码

基本概念

术语 定义
路径长度 从一个结点到另一个结点的边数
树的路径长度 从根到所有结点的路径长度之和
带权路径长度(WPL) $\sum_{i=1}^{n} w_i \times l_i$($w_i$ 为叶子权值,$l_i$ 为叶子到根的路径长度)
哈夫曼树 WPL 最小的二叉树(最优二叉树)

哈夫曼树构造算法

步骤

  1. 将 $n$ 个权值作为 $n$ 棵只有根结点的树,构成森林 $F$
  2. 在 $F$ 中选取两棵权值最小的树作为新结点的左右子树,新根权值 = 左 + 右
  3. 从 $F$ 中删除这两棵树,将新树加入 $F$
  4. 重复 2-3 直到 $F$ 中只剩一棵树

示例:权值集合 ${7, 5, 2, 4}$

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
初始:  (7) (5) (2) (4)
第1步:选 2 和 4 合并 → (6)
(7) (5) (6)
第2步:选 5 和 6 合并 → (11)
(7) (11)
第3步:选 7 和 11 合并 → (18)

哈夫曼树:
18
/ \
7 11
/ \
5 6
/ \
2 4

WPL = 7×1 + 5×2 + 2×3 + 4×3 = 7 + 10 + 6 + 12 = 35

哈夫曼树的特点

  1. 没有度为 1 的结点(因为每次合并产生一个新结点,度为 2)
  2. $n$ 个叶子结点的哈夫曼树共有 $2n - 1$ 个结点
  3. 每次选最小的两个合并 → 贪心算法
  4. 权值越大(越常用)的叶子离根越近 → 编码越短

哈夫曼编码

前缀编码:任何字符的编码都不是另一个字符编码的前缀。

构造

  1. 以字符出现频率(权值)构建哈夫曼树
  2. 左分支编码 0,右分支编码 1
  3. 从根到叶子的路径即为该字符的编码

示例

字符 频率 哈夫曼编码
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
2
3
4
5
6
#define MaxVertexNum 100
typedef struct {
char vex[MaxVertexNum]; // 顶点表
int edge[MaxVertexNum][MaxVertexNum]; // 邻接矩阵
int vexnum, arcnum; // 顶点数、边数
} MGraph;

无权图

  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct ArcNode {
int adjvex; // 邻接点下标
int weight; // 边的权值(网图用)
struct ArcNode *next; // 指向下一条边
} ArcNode;

typedef struct {
char data; // 顶点数据
ArcNode *first; // 第一条边
} VNode, AdjList[MaxVertexNum];

typedef struct {
AdjList vertices;
int vexnum, arcnum;
} ALGraph;

特点

  • 空间复杂度:$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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
bool visited[MaxVertexNum];

void BFS(Graph G, int v) {
visit(v);
visited[v] = true;
EnQueue(Q, v);
while (!IsEmpty(Q)) {
DeQueue(Q, &v);
for (int w = FirstNeighbor(G, v); w >= 0; w = NextNeighbor(G, v, w)) {
if (!visited[w]) {
visit(w);
visited[w] = true;
EnQueue(Q, w);
}
}
}
}

// 处理非连通图
void BFSTraverse(Graph G) {
for (int i = 0; i < G.vexnum; i++) visited[i] = false;
for (int i = 0; i < G.vexnum; i++)
if (!visited[i]) BFS(G, i); // 对每个连通分量调用 BFS
}

时间复杂度

  • 邻接矩阵:$O(n^2)$
  • 邻接表:$O(n + e)$

空间复杂度:$O(n)$(辅助队列)

BFS 的应用

  • 无权图的单源最短路径
  • 求连通分量
  • 判断图是否连通

深度优先搜索(DFS)

类似先序遍历,用递归(隐式栈)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void DFS(Graph G, int v) {
visit(v);
visited[v] = true;
for (int w = FirstNeighbor(G, v); w >= 0; w = NextNeighbor(G, v, w)) {
if (!visited[w])
DFS(G, w);
}
}

void DFSTraverse(Graph G) {
for (int i = 0; i < G.vexnum; i++) visited[i] = false;
for (int i = 0; i < G.vexnum; i++)
if (!visited[i]) DFS(G, i);
}

时间复杂度

  • 邻接矩阵:$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)

连通无向带权图中,总权值最小的生成树。

性质(重要!):

  1. 最小生成树不唯一(权值有相同时)
  2. 最小生成树的边数 = $n - 1$
  3. 切割性质:将顶点集分成两个集合,连接两个集合的最小权边一定在 MST 中

Prim 算法(加点法)

1
2
3
1. 从任意顶点开始,加入已选集合 U
2. 重复:选连接 U 和 V-U 的最小权边,将对应顶点加入 U
3. 直到 U = V
  • 时间:$O(n^2)$
  • 适合稠密图
  • 用邻接矩阵实现

Kruskal 算法(加边法)

1
2
3
4
5
1. 将所有边按权值从小到大排序
2. 依次选最小的边:
a. 如果该边连接的两个顶点不在同一连通分量 → 加入 MST
b. 否则跳过(避免回路)
3. 直到选了 n-1 条边
  • 时间:$O(e\log e)$
  • 适合稀疏图
  • 判断两点是否在同一连通分量:并查集($O(\alpha(n))$)

Prim vs Kruskal 对比

对比 Prim Kruskal
策略 加点 加边
时间 $O(n^2)$ $O(e\log e)$
适合 稠密图 稀疏图
数据结构 数组 并查集

最短路径

Dijkstra 算法(单源最短路,无负权边)

1
2
3
4
5
初始化:dist[i] = ∞, dist[src] = 0
重复 n 次:
1. 选 dist 最小的未确定顶点 u
2. 标记 u 为已确定
3. 用 u 更新所有邻居的 dist:dist[v] = min(dist[v], dist[u] + w(u,v))
  • 时间:$O(n^2)$(朴素),$O((n+e)\log n)$(优先队列优化)
  • 不适用于负权边!(贪心策略被破坏)
  • 不能处理负权回路!

Floyd 算法(多源最短路)

1
2
3
初始化:D = 邻接矩阵
三重循环 k, i, j:
D[i][j] = min(D[i][j], D[i][k] + D[k][j])
1
2
3
4
5
6
7
void Floyd(MGraph *G) {
for (int k = 0; k < G->vexnum; k++) // 中间点
for (int i = 0; i < G->vexnum; i++) // 起点
for (int j = 0; j < G->vexnum; j++) // 终点
if (G->edge[i][k] + G->edge[k][j] < G->edge[i][j])
G->edge[i][j] = G->edge[i][k] + G->edge[k][j];
}
  • 时间:$O(n^3)$
  • 可处理负权边(但不能有负权回路)
  • 可求任意两点间最短路径
  • 不能用于负权回路(最短路径无意义)

Dijkstra vs Floyd 对比

对比 Dijkstra Floyd
功能 单源最短路 多源最短路
时间 $O(n^2)$ $O(n^3)$
负权边 ❌ 不支持 ✅ 支持
负权回路

拓扑排序(AOV网)

AOV网:用顶点表示活动、边表示先后关系的有向图。

拓扑序列:满足所有先后关系的顶点线性序列。

算法

  1. 选一个入度为 0 的顶点输出
  2. 删除该顶点及其所有出边(入度 -1)
  3. 重复 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
2
3
4
5
int SeqSearch(int A[], int n, int key) {
for (int i = 0; i < n; i++)
if (A[i] == key) return i;
return -1;
}

平均查找长度(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
2
3
4
5
6
int SeqSearch_Sentinel(int A[], int n, int key) {
A[0] = key; // 哨兵
int i = n;
while (A[i] != key) i--;
return i; // 返回 0 表示失败
}

7.2 折半查找(二分查找)

前提:有序顺序表(不能是链表!因为需要随机访问)

1
2
3
4
5
6
7
8
9
10
int BinarySearch(int A[], int n, int key) {
int low = 0, high = n - 1, mid;
while (low <= high) {
mid = (low + high) / 2;
if (A[mid] == key) return mid;
else if (A[mid] > key) high = mid - 1;
else low = mid + 1;
}
return -1;
}

判定树

  • $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$ 块最小值)

步骤

  1. 索引表中折半/顺序查找确定所在块
  2. 在块内顺序查找

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
2
3
4
5
6
7
BSTNode *BST_Search(BiTree T, int key) {
while (T && T->data != key) {
if (key < T->data) T = T->lchild;
else T = T->rchild;
}
return T;
}

递归版本

1
2
3
4
5
BSTNode *BST_Search_Recursive(BiTree T, int key) {
if (!T || T->data == key) return T;
if (key < T->data) return BST_Search_Recursive(T->lchild, key);
else return BST_Search_Recursive(T->rchild, key);
}

插入

1
2
3
4
5
6
7
8
9
10
11
bool BST_Insert(BiTree *T, int key) {
if (!(*T)) {
*T = (BiTree)malloc(sizeof(BiTNode));
(*T)->data = key;
(*T)->lchild = (*T)->rchild = NULL;
return true;
}
if (key == (*T)->data) return false; // 已存在
if (key < (*T)->data) return BST_Insert(&(*T)->lchild, key);
else return BST_Insert(&(*T)->rchild, 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
2
3
4
5
    A              B
/ \ / \
B C → D A
/ \ / \
D E E C

2. RR 型(左旋):在右孩子的右子树插入

1
2
3
4
5
  A                  B
/ \ / \
C B → A D
/ \ / \
E D C E

3. LR 型(先左旋再右旋):在左孩子的右子树插入

1
2
3
4
5
    A              C
/ \ / \
B D → B A
/ \ / / \
C E F E D

4. RL 型(先右旋再左旋):在右孩子的左子树插入

1
2
3
4
5
  A                  C
/ \ / \
D B → A B
/ \ / \ \
C E D F E

判断方法

  • 插入位置相对于最近失衡结点的关系
  • 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阶)

定义

  1. 每个结点最多 $m$ 棵子树,$m-1$ 个关键字
  2. 每个结点(除根)至少 $\lceil m/2 \rceil$ 棵子树
  3. 根结点至少 2 棵子树(除非只有根)
  4. 所有叶子结点在同一层(查找失败结点)
  5. 关键字递增排列:$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$$

查找

  1. 在结点内顺序/折半查找关键字
  2. 若找到则返回
  3. 若没找到,沿指针到子树继续查找
  4. 时间 $O(\log n)$

插入

  1. 找到叶子结点位置插入
  2. 若关键字数超过 $m-1$,取中间关键字上提到父结点,左右分裂
  3. 若父结点也溢出,继续分裂(可能直到根,树增高)

删除

  • 非叶子结点:用中序前驱/后继替代后,在叶子中删除
  • 叶子结点
    • 关键字够多:直接删
    • 关键字太少:向兄弟借(旋转)或与兄弟合并
    • 合并后父结点关键字减少,可能继续调整

B+树

定义(m阶):

  1. 非叶子结点只存索引(关键字),不存数据
  2. 叶子结点存所有关键字和数据指针,用链表相连
  3. 非叶子结点的每个关键字是其子树中最大(或最小)关键字的副本
  4. 叶子包含全部关键字信息

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} → 第一趟 35a 交换 → {3, 5b, 5a}5a5b 相对位置变了
  • 快速排序:{3, 3, 3} → Partition 可能交换相等元素
  • 堆排序:建堆过程可能交换相等元素

8.2 插入排序

直接插入排序

思想:将待排序元素插入到已排序序列的正确位置。

1
2
3
4
5
6
7
8
9
10
11
void InsertSort(int A[], int n) {
int i, j, temp;
for (i = 1; i < n; i++) { // 从第二个元素开始
if (A[i] < A[i-1]) {
temp = A[i];
for (j = i - 1; j >= 0 && A[j] > temp; j--)
A[j+1] = A[j]; // 后移
A[j+1] = temp; // 插入
}
}
}

复杂度

情况 比较次数 移动次数 时间
最好(已有序) $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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void BinaryInsertSort(int A[], int n) {
int i, j, temp, low, high, mid;
for (i = 1; i < n; i++) {
temp = A[i];
low = 0; high = i - 1;
// 折半查找插入位置
while (low <= high) {
mid = (low + high) / 2;
if (A[mid] > temp) high = mid - 1;
else low = mid + 1;
}
// 移动元素
for (j = i - 1; j >= high + 1; j--)
A[j+1] = A[j];
A[high+1] = temp;
}
}
  • 比较次数减少为 $O(n\log n)$
  • 移动次数不变,仍为 $O(n^2)$
  • 总时间仍为 $O(n^2)$
  • 稳定 ✅

希尔排序

思想:按增量序列分组,组内直接插入排序,逐步缩小增量至 1。

1
2
3
4
5
6
7
8
9
10
11
12
13
void ShellSort(int A[], int n) {
int dk, i, j, temp;
for (dk = n/2; dk >= 1; dk /= 2) { // 增量序列
for (i = dk; i < n; i++) { // 从第 dk 个开始
if (A[i] < A[i - dk]) {
temp = A[i];
for (j = i - dk; j >= 0 && A[j] > temp; j -= dk)
A[j + dk] = A[j];
A[j + dk] = temp;
}
}
}
}

特点

  • 时间:$O(n^{1.3})$(与增量序列有关)
  • 空间:$O(1)$
  • 不稳定 ❌(分组排序可能改变相等元素相对位置)
  • 最后一趟增量为 1,等价于直接插入排序

8.3 交换排序

冒泡排序

思想:相邻元素比较交换,每趟将最大(或最小)元素”冒泡”到末尾。

1
2
3
4
5
6
7
8
9
10
11
12
void BubbleSort(int A[], int n) {
for (int i = 0; i < n - 1; i++) {
bool flag = false; // 本趟是否有交换
for (int j = n - 1; j > i; j--) { // 从后往前
if (A[j-1] > A[j]) {
swap(A[j-1], A[j]);
flag = true;
}
}
if (!flag) break; // 无交换说明已有序
}
}

复杂度

情况 比较次数 交换次数 时间
最好(已有序) $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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int Partition(int A[], int low, int high) {
int pivot = A[low]; // 选第一个元素为基准
while (low < high) {
while (low < high && A[high] >= pivot) high--;
A[low] = A[high]; // 比 pivot 小的移到左边
while (low < high && A[low] <= pivot) low++;
A[high] = A[low]; // 比 pivot 大的移到右边
}
A[low] = pivot; // pivot 放到最终位置
return low;
}

void QuickSort(int A[], int low, int high) {
if (low < high) {
int pivotpos = Partition(A, low, high);
QuickSort(A, low, pivotpos - 1); // 排左半部分
QuickSort(A, pivotpos + 1, high); // 排右半部分
}
}

复杂度

情况 时间 空间
最好(每次均匀划分) $O(n\log n)$ $O(\log n)$
最坏(已有序/逆序) $O(n^2)$ $O(n)$
平均 $O(n\log n)$ $O(\log n)$
  • 不稳定 ❌
  • 内部排序中平均性能最好
  • 每趟 Partition 确定一个元素的最终位置

优化策略

  1. 随机选基准:避免最坏情况
  2. 三数取中:取首、中、尾的中位数作为基准
  3. 小规模切换:$n$ 小时切换为直接插入排序

快排的递归深度:最坏 $O(n)$(退化为链式递归),最好 $O(\log n)$(平衡递归)。

8.4 选择排序

简单选择排序

思想:每趟从未排序部分选最小的,放到已排序部分末尾。

1
2
3
4
5
6
7
8
void SelectSort(int A[], int n) {
for (int i = 0; i < n - 1; i++) {
int min = i;
for (int j = i + 1; j < n; j++)
if (A[j] < A[min]) min = j;
if (min != i) swap(A[i], A[min]);
}
}

复杂度

  • 比较次数:固定 $n(n-1)/2$(与初始状态无关)
  • 移动次数:最好 0,最坏 $3(n-1)$
  • 时间:$O(n^2)$
  • 不稳定 ❌

为什么不稳定:例如 {5a, 5b, 3},第一趟选最小 35a 交换 → {3, 5b, 5a}

堆排序

堆的定义

  • 大根堆:每个结点 ≥ 其孩子,堆顶最大
  • 小根堆:每个结点 ≤ 其孩子,堆顶最小

存储:完全二叉树 + 数组,结点 $i$ 的左孩子 $2i$,右孩子 $2i+1$。

向下调整(大根堆)

1
2
3
4
5
6
7
8
9
10
void HeadAdjust(int A[], int k, int len) {
A[0] = A[k]; // 暂存根
for (int i = 2*k; i <= len; i *= 2) { // 沿较大的孩子向下
if (i < len && A[i] < A[i+1]) // 右孩子更大
i++;
if (A[0] >= A[i]) break; // 根已最大
A[k] = A[i]; k = i; // 孩子上移
}
A[k] = A[0];
}

建堆:从最后一个非叶子结点 $\lfloor n/2 \rfloor$ 开始,依次向下调整。

1
2
3
4
void BuildMaxHeap(int A[], int len) {
for (int i = len/2; i >= 1; i--)
HeadAdjust(A, i, len);
}

建堆时间:$O(n)$(不是 $O(n\log n)$!)

堆排序

1
2
3
4
5
6
7
void HeapSort(int A[], int len) {
BuildMaxHeap(A, len); // 建堆
for (int i = len; i > 1; i--) {
swap(A[1], A[i]); // 堆顶(最大)移到末尾
HeadAdjust(A, 1, i-1); // 对前 i-1 个元素重新调整
}
}

复杂度

  • 建堆:$O(n)$
  • 排序:$O(n\log n)$
  • 总时间:$O(n\log n)$(最好最坏平均都是)
  • 空间:$O(1)$
  • 不稳定 ❌

堆的插入:新元素放在末尾,向上调整(与父比较交换)。

堆的删除:用末尾元素替代堆顶,向下调整。

向上调整(插入用):

1
2
3
4
5
6
7
8
9
void HeapInsert(int A[], int k) {
int i = k;
A[0] = A[i];
while (i > 1 && A[i/2] < A[0]) {
A[i] = A[i/2];
i = i / 2;
}
A[i] = A[0];
}

8.5 归并排序

思想:将两个有序表合并为一个有序表。

二路归并

1
2
3
4
5
6
7
8
9
10
11
12
int *B;  // 辅助数组

void Merge(int A[], int low, int mid, int high) {
for (int k = low; k <= high; k++) B[k] = A[k]; // 复制到辅助数组
int i = low, j = mid + 1, k = low;
while (i <= mid && j <= high) {
if (B[i] <= B[j]) A[k++] = B[i++]; // 取较小的
else A[k++] = B[j++];
}
while (i <= mid) A[k++] = B[i++]; // 左边剩余
while (j <= high) A[k++] = B[j++]; // 右边剩余
}

归并排序

1
2
3
4
5
6
7
8
void MergeSort(int A[], int low, int high) {
if (low < high) {
int mid = (low + high) / 2;
MergeSort(A, low, mid); // 排左半
MergeSort(A, mid + 1, high); // 排右半
Merge(A, low, mid, high); // 合并
}
}

复杂度

  • 时间:$O(n\log n)$(最好最坏平均都是)
  • 空间:$O(n)$(辅助数组)
  • 稳定 ✅

归并排序的特点

  • 唯一最好最坏都是 $O(n\log n)$ 且稳定的排序算法
  • 空间 $O(n)$ 是主要缺点
  • 外部排序的基础

$k$ 路归并:每趟从 $k$ 个有序段中选最小元素,需要 $\lceil \log_k n \rceil$ 趟。

8.6 基数排序

思想:按关键字各位分配收集,从最低位到最高位(LSD)。

过程(以三位数为例):

  1. 按个位分配到 10 个桶,依次收集
  2. 按十位分配到 10 个桶,依次收集
  3. 按百位分配到 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)$ 关键字位数少

重要结论

  1. 基于比较的排序,时间下界为 $O(n\log n)$(决策树证明)
  2. 快速排序是内部排序中平均性能最好
  3. 归并排序是唯一最好最坏都是 $O(n\log n)$ 且稳定的
  4. 堆排序最好最坏都是 $O(n\log n)$ 且空间 $O(1)$
  5. $n$ 较小($n \leq 50$):直接插入或简单选择
  6. $n$ 较大:快排/堆排/归并
  7. 关键字基本有序:直接插入或冒泡
  8. 关键字范围不大:基数排序

排序过程手算技巧

快速排序:每趟 Partition 确定一个元素的最终位置,手算时跟踪 pivot 的位置变化。

堆排序:手算时画出完全二叉树,从最后一个非叶子开始调整。每趟取出堆顶后,将末尾元素放到堆顶,重新调整。

归并排序:手算时画出递归分解过程,然后从底向上合并。

冒泡排序:每趟从一端开始,相邻比较交换,一趟确定一个最值的最终位置。