Skip to content

CLRS《算法导论》核心知识补充 ​

#数据结构 · #算法 · #CLRS · #主定理 · #摊还分析 · #NP完全 · #进阶

基于 Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, Clifford Stein 合著的 Introduction to Algorithms(第 4 版)。本文补充 Hello 算法基础 未覆盖的深层内容:主定理、摊还分析、下界证明、斐波那契堆、van Emde Boas 树、字符串匹配、NP 完全性。


1. 算法分析进阶 ​

1.1 主定理(Master Theorem)— 分治递归的通用解法 ​

对于形如 T(n)=a⋅T(n/b)+f(n) 的递归式(a≥1,b>1):

情况 1:若 f(n)=O(nlogb⁡a−ϵ)(ϵ>0),则 T(n)=Θ(nlogb⁡a)

情况 2:若 f(n)=Θ(nlogb⁡alogk⁡n),则 T(n)=Θ(nlogb⁡alogk+1⁡n)

情况 3:若 f(n)=Ω(nlogb⁡a+ϵ) 且 af(n/b)≤cf(n)(c<1),则 T(n)=Θ(f(n))

实战练习 ​

算法递归式abf(n)情况结果
归并排序T(n)=2T(n/2)+O(n)22O(n)2 (k=0)Θ(nlog⁡n)
二分查找T(n)=T(n/2)+O(1)12O(1)2 (k=0)Θ(log⁡n)
Strassen 矩阵乘T(n)=7T(n/2)+O(n2)72O(n2)1Θ(nlog2⁡7)≈Θ(n2.81)
遍历二叉树T(n)=2T(n/2)+O(1)22O(1)1Θ(n)

思路:比较 f(n) 和 nlogb⁡a 的增长速度——谁大谁主导。一样大则乘以 log⁡n。

mermaid
flowchart TD
    Recur["T(n) = a·T(n/b) + f(n)"] --> Compare{"比较 f(n) 与 n^log_b_a"}
    Compare -->|"f(n) 更小<br/>(差 ε 倍)"| Case1["情况 1: T(n)=Θ(n^log_b_a)<br/>例: 二叉树遍历 O(n)"]
    Compare -->|"同等增长<br/>(可能差 log^k n)"| Case2["情况 2: T(n)=Θ(n^log_b_a·log^{k+1}n)<br/>例: 归并排序 O(n log n)"]
    Compare -->|"f(n) 更大<br/>(差 ε 倍, 且 af(n/b)≤cf(n))"| Case3["情况 3: T(n)=Θ(f(n))<br/>例: 快速选择分区的部分递归"]
    Compare -->|"无法比较"| Fail["主定理不适用<br/>尝试递归树或代入法"]

    style Case1 fill:#4CAF50,color:#fff
    style Case2 fill:#2196F3,color:#fff
    style Case3 fill:#FF9800,color:#fff
    style Fail fill:#F44336,color:#fff
    style Recur fill:#607D8B,color:#fff

1.2 摊还分析(Amortized Analysis) ​

摊还分析回答:"n 次操作的总时间是多少?"——而不是单次最坏时间。

方法一:聚合分析(Aggregate Method) ​

直接算 n 次操作的总代价,除以 n。

实例:动态数组扩容

插入 n 个元素的扩容次数:
  第 1 次扩容:拷贝 1 个元素
  第 2 次扩容:拷贝 2 个元素
  第 4 次扩容:拷贝 4 个元素
  ...
  第 2^k 次扩容:拷贝 2^k 个元素

总拷贝次数 ≤ n + n/2 + n/4 + ... ≤ 2n
n 次插入总代价 ≤ n*O(1) + 2n = O(n)
→ 每次插入摊还 O(1)

方法二:记账法(Accounting Method) ​

每次操作"预存"一些代价给未来。插入时多收一点,存到"账户"里,扩容时从账户扣。

每次插入收费 3 元:1 元插入,2 元存入账户
扩容时:拷贝每个元素的 1 元从账户中出
→ 账户永不透支 → 摊还 O(1)

方法三:势能法(Potential Method) ​

定义势能函数 Φ(Di) 表示数据结构 Di 的"势能"。摊还代价 = 实际代价 + ΔΦ。

动态数组的势能函数:Φ=2⋅size−capacity(插入后势能升,扩容后势能降)。


1.3 基于比较的排序下界 ​

定理:任何基于比较的排序算法,最坏情况下至少需要 Ω(nlog⁡n) 次比较。

