☰
递归式与渐进分析:从Big-O到主定理彻底搞懂时间复杂度
2026/10/15 9:03:25 网站建设 项目流程

T(n)=2T(n/2)+n 为什么是 O(n log n)?我在给团队新人做算法分析培训时发现,被递归式卡住的人远比我预想的多。教材往往把渐进符号、递归树、主定理拆成独立章节,每章单独看都清楚,可一旦要合起来用,大家就不知道先看什么、后补什么了。这篇文章沿着“渐进符号、递归与解法”这条线,把 Big-O/Ω/Θ 的定义直觉、递推式怎么从分治代码里提炼出来,以及代入法、递归树法、主定理三种解法之间的分工关系完整串一遍。目标只有一个:看完之后,你能独立处理一个陌生递归式,而不是只会背主定理的三个 case。

1. 渐进符号:上界、下界和“紧”到底在说什么

1.1 你为什么需要渐进符号

先回答一个根本问题:既然我们能写出精确的运行时间表达式,比如某个算法需要3n^2 + 5n + 9次操作,为什么不直接用它,非要套一层 O、Ω、Θ?

原因是“精确表达式”在工程上既难得又脆弱。同样一段代码,换成不同编译器、不同 CPU、不同输入规模,常数项可能差几十倍。但大家真正关心的往往是定性结论:当 n 从 1 万涨到 100 万,这个算法的时间是线性增长还是平方增长?如果是平方增长,即使常数再小,规模一大也撑不住。渐进符号就是用来描述这种“增长阶”的,把常数和低阶项全部吸收掉,只保留随 n 变化最猛的那一项。

再看一个例子:1000n和0.001n^2,在小规模下后者可能更快,但一旦 n 超过 1e7,平方项会迅速碾压线性项。渐进分析的价值不在预测小数据上的具体耗时,而在预判大数据上的趋势。

1.2 O、Ω、Θ的正式定义与直观翻译

三个符号最常用的定义如下:

  • Big-O(上界):存在常数 c>0 和 n₀,使得对所有 n≥n₀,有 0 ≤ f(n) ≤ c·g(n)。写作 f(n)=O(g(n))。
  • Big-Ω(下界):存在常数 c>0 和 n₀,使得对所有 n≥n₀,有 0 ≤ c·g(n) ≤ f(n)。写作 f(n)=Ω(g(n))。
  • Big-Θ(紧界):f(n)=O(g(n)) 且 f(n)=Ω(g(n))。写作 f(n)=Θ(g(n))。

我习惯用“收入上限”帮助记忆:O 就像“月薪最多不会超过某个数”,Ω 是“月薪至少不会低于某个数”,Θ 是“月薪就稳定锁死在这个范围内”。这里的“某个数”不是绝对值,而是一个以 n 为自变量的增长函数。

定义里最值得注意的细节是∃ n₀。它意味着渐进关系只关心“足够大之后”的行为。前 100 次运算再怎么反常都无所谓,n 超过 n₀ 之后,函数必须一直被 c·g(n) 压住或托住。这也解释了为什么我们敢在分析时随便忽略低阶项:因为对足够大的 n,低阶项相对主导项可以忽略不计。

1.3 最容易被误用的几个地方

第一个常见误区是“大 O 等于精确值”。f(n)=O(n^2)只表示 f 是 n² 的上界,不代表 f 就是 n²。一个线性函数f(n)=3n完全满足 f(n)=O(n²),你甚至写 f(n)=O(n^100) 也不算“错”,只是没人会这么写。正因为“上界”允许松,我们才特别强调要找“紧的上界”。

第二个误区是把 O 和 Θ 混为一谈。线性算法既是 O(n),也是 O(n²),但它不是 Θ(n²)。所以当你听到“这个算法是 O(n log n)”时,它只给了上限;如果要表达“就是这么多,不多不少”,必须用 Θ。作业里最常见的问题就是把 O 写了一堆,最后根本没证明下界。

