目录
正在加载目录…
专栏文章
专栏文章
经典算法专栏
1. 图算法:遍历、最短路、MST、拓扑与 Tarjan 2. 排序与搜索:十大排序、二分查找与动态规划 3. 动态规划:五类经典问题的状态转移方法 4. 业务场景算法:限流、Top-K 与一致性哈希 5. 算法复杂度:递归树、主定理与摊还分析 6. 基础数据结构:树、堆、哈希与并查集 7. 分治与贪心:拆解问题与局部最优策略 8. 字符串算法:KMP、BM、RK 与 Trie 9. 网络流与匹配:最大流、最小割与二分图 10. 二分算法:边界查找与答案二分模板 11. 位运算速查:二进制原理与高频解题模板 11. 并查集:连通性、路径压缩与进阶变体 12. 显式栈与状态机:替代递归的通用方法

算法复杂度:递归树、主定理与摊还分析

发布于 2026-06-10 10:31 · 最后编辑于 2026-08-17 15:08 · 字数 3,774 👁 196 次阅读

复杂度分析是算法设计的基础语言。本文不止于列公式——重点建立直觉:为什么归并排序是 nlogn 而不是 n²?递归树怎么画?摊还分析的"信用"从哪来? 对应《算法导论》第 3、4、17 章。

目录

章节说明
渐近符号Θ/O/Ω 的精确定义与常见误用
复杂度直觉:增长速度意味着什么从数字感受差距,而不只是符号
递归树法:手推复杂度画图推导,不靠死记结论
主定理:递归树的快捷方式三种情况的直觉解释
摊还分析:均摊而非单次最坏聚合法、记账法、势能法深度推导
下界分析:证明算法已最优比较排序 Ω(nlogn) 的决策树证明
常见算法复杂度速查排序、数据结构、图算法一览

渐近符号

精确定义

渐近符号描述 n → ∞ 时函数的增长行为,忽略常数因子和低阶项:

符号含义形式定义数学类比
Θ(g)紧确界,f 与 g 同阶∃c₁,c₂,n₀:∀n≥n₀,c₁g(n) ≤ f(n) ≤ c₂g(n)f = g
O(g)上界,f 不超过 g∃c,n₀:∀n≥n₀,f(n) ≤ cg(n)f ≤ g
Ω(g)下界,f 不低于 g∃c,n₀:∀n≥n₀,f(n) ≥ cg(n)f ≥ g
o(g)严格上界∀c>0,∃n₀:∀n≥n₀,f(n) < cg(n)f < g(严格)
ω(g)严格下界∀c>0,∃n₀:∀n≥n₀,f(n) > cg(n)f > g(严格)

关键区别:O 允许 f=g(≤),o 不允许(<)。例如 n = O(n) 成立,但 n ≠ o(n)。

为什么忽略常数和低阶项是合理的?

f(n) = 3n² + 100n + 500,当 n=10000 时:

3n²    = 300,000,000   占比 99.97%
100n   =   1,000,000   占比  0.03%
500    =         500   可忽略不计

低阶项在 n 足够大时完全被主项淹没。常数因子则取决于硬件、语言、编译器——同一算法在 GPU 上可能快 100 倍,所以用渐近符号剥离硬件差异,只关注算法本质。

常见误用

❌ "快速排序是 O(nlogn)"
✅ "快速排序平均情况 Θ(nlogn),最坏情况(已排序输入)Θ(n²)"
   O 是上界,说"O(nlogn)" 技术上没错但信息量不足

❌ "A 算法 O(n²) 比 B 算法 O(nlogn) 慢"
✅ 渐近比较要在 n 足够大时才成立:
   若 A = 0.001n²,B = 1000nlogn
   当 n < 10000 时 A 反而更快
   → 小数据量时常数因子比阶数更重要

❌ "二分查找是 O(logn),所以和 O(1) 差不多"
✅ O(logn) 对 n=10⁹ 只需约 30 步,实践中确实接近 O(1),
   但这是直觉,不是定义

复杂度直觉:增长速度意味着什么

光看符号没有感觉,用真实数字感受差距。假设每步操作耗时 1 纳秒(10⁻⁹ 秒):

复杂度n = 10²n = 10⁶n = 10⁹能做什么
O(1)1ns1ns1ns哈希表查找
O(logn)7ns20ns30ns二分查找
O(n)100ns1ms1s线性扫描
O(nlogn)700ns20ms30s归并排序
O(n²)10μs17分钟31年冒泡排序
O(2ⁿ)10²⁴年暴力枚举

