0032 · 把算法讲给别人听

把算法讲给别人听

本节目标:能对一个算法讲清正确性、复杂度与被淘汰的数据规模。

讲不清就是没懂

这门课一开始定的目标是「能做出来」。到这一课要升一级:能讲明白。

这两件事差的不是表达能力,是理解的深度。有两句话听起来很像,其实完全不同:

第一句在第 26 课那种「照葫芦画瓢」的场合够用,换一道题就不够了——因为你不知道那条规则是为哪个目的服务的,也就不知道什么时候可以违反它。

这一课给一套可自查的讲解结构,然后拿它去拷问你自己学过的每个算法。凡是讲不下来的地方,就是还有一层没吃透。

三分钟讲解稿的四段

一份能自查的讲解稿,固定分成四段。顺序不能换,因为后一段要用到前一段的结论。

第一段:这个东西在解决什么问题。 一句话,不许出现算法名字和代码。比如「并查集在解决:一堆元素分成若干组,我要快速知道两个元素在不在同一组」。

第二段:为什么它是正确的。 这是最难也最关键的一段。不许复述代码流程——「先这样再那样然后返回」是流程,不是论证。要讲的是不变式:每一步结束时,什么东西一定成立;以及为什么这个不变式能推出最终答案。

第三段:复杂度怎么推出来的。 不许只报结论。O(n \log n) 要能说清楚那个 \log n 是从哪一步来的——是每层分一半(分治),还是每次操作让某个量减半(堆的上浮)。

第四段:多大的规模会把它淘汰掉。 这是最常被跳过的一段,也是最能看出你懂没懂的一段。任何算法都有它不适用的输入,说不出失效条件的,等于不知道它的边界在哪。

下面三节分别把第二、三、四段拿出来,各量一组数据给你看。

讲得出为什么,才写得出对的

先看最直接的一种检验方式:如果你说不清「为什么」,那你多半也写不出对的。

0/1 背包的内层循环要逆序,这是第 25 课的内容。正序写会怎样?

W = 10
items = [(2, 3), (3, 4), (4, 5), (5, 6)]


def knap(direction, w):
    """direction='back' 是 0/1 背包,'forward' 是完全背包。"""
    dp = [0] * (w + 1)
    for wt, val in items:
        js = range(w, wt - 1, -1) if direction == 'back' else range(wt, w + 1)
        for j in js:
            dp[j] = max(dp[j], dp[j - wt] + val)
    return dp[w]


print(f'   物品(重量, 价值)= {items},背包容量 {W}')
print(f'   内层循环逆序(0/1 背包): {knap("back", W)} 分')
print(f'   内层循环正序(完全背包): {knap("forward", W)} 分')
print()
print('   逆序时每一轮 dp(容量 0..10):')
dp = [0] * (W + 1)
for wt, val in items:
    for j in range(W, wt - 1, -1):
        dp[j] = max(dp[j], dp[j - wt] + val)
    print(f'      处理完({wt}, {val}): {dp}')
print('   正序时每一轮 dp(容量 0..10):')
dp = [0] * (W + 1)
for wt, val in items:
    for j in range(wt, W + 1):
        dp[j] = max(dp[j], dp[j - wt] + val)
    print(f'      处理完({wt}, {val}): {dp}')
   物品(重量, 价值)= [(2, 3), (3, 4), (4, 5), (5, 6)],背包容量 10
   内层循环逆序(0/1 背包): 13 分
   内层循环正序(完全背包): 15 分

   逆序时每一轮 dp(容量 0..10):
      处理完(2, 3): [0, 0, 3, 3, 3, 3, 3, 3, 3, 3, 3]
      处理完(3, 4): [0, 0, 3, 4, 4, 7, 7, 7, 7, 7, 7]
      处理完(4, 5): [0, 0, 3, 4, 5, 7, 8, 9, 9, 12, 12]
      处理完(5, 6): [0, 0, 3, 4, 5, 7, 8, 9, 10, 12, 13]
   正序时每一轮 dp(容量 0..10):
      处理完(2, 3): [0, 0, 3, 3, 6, 6, 9, 9, 12, 12, 15]
      处理完(3, 4): [0, 0, 3, 4, 6, 7, 9, 10, 12, 13, 15]
      处理完(4, 5): [0, 0, 3, 4, 6, 7, 9, 10, 12, 13, 15]
      处理完(5, 6): [0, 0, 3, 4, 6, 7, 9, 10, 12, 13, 15]