第三个误区是混淆“最坏情况”和“渐进上界”。插入排序最坏情况是 Θ(n²),但最好情况是 Θ(n)。渐进符号描述的是“某个输入场景下增长阶”,不是“算法整体就是 O(n²)”。严谨的表述是:“插入排序在最坏情况下运行时间为 Θ(n²),在最好情况下为 Θ(n)”。两者不冲突,但常被人简化成一个概括性结论,结果在讨论随机数据时把自己绕晕。

还有个小细节:n log n和log n之间差着 n 倍,无论如何不能合并。而log(n^2)是 O(log n),log^2 n是 (log n)²。后者比前者大得多,写法上差一个指数符号,渐进阶完全不同。这些坑在后续推导递推式时会频繁出现,先记下。

2. 递推式不是天上掉下来的:分治代码如何变成 T(n)=aT(n/b)+f(n)

2.1 从分治三步到递推骨架

很多同学可以背下T(n)=2T(n/2)+n,却说不清这个式子怎么来的。其实只要抓住分治算法的三段式结构:分解、递归、合并。

以归并排序为例:把长度为 n 的数组分成两半,这是分解;分别对两半排序,这是递归,代价就是 2T(n/2);最后把两个有序数组合并,需要线性扫描一遍,这是合并,代价 Θ(n)。把三部分加起来:

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

一般的分治策略同理:如果一个问题被拆成 a 个子问题,每个子问题规模约为 n/b,分解加合并的总代价是 f(n),那么递推式就是:

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

这里 a、b、f(n) 不是随便给的。a 必须是非负整数,表示子问题个数;b 必须大于 1,表示规模缩放;f(n) 必须渐近非负。可以认为,这个式子就是“分治代码”的数学投影:你把代码里的递归调用抽象成 T,把循环合并抽象成 f(n),递推式自然就出来了。

2.2 边界条件与 floor/ceil 的处理逻辑

一个常被忽略的细节是初始条件。T(1)=Θ(1) 通常表示问题小到某个常数规模时,直接返回即可。边界条件本身往往不改变渐进答案,它负责决定最终常数 c 或 d 的取值——在后面证明代入法时会发现,很多“猜解”本身是成立的,唯独基准条件过不了,需要把常数调大或调小来兜住。

实际写代码时递归规模未必正好是 n/2。比如每次递归要做T(⌊n/2⌋)+T(⌈n/2⌉),这时候递推式里出现 floor/ceil。严谨的分析会麻烦一些,但绝大多数教材都会告诉你:带 floor/ceil 的递推式和理想版本T(n)=aT(n/b)+f(n)有相同的渐进解。因为 floor/ceil 的偏差只会让子问题规模差一个常数,折叠进分析后不改变主导项。你可以暂时放心地丢掉取整符号,只保留数学上的干净形态。

2.3 哪些递推式可以归约成标准形态

还有一些递推式表面不是aT(n/b)+f(n),但通过换元能变回标准形态。最典型的是:

T(n)=2T(√n)+log n。

根号下的规模没法直接套主定理。做法是令 n=2^m,于是 T(2^m)=2T(2^{m/2})+m。再定义 S(m)=T(2^m),就得到:

S(m)=2S(m/2)+m。

这已经是标准形态,套主定理得到 S(m)=Θ(m log m)。代回去:m=log n,所以 T(n)=Θ(log n · log log n)。换元的本质是“把不好处理的规模变换变成线性递推”,遇到指数、对数、根号嵌套时尤其好用。

另一类常见的变体是子问题规模不相等,比如T(n)=T(n/3)+T(2n/3)+n。它不符合 a 个等规模子问题的形态,主定理无法直接用。这种递推要回到递归树去分析,我会在第四节专门展开。它也是很多面试题里“故意不让你套模板”的那类题。

3. 代入法:用数学归纳把猜到的答案稳稳钉死

3.1 猜测哪来的:两条常用路径

代入法(Substitution Method)核心只有两步:猜出解,再用数学归纳证明。它不负责“猜”,只负责“证”。那猜测从哪来?常见路径有两个:一是先画递归树得出大致形态,再把形态写成渐近表达式;二是凭经验判断:分治比例规整、合并代价为 n,答案往往是 n log n;合并代价是 n²,答案往往是 n² 或 n 的更高次幂。

