Skip to content

复杂度分析与渐进记号

1. 为什么需要复杂度分析?

在编写算法时,最朴素的问题就是:「这段代码运行得快不快?占多少内存?」我们可以用秒表计时,但这种方法有严重缺陷:

  • 依赖硬件:同样的代码,在 i9 和 i3 上跑的时间完全不同
  • 依赖数据:排序一个已排好序的数组 vs 一个逆序数组,时间差很多
  • 不能预测:小规模测试跑得快,不代表 100 万数据时也能跑

复杂度分析正是为了解决这些问题而诞生的。它不关心具体多少秒,而是关注算法的运行时间如何随输入规模 n 增长。这是算法分析中最核心的思想。


2. 渐进记号:大 O、大 Ω、大 Θ

2.1 大 O 记号(上界)

定义f(n)=O(g(n)) 当且仅当存在正常数 cn0,使得对所有 nn0,有 0f(n)cg(n)

用人话说:f(n) 的增长速度不超过 g(n) 的某个常数倍。大 O 给出了算法复杂度的最坏情况上界

3n2+2n+1=O(n2),因为当 n 足够大时,3n2+2n+14n2

2.2 大 Ω 记号(下界)

定义f(n)=Ω(g(n)) 当且仅当存在正常数 cn0,使得对所有 nn0,有 0cg(n)f(n)

大 Ω 给出了最好情况的下界。例如,比较排序算法的下界是 Ω(nlogn)

2.3 大 Θ 记号(紧确界)

定义f(n)=Θ(g(n)) 当且仅当 f(n)=O(g(n))f(n)=Ω(g(n))

大 Θ 是最精确的描述——它说明两个函数增长速度相同。例如,快速排序的平均时间复杂度是 Θ(nlogn)

大O、大Ω、大Θ三种渐进记号的可视化对比——三条不同颜色的曲线分别表示上界、下界和紧确界,横轴为输入规模n,纵轴为运行时间

2.4 渐进分析的实用法则

在实践中,我们通常这样分析:

  1. 忽略常数因子3n100n 都是 O(n)
  2. 忽略低阶项n2+nlogn+n 就是 O(n2)
  3. 只留增长最快的项2n+n100 仍是 O(2n)(指数压倒多项式)

3. 常见复杂度等级

按照增长速度从低到高排列(越往后越"慢"):

记号名称n=10n=103n=106n=109典型算法
O(1)常数1111数组索引、哈希查找
O(logn)对数~3~10~20~30二分查找、平衡树查找
O(n)线性10103106不可行线性搜索、数组遍历
O(nlogn)线性对数~33~104~2×107不可行归并排序、堆排序
O(n2)平方100106不可行不可行冒泡排序、简单DP
O(n3)立方1000109不可行不可行Floyd-Warshall
O(2n)指数1024灾难灾难灾难暴力搜索子集
O(n!)阶乘3.6×106灾难灾难灾难暴力旅行商问题

关键洞察:当 n=109 时,任何超过 O(nlogn) 的算法在常规计算机上都不可行。这就是为什么我们需要高效算法。

常见复杂度函数的增长曲线对比图——从O(1)到O(n!)的8条曲线在同一对数坐标系中,直观展示不同复杂度等级的增速差异


4. 主定理(Master Theorem)

对于分治算法,其时间复杂度通常满足递推式:

T(n)=aT(nb)+f(n)