两种写法循环体一字不差,只有 range 的方向不同,答案差了 2 分。 而且注意一个细节:

正序处理完第一件物品 (2, 3) 时,dp[10] 已经是 15 了——15 = 3 × 5,意思是这件重量为 2 的物品被用了 5 次。可题目说每件物品只有一件。这就是那个 bug 的全部:正序时 dp[j - wt] 读到的已经被这一轮更新过了,所以当前物品被重复使用。

反过来,逆序处理完 (2, 3) 时 dp[10] = 3,只用了一次——因为 dp[j - wt] 读到的还是「上一轮」的值。

这就是第二段该讲的东西。 它比「要逆序」这句话长,但它能回答一个「要逆序」回答不了的问题:如果我确实想要完全背包(物品能用无限次),该怎么写? ——把方向倒过来。

复杂度要推,不能背

第三段的要求是「推出来」,不是「报结论」。看并查集,这是最容易被一句「查得很快」糊过去的结构。

第 19 课讲过并查集。它的查询代价有一个微妙之处:不同的实现方式,总代价差三个数量级。

def uf(n, mode):
    """n-1 次合并 + n 次查询,返回(总步数, 单次查询最坏步数)。

    合并时一律 parent[find(i-1)] = i,也就是每次都把「已经建好的那棵树」
    挂到新结点下面 —— 这样不管什么模式,合并后 parent 都是一条长度 n 的链。
    四种模式的区别只在「查询时做不做路径压缩」和「合并时看不看秩」。
    """
    parent = list(range(n))
    rank = [0] * n
    total = 0
    worst = 0

    def find(x):
        nonlocal total, worst
        root, d = x, 0
        while parent[root] != root:
            root = parent[root]
            d += 1
        total += d
        if d > worst:
            worst = d
        if mode in ('compress', 'both'):
            while parent[x] != root:
                parent[x], x = root, parent[x]
        return root

    for i in range(1, n):
        if mode in ('rank', 'both'):
            a, b = find(0), find(i)
            if a != b:
                if rank[a] < rank[b]:
                    a, b = b, a
                parent[b] = a
                if rank[a] == rank[b]:
                    rank[a] += 1
        else:
            parent[find(i - 1)] = i
    for i in range(n):
        find(i)
    return total, worst


N = 2000
print(f'   n = {N}:{N - 1} 次合并 + {N} 次查询,数「沿 parent 走了多少步」')
print('   做法                        总步数      平均每次   单次最坏')
for mode, name in (('chain', '不压缩、不按秩'),
                   ('compress', '只用路径压缩'),
                   ('rank', '只按秩合并'),
                   ('both', '按秩合并 + 路径压缩')):
    t, w = uf(N, mode)
    print(f'   {name:<26} {t:>9,}    {t / N:>7.2f}   {w:>7,}')
print(f'   n(n-1)/2 = {N * (N - 1) // 2:,}')
   n = 2000:1999 次合并 + 2000 次查询,数「沿 parent 走了多少步」
   做法                        总步数      平均每次   单次最坏
   不压缩、不按秩                    1,999,000     999.50     1,999
   只用路径压缩                         3,997       2.00     1,999
   只按秩合并                          1,999       1.00         1
   按秩合并 + 路径压缩                    1,999       1.00         1
   n(n-1)/2 = 1,999,000

「不压缩、不按秩」那一行的总步数是 1,999,000,正好等于 n(n-1)/2。 这个数字不是巧合——它就是把一条长度 n 的链从每个结点各走一遍的总和,也就是 O(n^2)。能说出「这个数字为什么正好是 n(n-1)/2」,就说明你真的推出来了,而不是背了个结论。

「摊还」是什么意思

上面那张表最值得盯的是第三行:只用路径压缩,总步数 3,997,但单次最坏仍然是 1,999。

这两个数放在一起会让人困惑:到底快不快?答案是两个都对——它说的是两件不同的事:

「摊还」就是把这两件事分开说的术语:不是「每一次都快」,而是「总代价小」——某一次特别慢的账,由它顺手压平路径、让后面所有查询都变快这笔收入抵掉了。

所以「并查集查询是 O(\alpha(n))」这句话的准确含义是摊还 O(\alpha(n)),不是每一次都 O(\alpha(n))。面试或讲题时把「摊还」两个字说清楚,和不说的差别很大。

我这张表里的「只按秩合并」和「按秩合并 + 路径压缩」总步数完全一样(都是 1,999)。这不是我写错了,而是这个合并顺序恰好让按秩合并单独用就够了:每次合并都是「大树 + 一个孤立点」,按秩规则会自然把孤立点挂到树下,深度停在 1,路径压缩无事可做。

换一种合并顺序,两者就分开了。 下一节量给你看。

def uf_tournament(n, compress):
    """先合相邻两块、再合相邻两大块(像淘汰赛)。返回(总步数, 单次最坏)。"""
    parent = list(range(n))
    rank = [0] * n
    total = 0
    worst = 0

    def find(x):
        nonlocal total, worst
        root, d = x, 0
        while parent[root] != root:
            root = parent[root]
            d += 1
        total += d
        if d > worst:
            worst = d
        if compress:
            while parent[x] != root:
                parent[x], x = root, parent[x]
        return root

    step = 1
    while step < n:
        i = 0
        while i + step < n:
            a, b = find(i), find(i + step)
            if a != b:
                if rank[a] < rank[b]:
                    a, b = b, a
                parent[b] = a
                if rank[a] == rank[b]:
                    rank[a] += 1
            i += 2 * step
        step *= 2
    for i in range(n):
        find(i)
    return total, worst


for N in (2000, 4000):
    print(f'   n = {N}')
    for compress, name in ((False, '只按秩合并'), (True, '按秩合并 + 路径压缩')):
        t, w = uf_tournament(N, compress)
        print(f'      {name:<24} 总步数 {t:>9,}   平均每次 {t / N:>6.2f}   单次最坏 {w:>5,}')
    print(f'      n × log2(n) = {N * (N.bit_length() - 1):,}')
   n = 2000
      只按秩合并                    总步数    10,864   平均每次   5.43   单次最坏    10
      按秩合并 + 路径压缩              总步数     3,987   平均每次   1.99   单次最坏     2
      n × log2(n) = 20,000
   n = 4000
      只按秩合并                    总步数    23,728   平均每次   5.93   单次最坏    11
      按秩合并 + 路径压缩              总步数     7,986   平均每次   2.00   单次最坏     2
      n × log2(n) = 44,000

这张表要横着看,看「规模翻倍时总步数翻了几倍」:

这就是第三段该讲的东西。 说「并查集接近 O(1)」是背结论;能指出「规模翻倍时总步数只翻一倍」是推导;能进一步说清「因为路径压缩把长链摊平了,代价被后面的查询分掉」才是真懂。

顺带一个反过来的结论:这张表也说明了只按秩合并不是没用——它在上一节的合并顺序下已经足够好,在这一节也只是 O(n \log n)。路径压缩的收益取决于合并顺序可不可控。讲清「什么时候可以省掉路径压缩」,比背「两个优化都要写」有用。

适用的边界在哪

第四段是「多大的规模会把它淘汰掉」。这一段的关键在于任何算法都有不适用的输入,而且失效条件通常不在平均情况上,在某个特定的输入形状上。

看快排。它最常被讲成「平均 O(n \log n),很快」,但这句里藏了一个前提:主元挑得够随机。取首元素当主元,遇到已经排好序的输入会怎样:

import random


def qsort_count(a):
    """朴素快排,取首元素当主元,返回比较次数。"""
    if len(a) <= 1:
        return 0
    p = a[0]
    lo = [x for x in a[1:] if x < p]
    hi = [x for x in a[1:] if x >= p]
    return len(a) - 1 + qsort_count(lo) + qsort_count(hi)