举例来说,看到T(n)=4T(n/2)+n时,你可能猜 Θ(n²)。理由:4 个子问题,每个规模减半,总的叶子数量增长速度为 4^i,比单层规模收缩 2^i 快,所以叶子层会主导。看到T(n)=2T(n/2)+n²,你会猜 Θ(n²),因为合并代价增长太快,根层一家独大。这两种直觉其实对应递归树的两种形态,后面会解释。

3.2 一次完整的归纳证明演示

用最经典的T(n)=2T(n/2)+n演示如何证明上界。目标:T(n)=O(n log n)。

第一步写归纳假设:存在常数 c>0,使所有 k<n 都有 T(k)≤c·k·log k。

第二步代入递推关系:

T(n) ≤ 2·c·(n/2)·log(n/2) + n = c·n·(log n - 1) + n = c·n·log n - c·n + n = c·n·log n - n·(c - 1)

只要 c≥1,-n(c-1)≤0,所以 T(n)≤c·n·log n。归纳成立。

第三步处理基准。选择足够大的 c,使得 T(1)、T(2) 也满足 T(k)≤c·k·log k。因为 log 1=0,需要额外处理小 k 的情况——常见做法是直接验证 k=2 或 k=3,并让 c 大于 T(1)+某个常数即可。这一步看起来技术性很强,但本质很简单:c 是我们自己选的,调大一点就能覆盖有限个基准点。

下界证明同理,但要换一个常数 d,并且方向反过来。假设 T(k)≥d·k·log k:

T(n) ≥ 2·d·(n/2)·log(n/2) + n = d·n·log n - d·n + n = d·n·log n + n·(1 - d)

只要 d≤1,n(1-d)≥0,所以 T(n)≥d·n·log n。基准处再取一个足够小的 d 保证成立。注意:上界和下界用的是两套常数,c 不需要等于 d。很多初学者非要同一个常数搞定两方向,结果把自己卡死,其实完全没必要。

3.3 常数调整、低阶项与上下界分开证

代入法最常踩的坑是拿大 O 符号直接做归纳。例如有人写“假设 T(k)=O(k log k),代入得到 T(n)=O(n log n)”——这不算严谨证明,因为大 O 丢掉了常数,你根本不知道代入后不等式还成不成立。正确做法是每一步都保留具体常数 c,写成T(k)≤c k log k。

还有一个现象值得注意:归纳假设的强度不够时,证明可能会“卡住”。经典例子是T(n)=2T(n/2)+n若直接猜 T(n)=O(n),代入后只能得到 T(n)≤2c(n/2)+n=cn+n,比 cn 多了一项 n,证不动。这时候不是解错了,而是猜得太紧。正确猜想是 O(n log n),这才有足够的余量吸收低阶项。此类“少一个 log 就迈不过去”的现象,在代入法里叫“归纳失败”,它常常提示你答案里应该多一个对数因子。

另外,下界和上界一定要分开证。许多人证完 O 就宣布“Θ”,严格来说这是不完整的。尤其在工作面试中,面试官问复杂度时,如果只给出上界,没有说明下界为什么匹配,理解深度会打折扣。

4. 递归树法:不用硬记,把复杂度画出来

4.1 递归树怎么画、每一层代价怎么算

递归树的价值在于让“累加过程”可视化。画法分四步:

  1. 根节点代表整个问题,代价写 f(n)。
  2. 从根分出 a 个孩子,每个孩子规模是 n/b,对应代价 f(n/b)。
  3. 重复画到叶子层,叶子代价为 Θ(1)。
  4. 把每一层所有节点代价加起来,得到该层总代价;再把所有层相加,就是总复杂度。

以T(n)=4T(n/2)+n为例。第 i 层有 4^i 个节点,每个节点规模是 n/2^i,所以节点代价是 f(n/2^i)=n/2^i。第 i 层总代价:

