前两篇讲体系。这一篇开始钻算法本身,从「怎么判断一个算法好不好」讲起。

大多数人学复杂度是为了应付考试,学完就忘。真正的问题不在公式,在于不知道它拿来干什么。


一、先破一个误解

O(n²) 不等于「慢」,O(n) 不等于「快」。

举个例子:

  • 一个 O(n²) 的算法,n 只有 10,那是 100 次操作——眨眼的事
  • 一个 O(n) 的算法,n 是 10 亿,那是 10 亿次操作——几秒钟

所以大 O 后面那个东西,量的不是时间,是「增长」——n 变大时,代价怎么变。

这正是它的价值所在:它不预测现在,它预测将来。

你写的脚本今天跑 1 秒,明天数据涨十倍,是跑 10 秒还是跑 100 秒?大 O 回答这个问题,秒表回答不了。


二、大 O 的定义,一句话说清

形式定义是这样的(数学上严格,但第一次看容易晕):

若存在两个正的常数 c 和 n₀,使得对所有 n ≥ n₀,都有 T(n) ≤ c·f(n),则称 T(n) = O(f(n))。

翻译成人话:

当 n 足够大的时候,T(n) 的增长速度不会超过 f(n) 的某个倍数。

三个关键点,几乎所有人都漏:

1. 「足够大」——小数据不算数

大 O 只描述趋势,不管小规模。所以「我的数据才 100 条,用冒泡就行」这句话是对的——大 O 并没有否定它。

2. 常数被扔掉了

O(2n) 和 O(100n) 都写成 O(n)。这在实际工程里是个坑:常数可能差 50 倍。

真正跑得快的东西,往往是同一复杂度下常数更小的那个。 这就是为什么实际代码里,O(n log n) 的排序常常输给精心调过的 O(n²) 插入排序——当 n 很小的时候。

3. 只留最高阶项

n² + 100n + 10000,写成 O(n²)。因为 n 大的时候,其他项都微不足道。

nn²100nn² 占多少
101001,0008%
10010,00010,00048%
1,0001,000,000100,00090%
10,000100,000,0001,000,00099%

这张表就是「只留最高阶」的全部理由。


三、怎么数:三个步骤,一种方法

书上给的方法很朴素,但足够用:

  1. 找出输入规模 n(数据的个数?字符串长度?图的顶点数?)
  2. 找出基本语句(执行次数最多的那句,通常在最内层循环里)
  3. 数它执行了多少次,取数量级

数几次就懂了。

例一:单层循环

for (i = 0; i < n; i++)
    sum += a[i];        // 执行 n 次

→ O(n)

例二:双层循环

for (i = 0; i < n; i++)
    for (j = 0; j < n; j++)
        sum += a[i][j];  // 执行 n² 次

→ O(n²)

例三:循环变量每次乘 2

for (i = 1; i < n; i = i * 2)
    count++;             // 执行 log₂n 次

→ O(log n)

为什么是 log? 因为 i 每次翻倍,从 1 到 n 只要 log₂n 步。n = 1024 时只要 10 步。

这条最值钱:折半查找、平衡树、二分答案——所有「每次砍一半」的算法,都是 O(log n)。

例四:内外层互相独立的两个循环

for (i = 0; i < n; i++) sum1 += a[i];      // n 次
for (j = 0; j < m; j++) sum2 += b[j];      // m 次

→ O(n + m),不是 O(n·m)。并列用加法,嵌套用乘法。


四、最好、最坏、平均:为什么要分三种

同一个算法,输入不同,代价可能差一个量级。

顺序查找找一个数:

  • 正好是第一个 → 1 次比较(最好)
  • 不在里面 → n 次比较(最坏)
  • 平均 → n/2 次

工程上默认看最坏情况,原因很实在:

最好情况不保证下限,平均情况要假设分布,只有最坏情况是硬承诺。

你说「平均很快」,但用户碰上的那次可能正好是最坏的。做系统的人必须有最坏情况的数字,否则没法做容量规划。

例外:快排的最坏情况是 O(n²)(已经有序时),但平均 O(n log n),所以实际都用随机化来打散。这就是为什么「随机选主元」不是锦上添花,是必需品。


五、常见复杂度,按「n 涨十倍会怎样」排序

这比记符号有用得多:

复杂度n=100n=1,000n=10,000一句话印象
O(1)111跟 n 无关
O(log n)71013几乎不涨
O(n)1001,00010,000线性,能接受
O(n log n)6649,966132,877排序的下限
O(n²)10,0001,000,000100,000,000一万条就卡了
O(n³)1,000,00010⁹10¹²基本不能用
O(2ⁿ)10³⁰——n 超过 30 就别想了

看表记住三件事:

1. O(n log n) 是排序的「及格线」 基于比较的排序,理论下界是 Ω(n log n)——这不是「还没找到更好的」,是「靠比较不可能更好」。 想更快只能换路子:不靠比较(计数排序、基数排序)。

2. O(n²) 是分水岭 一万条数据以内还能忍,十万条就完蛋。所以「双重循环」是个危险信号——看到它的第一反应应该是「有没有办法拆开」。

3. O(2ⁿ) 是「不能用」的同义词 n=30 就是十亿次,n=40 就是万亿次。这就是为什么背包、旅行商这类问题不能用蛮力。


六、空间复杂度,别忘了一半

时间有复杂度,空间也有。但工程上两者的优先级不一样:

  • 时间换空间:缓存、索引、预计算——几乎所有性能优化都在干这个
  • 空间换时间:也能,但内存是硬限制,超了直接崩

一份经验:

当内存够用时,优先优化时间;当内存不够时,先优化空间再谈速度。

还有一类容易被忽略的:递归的空间开销。递归深度就是栈深度,深度一万的递归会爆栈。这是 O(n) 的空间代价,很多人算复杂度时只算了时间。


七、这些分析拿来干什么:三个真实用途

用途一:判断「这个需求能不能做」

老板要「实时查一亿条记录里的重复」。你算一下:两两比对是 O(n²),一亿的平方——这个需求现在做不了,不是我不会,是数学不允许。 必须换方案(哈希、分块、外部排序)。

这会省下大量白费的努力。

用途二:判断「慢在哪一层」

一个接口要 2 秒。

  • 数据库查询 O(n) 扫了 100 万行 → 加索引变 O(log n)
  • 代码里有个嵌套循环 O(n²) → 改结构
  • 网络调了 50 次 → 批量合并

不同层的问题,修法完全不同。乱猜就是在浪费时间。

用途三:判断「要不要优化」

如果现在是 O(n log n),n 只有 1000,优化到 O(n) 只省 1 毫秒——别改,风险大于收益。

知道什么时候不优化,比知道怎么优化更值钱。


八、下一篇讲什么

分析是判断,设计是创造。

下一篇讲五个设计范式:蛮力、分治、贪心、动态规划、回溯与限界。核心不是五个模板,是「什么时候该用哪个」。

而且会给你看一个反例:同一道题,五种写法,其中最快的那个,答案是错的。


参考

  • 大 O 定义、基本语句、最好/最坏/平均:见《算法设计与分析(微课版)》第 1 章(印刷 2–18)。
  • 复杂度对照表与三个用途是我自己的整理。
  • 排序下界、可计算性(Turing 论题、不可计算问题)、NP 完全与归约:见同书第 12 章(印刷 192 起)。
  • 注:文中「O(n log n) 下界」只针对基于比较的排序,非比较排序可突破,这一点书里没有专门展开,是我补充的限定。