证明思路(决策树模型):

  • n 个元素有 n! 种排列 → 决策树至少 n! 个叶子
  • 高度为 h 的二叉树最多 2h 个叶子
  • 2h≥n! → h≥log⁡(n!)≈nlog⁡n−nlog⁡e=Ω(nlog⁡n)

推论:堆排序和归并排序在最坏意义上已经是最优的比较排序。


1.4 线性时间排序的下界突破 ​

算法时间条件
计数排序O(n+k)整数,范围 k 不大
基数排序O(d(n+k))d 位数字,每位范围 k
桶排序O(n) 期望均匀分布

这些算法不通过比较,因此突破了 Ω(nlog⁡n) 下界。

基数排序的精妙之处 ​

基数排序有两种顺序:

  • LSD (Least Significant Digit):从低位到高位,必须用稳定排序
  • MSD (Most Significant Digit):从高位到低位,递归分桶
对 32 位整数排序:
  用 8 位为基数 → 4 轮 → 每轮 O(n+256)
  总计 O(4n) = O(n)

2. 高级数据结构 ​

2.1 斐波那契堆(Fibonacci Heap) ​

二叉堆的插入是 O(log⁡n)。斐波那契堆的插入只需 O(1) 摊还。

核心操作对比 ​

操作二叉堆斐波那契堆说明
insertO(log⁡n)O(1) 摊还直接挂到根链表
extract-minO(log⁡n)O(log⁡n) 摊还删除最小,consolidate
decrease-keyO(log⁡n)O(1) 摊还剪切 + 级联剪切
deleteO(log⁡n)O(log⁡n) 摊还decrease-key + extract-min
mergeO(n)O(1)连接两个根链表

为什么摊还 O(1) 插入? ​

插入时不做任何结构调整——直接把新节点挂到根链表中。只有在 extract-min 时才做 consolidate(合并同度树)。

插入 1 → 根链表: [1]
插入 2 → 根链表: [1] [2]
...
插入 n → 根链表: [1] [2] ...[n]
extract-min → 遍历根链表找最小,然后两两合并同度树 → O(max_degree + n)

关键应用:Dijkstra 算法 + 斐波那契堆 → O(E+Vlog⁡V)(vs 二叉堆的 O((V+E)log⁡V))。

但实际工程中几乎不用斐波那契堆——常数因子太大,且现代 CPU 对二叉堆的缓存友好度更好。


2.2 van Emde Boas 树(vEB Tree) ​

当 key 是小范围整数(0 到 u-1)时,vEB Tree 的增删查改都是 O(log log u)——比二叉搜索树的 O(log n) 更快。

原理 ​

将全域 {0, 1, ..., u-1} 分为 √u 个簇,每个簇 √u 个元素。
递归地在簇内建立 vEB Tree。
结构时间复杂度空间适用
二叉搜索树O(log⁡n)O(n)通用
vEB TreeO(log⁡log⁡u)O(u)key 范围小
哈希表O(1) 期望O(n)不需要有序

应用:IP 路由表(32 位整数 key)、网络交换机的 TCAM 替代、DNS 缓存。

但在实际系统中更多用**压缩前缀树(Patricia Trie)**来替代 vEB Tree——空间更小。


2.3 并查集(Disjoint-Set / Union-Find) ​

并查集维护一个不相交集合的划分。经典实现同时使用:

  1. 按秩合并:矮树挂在高树下
  2. 路径压缩:查找时把路径上所有节点直接挂到根
go
type UnionFind struct {
    parent []int
    rank   []int
}

func (uf *UnionFind) Find(x int) int {
    if uf.parent[x] != x {
        uf.parent[x] = uf.Find(uf.parent[x]) // 路径压缩
    }
    return uf.parent[x]
}

func (uf *UnionFind) Union(x, y int) {
    rx, ry := uf.Find(x), uf.Find(y)
    if rx == ry { return }
    if uf.rank[rx] < uf.rank[ry] {
        rx, ry = ry, rx
    }
    uf.parent[ry] = rx
    if uf.rank[rx] == uf.rank[ry] {
        uf.rank[rx]++
    }
}

摊还复杂度:O(α(n)),其中 α(n) 是阿克曼函数的反函数——对任何实际 n≤宇宙原子数,α(n)≤4。近乎常数时间。

应用:

  • Kruskal 最小生成树(判断加入边是否形成环)
  • 图的连通分量
  • 图像处理(连通区域标记)
  • 等价类划分(编译器中的类型等价)

