一、为什么要丢掉常数

先看一组真实数字。假设一台机器每秒执行 10⁹ 次基本操作:

n n log n 2ⁿ
10 33 ns 100 ns 1 μs
1 000 10 μs 1 ms 远超宇宙年龄
10⁶ 20 ms 17 分钟
10⁹ 30 s 31 年

n = 10⁶ 时,n log n 与 n² 差了约 5 万倍。没有任何常数因子、编译器优化或换语言能弥补这个差距。 把 Python 换成 C 大约快 50 倍——买不到这 5 万倍。

这就是渐近分析的全部动机:在足够大的输入上,增长率支配一切,常数因子无关紧要。 于是我们设计一套记号,故意把常数和低阶项扔掉。

⚠️ 但请记住前提是"足够大的输入"。如果 n 永远不超过 20,渐近分析可能给出错误的工程建议——这也是标准库排序在小数组上切回插入排序的原因(第 11 讲)。


二、三个记号的精确定义

设 f(n)、g(n) 是定义在自然数上的非负函数。

O:渐近上界

f(n) = O(g(n))  ⟺  ∃ c > 0, n₀ > 0, 使得对所有 n ≥ n₀:
                     0 ≤ f(n) ≤ c · g(n)

读作"f 至多以 g 的速度增长"。注意两个量词:常数 c 是存在,n 是对所有足够大的

Ω:渐近下界

f(n) = Ω(g(n))  ⟺  ∃ c > 0, n₀ > 0, 使得对所有 n ≥ n₀:
                     0 ≤ c · g(n) ≤ f(n)

Θ:渐近紧确界

f(n) = Θ(g(n))  ⟺  f(n) = O(g(n)) 且 f(n) = Ω(g(n))
                ⟺  ∃ c₁, c₂ > 0, n₀:n ≥ n₀ 时 c₁g(n) ≤ f(n) ≤ c₂g(n)
      Θ(g):夹住            O(g):压住          Ω(g):托住
   │      c₂·g(n)        │   c·g(n)         │    f(n)
   │     ╱               │  ╱               │   ╱
   │    ╱ f(n)           │ ╱  f(n)          │  ╱ c·g(n)
   │   ╱  ╱              │╱  ╱              │ ╱  ╱
   │  ╱  ╱ c₁·g(n)       │  ╱               │╱  ╱
   └────┼──────► n       └──┼──────► n      └──┼──────► n
        n₀                  n₀                 n₀

严格记号 o 与 ω

f(n) = o(g(n))  ⟺  ∀ c > 0, ∃ n₀:n ≥ n₀ 时 f(n) < c·g(n)   (lim f/g = 0)
f(n) = ω(g(n))  ⟺  lim f/g = ∞

类比(不严谨但好记):

记号 O Ω Θ o ω
类比 = < >

⚠️ 类比在一处失效:实数上任意两数可比,函数不一定。n^(1+sin n)n 之间三种关系都不成立。


三、四个必须避免的误用

误用 1:把 O 当成"就是这么快"

O 是上界,可以任意松。“插入排序是 O(n¹⁰⁰)“在数学上完全正确。

知道紧确界时就说 Θ。说 O 只是不肯把话说满。

工程语境里用 O 表达 Θ 的意思是可接受的约定,但读论文和写证明时必须分清。尤其是下界结论第 12 讲的"比较排序需要 Ω(n log n)",说成 O(n log n) 意思完全相反。

误用 2:把「最坏情况」当成「上界」

这是两个正交的维度:

上界 O 下界 Ω
最坏情况 插入排序最坏是 O(n²) 插入排序最坏是 Ω(n²)(逆序输入)
最好情况 插入排序最好是 O(n)(已有序) 插入排序最好是 Ω(n)

所以"插入排序最好情况 Θ(n)、最坏情况 Θ(n²)“完全合法。O 回答"多快之内”,最坏情况回答"对哪个输入”。

误用 3:等号是滥用记号

f(n) = O(g(n)) 真正的意思是 f ∈ O(g)(O(g) 是函数集合)。因此不对称

  • 2n² = O(n³)  ✗ O(n³) = 2n²

在表达式内部,O(...) 表示"某个属于该集合的匿名函数”。2n² + Θ(n) 意为 2n² + f(n),其中 f ∈ Θ(n)。

误用 4:多参数下擅自省略

图算法是 O(V + E),不是 O(V),也不是 O(E)。两个独立参数时不能假设谁支配谁:稀疏图 E = Θ(V),稠密图 E = Θ(V²),结论完全不同。见第 21 讲


四、增长率层级

从慢到快(每一行都是下一行的 o):

