一、问题、实例与算法

一个计算问题(problem) 是输入集合到输出集合的一个关系:它规定了"对每个合法输入,什么样的输出算正确"。

排序问题 输入:n 个数的序列 ⟨a₁, a₂, …, aₙ⟩ 输出:该序列的一个重排 ⟨a′₁, a′₂, …, a′ₙ⟩,满足 a′₁ ≤ a′₂ ≤ … ≤ a′ₙ

注意这里的措辞极其克制。它没有说"用什么方法",只说了"什么结果算对"。这是本课程反复出现的分离:

概念 含义
问题(problem) 输入到输出的规范。不含方法。
实例(instance) 一个具体输入,如 ⟨31, 41, 59, 26⟩
算法(algorithm) 一个良定义的计算过程,把输入变成输出

一个算法称为对该问题正确(correct),当且仅当:对每一个合法实例,它都能停机,并输出满足规范的结果。

⚠️ 注意"每一个"。一个在 99% 输入上正确的过程不叫正确的算法,它叫启发式(heuristic)。启发式有它的位置(见第 34 讲),但不能混为一谈。


二、计算模型:RAM

说"这个算法要 n log n 时间",前提是我们约定了什么算一步。没有模型,效率无从谈起。

本课程使用 RAM 模型(Random-Access Machine)

(1)指令集:以下每条算作一步(常数时间):

  • 算术:+ - × ÷ mod、下取整、上取整
  • 数据移动:读内存、写内存、赋值
  • 控制:条件跳转、无条件跳转、子程序调用与返回
  • 比较

(2)内存:一个无限大的数组,任意下标的读写都是一步。这就是"随机访问"的含义——访问 A[0] 和访问 A[10⁶] 代价相同。

(3)字长(word size):内存的每个单元存放 w 比特。标准假设是

w ≥ log₂ n

为什么必须有这个假设? 因为我们需要一个下标能寻址整个输入。如果 w < log n,连"指向第 n 个元素的指针"都存不下,遍历数组都不是 O(n) 了。反过来,我们也不允许 w 任意大——否则可以把整个输入编码进一个整数,用几次算术运算解决所有问题,模型就失去意义。

模型是一种谎言,但是有用的谎言

RAM 模型明显不真实:

RAM 假设 真实机器
所有内存访问代价相同 L1 缓存 ~1ns,主存 ~100ns,SSD ~100μs
乘法和加法都是一步 乘法慢几倍,除法慢十几倍
内存无限 会 OOM

那为什么还用它?因为它在正确的粒度上区分算法。O(n²) 和 O(n log n) 的差距,在 n = 10⁶ 时是 5 万倍,任何常数因子和缓存效应都盖不住它。RAM 模型丢掉了常数,保留了量级——而量级正是设计决策的依据。

当常数真的开始主导时(数据放不进内存、或算法都在同一量级),我们会换模型。第 18 讲外存模型(External Memory Model) 就是这样一个例子:它只数磁盘块传输次数,把 CPU 计算当成免费的。


三、正确性:循环不变式

如何证明一个算法对所有输入都正确?穷举测试是不可能的。标准工具是循环不变式(loop invariant)——本质上是伪装成程序注释的数学归纳法。

插入排序为例(详见第 9 讲):

func InsertionSort(a []int) {
    for j := 1; j < len(a); j++ {
        key := a[j]
        i := j - 1
        for i >= 0 && a[i] > key {
            a[i+1] = a[i]
            i--
        }
        a[i+1] = key
    }
}

循环不变式:在外层 for 每次迭代开始前,子切片 a[0:j] 由原来 a[0:j] 中的元素组成,且已排好序。

证明分三段,必须三段都写:

① 初始化(Initialization)——第一次迭代前不变式成立。 j = 1 时,a[0:1] 只有一个元素,就是原来的 a[0],平凡有序。✓

② 保持(Maintenance)——若某次迭代前成立,则下次迭代前仍成立。 内层 fora[j-1], a[j-2], … 中所有大于 key 的元素依次右移一位,直到找到 ≤ key 的位置,把 key 放进去。结果 a[0:j+1] 有序,且元素集合不变。j 加 1 后,不变式对新的 j 成立。✓

③ 终止(Termination)——循环结束时,不变式给出我们要的结论。 循环在 j = n 时结束。代入不变式:a[0:n] 由原元素组成且有序。这正是排序问题的规范。

第三步是最容易被跳过、也最关键的一步。 一个不变式如果在终止时推不出目标结论,它再"真"也没用。选不变式的技巧就是倒着想:先写下终止时想要什么,再想在循环中途它的"部分版本"是什么样。