工程界限(1秒内能完成的 n 上限):O(n) → 10⁸;O(nlogn) → 10⁷;O(n²) → 10⁴;O(2ⁿ) → 25。

logn 到底有多小?

log₂(10)      ≈ 3.3
log₂(1000)    ≈ 10
log₂(10⁶)     ≈ 20
log₂(10⁹)     ≈ 30
log₂(10¹⁸)    ≈ 60   ← 宇宙中原子数量的数量级

即使 n 是宇宙中的原子数,log₂(n) 也只有 270

这就是为什么二分查找、平衡树在任何实际规模下都是"瞬间完成"的。

递归树法:手推复杂度

为什么需要递归树

主定理只适用于 T(n) = aT(n/b) + f(n) 这种规整形式。遇到不规整的递推式(子问题规模不同、有多个递推项),必须靠递归树。

例 1:归并排序 T(n) = 2T(n/2) + n

画出递归树,每层列出所有子问题及其代价:

graph TD
    A["n<br/>代价 n"] --> B["n/2<br/>代价 n/2"]
    A --> C["n/2<br/>代价 n/2"]
    B --> D["n/4"]
    B --> E["n/4"]
    C --> F["n/4"]
    C --> G["n/4"]
    D --> H["..."]
    E --> H2["..."]
    F --> H3["..."]
    G --> H4["..."]

逐层统计代价

层数节点数每节点代价本层总代价
01nn
12n/2n
24n/4n
k2^kn/2^kn
lognn1n

关键观察:每层代价恰好都是 n,树高 logn,总代价 = n × logn = Θ(nlogn)

直觉:递归树每层"合并"的总工作量相等,都是 n。树有 logn 层,所以是 nlogn。

例 2:不规整递推式 T(n) = T(n/3) + T(2n/3) + n

这个主定理无法直接套用(两个子问题规模不同),必须用递归树。

graph TD
    A["n"] --> B["n/3"]
    A --> C["2n/3"]
    B --> D["n/9"]
    B --> E["2n/9"]
    C --> F["2n/9"]
    C --> G["4n/9"]

关键问题:树有多高?左侧(每次取 1/3)最浅,右侧(每次取 2/3)最深:

最浅路径(左侧):n → n/3 → n/9 → ... → 1    高度 = log₃(n)
最深路径(右侧):n → 2n/3 → 4n/9 → ... → 1  高度 = log_{3/2}(n)

每层代价:验证每层之和是否仍为 n:

层 0:n
层 1:n/3 + 2n/3 = n
层 2:n/9 + 2n/9 + 2n/9 + 4n/9 = n
...(可以证明每层之和 = n)

结论:层数介于 log₃(n) 和 log_{3/2}(n) 之间,每层代价 = n:

T(n) = Θ(n · log_{3/2}(n)) = Θ(nlogn)

递归树法的步骤总结:① 画出树形展开;② 统计每层节点数和各自代价;③ 求每层总代价;④ 累加所有层。

主定理:递归树的快捷方式

主定理是递归树法的"快捷键"。理解了递归树,主定理就不是死记,而是一目了然。

递推式结构

T(n) = aT(n/b) + f(n)

  • a 个子问题,每个规模 n/b,合并代价 f(n)
  • 叶节点总数 = n^(log_b a)(记为 n^p)
  • 叶节点总代价 = Θ(n^p)(每个叶代价 O(1),共 n^p 个)

三种情况的直觉

flowchart TD
    Q["f(n) 与 n^p 谁主导?"] --> C1["f(n) 远小于 n^p<br/>叶节点主导"]
    Q --> C2["f(n) 与 n^p 同阶<br/>每层代价相等"]
    Q --> C3["f(n) 远大于 n^p<br/>根节点主导"]
    C1 --> R1["T(n) = Θ(n^p)<br/>情况 1"]
    C2 --> R2["T(n) = Θ(n^p · logn)<br/>情况 2"]
    C3 --> R3["T(n) = Θ(f(n))<br/>情况 3"]

    style R1 fill:#dae8fc,stroke:#6c8ebf
    style R2 fill:#fff2cc,stroke:#d6b656
    style R3 fill:#d5e8d4,stroke:#82b366
情况条件直觉结论
情况 1f(n) = O(n^(p-ε))递归树底层(叶节点)代价主导,根和中间层可忽略T(n) = Θ(n^p)
情况 2f(n) = Θ(n^p · logᵏn)每层代价相等,logn 层,乘以多一个 logT(n) = Θ(n^p · log^(k+1)n)
情况 3f(n) = Ω(n^(p+ε))根节点(合并代价)主导,叶节点可忽略T(n) = Θ(f(n))

