写出一个程序, 和设计一个算法, 并不是同一件事. 一个程序可以在若干测试样例上给出正确结果, 但算法设计与分析还要求我们进一步回答三个问题:
- 它是否对所有允许的输入都正确?
- 它需要多少时间和空间?
- 我们还能不能做得更好?
这三个问题几乎会贯穿整门算法课程. 本文从两个最基本的例子出发: 计算 Fibonacci 数和插入排序. 前者说明“同一个问题, 算法不同, 代价可能天差地别”; 后者则第一次系统展示如何证明算法正确, 又如何把一段伪代码转化为精确的运行时间分析.
1. 课程要研究什么
从程序设计一路向上看, 我们会依次遇到编程语言、数据结构、算法, 再到人工智能、大语言模型、密码学等更复杂的应用. 程序设计解决的是“如何把计算过程写出来”, 数据结构关心“如何组织数据”, 而算法设计与分析开始追问一个更本质的问题:
面对同一个计算任务, 怎样找到一个既正确又高效的计算过程?
这正是算法这门课的核心.
从这个角度看, 算法本身也是一种技术. 更快的硬件当然可以让程序变快, 但算法上的改进往往会带来数量级上的差异. 一个指数时间算法即使放在快很多的机器上, 可处理的输入规模通常也只会增加一点点; 而把算法从指数时间改成多项式时间, 才可能真正改变问题是否可计算.
课程的整体内容也围绕这一目标展开:
- 算法分析: 正确性, 最好、平均与最坏情况, 渐近记号, 排序.
- 算法设计技术: 分治, 递推式, 贪心, 动态规划, 摊还分析.
- 图算法: BFS, DFS, 最小生成树, 最短路, 最大流.
- 困难问题: NP 完全性与近似算法.
2. 什么是算法
算法并不是计算机出现之后才有的概念.
欧几里得算法用重复取余计算最大公因数; 刘徽的割圆术通过不断加倍正多边形边数逼近圆周率; 九世纪的花拉子米系统整理了十进制算术与解方程的机械步骤. “algorithm” 一词本身就来自花拉子米名字的拉丁化形式.
这些例子有一个共同点: 它们都把“如何得到答案”写成了一套明确、机械、可执行的步骤.
Definition 1 (算法)
因此, 一个计算问题首先应被看成一个输入与输出之间的关系:
算法的任务, 就是对每一个合法输入, 按照固定规则得到满足问题要求的输出.
这个定义看似简单, 却已经暗含了几个关键要求:
- 明确性: 每一步都应当没有歧义.
- 机械性: 每一步都能由计算模型执行.
- 有限终止: 对合法输入, 计算过程最终停止.
- 规格: 我们必须先知道“什么输出才算正确”, 才能讨论算法正确性.
于是, 对以后遇到的每一个算法, 都可以固定地问三遍:
3. 大语言模型是不是算法
一个固定的大语言模型推理过程, 从输入 token 开始, 反复执行确定的神经网络运算, 再按给定采样规则生成下一个 token. 随机性本身并不会让它“不是算法”: 随机比特可以视为额外输入, 随机算法仍然是算法.
终止问题也类似. 如果系统规定遇到停止 token 或达到最大生成长度时结束, 那么整个过程具有明确的终止规则.
真正不同的是“正确性”这一问.
传统算法问题通常有明确规格. 例如排序要求输出必须是输入元素的一个排列, 且按非递减顺序排列. 因而我们可以证明一个排序算法对所有输入都正确.
而一般性的 LLM 回复通常没有这样一个数学上唯一、可检验的输出规格. 我们可以用 benchmark 或人工评价衡量它, 却很难像证明排序算法那样证明“所有回答都正确”.
因此, LLM 推理完全可以作为计算过程研究其时间与空间代价; 但本课程主要研究的是那些具有明确规格, 因而能够进行数学正确性证明的问题.
4. 第一个例子:Fibonacci 数
Fibonacci 数列满足
并且对 ,
前几项是
问题是: 给定非负整数 , 如何计算 ?
这个问题之所以适合作为算法课的第一个例子, 是因为它会很快暴露三个层次的问题:
- 按定义写出来的算法未必高效.
- “循环执行了多少次”并不等于真实计算代价.
- 在谈复杂度之前, 必须先说清楚采用什么计算模型.
4.1 直接照抄定义:递归算法
最自然的想法就是把递推定义翻译成程序.
Fib1(n):
if n = 0:
return 0
if n = 1:
return 1
return Fib1(n - 1) + Fib1(n - 2)
正确性几乎不需要额外解释: 它就是 Fibonacci 数的定义本身.
困难出现在运行时间.
设 是计算 Fib1(n) 需要的基本步骤数. 对 , 有 ; 对 ,
这个递推式和 Fibonacci 数本身非常相似. 至少有
而 Fibonacci 数满足
因此 Fib1 的运行时间是指数级的. 更精确地说,
问题并不是递归这个语法本身, 而是它产生了大量重复子问题. 例如在计算 时, 我们先计算一次 ; 随后为了计算 , 又会再次计算 . 递归树越往下, 重复越严重.
4.2 为什么硬件升级救不了指数算法
因为
输入规模只从 增加到 , 工作量就大约增加 倍.
假设计算机性能每隔一段时间提高约 倍, 那么这次硬件提升所换来的, 可能只是“以前能算到 , 现在能算到 “.
这就是指数时间的危险: 硬件速度按常数倍增长, 输入规模的可承受范围却只按加法增长.
因此, 算法效率不能被当成硬件性能的附属问题. 算法本身就是一种技术.
4.3 去掉重复计算:迭代算法
既然每一项只依赖前两项, 我们可以从小到大依次算出所有需要的值.
课件中的版本保存整个数组:
Fib2(n):
if n = 0:
return 0
create f[0..n]
f[0] = 0
f[1] = 1
for i = 2 to n:
f[i] = f[i - 1] + f[i - 2]
return f[n]
循环只执行 次, 所以如果把一次加法视为常数代价, 运行时间是
与指数时间相比, 这是本质性的改进.
不过这里有一个容易混淆的细节: 课件版本与课堂 notes 中的空间复杂度并不相同.
课件中的 Fib2 保存 , 因而需要 个额外存储单元. 但如果我们只保留相邻两项, 就可以写成:
Fib-Rolling(n):
if n = 0:
return 0
a = 0
b = 1
for i = 2 to n:
c = a + b
a = b
b = c
return b
此时额外空间变为
两种写法的算术操作次数都是 ; 区别只在于是否保留全部中间结果.
这也是算法分析里一个重要习惯: 不要只记住“某个算法的复杂度”, 而要看清楚你分析的究竟是哪一个实现. 两段实现思路相同的程序, 空间复杂度仍可能不同.
5. “一次加法”真的只需要一步吗
到这里似乎已经得到结论: Fib2 是线性时间算法.
但这个结论隐含了一个假设: 每次加法都是常数时间.
当 很大时, 这个假设不再成立.
Fibonacci 数大约满足
所以 的二进制位数约为
也就是说, 数字本身的长度随着 线性增长. 例如, 到 时已经不能再用通常的有符号 64 位整数表示 .
如果两个整数各有 位, 按逐位加法计算, 一次加法需要 个位操作. 因而 Fibonacci 的后期加法不应该继续被视为常数代价.
于是我们得到两套不同但都合理的计数方式.
5.1 按“算术操作次数”计数
如果暂时把任意整数加法都视为一步:
Fib1: 次量级的操作.Fib2: 次加法.
5.2 按“位操作”计数
由于参与运算的整数最多有 位:
Fib1: 大约为 个位操作.Fib2: 次加法, 每次至多处理 位, 因而为 个位操作.
更细地求和也会得到同一量级:
这说明复杂度从来不是脱离模型的绝对数字. 在说“算法是 “之前, 必须先问一句:
这里的一步到底是什么?
6. RAM 计算模型
真实计算机包含缓存、流水线、主存等复杂机制. 为了得到与具体机器无关的复杂度分析, 课程采用 RAM (Random-Access Machine) 模型.
Definition 1 (RAM 模型)
word 是机器进行基本存储和运算的单位, 其长度称为字长. 为了使“访问任意内存位置需要 时间”这一假设成立, 一个 word 至少应能存下一个内存地址. 若输入规模为 , 区分 个位置至少需要 位, 因此 word-RAM 通常取
其中 为常数.
这并不表示真实机器的字长会随输入改变, 而是渐近分析中的理论假设: 随着 增大, 允许机器字长以 增长, 从而始终能够表示相应规模的地址.
字长假设也决定了哪些数据运算可以视为一步. 若一个整数能放入常数个 word, 其基本运算可视为 ; 若需要越来越多个 word 才能表示, 就必须计算多字长运算的代价.
例如 有 个二进制位, 而一个 word 只有 位, 因此 需要 个 word 才能表示. 所以在 Fibonacci 的复杂度分析中, 大整数加法不能始终视为一步.
RAM 模型有意忽略很多真实硬件细节, 例如缓存层次、不同指令的实际延迟和并行执行. 当数据搬运本身成为主要瓶颈时, 还可以采用 external-memory、cache-oblivious 等模型. 本课程则主要使用 RAM 模型.
7. 从数列进入排序
Fibonacci 例子让我们第一次看到“效率分析”. 接下来, 排序会让我们第一次完整地把以下内容放到同一个算法里:
- 问题的形式化定义.
- 算法伪代码.
- 正确性证明.
- 对具体语句执行次数的分析.
- 最好、平均与最坏情况.
Definition 1 (排序问题)
输入一个含 个元素的序列
输出该序列元素的一个排列
使得
这里元素未必只是数字. 实际应用中通常是“记录 + key”: 排序比较的是 key, 与 key 绑定的数据也要一起移动.
排序之所以是算法分析的经典起点, 一方面是因为它本身极其常用, 另一方面是因为许多更复杂算法都把排序作为子过程.
8. 插入排序:把新元素放进已经有序的前缀
插入排序可以类比整理手里的扑克牌.
假设左边一部分牌已经排好. 每次从右边取一张新牌 key, 从已排序部分最右端开始向左寻找位置. 所有大于 key 的元素向右移动一格, 最后把 key 放进空出来的位置.
Insertion-Sort(A, n):
for i = 2 to n:
key = A[i]
j = i - 1
while j > 0 and A[j] > key:
A[j + 1] = A[j]
j = j - 1
A[j + 1] = key
这里先保存 key 非常重要. 因为后续会执行 A[j+1] = A[j], 如果不提前保存原来的 A[i], 它可能在元素右移过程中被覆盖.
而且移动必须从右向左进行. 这和在数组中为新元素“腾位置”是同一个道理.
9. 如何证明插入排序正确:循环不变式
一个程序“看起来对”, 不是证明.
对于循环算法, 最常见的证明工具是循环不变式. 它和数学归纳法本质上是同一种逻辑结构.
Definition 1 (循环不变式)
对插入排序, 课件给出的不变式可以写得更完整一些:
Theorem 2 (插入排序的循环不变式)
在外层 for 循环每次以索引 开始时, 子数组 :
- 已按非递减顺序排列;
- 恰好由原数组最初的前 个元素组成, 只是次序可能改变.
第二条很重要. “已经有序”只说明顺序对了, 还必须保证我们没有丢掉元素、复制元素或凭空产生新元素. 排序要求输出是原输入的一个排列, 因而“元素集合保持不变”也是正确性的一部分.
证明
初始化. 第一次迭代时 . 子数组 只有一个元素, 因而天然有序, 而且显然就是原数组的第一个元素.
保持. 假设某次迭代开始时, 已经有序, 且由原来的前 个元素组成. 算法先保存 为 key. 随后从右向左检查有序前缀, 把所有严格大于 key 的元素向右移动一格.
由于前缀原本有序, 当循环停止时, key 左边的元素都不大于它, 右边被移动的元素都大于它. 因此把 key 放入 后, 仍然有序.
整个过程中只是移动原有元素并重新放回 key, 因而 仍恰好由原数组最初的前 个元素组成.
终止. 外层循环结束时 . 把 代入不变式, 得到 已按非递减顺序排列, 并且由原数组全部 个元素组成. 这正是排序问题要求的输出.
这就是循环不变式证明的标准三步:
如果把“迭代次数”看成归纳变量, 它实际上就是数学归纳法的算法版本.
10. 从伪代码到运行时间
正确性证明回答了“它会不会算错”. 接下来还要回答“它要算多久”.
设第 次外层循环中, while 条件被检查了 次. 对每一条语句赋予一个常数代价 , 就可以把总运行时间写成一个加权和.
不必执着于每个常数的具体数值. 真正决定增长率的是 如何随输入变化.
10.1 最好情况:输入已经有序
如果
那么每一轮都立即发现 . while 不会真正进入移动阶段, 对每个 只做常数次检查.
因此
一个细节是, “最好情况”不只包括严格递增序列. 如果所有元素都相等, 条件 A[j] > key 同样立刻为假, 也只需要 次 key 比较.
10.2 最坏情况:输入严格逆序
如果
那么第 轮插入时, key 必须从位置 一路移动到最前面.
这一轮需要移动 个元素. 总移动次数为
因此
10.3 平均情况:先说明概率模型
“平均运行时间”不能脱离概率分布单独讨论.
如果假设输入是 个互异元素的均匀随机排列, 那么在第 轮中, key 在前 个元素里的相对位置也是均匀的. 平均而言, 已排序前缀里大约有一半元素比 key 大, 因而仍需要 次移动.
所以
还可以从逆序对的角度看得更干净. 插入排序每消除一个逆序对, 就会执行一次相邻元素意义上的右移. 对任意一对元素, 在随机排列中它们构成逆序对的概率为 . 因而期望逆序对数是
这就解释了为什么平均情况和最坏情况虽然常数不同, 增长阶仍然相同.
11. 为什么课程通常先看最坏情况
最坏情况运行时间定义为: 在所有规模为 的输入中, 算法可能达到的最长运行时间.
它受到重视主要有三个原因.
第一, 它给出一个保证. 如果我们证明最坏情况为 , 那么任何规模为 的输入都不会比这个界更坏.
第二, 某些问题中的最坏情况并不罕见. 例如线性查找一个不存在的元素, 就必须检查整个序列.
第三, 最坏情况不需要额外假设输入来自什么概率分布, 因而适合作为不同算法之间的统一比较标准.
当然, 这不意味着平均情况不重要. 后面的随机算法、QuickSort 等内容里, 平均或期望运行时间会变得非常关键. 只是那时我们必须把随机性的来源写清楚.
12. 一个可反复使用的算法分析框架
从 Fibonacci 和插入排序两个例子中, 可以提炼出一套以后反复使用的分析顺序.
Fibonacci 的故事可以概括为
插入排序则把另一条主线建立起来:
这两条线合在一起, 就构成了算法设计与分析最基本的思维方式.
13. 本节小结
读完这一部分, 最值得真正掌握的并不是几个复杂度结论, 而是下面这些判断习惯:
- 算法必须有明确输入、输出、步骤与终止条件.
- “正确”与“高效”是两个独立问题.
- 对每个算法都要问: 是否正确, 运行多久, 能否更好.
- 指数时间和多项式时间之间的差异, 通常远大于硬件常数倍加速.
- 复杂度依赖计算模型; 大整数运算不能无条件视为常数时间.
- RAM 模型把一个 位 word 上的常见基本运算视为常数时间.
- 插入排序正确性的核心是循环不变式, 而不变式必须同时保证“有序”和“仍是原元素的排列”.
- 插入排序最好情况为 , 在均匀随机排列下平均情况为 , 最坏情况也为 .
- 平均情况分析必须给出概率分布; 最坏情况分析则提供对所有输入有效的保证.
以后学习渐近记号、分治、动态规划或图算法时, 形式会越来越复杂, 但基本问题不会改变:
我们要设计一个什么样的计算过程, 为什么它一定正确, 它究竟付出了多少代价, 以及还有没有更好的办法?