n = 300
rng = random.Random(5)
rand_data = list(range(n))
rng.shuffle(rand_data)
print(f'   取首元素当主元的快排,n = {n}:')
print(f'      随机数据   : {qsort_count(list(rand_data)):>8,} 次比较')
print(f'      已经排好序 : {qsort_count(list(range(n))):>8,} 次比较')
print(f'      完全逆序   : {qsort_count(list(range(n, 0, -1))):>8,} 次比较')
print(f'   n(n-1)/2 = {n * (n - 1) // 2:,}')
   取首元素当主元的快排,n = 300:
      随机数据   :    3,298 次比较
      已经排好序 :   44,850 次比较
      完全逆序   :   44,850 次比较
   n(n-1)/2 = 44,850

同一份代码、同一个 n,随机数据 3,298 次比较,已经排好序 44,850 次——差 13.6 倍。 而且 44,850 正好是 n(n-1)/2,也就是退化成了 O(n^2)。

为什么会这样:主元取首元素,而已经排好序的数组里首元素正好是最小值。于是每次划分都得到「空 + 剩下的全部」,递归深度变成 n,每次只消掉一个元素。

注意这有多危险:快速排序「平均 O(n \log n)」这句话没有错,但如果你只讲这一句,听的人会以为它在所有输入上都安全。一个只讲平均情况的讲解,等于没讲边界。

第四段该讲的东西长这样:「朴素快排平均 O(n \log n),但如果主元取首元素、且输入已经有序或逆序,会退化成 O(n^2),n = 300 时比较次数从 3,298 涨到 44,850。修法是随机选主元或三数取中。」

这段话里「n = 300 时 3,298 对 44,850」就是最有说服力的部分——具体数字比「会退化」三个字有分量得多,因为它能让人估出多大规模会出事。这正是第四段要回答的问题。

把术语翻译成直觉

最后一段是贯穿全程的要求:能用日常语言说一遍。

术语是压缩过的信息,它省了字,但也藏了理解。「摊还」「不变式」「宽松上界」这些词,能背下来不代表能用。检验方法是把它翻译回具体的动作和数字:

术语翻译成直觉
摊还 O(\alpha(n))某一次查询可能走 1,999 步,但它顺手把整条路径压平了,后面几百次查询都只走 1 步
不变式每做完一步,这两个堆的长度差不超过一——不是某一步成立,是每一步都成立
退化成 O(n^2)主元每次都取到最小值,划分变成「空 + 其余」,n = 300 时要比较 44,850 次
近似常数规模翻倍时总步数只翻一倍(3,987 → 7,986)
上界是紧的不是「不超过这个数」,是真的能取到这个数(1,999,000 恰好等于 n(n-1)/2)

这张表的用法:讲到一个术语时,强迫自己往右列说一遍。说得出来说明懂了,说不出来就是还在背。

这一课用到的判断

上手做 挑五个算法,各写一份三分钟讲解稿

这一课是概念课,动手部分不配 lab,落在纸面上。挑五个你已经学过的算法,照下面的结构各写一份讲解稿,然后讲出来。

  1. 先列五个算法。 建议从这些里挑,它们的「为什么」都不好讲:并查集、0/1 背包、区间 DP、二分答案、堆求前 k 大。至少挑一个你觉得自己「会用但讲不清」的。
  1. 每份稿子照四段写。 一段一段来,不许跳过任何一段:
    • 它在解决什么问题(一句话,不许出现算法名字和代码)
    • 为什么它是正确的(讲不变式,不许复述代码流程)
    • 复杂度怎么推出来的(O(\cdot) 里每一项是从哪一步来的)
    • 多大的规模会把它淘汰掉(必须带一个具体数字)
  1. 讲一遍,最好录音。 讲给自己听也算。不要照着稿子念——念稿子不会暴露你没懂的地方。讲完记下被问住或卡壳的位置,那些就是还没吃透的地方。
  1. 把术语翻译一遍。 每份稿子里至少挑一个术语(如「摊还」「不变式」「宽松上界」),用日常语言说一遍,并带上一个具体数字。
  1. 回头补洞。 卡住的地方回到对应课件重看那一节,然后只重写那一段,不用整篇重来。
没看懂?随时问 把看不懂的段落(或报错)原样复制回会话,说一句哪里不懂就行,我只解释这一段,不打断你的进度。