4^i × (n/2^i) = (4/2)^i · n = 2^i · n。

树的高度是 log₂n。总代价:

n + 2n + 4n + ... + n·2^{log n}≈ Θ(n²)。

这个答案和主定理 case 1 完全一致:临界指数 log₂4=2,f(n)=n 比 n² 低一阶,最终由叶子层主导。

4.2 三种收敛形态:根支配、叶子支配、均匀分布

递归树最关键的概念是“每一层总代价随 i 增大是变大、变小,还是不变”。

  • 变大(等比发散):叶子层贡献最大,复杂度通常落在 Θ(n^{log_b a})。对应主定理 case 1。
  • 不变(均匀分布):每层总代价接近相同,层数 log n 乘上去,结果形如 Θ(n^{log_b a} log n)。对应主定理 case 2。
  • 变小(等比收敛):根层贡献最大,复杂度由 f(n) 决定。对应主定理 case 3。

比如T(n)=2T(n/2)+n²:第 i 层总代价是2^i × (n/2^i)² = n²/2^i,等比收敛到一个常数倍 n²,所以答案是 Θ(n²)。根层代价 f(n)=n² 是绝对主导,递归树越往下越不重要。

判断“变大变小”不需要严格求和,只要看每层总代价的比值:如果比值大于 1,就是发散;小于 1,就是收敛;等于 1,就是均匀。后面的主定理,本质上就是在把这个比值翻译成 f(n) 与 n^{log_b a} 的大小关系。

4.3 不均衡树的经典例子:T(n)=T(n/3)+T(2n/3)+n

现在回到那个没法套主定理的例子。画出递归树:根节点代价 n;下一层两个节点,规模分别是 n/3 和 2n/3,代价加起来仍是 n;再下一层是 n/9、2n/9、2n/9、4n/9,代价总和还是 n。

这个现象可以严格验证:在同一层内,所有节点规模之和等于 n。于是每层总代价都是 n。关键是层数。最左路径每次都除以 3,深度是 log₃n;最右路径每次都乘 2/3,深度是 log_{3/2}n。这两条路径一短一长,说明这棵树既不完整也不均匀。

上界:层数不会超过最长路径,即 log_{3/2}n 量级,所以总代价 ≤ n×(log_{3/2}n+1),即 O(n log n)。

下界:前 log₃n 层是完整的,每一层代价确实等于 n,所以总代价 ≥ n×log₃n,即 Ω(n log n)。

两边一夹,答案就是 Θ(n log n)。递归树在这个例子里的优势非常明显:主定理帮不上忙,代入法如果你猜不出答案也难以下手,但树一画,每一层代价相同、层数介于两个 log 之间,结论直接浮出水面。

5. 主定理:三句话判定,但你要知道它背后在比什么

5.1 记住一个关键数:临界指数 p=log_b a

主定理处理标准递推T(n)=aT(n/b)+f(n)。方法是先算一个关键指数:

p = log_b a。

为什么是这个数?因为分治的叶子数量是 a^h,其中 h=log_b n,所以叶子总数是 a^{log_b n}=n^{log_b a}=n^p。也就是说,n^p代表“把所有递归全部走到叶子”时的工作量下限。之后你只需要比较 f(n) 和 n^p 的相对大小。

这里顺带提醒一个常见细节:对数的底 b 在普通对数log n里可以随便换,因为换底只差常数;但b出现在指数位置、形成n^{log_b a}时,换底会改变指数本身,不能随意忽略。比如 log₂4=2,log₈4≈0.667,完全是两个增长阶。

5.2 三个 case 与递归树三种形态的对应

主定理常用版本如下,其中 p=log_b a:

条件结论背后的树形态
f(n)=O(n^{p-ε}),ε>0T(n)=Θ(n^p)叶子主导,等比发散
f(n)=Θ(n^p log^t n),t≥0T(n)=Θ(n^p log^{t+1} n)每层相当,均匀分布
f(n)=Ω(n^{p+ε}),ε>0,且存在 c<1 使 af(n/b)≤c f(n)T(n)=Θ(f(n))根主导,等比收敛