带权并查集 ​

节点不仅记录父节点,还记录到父节点的"权值"(如距离、差值):

go
// 食物链问题 / 变量等式和不等式
// parent[x] 和 weight[x](x 到 parent[x] 的关系)
func (uf *UnionFind) Find(x int) int {
    if uf.parent[x] != x {
        root := uf.Find(uf.parent[x])
        uf.weight[x] += uf.weight[uf.parent[x]] // 累加权值
        uf.parent[x] = root
    }
    return uf.parent[x]
}

3. 选择算法(Selection)进阶 ​

3.1 BFPRT 算法 — 最坏 O(n) 的选择 ​

快速选择的平均时间是 O(n),但最坏 O(n²)。BFPRT(Blum-Floyd-Pratt-Rivest-Tarjan, 1973)保证最坏 O(n):

1. 将 n 个元素分成 ⌈n/5⌉ 组,每组 5 个
2. 每组内部排序,取中位数 → ⌈n/5⌉ 个中位数
3. 递归求这些中位数的中位数 → pivot
4. 以 pivot 分区 → 类似快速选择递归一侧

T(n) ≤ T(n/5) + T(7n/10) + O(n) → T(n) = O(n)

工程现实:BFPRT 常数因子很大(约 50×),实践中快速选择 + 随机 pivot 几乎不会碰到最坏情况。


3.2 顺序统计量(Order Statistics) ​

问题算法复杂度
最小值/最大值遍历O(n)
同时找最小和最大成对比较⌈3n/2⌉−2 次比较
第 k 小快速选择O(n) 期望
第 k 小(最坏保证)BFPRTO(n)
中位数快速选择 / BFPRTO(n)
Top K堆O(nlog⁡k)
Top K(全排序)排序O(nlog⁡n)

4. 动态规划进阶 ​

4.1 最优子结构与重叠子问题的严格区分 ​

特性含义检验方法
最优子结构问题的最优解包含子问题的最优解反证法:如果不是,可以替换得到更好的解
重叠子问题递归树中反复出现相同子问题画出递归树,看是否有重复节点

不具备最优子结构的例子:最长简单路径(不含重复顶点)——子路径的最长简单路径组合起来可能不是简单路径(有环)。

4.2 两种实现方式 ​

自顶向下(记忆化)自底向上(填表)
实现递归 + memo迭代填表
栈溢出可能(递归深度)不会
计算顺序按需计算必须全部计算
适用子问题稀疏子问题密集

4.3 经典问题深入 ​

最长公共子序列(LCS) ​

dp[i][j] =
  dp[i-1][j-1] + 1              (if X[i] == Y[j])
  max(dp[i-1][j], dp[i][j-1])   (if X[i] != Y[j])

应用:diff 工具(git diff)、DNA 序列比对、文件比较

最优二叉搜索树(Optimal BST) ​

给定 key 的查找概率分布,构建使平均查找代价最小的 BST。O(n3) DP。

dp[i][j] = 以 i..j 为 key 的最优 BST 期望代价
dp[i][j] = min_{k=i}^{j} (dp[i][k-1] + dp[k+1][j]) + sum_{p=i}^{j} prob[p]

Knuth 优化 → O(n²)

5. 字符串匹配 ​

5.1 算法总览 ​

算法预处理匹配特点
朴素0O(nm)简单,短模式足够快
KMPO(m)O(n)标准,不回溯文本
Boyer-MooreO(m)O(n/m) 最好从右向左匹配,实际最快
Rabin-KarpO(m)O(n+m) 期望滚动哈希,多模式
基于自动机O(m|Σ|)O(n)理论优美,空间大

5.2 KMP(Knuth-Morris-Pratt) ​

核心是前缀函数 π:模式串的每个前缀,其最长真前缀且是真后缀的长度。

pattern = "ababc"
π[0]=0  (a)
π[1]=0  (ab)
π[2]=1  (aba: a 是前后缀)
π[3]=2  (abab: ab 是前后缀)
π[4]=0  (ababc)

