两个 位数相乘,小学竖式要做 次个位乘法。几千年来所有人都默认这就是乘法的代价,直到 1960 年 Kolmogorov 在研讨会上提出这个猜想,一周后 23 岁的学生 Karatsuba 就找到了更快的方法。好算法很少来自灵光一现,更多来自几种反复出现的套路:分治、随机化、贪心、动态规划,以及把问题看成图。下面按这几种套路依次走一遍。
先学会数:复杂度分析
比较算法之前,要有一把尺子。我们数基本操作次数随输入规模 的增长,用 、 记号忽略常数(定义见 离散数学)。三个约定:
- 默认分析最坏情况:它给出对所有输入都成立的保证,不依赖对输入分布的猜测。
- 平均情况需要假设输入分布;期望复杂度则是算法自己掷硬币,对任何输入都成立(下面的快排)。
- 均摊是对一串操作的总代价求平均,动态数组的
append就是例子(见 数据结构)。
为什么要在乎增长速度而不是常数?因为规模一大,增长速度压倒一切。在 Python 里每秒大约能做 次简单操作, 时 是两秒, 是一天。
分治:拆开、递归、合并
分治的模板是:把问题拆成几个同类的小问题,递归解决,再合并答案。
归并排序是最干净的例子:拆成两半,分别排序,再用两个指针把两个有序数组合并,合并是 。运行时间满足
画递归树:第 层有 个子问题,每个大小 ,这一层的合并总共 ;一共 层,所以 。
回到乘法。把两个 位数各拆成高低两半,,,则
直接做需要 4 个 位乘法,,没有进步。Karatsuba 的观察是 ,所以只需要 3 次乘法:
def karatsuba(x, y, counter):
if x < 10 or y < 10:
counter[0] += 1; return x * y
m = max(len(str(x)), len(str(y))) // 2
a, b = divmod(x, 10**m); c, d = divmod(y, 10**m)
ac = karatsuba(a, c, counter); bd = karatsuba(b, d, counter)
# 一次乘法换掉两次:(a+b)(c+d) - ac - bd = ad + bc
mid = karatsuba(a + b, c + d, counter) - ac - bd
return ac * 10**(2 * m) + mid * 10**m + bd
import random
random.seed(0)
for n in [8, 32, 128, 512]:
x, y = random.randrange(10**(n-1), 10**n), random.randrange(10**(n-1), 10**n)
cnt = [0]
assert karatsuba(x, y, cnt) == x * y
print(f"n={n:4d} 个位乘法次数={cnt[0]:7d} 竖式 n^2={n*n:7d}")输出:
n= 8 个位乘法次数= 41 竖式 n^2= 64
n= 32 个位乘法次数= 361 竖式 n^2= 1024
n= 128 个位乘法次数= 3097 竖式 n^2= 16384
n= 512 个位乘法次数= 28675 竖式 n^2= 262144每翻 4 倍,竖式的次数翻 16 倍,Karatsuba 只翻约 9 倍()。同样的想法用在矩阵上就是 Strassen 算法: 分块矩阵乘法用 7 次而不是 8 次子乘法,得到 。
这类递推有一个统一的读法,主定理:对 ,比较 和 。
| 情况 | 结果 | 含义 |
|---|---|---|
| 根部的合并工作占主导 | ||
| 每层工作量相同,乘以层数 | ||
| 叶子的数量占主导 |
直觉是:每往下一层,子问题数量乘 (工作量变多),每个子问题的工作量除以 (变少),谁赢决定了总量集中在树的哪一端。归并排序 ,属于第二种;Karatsuba ,属于第三种;二分查找 ,第二种,。
某分治算法把问题拆成 4 个规模为 n/2 的子问题,合并代价是 O(n^2)。它的运行时间是?
这里 a 等于 4,b 等于 2,d 等于 2,b 的 d 次方是 4,与 a 相等,每层工作量都是 n^2 级别,乘以 log n 层。
O(n^2) 漏掉了层数;O(n^4) 可能是把 4 当成指数;O(n log n) 套用了归并排序的结论,但这里合并代价更高。
排序的下界:有些事做不到更快
归并排序是 。能不能更快?对基于比较的排序,答案是不能,而且可以证明。
任何比较排序都可以画成一棵决策树:每个内部节点是一次比较 ”?”,根据结果走向左或右,叶子是最终输出的排列。 个元素有 种排列,每种都必须出现在某个叶子上,否则算法会对某个输入输出错误。高度为 的二叉树最多 个叶子,所以
最坏情况下的比较次数就是树高。任何比较排序在最坏情况下都需要 次比较,这是关于问题本身而不是某个算法的结论。
下界只约束”只能比较”的算法。如果键是范围不大的整数,计数排序数一数每个值出现几次就能 排序;基数排序按位做多轮计数排序。它们绕过了下界,因为它们用键的值做数组下标,获得的信息不止一次比较的一比特。
import math, random
from functools import cmp_to_key
def count_comparisons(arr):
cnt = [0]
def cmp(a, b):
cnt[0] += 1; return (a > b) - (a < b)
sorted(arr, key=cmp_to_key(cmp)) # CPython 的 Timsort
return cnt[0]
random.seed(0)
for n in [10, 100, 1000, 10000]:
arr = random.sample(range(n * 10), n)
lb = math.lgamma(n + 1) / math.log(2) # log2(n!)
print(f"n={n:5d} Timsort 比较次数={count_comparisons(arr):7d} 下界 log2(n!)={lb:9.0f}")
print("已排序输入 n=10000:", count_comparisons(list(range(10000))))输出:
n= 10 Timsort 比较次数= 25 下界 log2(n!)= 22
n= 100 Timsort 比较次数= 529 下界 log2(n!)= 525
n= 1000 Timsort 比较次数= 8617 下界 log2(n!)= 8529
n=10000 Timsort 比较次数= 119749 下界 log2(n!)= 118458
已排序输入 n=10000: 9999Timsort 在随机输入上的比较次数只比信息论下界多约 1%。最后一行说明下界是最坏情况的:Timsort 会识别已经有序的片段,对排好序的输入只需 次比较。
随机化:让坏输入不存在
快速排序的思路和归并排序互补:先选一个枢轴(pivot),把比它小的放左边、大的放右边(这一步 ),再递归排两边,不需要合并。如果枢轴每次都恰好是中位数,;如果每次都是最小值(比如对已排序数组总选第一个元素),。
固定的枢轴规则总会被某种输入打败。解决办法是随机选择枢轴。这时不存在”坏输入”,只存在”坏运气”,而坏运气的概率极小。期望比较次数可以用指示变量算出来:排序后第 小和第 小的元素被比较,当且仅当它们之间的 个元素里,第一个被选为枢轴的是它俩之一,概率 。用期望的线性性(见 概率论)对所有对求和:
import random, sys
sys.setrecursionlimit(20000)
def quicksort(a, choose, cnt):
if len(a) <= 1: return a
p = choose(a)
cnt[0] += len(a) - 1 # 划分时其余每个元素都和枢轴比一次
return (quicksort([x for x in a if x < p], choose, cnt) + [p] +
quicksort([x for x in a if x > p], choose, cnt))
n = 3000
for name, choose in [("取第一个", lambda a: a[0]), ("随机", random.choice)]:
for label, data in [("随机输入", random.sample(range(n), n)), ("已排序输入", list(range(n)))]:
random.seed(1); cnt = [0]
quicksort(data, choose, cnt)
print(f"{name:4s} {label:5s} 比较次数={cnt[0]:8d}")
import math; print("2 n ln n ≈", round(2 * n * math.log(n)))输出:
取第一个 随机输入 比较次数= 37551
取第一个 已排序输入 比较次数= 4498500
随机 随机输入 比较次数= 37511
随机 已排序输入 比较次数= 38914
2 n ln n ≈ 48038取第一个元素做枢轴时,已排序输入让比较次数暴涨到约 ;随机枢轴对两种输入表现一样,都在 3.8 万左右。它比 万略低,因为 只是上界估计,精确的期望约为 。
同样的划分思想解决选择问题:找第 小的元素不需要排序。划分后只递归进 所在的那一边,期望 (快速选择)。还有一个确定性的 算法(中位数的中位数),选枢轴时先把元素五个一组取中位数,再递归取这些中位数的中位数,保证枢轴足够居中,理论上漂亮,实践中常数较大。
关于随机化快速排序,哪些说法正确?
随机枢轴让分析只依赖算法自己的随机数,所以对每一个输入期望都是 O(n log n),这比"平均情况"更强。
最坏情况(每次都抽到极端值)仍然是 O(n^2),只是概率极小。已排序输入只会打败固定的"取第一个"规则,对随机枢轴没有影响,上面的实验正是这样。
图算法:把问题变成图
大量问题一旦建模成图,就能套用现成的算法。图的表示(邻接表)见 数据结构,下面的复杂度都基于邻接表。
BFS(广度优先搜索)从起点一层一层往外扩,用队列实现,。它天然给出无权图的最短路径:第一次到达某个顶点时经过的边数就是最短距离。DFS(深度优先搜索)一条路走到底再回溯,用栈或递归实现,也是 。DFS 的价值在于它记录的完成时间:在有向无环图上,按完成时间倒序排列就是一个拓扑序,这是调度有依赖关系的任务的标准做法(构建系统、反向传播的计算图)。在 DFS 的基础上再做一次反向图上的 DFS,还能在线性时间内找出强连通分量(Kosaraju 算法)。
带权最短路。边有长度时,BFS 不再正确,因为边数少的路不一定短。Dijkstra 算法的想法是:维护每个顶点的当前最短距离估计,每次从未确定的顶点里取出估计最小的那个,宣布它已确定,再用它去更新邻居(松弛)。为什么取出时就能确定?因为边权非负,任何经过其他未确定顶点的路径都不会更短。用二叉堆实现,。
import heapq
def dijkstra(adj, s):
dist = {s: 0}; pq = [(0, s)]; done = set()
while pq:
d, u = heapq.heappop(pq)
if u in done: continue # 同一顶点可能多次入堆,只认第一次弹出
done.add(u)
for v, w in adj[u]:
if d + w < dist.get(v, float("inf")):
dist[v] = d + w; heapq.heappush(pq, (d + w, v))
return dist
adj = {"A": [("B", 4), ("C", 1)], "C": [("B", 2), ("D", 5)],
"B": [("D", 1)], "D": []}
print(dijkstra(adj, "A"))输出:
{'A': 0, 'B': 3, 'C': 1, 'D': 4}A 到 B 直接走是 4,绕道 C 是 3;到 D 的最短路 A→C→B→D 是 4。
Dijkstra 依赖”边权非负”。有负权边时(比如汇率套利问题,取对数后变成负权),要用 Bellman-Ford:对所有边重复松弛 轮,。为什么 轮够?因为最短路最多有 条边,第 轮之后,所有”最多 条边”的最短路都已正确。如果第 轮还能松弛,说明存在负环,最短路无定义。这个”按边数逐轮推进”的结构,其实已经是动态规划了。
最小生成树(MST):用最小总权重的边把所有顶点连起来。两个贪心算法都正确:Prim 从一个顶点出发,每次加入连接”已选部分”和”未选部分”的最便宜的边(用堆);Kruskal 把所有边从小到大排序,依次加入,跳过会形成环的边(用并查集判断)。两者都是 。它们正确的共同原因是割性质:对任何把顶点分成两部分的切割,跨越切割的最便宜的边一定属于某棵 MST。
一张有向图里有负权边但没有负环。用 Dijkstra 求单源最短路会怎样?
Dijkstra 取出一个顶点就认定其距离最终确定,这依赖"后面的路只会更长"。负权边会让一条先绕远、再走负边的路更短,而那时该顶点已经被确定,答案就错了。
没有负环只保证最短路有定义,不保证 Dijkstra 的贪心正确,此时该用 Bellman-Ford。Dijkstra 每个顶点只处理一次,不会死循环。
动态规划:记住子问题的答案
有些问题能拆成子问题,但子问题之间大量重叠。斐波那契的朴素递归 fib(n) = fib(n-1) + fib(n-2) 会把 fib(n-2) 算两遍、fib(n-3) 算三遍……,总次数指数增长。把每个子问题的答案存起来,每个只算一次,就是动态规划(DP)。记忆化递归和自底向上填表是同一件事的两种写法(记忆化见 高阶函数与递归)。
DP 的难点不在写代码,而在定义子问题。CS161 的套路是问三个问题:
- 最优解的最后一步是什么?
- 去掉最后一步,剩下的是不是一个同类的更小问题(最优子结构)?
- 一共有多少个不同的子问题,每个要花多少时间合并?复杂度 = 子问题数 × 每个的代价。
0/1 背包: 件物品,各有重量 和价值 ,背包容量 ,选哪些使总价值最大?问最后一件物品 放不放:不放,就是”前 件、容量 “的子问题;放,就是”前 件、容量 “再加 。
子问题 个,每个 ,总共 。
from itertools import combinations
w = [2, 3, 4, 5]; v = [3, 4, 5, 6]; W = 5
n = len(w)
K = [[0] * (W + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for c in range(W + 1):
K[i][c] = K[i - 1][c]
if w[i - 1] <= c:
K[i][c] = max(K[i][c], K[i - 1][c - w[i - 1]] + v[i - 1])
for row in K: print(row)
# 暴力枚举所有子集交叉验证
best = max(sum(v[i] for i in s) for r in range(n + 1) for s in combinations(range(n), r)
if sum(w[i] for i in s) <= W)
print("DP:", K[n][W], " 暴力:", best)输出:
[0, 0, 0, 0, 0, 0]
[0, 0, 3, 3, 3, 3]
[0, 0, 3, 4, 4, 7]
[0, 0, 3, 4, 5, 7]
[0, 0, 3, 4, 5, 7]
DP: 7 暴力: 7最优是选重量 2 和 3 的两件,价值 7。注意 看起来是多项式,但 是一个数值,写下它只需要 位,所以这是伪多项式时间。背包问题是 NP 完全的,这并不矛盾。
编辑距离是另一个经典:把字符串 变成 最少需要几次插入、删除、替换?看两串的最后一个字符:如果相同,问题化为两串各去掉末字符;否则三种操作各对应一个更小的子问题,取最小加 1。 个子问题,这是 diff、拼写纠错、DNA 比对的基础。前面的 Bellman-Ford 也是 DP,子问题是”最多用 条边到达 的最短距离”。
对长度为 m 和 n 的两个字符串求编辑距离,标准 DP 的时间复杂度是?
子问题是"s 的前 i 个字符变成 t 的前 j 个字符",共 (m+1)(n+1) 个,每个只看三个更小的子问题,所以是 O(mn)。
线性时间无法比较所有前缀组合。指数时间是不记忆化的朴素递归,DP 正是通过合并重叠子问题把它降下来。对数因子没有来源。
贪心:每一步都选眼前最好的
DP 要考虑最后一步的所有选择。如果能证明某个局部最优的选择一定能扩展成全局最优,就不用比较了,直接选它,这就是贪心。它通常更快更简单,但正确性必须证明,直觉经常出错。
活动选择:一堆活动各有开始和结束时间,一个会议室,最多能安排多少个不冲突的活动?候选贪心规则有好几种:最短的优先、最早开始的优先、冲突最少的优先,它们都有反例。正确的是最早结束的优先。证明用交换论证:取任意一个最优解,把其中最早结束的活动换成全局最早结束的那个,不会和后面的活动冲突(它结束得更早),活动数不变,所以存在一个包含贪心选择的最优解;然后对剩下的问题归纳。
from itertools import combinations
def greedy(acts, key):
chosen = []
for a in sorted(acts, key=key):
# 和已选的每个活动都不重叠才收下
if all(a[1] <= b[0] or b[1] <= a[0] for b in chosen): chosen.append(a)
return chosen
def optimum(acts):
ok = lambda sub: all(x[1] <= y[0] for x, y in zip(sorted(sub), sorted(sub)[1:]))
return max(r for r in range(len(acts) + 1) for sub in combinations(acts, r) if ok(sub))
rules = {"最早结束": lambda a: a[1], "最短": lambda a: a[1] - a[0], "最早开始": lambda a: a[0]}
cases = {"短活动横跨两个长活动": [(0, 5), (4, 7), (6, 11)],
"长活动最早开始": [(0, 10), (1, 2), (3, 4), (5, 6)]}
for name, acts in cases.items():
res = {r: len(greedy(acts, k)) for r, k in rules.items()}
print(name, res, "最优:", optimum(acts))输出:
短活动横跨两个长活动 {'最早结束': 2, '最短': 1, '最早开始': 2} 最优: 2
长活动最早开始 {'最早结束': 3, '最短': 3, '最早开始': 1} 最优: 3第一组里”最短优先”选中中间的短活动,一下挡掉两边;第二组里”最早开始优先”被一个早开始的长活动拖累。只有最早结束优先在两组都拿到最优。
再比如找零:用面值 25、10、5、1 的硬币凑出某个金额,每次拿不超过余额的最大面值,结果总是最少枚数。但换成面值 1、3、4 凑 6,贪心拿 4+1+1 是三枚,最优是 3+3 两枚。贪心对的时候是因为问题结构特殊,前面的 MST(割性质)、Dijkstra(非负权)、Huffman 编码都有各自的交换论证。拿不准时,用 DP 或者用暴力在小输入上对拍。
硬币面值为 1、3、4,要凑出 6 且枚数最少。下面哪个说法正确?
贪心先拿 4,余额 2 只能拿两个 1,共 3 枚;而 3 加 3 只要 2 枚。DP 以"凑出金额 c 的最少枚数"为子问题,对每种面值作为最后一枚取最小,能找到 2 枚的解。
贪心在这组面值上不满足交换论证,所以不是最优。找零问题有最优子结构:最优解去掉最后一枚硬币,剩下的是较小金额的最优解。
当找不到快算法:NP 完全
学完这些套路,会遇到一些问题怎么都套不上:旅行商(访问所有城市的最短回路)、图着色、布尔可满足性 SAT、背包(以输入位数衡量)。它们有一个共同点:给出一个解,验证它很容易;但找到一个解,没有人知道多项式算法。
P 是能在多项式时间内求解的问题,NP 是解能在多项式时间内验证的问题。归约是这里的核心工具:如果问题 A 能在多项式时间内转化为问题 B,那么 B 至少和 A 一样难。NP 完全问题是 NP 中最难的一批,任何 NP 问题都能归约到它们。所以只要其中一个有多项式算法,所有 NP 问题都有,即 P = NP。这是计算机科学最著名的未解问题,普遍相信 P ≠ NP。
识别出一个问题是 NP 完全的,并不意味着放弃,而是换一个目标:规模小就用指数算法加剪枝;接受近似解(MST 能给出度量 TSP 的 2 倍近似);用启发式与求解器(现代 SAT 求解器能处理数百万变量的工业实例);或者利用实际输入的特殊结构(背包的 不大时,伪多项式 DP 完全够用)。
记住这三件事
- 分治的代价由递归树决定,主定理比较”子问题增多”和”每个变小”谁更快;比较排序有 的下界。
- 随机化让坏输入消失,期望用线性性分析;图问题先选对模型:BFS 求无权最短路,Dijkstra 要非负权,MST 靠割性质。
- 子问题重叠就用 DP,复杂度 = 子问题数 × 每个的代价;贪心必须有交换论证,否则用 DP 或对拍检查。
延伸阅读
- Stanford CS161 Design and Analysis of Algorithms(课程讲义)
- Tim Roughgarden,《Algorithms Illuminated》系列及 配套视频
- Kleinberg & Tardos,《Algorithm Design》(贪心、DP、归约讲得最好)
- Cormen et al.,《Introduction to Algorithms》(CLRS)第 4、7、8、15、16、22–24、34 章
- Jeff Erickson,《Algorithms》(UIUC 免费教材)