看几个具体例子,感受一下判断流程:

递推式pf(n)情况解
T(n)=2T(n/2)+n1ncase 2(t=0)Θ(n log n)
T(n)=2T(n/2)+n²1n²case 3(n²=Ω(n^{1+ε}),且 2·(n/2)²=n²/2≤c n²)Θ(n²)
T(n)=8T(n/2)+n²3n²case 1(n²=O(n^{3-ε}))Θ(n³)
T(n)=4T(n/2)+n²2n²case 2(t=0)Θ(n² log n)
T(n)=3T(n/4)+nlog₄3≈0.792ncase 3Θ(n)

最后一个例子很多新手会犹豫:p 小于 1,f(n)=n 明显大于 n^p,还要验证正则条件:3·(n/4)=0.75n≤c·n,取 c=0.75 即可。所以答案是 Θ(n),合并代价主导一切。

5.3 正则条件、失效场景与更一般的 case 2

为什么 case 3 要额外加一条“正则条件”af(n/b)≤c f(n)?因为仅仅 f(n)=Ω(n^{p+ε}) 还不够,递归树每一层代价虽然整体在衰减,但如果不衰减得更快,根层未必能绝对主导。正则条件实质是在说:从根向下一层,总代价至少乘以一个小于 1 的常数 c,这样几何级数才收敛。幸运的是,绝大多项式 f(n) 自动满足这个条件,它只在 f(n) 形态很怪时才需要逐条检查。

主定理真正的软肋是“间隙”和“对数因子不足”。

看T(n)=2T(n/2)+n/log n。这里 p=1,f(n)=n/log n。它比 n^p 小,但不是“多项式意义下的小”,因为任意 ε>0 都有 n^{1-ε} 远小于 n/log n?反过来,n/log n 增长得比 n^{1-ε} 快,所以不是 O(n^{1-ε}),case 1 不适用;它也明显不是 Ω(n^{1+ε}),case 3 也不适用。主定理直接失效。这种场景无法套模板,只能回到递归树或代入法。第四节插曲和第六节的综合例子会告诉你答案长什么样。

补充一点:很多书中 case 2 只写了 t=0 的形态,也就是 f(n)=Θ(n^p) 时答案是 Θ(n^p log n)。但实际常遇到T(n)=2T(n/2)+n log n:p=1,f(n)=n log n。如果只记 t=0 的版本,你会误判成 case 3。正确做法是把 case 2 扩展成“f(n)=Θ(n^p log^t n)”,结论是 Θ(n^p log^{t+1} n)。本例如 t=1,答案是 Θ(n log²n)。所以平时练习尽量用更一般的形式。

6. 实战工作流:遇到陌生递推式,我按什么顺序处理

6.1 三步决策顺序:先主定理,再递归树,最后代入法

我个人处理陌生递归式的顺序非常固定,几乎每次都一样。

第一步判断是不是标准形态T(n)=aT(n/b)+f(n)。不是的话先想办法换元,比如根号、指数、对数类的变换。换完还是不行,比如子问题规模不等,就跳过主定理,直接画递归树。

第二步如果能用主定理,算出 p=log_b a 和 f(n) 比较。差一个 n^ε 或 log 因子,就直接套结论。如果发现 f(n) 落在两个 case 的“间隙”里,比如 n/log n 这种,别硬套,立刻转递归树。

第三步用递归树得到猜测,再用代入法严格证明。代入法永远充当最后一道工序,因为只有它能给出无懈可击的证明。递归树负责直觉,主定理负责快速判断,代入法负责收尾。三者不是三选一,而是流水线。

6.2 验算习惯:小规模数值与画树自检

纸上推导容易出现符号错误,我习惯用几行代码做快速验证。比如想知道T(n)=2T(n/2)+n的增长趋势,可以这样写:

from functools import lru_cache @lru_cache(maxsize=None) def T(n): if n <= 1: return 1 return 2 * T(n // 2) + n for n in [16, 32, 64, 128, 256, 512, 1024]: print(n, T(n), n * (n.bit_length()))

跑出来的数据会落在 n log n 的常数倍范围内,心里就踏实了。验证像T(n)=T(n/3)+T(2n/3)+n这种递推式时,也可以直接复制成两句递归调用来打印每层规模,方便确认“每层总规模始终是 n”这个关键论断。

还有一个省事技巧:对指数结果,手动算 T(2)、T(4)、T(8) 三个点,和猜测公式对比。比如T(n)=4T(n/2)+n²猜 Θ(n² log n),代入 n=8、T(1)=1,算出来是 192,而 8²·log₂8=192,完全吻合。这种数值抽查虽然不能证明,但能在 30 秒内拦截掉大部分粗心错误。

6.3 作业和面试中最常翻车的五个点

根据我看到的代码评审和面试反馈,递归式求解的错误高度集中在这几处:

第一,把下界写成 O。想表达至少是某个量级时必须用 Ω,大 O 只能描述上限。“至少 O(n)”这种写法没有信息量,任何 O(n²) 算法同时也是 O(n),但它不一定是 Ω(n)。

第二,代入法里用大 O 当归纳假设。这会让推导在形式上成立,实际上却站不住脚。任何时候都要写成T(k)≤c k log k而不是T(k)=O(k log k)。

第三,只证上界不证下界。有人得到 O 之后习惯性写成 Θ,但如果你的递推式里含有 floor/ceil 或常系数摇摆,上下界未必匹配。除非你有完整证据,不要把 O 直接升级成 Θ。

第四,错误处理 log 的底。普通log n换底无所谓,但n^{log_4 3}和n^{log_3 2}完全不同。临界指数里的底 b 如果看错,整个答案都会跑偏。

第五,分治代码里忘记合并代价。二分查找的正确递推是T(n)=T(n/2)+Θ(1),结果 Θ(log n);如果误写成T(n)=T(n/2)+Θ(n),结果会变成 Θ(n)。差一个代价项,复杂度从对数变成线性,这属于基本面错误。

6.4 一个综合到位的例子:T(n)=2T(n/2)+n/log n

最后用一个综合性较强的例子把全流程串起来。递推式是T(n)=2T(n/2)+n/log n。

按前面顺序,先看主定理:a=2、b=2,所以 p=1。f(n)=n/log n 小于 n^p,但没有差到 n^{1-ε} 那么多;也谈不上大于 n^{1+ε}。主定理的三个 case 全部失效。于是转递归树。

第 i 层有 2^i 个节点,每个节点规模 n/2^i,节点代价为 f(n/2^i)=(n/2^i)/log(n/2^i)。第 i 层总代价:

2^i × (n/2^i)/log(n/2^i) = n / (log n - i)。

总代价就是对 i=0 到 log n-1 求和:

T(n)=Θ(n · Σ_{i=0}^{log n-1} 1/(log n - i)) = Θ(n · H_{log n})。

其中 H 是调和数,和为 Θ(log log n)。所以 T(n)=Θ(n log log n)。

这个结果第一次看很反直觉:合并代价 n/log n 只比 n 差了 log n 倍,但最终答案不是 n,也不是 n log n,而是 n log log n。为什么?因为递归层数越多,合并代价里的 log n 被逐步压缩,每一层贡献的“单位份额”越来越小,最后只多出一个 log log n 的累积。递归树把这种微妙变化画得明明白白,而主定理在这种情况下无能为力。

拿到这个猜测后,最后用代入法确认上下界,过程稍显繁琐,但思路和第三节完全一致:对第 i 层求和的上界用调和数上界,对后半段求和用调和数下界,夹逼出 Θ(n log log n)。

最后聊一点个人体会。我学这块最大的教训是试图“背结论”。后来强迫自己把每个递推式在纸上画到第六层,画了十来个之后,主定理的三个 case 不背也忘不掉,因为它们其实就是递归树三种收敛模式的速记。如果你正在为递归式头疼,别急着刷题,先挑六个递推式,亲手把每一层代价算一遍,你会上手得比我快得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询