算法课真正要训练什么

算法课表面上有很多具体算法:Dijkstra、Bellman-Ford、Kruskal、Ford-Fulkerson、Simplex、Knapsack DP。

但最重要的不是把它们的代码背下来。

更核心的问题是:

  • 一个自然语言问题怎样变成 formal problem
  • 约束应该编码成状态、边、容量,还是不等式
  • 为什么这个算法适用
  • 为什么它不会错
  • 它的成本由什么决定

很多看似和编程完全无关的问题,比如排课、分配、运输、活动选择、网络容量、路径限制,最后都可以转成一个算法问题。

这个转化过程才是 Design and Analysis of Algorithms 的中心。

问题建模:先把对象拆开

看到一个问题,先不要急着套算法。先把它拆成四件事:

维度要问的问题
Input给了哪些对象?数字、序列、图、集合、时间区间、容量?
Constraint什么是合法解?不能冲突、不能超过容量、必须连通、必须覆盖?
Objective要最大化还是最小化什么?长度、成本、收益、数量、风险?
Output要输出一个值,还是输出具体方案?

很多题卡住,是因为没有先把这些东西分开。

比如“安排最多活动”:

  • Input:一组活动,每个活动有 start time 和 finish time
  • Constraint:被选活动之间不能重叠
  • Objective:最大化活动数量
  • Output:最多能选多少个,或者具体选哪些

再比如“每个学生分配一个项目,满足偏好并最大化匹配数”:

  • Input:学生、项目、可接受关系
  • Constraint:每个学生最多一个项目,每个项目最多一个学生
  • Objective:最大化匹配数量
  • Output:matching

第二个问题自然会变成 bipartite matching,第一个问题可能是 greedy 或 DP,取决于活动有没有 weight。

从问题到模型

算法设计中最常见的模型转换大概有几类:

原始问题特征常见模型
依赖关系、先后顺序DAG / topological order
路径、距离、可达性Graph shortest path / reachability
有限次特殊操作Layered graph
两类对象配对Bipartite matching / max flow
容量、供需、运输Flow / min-cost flow
线性目标 + 线性约束Linear programming
最优子结构 + 重叠子问题Dynamic programming
局部选择可能推出全局最优Greedy
搜索空间太大且难验证边界NP / approximation / randomization

这里没有一个万能公式。关键是读出问题里的结构。

如果约束像“每一步从一个状态走到另一个状态”,通常可以建图。

如果约束像“每个对象只能被用一次”,通常会想到 matching。

如果约束像“总量不能超过某个容量”,可能是 knapsack、flow 或 LP。

如果约束像“我现在的最优解依赖前面某些更小问题的最优解”,通常是 DP。

正确性证明比代码重要

算法课里,代码只是算法思想的一种表达。真正要说明的是:为什么这个方法一定得到正确答案。

常见证明方法:

方法适用场景
InductionDP、递归算法、图上按拓扑序计算
Exchange argumentGreedy 算法
Cut property / cycle propertyMST
Loop invariant迭代算法、Dijkstra、Bellman-Ford
DualityLP、max-flow min-cut
ReductionNP-completeness、把新问题转成旧问题

DP 的证明通常是 induction。

先定义 $dp[i]$ 的含义,再证明每个状态的递推确实覆盖了所有可能的最后一步决策,并且没有漏掉更优解。

Greedy 的证明通常是 exchange argument。

先假设有一个最优解和 greedy 解不同,然后把最优解中的某个选择换成 greedy 的选择,证明换完不变差。重复这个过程,最后得到 greedy 解也是最优。

图算法经常靠 invariant。

Dijkstra 的核心 invariant 是:每次从 priority queue 中取出的点,其最短距离已经确定。这个 invariant 依赖非负边权。如果有负边,这个保证就没了。

复杂度分析:先确定输入规模

复杂度分析第一步是确定 input size。

如果输入是数组,通常用 $n$。

如果输入是图,通常用:

  • $n = |V|$
  • $m = |E|$

如果输入有数值容量,比如 knapsack 的容量 $C$,要特别小心。$O(nC)$ 看起来像多项式,但如果 $C$ 用二进制表示,输入长度是 $\log C$,那 $O(nC)$ 就是 pseudo-polynomial。

复杂度通常来自几种来源:

来源怎么数
遍历数组$O(n)$
遍历图$O(n+m)$
状态表状态数量 × 每个状态转移成本
排序$O(n\log n)$ 或 $O(m\log m)$
Priority queue每次 extract / update 的 log factor
递归层数 × 每层工作,或解 recurrence
Flow增广次数 × 每次找增广路成本

很多算法题不需要把实现细节写满,但必须清楚成本来自哪里。

分治:把跨越部分单独处理

分治的基本结构:

solve(input[左..右]):
    if 足够小: 直接返回答案

    中点 m = (左+右)/2
    左答案 = solve(input[左..m])
    右答案 = solve(input[m+1..右])
    跨越答案 = ???

    return 合并(左答案, 右答案, 跨越答案)

分治的难点通常不在“递归解决左右两边”,而在 crossing case。

以最大子数组和为例:

  • 最优子数组完全在左半边
  • 最优子数组完全在右半边
  • 最优子数组跨过中点

前两种递归处理。跨越中点的情况需要从中点向左扫一个最大后缀和,再从中点向右扫一个最大前缀和,二者相加。

于是:

$$ T(n)=2T(n/2)+O(n) $$

根据 Master Theorem:

$$ T(n)=O(n\log n) $$

Master Theorem 的直觉

递归式:

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

其中:

  • $a$:每层分出几个子问题
  • $n/b$:每个子问题多大
  • $f(n)$:当前层额外做多少合并工作

比较的是当前层工作 $f(n)$ 和叶子规模贡献 $n^{\log_b a}$。

情况直觉结果
$f(n)$ 更小工作主要在叶子$T(n)=\Theta(n^{\log_b a})$
$f(n)$ 同阶每层贡献差不多$T(n)=\Theta(n^{\log_b a}\log n)$
$f(n)$ 更大工作主要在根部$T(n)=\Theta(f(n))$

Merge sort:

$$ T(n)=2T(n/2)+O(n) $$

这里 $n^{\log_2 2}=n$,每层都是 $O(n)$,一共 $\log n$ 层,所以 $O(n\log n)$。

二分查找:

$$ T(n)=T(n/2)+O(1) $$

每层只做常数工作,一共 $\log n$ 层,所以 $O(\log n)$。

Strassen:

$$ T(n)=7T(n/2)+O(n^2) $$

这里 $n^{\log_2 7}\approx n^{2.81}$,递归子问题贡献更大,所以复杂度是:

$$ O(n^{\log_2 7}) $$

算法选择的高层判断

很多题可以先按结构粗分:

看到什么先想到什么
最优解依赖更小子问题DP
局部最优看起来可交换Greedy + exchange proof
有节点、边、路径、可达性Graph
有额外路径状态Layered graph
有两类对象一对一配对Bipartite matching
有容量、流量、供需Flow
线性目标和线性约束LP
要证明新问题很难Reduction / NP-completeness

这张表的作用是降低搜索空间,不是让人机械套模板。

算法设计最常见的失败方式,是在还没看清结构前就开始写某个熟悉算法。更好的顺序是:先建模,再判断范式,再证明正确性,最后分析复杂度。

Part I 的重点就是这套顺序。