完整推导示例:归并排序

T(n) = 2T(n/2) + n

① a=2, b=2, f(n)=n
② p = log₂2 = 1,即 n^p = n¹ = n
③ 比较 f(n)=n 与 n^p=n:
   f(n) = Θ(n^p · log⁰n),情况 2(k=0)
④ 结论:T(n) = Θ(n · log¹n) = Θ(nlogn) ✅

主定理的盲区(需改用递归树)

❌ 子问题规模不等:T(n) = T(n/3) + T(2n/3) + n
❌ 子问题规模相减:T(n) = T(n-1) + n(链式展开,不是树形)
❌ f(n) 与 n^p 差距不够大:T(n) = 2T(n/2) + n/logn(卡在两种情况边界)

主定理应用速查

算法递推式pf(n)情况结论
归并排序2T(n/2)+n1n=Θ(n¹)2(k=0)Θ(nlogn)
二分查找T(n/2)+101=Θ(n⁰)2(k=0)Θ(logn)
Strassen7T(n/2)+n²2.81n²<n^2.811Θ(n^2.81)
三路归并3T(n/3)+n1n=Θ(n¹)2(k=0)Θ(nlogn)
4叉树建堆4T(n/4)+n1n=Θ(n¹)2(k=0)Θ(nlogn)

摊还分析:均摊而非单次最坏

为什么需要摊还分析

有些数据结构操作代价极不均匀:大多数操作很快,偶尔一次很慢。如果只看最坏单次,会严重高估总体性能。

错误分析

动态数组 append n 次
最坏单次 = O(n)(触发扩容时复制所有元素)
→ n 次操作 = O(n²)?

实际是 O(n),差了一个 logn。摊还分析告诉你真正的均摊代价。

聚合法:直接数总代价

思路:算清 n 次操作的总代价上界,再除以 n。

例:动态数组 append n 次

扩容发生在 size = 1, 2, 4, 8, ..., 2^k 时,每次复制代价分别为 1, 2, 4, ..., n/2:

总扩容代价 = 1 + 2 + 4 + ... + n/2 = n - 1 < n
总 append 代价(不含扩容)= n

总代价 < 2n
均摊代价 = 2n/n = O(1) ✅

例:栈的 MULTIPOP 操作序列

操作序列:若干次 PUSH / POP / MULTIPOP(k),分析 n 次操作的总代价:

关键观察:每个元素最多被 PUSH 一次、POP(或 MULTIPOP 弹出)一次
→ 所有 POP 类操作的总代价 ≤ PUSH 次数 ≤ n
→ 总代价 ≤ 2n
均摊代价 = O(1)/次 ✅

记账法:预存信用

思路:给每次操作预存一定"信用",实际代价高时从信用中支取。关键:信用余额始终 ≥ 0

例:动态数组 append

设计信用分配:每次 append 收取 3 个单位代价(摊还代价 = 3):

  • 1 个单位:支付本次 append 的实际代价
  • 2 个单位:存入"信用账户",专为未来扩容准备

验证信用不透支

扩容前,数组已满,size = capacity = m
信用账户中存了多少?
  从上次扩容(size=m/2)到现在,共 append 了 m/2 次
  每次存入 2 个单位
  信用余额 = 2 × (m/2) = m 个单位

扩容时需要复制 m 个元素,代价 = m
信用账户恰好够支付 ✅,信用余额不会为负

势能法:最严格的工具

思路:定义势能函数 Φ(反映数据结构"蓄积的潜力"),摊还代价 = 实际代价 + ΔΦ。

设计原则

  • 操作便宜时,Φ 增大(蓄积势能)
  • 操作昂贵时,Φ 大幅减小(释放势能抵消代价)
  • 必须保证 Φ ≥ Φ₀(初始势能),否则摊还代价可能为负

例:动态数组,令 Φ = 2·size - capacity

验证势能函数合法性:

初始(size=0, capacity=1):Φ = 2×0 - 1 = -1
但初始后第一次 append:size=1, cap=1,Φ = 2×1 - 1 = 1
为了统一,设 Φ₀ = 0,只要 Φ ≥ 0 时才开始计

Case 1:不触发扩容(size < capacity)

实际代价 = 1
ΔΦ = (2(size+1) - cap) - (2·size - cap) = 2
摊还代价 = 1 + 2 = 3

Case 2:触发扩容(size = capacity = m)