其中:

  • a:子问题的个数(a1
  • b:每次将问题规模缩小的因子(b>1
  • f(n):分解与合并的代价

主定理给出了三种情形下的解:

情形 1:叶节点主导

f(n)=O(nlogbaϵ) 对某个 ϵ>0 成立,则:

T(n)=Θ(nlogba)

直觉:合并代价比子问题增长慢,复杂度由叶子节点决定。

情形 2:均匀分布

f(n)=Θ(nlogba),则:

T(n)=Θ(nlogbalogn)

直觉:每层的代价都差不多,乘上层数 logbn

情形 3:根节点主导

f(n)=Ω(nlogba+ϵ)af(n/b)cf(n)c<1 成立,则:

T(n)=Θ(f(n))

直觉:合并代价比子问题增长快,复杂度由根节点决定。

经典例子

  • 归并排序:T(n)=2T(n/2)+O(n)a=2,b=2log22=1f(n)=Θ(n1),属于情形 2,T(n)=Θ(nlogn)
  • 二分查找:T(n)=1T(n/2)+O(1)a=1,b=2log21=0f(n)=Θ(1)=Θ(n0),情形 2,T(n)=Θ(logn)
  • Strassen 矩阵乘法:T(n)=7T(n/2)+O(n2)log272.81f(n)=O(n2),情形 1,T(n)=Θ(n2.81)

主定理三种情形的判定流程图——决策树形式展示如何判断a、b、f(n)的取值属于哪种情形


5. 均摊分析(Amortized Analysis)

均摊分析关注的是一系列操作的平均代价,而不是单次操作的最坏情况。它解释了为什么某些操作虽然偶尔很"贵",但平均下来很便宜。

5.1 聚合分析(Aggregate Method)

直接计算 n 次操作的总代价 T(n),然后均摊到每次操作:每次操作的平均代价 = T(n)/n

经典例子——动态数组(Python 的 list.append)

当一个数组满时,需要分配一个更大的数组(通常是原来的 2 倍),然后将所有元素复制过去。这个操作是 O(n) 的。但这 O(n) 的代价只需每 n 次操作才发生一次。

均摊分析:经过 n 次 append,总复制代价为:

1+2+4+8++2log2n2n

所以总代价为 O(n),均摊每次操作 O(1)

5.2 记账法(Accounting Method)

给每次便宜的操作多收一点"费用"存入银行(credit),用来支付偶尔出现的操作的代价。

例子:动态数组的 append。

  • 每次 append 收 3 元:1 元支付本次插入,2 元存入 credit
  • 当数组满了需要扩容时,已有足够 credit 支付复制操作
  • 结果:每次操作的均摊代价为 O(1)

5.3 势能法(Potential Method)

定义势能函数 Φ(Di) 表示数据结构在状态 i 时的"势能"。均摊代价为:

c^i=ci+Φ(Di)Φ(Di1)

对于动态数组,可以定义 Φ=2(sizecapacity/2) 或类似函数,使得扩容时 Φ 的下降恰好抵消实际代价的上升。

动态数组扩容过程的均摊分析可视化——展示数组从容量4扩容到8再到16的过程,每次扩容时复制元素的代价标注在时间轴上,展示均摊后每次操作O(1)的直观理解


6. 空间复杂度

除了时间,我们还需要考虑空间复杂度——算法运行中占用的额外内存(不包括输入数据本身)。

  • O(1) 空间:原地算法,只使用常数额外空间(如冒泡排序)
  • O(n) 空间:需要与输入同规模的额外空间(如归并排序的临时数组)
  • O(logn) 空间:递归栈的空间(如平衡树的递归操作)

时空权衡:在许多算法中,时间和空间是可以互换的。例如,哈希表用 O(n) 额外空间换来了 O(1) 的查找时间。动态规划用 O(n) 的表格空间换来了指数级时间到多项式级时间的改进。


本章总结

复杂度分析是算法设计的基石。本章我们学习了:

  1. 渐进记号:大 O(上界)、大 Ω(下界)、大 Θ(紧确界)是描述复杂度的数学语言
  2. 常见复杂度等级:从 O(1)O(n!),理解各等级的增速差异是选择算法的前提
  3. 主定理:提供了分治算法复杂度分析的标准工具,三种情形覆盖了大多数常见递推式
  4. 均摊分析:解释了看似"贵"的操作如何通过长期平均变得"便宜",动态数组是最经典的例子
  5. 空间复杂度:时间和空间常常需要权衡,好的设计能在这两者间找到平衡

这些工具将在后续每个章节中被反复使用——每当我们介绍一个新算法,第一件事就是分析它的复杂度。


📥 Code

FileViewDownload
demo.pyOpenDownload
exercise.pyOpenDownload

参考

  1. Cormen, T. H., Leiserson, C. E., Rivest, R. L., & Stein, C. (2009). Introduction to Algorithms (3rd ed.). MIT Press. (CLRS 经典教材,第 3、17 章)
  2. Sedgewick, R., & Wayne, K. (2011). Algorithms (4th ed.). Addison-Wesley.
  3. Kleinberg, J., & Tardos, E. (2005). Algorithm Design. Pearson.

下一章数组、链表与哈希表