为什么这是归纳法

初始化 ≙ 归纳基础;保持 ≙ 归纳步骤。区别只在于:数学归纳会一直归纳到无穷,而循环不变式的归纳在循环终止时停下——多出来的第三步,正是处理这个"停下"的。


四、递归算法的正确性

递归算法用强归纳法证明。模板:

  1. 基础情形:对最小规模的输入,直接验证。
  2. 归纳假设:假设对所有规模 < n 的输入,递归调用都返回正确结果。
  3. 归纳步骤:在此假设下,证明当前这一层的组合逻辑给出正确结果。

关键心态转变:你不必在脑子里展开递归。 你只需要相信"更小的调用是对的",然后检查这一层。展开递归去追踪调用栈,是初学者最常见也最耗时的错误习惯。

对应地,递归算法的时间分析用递归式(recurrence),见第 3 讲

另外还要证明停机性:找一个随每次递归调用严格减小、且有下界的量(通常是输入规模)。这个量在程序验证里叫变界函数(variant)——不变式保证"对",变界函数保证"停"。


五、抽象数据类型与数据结构

这是本课程 Unit 2 到 Unit 4 的组织主线,务必分清:

抽象数据类型(ADT) 数据结构(Data Structure)
是什么 一组操作的规范:能做什么,语义是什么 一种实现:数据在内存里怎么摆
回答的问题 What How
例子 优先队列:insertextract-min 二叉堆、配对堆、Fibonacci 堆
例子 有序字典:searchinsertpredecessor 红黑树、跳表、B 树
        ┌────────────────────────────────┐
        │   ADT:优先队列                  │   ← 接口 / 契约
        │   insert(x) · extract-min()     │
        └───────────────┬────────────────┘
             ┌──────────┼──────────┐
             ▼          ▼          ▼
        ┌────────┐ ┌────────┐ ┌──────────┐
        │ 有序数组│ │ 二叉堆  │ │ Fib 堆    │   ← 实现,各有取舍
        │ ins O(n)│ │ O(log n)│ │ O(1)*    │
        │ ext O(1)│ │ O(log n)│ │ O(log n)*│
        └────────┘ └────────┘ └──────────┘
                                  * 摊还

为什么这个区分重要? 因为选型问题永远是这样提出来的:

“我要一个能按优先级取最小、并且频繁下调优先级的结构。”

这句话确定了 ADT。剩下的工作是在实现之间比较各操作的代价,结合你的操作混合比例(workload mix) 做选择。如果 decrease-key 调用远多于 extract-min(Dijkstra 稠密图就是这样),Fibonacci 堆的 O(1) 摊还 decrease-key 才有意义;否则二叉堆的常数因子和缓存友好性会赢。

一句话总结:ADT 决定你能不能解决问题,数据结构决定你解决得多快。


六、输入规模与运行情况

输入规模(input size) 的定义随问题而变,选错会导致复杂度结论错误:

问题 合理的规模度量
排序 元素个数 n
图算法 顶点数 V 与边数 E(两个参数)
大整数乘法 二进制位数,不是数值大小
矩阵乘法 阶数 n(输入实际有 n² 个数)

⚠️ 一个经典陷阱:0-1 背包的 DP 是 O(nW),看起来是多项式。但 W 是数值,它的编码长度是 log W。以输入长度计,这是 O(n·2^{log W}),是指数级——这叫伪多项式(pseudo-polynomial) 时间,见第 31 讲第 34 讲

三种运行情况:

  • 最坏情况(worst case) T(n) = 规模 n 的所有输入中的最大代价。本课程的默认口径,因为它给出保证,且常常就是实际情况(数据库查不到的搜索总是走最坏路径)。
  • 平均情况(average case):需要假设输入分布,而这个假设常常不成立。
  • 期望情况(expected case):随机性来自算法自己(如随机化快排),与输入分布无关——所以比平均情况可靠得多。见第 11 讲

随堂自测

  1. 为什么 RAM 模型要求字长 w ≥ log n?如果允许 w 任意大,会出什么问题?
  2. 循环不变式证明的三个步骤是什么?哪一步最常被遗漏,为什么它不可省略?
  3. 给下面的函数写出循环不变式,并完成三步证明:
    func Sum(a []int) int {
        s := 0
        for i := 0; i < len(a); i++ { s += a[i] }
        return s
    }
    
  4. “优先队列"是 ADT 还是数据结构?“二叉堆"呢?用一句话说明二者的关系。
  5. 为什么说"平均情况分析"不如"期望运行时间"可靠?
  6. 一个算法在 n = 10⁶ 时跑得比另一个快,能否说明它渐近更优?反过来呢?