实际代价 = m + 1(复制 m 个 + 新 append)
扩容后:size = m+1,capacity = 2m
ΔΦ = (2(m+1) - 2m) - (2m - m) = 2 - m
摊还代价 = (m+1) + (2-m) = 3

两种情况摊还代价都是 3 = O(1) ✅

三种方法的选用

  • 聚合法:最直接,但需要能算出精确总代价
  • 记账法:直觉好,适合"预付费"场景
  • 势能法:最通用,适合复杂数据结构(斐波那契堆、伸展树)

下界分析:证明算法已最优

上面都是分析算法有多快(上界)。有时候需要证明没有任何算法能做得更好(下界)。

比较排序的 Ω(nlogn) 下界

问题:为什么所有基于比较的排序算法都至少需要 Ω(nlogn) 次比较?

证明工具:决策树

任何比较排序算法可以表示为一棵二叉决策树:

  • 每个内部节点:aᵢ ≤ aⱼ?(一次比较)
  • 每条从根到叶的路径:一种排序执行过程
  • 每个叶节点:一种排列结果(n! 种可能)
graph TD
    A["a₁ ≤ a₂?"] -->|是| B["a₂ ≤ a₃?"]
    A -->|否| C["a₁ ≤ a₃?"]
    B -->|是| D["a₁,a₂,a₃"]
    B -->|否| E["a₁ ≤ a₃?"]
    C -->|是| F["a₂,a₁,a₃"]
    C -->|否| G["..."]
    E -->|是| H["a₁,a₃,a₂"]
    E -->|否| I["a₃,a₁,a₂"]

推导

叶节点数 ≥ n!(必须覆盖所有 n! 种排列)
二叉树高度 h 满足:2^h ≥ n!

两边取对数:
h ≥ log₂(n!)

Stirling 近似:log₂(n!) ≈ nlog₂n - n/ln2 = Ω(nlogn)

因此任意比较排序的最坏比较次数 h = Ω(nlogn)

推论:归并排序和堆排序的 Θ(nlogn) 在渐近意义上已经最优。计数排序、基数排序之所以能突破 nlogn,是因为它们不是基于比较——利用了元素的数值结构。

常见算法复杂度速查

排序算法

算法平均最坏最好空间稳定下界最优?
冒泡Θ(n²)Θ(n²)Θ(n)O(1)
插入Θ(n²)Θ(n²)Θ(n)O(1)❌(但小数组最快)
归并Θ(nlogn)Θ(nlogn)Θ(nlogn)O(n)
快速Θ(nlogn)Θ(n²)Θ(nlogn)O(logn)✅(期望)
堆排Θ(nlogn)Θ(nlogn)Θ(nlogn)O(1)
计数Θ(n+k)Θ(n+k)Θ(n+k)O(k)N/A(非比较)
基数Θ(d(n+k))Θ(d(n+k))O(n+k)N/A(非比较)

数据结构操作

结构查找插入删除特殊操作推导来源
数组O(n)O(n)O(n)随机访问 O(1)线性扫描
链表O(n)O(1)*O(1)**已知位置
哈希表O(1) avgO(1) avgO(1) avg均匀分布假设
二叉堆O(n)O(logn)O(logn)取最值 O(1)树高 logn
BSTO(h)O(h)O(h)h≤logn 均衡时
红黑树O(logn)O(logn)O(logn)黑高保证
并查集O(α(n))O(α(n))路径压缩+按秩

图算法

算法时间空间推导关键点
BFS/DFSO(V+E)O(V)每条边访问 2 次
Dijkstra(堆)O((V+E)logV)O(V)每次 extract-min O(logV),共 V 次
Bellman-FordO(VE)O(V)松弛 V-1 轮,每轮 O(E)
Floyd-WarshallO(V³)O(V²)三重循环,DP
KruskalO(ElogE)O(V)排序 O(ElogE),并查集 O(Eα(V))
拓扑排序O(V+E)O(V)本质是 BFS/DFS

参考资料

  • 《算法导论》(CLRS)第 3 版 — 第 3、4、8.1、17 章
  • 《算法》(Sedgewick)— 第 1 章分析框架
  • 基础数据结构(红黑树黑高如何保证 O(logn)、并查集 α(n) 的来源)
  • 并查集O(α(n)) 的来由、路径压缩和按大小合并)
  • 分治与贪心(主定理在归并排序、Strassen 中的完整推导)
← 返回列表

评论 (0)

暂无评论,来留下第一条吧。
登录注册 后才能发表评论