1 ≺ log log n ≺ log n ≺ log²n ≺ √n ≺ n ≺ n log n ≺ n² ≺ n³ ≺ 2ⁿ ≺ n! ≺ nⁿ
复杂度 名称 n 翻倍时 典型来源
Θ(1) 常数 不变 数组下标、散列查找(期望)
Θ(α(n)) 反 Ackermann 几乎不变(≤ 4) 并查集(第 23 讲
Θ(log n) 对数 加 1 二分查找、平衡树
Θ(√n) 平方根 ×1.41 分块、试除判素
Θ(n) 线性 ×2 遍历、计数排序
Θ(n log n) 线性对数 略多于 ×2 比较排序、MST
Θ(n²) 平方 ×4 所有二元组、朴素 DP
Θ(n³) 立方 ×8 朴素矩阵乘法、Floyd-Warshall
Θ(2ⁿ) 指数 平方 枚举子集
Θ(n!) 阶乘 枚举排列

“n 翻倍时"这一列是最实用的判据:不用读代码,把输入翻倍看时间变几倍。变 2 倍是线性,变 4 倍是平方——这是性能排查的第一步。

三条常用事实

(1)对数的底无关紧要。 log_a n = log_b n / log_b a,换底只差常数,所以 O(log n) 不写底。⚠️ 但指数的底极其重要:2ⁿ 与 3ⁿ 之比是 (1.5)ⁿ,不是常数。

(2)任何多项式打败任何多对数:对任意 a, b > 0,log^b n = o(n^a)。log¹⁰⁰ n 也慢于 n^0.01。

(3)任何指数打败任何多项式:对任意 a > 0, b > 1,n^a = o(bⁿ)


五、常用求和与近似

算术级数:   1 + 2 + … + n          = n(n+1)/2          = Θ(n²)
平方和:     1² + 2² + … + n²        = n(n+1)(2n+1)/6    = Θ(n³)
等比 (r<1): 1 + r + r² + …         = 1/(1-r)           = Θ(1)
等比 (r>1): 1 + r + … + rⁿ         = (r^(n+1)-1)/(r-1) = Θ(rⁿ)
调和级数:   Hₙ = 1 + 1/2 + … + 1/n = ln n + γ + O(1/n) = Θ(log n)
Stirling:   n! ≈ √(2πn)·(n/e)ⁿ  ⟹  log(n!) = Θ(n log n)

⭐ 两条直觉规则:

  • 等比级数被最大项支配:几何和 ≈ 常数 × 最大项。递归树分析天天用(第 3 讲)。
  • 调和级数是对数级:“第 i 步做 n/i 次工作"总量是 Θ(n log n)——快排、跳表、随机化算法的分析都会遇到。

log(n!) = Θ(n log n) 直接给出比较排序下界(第 12 讲)。


六、怎么从代码读出复杂度

规则 1:顺序语句取最大。 A; B 的代价是 max(cost(A), cost(B))

规则 2:嵌套循环相乘——仅当内层次数与外层无关。

for i = 1 to n:            for i = 1 to n:
    for j = 1 to n:            for j = i to n:
        O(1)                       O(1)
  ⟹ Θ(n²)                   ⟹ Σ(n-i+1) = Θ(n²)   同为 n²,但要算和

规则 3:看变量怎么变化,别看循环写法。

i = 1                      for i = 1 to n:
while i < n:                   j = 1
    i = i * 2                  while j < n:
  ⟹ Θ(log n)                       j = j * 2
                             ⟹ Θ(n log n)

规则 4:调和和陷阱。

for i = 1 to n:
    for j = i to n step i:     # 内层跑 n/i 次
        O(1)
  ⟹ Σᵢ n/i = n·Hₙ = Θ(n log n)      不是 Θ(n²)

这正是埃拉托色尼筛的复杂度来源(只对素数 i 执行时进一步降为 Θ(n log log n))。


七、渐近分析骗你的时候

情况 说明
常数巨大 Fibonacci 堆理论优于二叉堆,但常数大、缓存不友好,实测常常更慢
n 很小 标准库排序在 n < 16 时切插入排序
缓存局部性 数组与链表遍历都是 Θ(n),实测可差 10 倍以上(第 5 讲
摊还 vs 单次 动态数组 push 摊还 O(1),但某一次是 Θ(n)——实时系统里可能致命(第 4 讲
分布敏感 散列表期望 O(1) 依赖散列质量;对抗输入下退化为 Θ(n)(第 7 讲

⭐ 正确态度:渐近分析用来排除坏方案,实测用来在好方案中选择。


随堂自测

  1. 用定义证明 3n² + 10n + 7 = Θ(n²),给出具体的 c₁、c₂、n₀。
  2. 2^(n+1) = O(2ⁿ) 成立吗?2^(2n) = O(2ⁿ) 呢?分别说明理由。
  3. 为什么"插入排序的最坏运行时间是 O(n²)“和"插入排序是 O(n²)“都对,但"插入排序是 Θ(n²)“要加限定?
  4. 按增长率排序:n log n2^(log₂ n)log(n!)4^(log₂ n)n^(1/log₂ n)n!
  5. 下面的循环是什么复杂度?
    for i = 1 to n:
        j = i
        while j > 0: j = j / 2
    
  6. n 从 10⁵ 增到 2×10⁵,时间从 3 s 增到 12.5 s。最可能的复杂度是什么?
  7. 为什么 O(log n) 不写底数,而 O(2ⁿ) 的底不能省略?