匹配时文本指针不回退,只移动模式指针到 π 值。
mermaid
sequenceDiagram
    participant T as 文本: "abababc"
    participant P as 模式: "ababc"
    participant Pi as 前缀函数 π

    Note over T,Pi: KMP 匹配过程

    rect rgb(240, 248, 255)
        Note over T,Pi: Step 1: 匹配到位置4时 "ababa" vs "ababc" 失配
        T->>P: "ababa" vs "ababc" (位置4: 'a'≠'c')
        P->>Pi: 查 π[3]=2 → 模式回退到位置2
    end

    rect rgb(255, 248, 240)
        Note over T,Pi: Step 2: 从"ab"继续匹配
        Pi->>P: π[3]=2, 已知前2字符"ab"已匹配, 从位置2继续
        P->>T: 文本指针不动, 继续比较 → 匹配成功
    end

    Note over T,Pi: 关键: 文本指针永不回退!

5.3 Rabin-Karp ​

使用滚动哈希,在 O(1) 时间内从 hash(s[i:i+m]) 计算 hash(s[i+1:i+m+1]):

hash(s[i+1:i+m+1]) = (hash(s[i:i+m]) - s[i] * d^{m-1}) * d + s[i+m]

应用:多模式匹配、抄袭检测(对文档的 n-gram 做哈希)。


6. 图算法进阶 ​

6.1 强连通分量(SCC)— Kosaraju 算法 ​

1. 对原图做 DFS,按完成时间(Black 顺序)记录节点
2. 反转图中所有边的方向
3. 按步骤 1 的逆序,在反转图上做 DFS
   → 每次 DFS 遍历到的节点构成一个 SCC

直觉:按完成时间逆序,保证每次从"汇集 SCC"(没有出边的 SCC)开始探索。

6.2 Tarjan 算法(单次 DFS 求 SCC) ​

与 Kosaraju 不同,Tarjan 只需一次 DFS,使用 dfn(发现时间)和 low(能回溯到的最早节点)。

6.3 最大流 — Ford-Fulkerson & Edmonds-Karp ​

残差网络 + 增广路径:
1. 初始化流量为 0
2. 在残差网络中找一条从 s 到 t 的增广路径
3. 沿路径推送瓶颈流量
4. 重复直到没有增广路径 → 得到最大流

Ford-Fulkerson: DFS 找增广路,O(E·|f_max|)(容量为整数时)
Edmonds-Karp:   BFS 找增广路,O(VE²)
Dinic:          O(V²E),实际更快

应用:二分图匹配、网络吞吐量、任务分配、图像分割。

6.4 最小生成树理论补充 ​

KruskalPrim
策略全局排序,从小到大加边局部生长,每次加最近节点
数据结构并查集优先队列
复杂度O(Elog⁡E)O(Elog⁡V)
稠密图劣优
稀疏图优劣
分布式天然支持难

7. NP 完全性 ​

7.1 问题复杂度分类 ​

P: 多项式时间可解         → 排序、最短路径、最小生成树
NP: 多项式时间可验证       → 数独(验证快)、哈密顿回路
NP-Complete: NP 中最难的   → 3-SAT、旅行商、子集和
NP-Hard: 至少和 NP 一样难  → 停机问题(不可判定)

P vs NP:尚未证明 P≠NP 或 P=NP,但多数计算机科学家相信 P≠NP。

7.2 经典 NP 完全问题 ​

问题描述
3-SAT第一个被证明为 NP 完全的问题(Cook-Levin)
子集和给定整数集,是否存在子集和为 0?
旅行商(TSP)访问所有城市一次,最短路径
哈密顿回路访问所有顶点一次的环
顶点覆盖用 k 个顶点覆盖所有边
图着色用 k 种颜色给图着色

7.3 面对 NP 完全问题的策略 ​

  1. 小规模穷举(n<20)
  2. 近似算法:不保证最优,但保证与最优的比值(TSP 的 Christofides 算法 ≤ 1.5× 最优)
  3. 参数化算法:对某个参数 k,运行时间 O(f(k)⋅nc)
  4. 启发式搜索:遗传算法、模拟退火、蚁群
  5. 限制问题:在特殊子类上多项式可解(如树上的 TSP)

8. 多线程算法 ​

8.1 性能度量 ​

度量符号含义
工作量(Work)T1单处理器总时间
跨度(Span)T∞无限处理器下的关键路径长度
并行度T1/T∞理论最大加速比

8.2 并行归并排序的 Work & Span ​

归并排序:
  T₁(n) = Θ(n log n)
  T∞(n) = T∞(n/2) + Θ(log² n) = Θ(log³ n)  ← 并行 merge 的 span
  并行度 = Θ(n / log² n)

参考 ​

批注模式

💬 文章评论

暂无评论,来说点什么吧 👇

编程学习笔记