0028 · 看数据规模挑算法

看数据规模挑算法

本节目标:能由数据范围与问法反查算法族,并说出选择的理由。

先看数据范围

到这一课为止,你学过的算法族已经不少了:哈希、树、堆、并查集、图和搜索、二分答案、贪心、动态规划。可真正坐在考场上,最难的一步往往不是写代码,而是第一分钟不知道这题该往哪个方向想。

有一个线索一直被放在题面最显眼的地方,很多人却不看它——数据范围。

题目说 n \le 10^5,这不是在描述数据,是在告诉你该用什么算法。范围到 10^5,平方级的做法就已经跑不完了;范围到 10^3,平方级甚至三次方级都还能活。范围到 20 上下,多半是在请你枚举子集。

这一课要做的,是把这条对应关系变成条件反射:先读范围,算出复杂度上限,再回头挑算法族。先挑算法再回头看范围,顺序反了。

本机一秒能跑多少次

要把范围和复杂度对上,得先知道一秒能跑多少次基本操作。这是个实测值,不是通用常数。

量法很简单:拿一个空转的加法循环,跑几个不同的 n,用 time.perf_counter() 卡住前后,把总用时除以 n 就是「每次循环多少纳秒」。这段代码放在本课最后的练习里,你要在自己这台机器上跑一遍,下面的数是本机的。

本机实测(每次跑都会有些浮动,看量级就行):

n用时每次循环
100,0000.002 s16.6 ns
1,000,0000.019 s19.0 ns
10,000,0000.197 s19.7 ns
100,000,0001.943 s19.4 ns

注意第三列基本不随 n 变——16.6、19.0、19.7、19.4,都在 20 纳秒上下。这说明一次循环的开销是个常数,所以总用时和 n 是成正比的。如果这一列随 n 明显变大或变小,说明你量错了(多半是循环体里干了别的事,或者机器正在忙别的)。

每次循环大约 20 纳秒,反过来说——每秒约五千万次。

这正好跟第 2 课给的那把尺子对上了(那一课定的是「每秒约 5 \times 10^7 次」)。两处是各自独立量出来的,量到同一个数,所以后面就用这把尺子算。

这是本机实测值,不是通用值。 换个 CPU、换 Python 版本、把循环体换成更重的操作、或者改用内置函数,都能差出好几倍。所以这一课所有算出来的「上限」都是量级参考,用来做取舍,不是精确的判决线。

范围换算成复杂度上限

有了尺子,就能反推:一秒的预算下,每种复杂度的 n 最多能到多少。

import math

RULER = 5 * 10 ** 7


def steps_log10(name, k):
    """第 name 种复杂度在规模 k 上的「步数的常用对数」。

    只算对数、不真把那个数算出来——O(2^n) 在 k 大时是个天文数字,
    真去构造它会撑爆内存(k = 5*10^8 时是 10^150514997 这个量级)。
    """
    if name == 'O(n)':
        return math.log10(k)
    if name == 'O(n log2 n)':
        return math.log10(k) + math.log10(max(1.0, math.log2(k)))
    if name == 'O(n^2)':
        return 2 * math.log10(k)
    if name == 'O(n^3)':
        return 3 * math.log10(k)
    return k * math.log10(2)


def fits(name, k):
    return steps_log10(name, k) <= math.log10(RULER)


print(f'   尺子:每秒 {RULER:,} 步(第 2 课那把)')
print()
print('   复杂度        1 秒内的 n 上限        跑到 10^8 步要多久')
for name in ('O(n)', 'O(n log2 n)', 'O(n^2)', 'O(n^3)', 'O(2^n)'):
    n = 1                                    # 先翻倍逼近,再二分
    while n * 2 <= 10 ** 9 and fits(name, n * 2):
        n *= 2
    lo, hi = n, min(n * 2, 10 ** 9)
    while lo < hi:
        mid = (lo + hi + 1) // 2
        if fits(name, mid):
            lo = mid
        else:
            hi = mid - 1
    big = steps_log10(name, 10 ** 8)
    if big >= 15:
        secs = f'约 10^{int(big - math.log10(RULER))} 秒'
    else:
        secs = f'{10 ** (big - math.log10(RULER)):.4g} 秒'
    print(f'   {name:<12} {lo:>16,}        {secs:>16}')
   尺子:每秒 50,000,000 步(第 2 课那把)

   复杂度        1 秒内的 n 上限        跑到 10^8 步要多久
   O(n)               50,000,000                     2 秒
   O(n log2 n)         2,361,678                 53.15 秒
   O(n^2)                  7,071                约 10^8 秒
   O(n^3)                    368               约 10^16 秒
   O(2^n)                     25         约 10^30102991 秒

O(2^n) 那一格是 25,不是 26。2^{25} = 33{,}554{,}432,一秒装得下;2^{26} = 67{,}108{,}864,就超了。

这个数值得留意,因为它正好演示了本课的主题:如果图省事,把 2^n 近似成 10^{0.301n} 再取整,会算出 10^7 并得出「26 也行」——而 2^{26} 的真实值比 10^7 大了将近七倍。近似在边界上就是错的,而一到卡线的时候,边界恰恰是唯一要紧的地方。

上一段那个 steps_log10 值得看一眼:它只算对数。O(2^n) 在 n = 10^8 时的步数是 2^{10^8},这个数有约三千万位十进制——真把它算出来,内存直接就没了。所以这里全程在对数域里比大小,只在最后要显示秒数时把指数取出来。约 10^30102991 秒 这个说法读作「这个量级」,别当精确值。

把中间那一列记住,这一课就学完了大半:

复杂度一秒内的 n 上限考场上的读法
O(n)约 5 \times 10^7范围到 10^7 也放心
O(n \log n)约 2.4 \times 10^6范围到 10^6 没问题
O(n^2)约 7 \times 10^3范围到 10^3 才行,10^5 就别想
O(n^3)约 370只有 n \le 400 左右能活
O(2^n)确切地说是 25只可能是 n \le 25 的枚举子集(卡得很死,别估)

反过来读同一张表,就是从范围反查:

同一道题的三个版本

「复杂度」这三个字听起来抽象,用一道题摆出来就具体了。最大子段和:给一串数,求和最大的那一段连续子序列。

这道题有三个写法,答案完全一样,步数差得很远:

def max_sum_cubic(a):
    """O(n^3):把左右端点全试一遍,中间再累加一遍。返回 (答案, 内层步数)。"""
    n = len(a)
    best = a[0]
    steps = 0
    for i in range(n):
        for j in range(i, n):
            s = 0
            for k in range(i, j + 1):
                s += a[k]
                steps += 1
            if s > best:
                best = s
    return best, steps


def max_sum_quadratic(a):
    """O(n^2):固定左端,右端往外扩时顺手累加。"""
    n = len(a)
    best = a[0]
    steps = 0
    for i in range(n):
        s = 0
        for j in range(i, n):
            s += a[j]
            steps += 1
            if s > best:
                best = s
    return best, steps


def max_sum_linear(a):
    """O(n):要么接上前面,要么从自己重新开始。"""
    best = cur = a[0]
    steps = 1
    for x in a[1:]:
        cur = x if cur < 0 else cur + x
        if cur > best:
            best = cur
        steps += 1
    return best, steps


A = [(-1) ** i * (i % 17) for i in range(100)]
print(f'   n = 100 时三个版本给出的答案一致吗: '
      f'{max_sum_cubic(A)[0] == max_sum_quadratic(A)[0] == max_sum_linear(A)[0]}'
      f'(都是 {max_sum_linear(A)[0]})')
print()
print('   规模     O(n^3) 步数     O(n^2) 步数     O(n) 步数')
for n in (50, 100, 200, 400):
    a = [(-1) ** i * (i % 17) for i in range(n)]
    print(f'   {n:>6}   {max_sum_cubic(a)[1]:>12,}   {max_sum_quadratic(a)[1]:>12,}   '
          f'{max_sum_linear(a)[1]:>10,}')
b1 = max_sum_cubic([0] * 200)[1] / max_sum_cubic([0] * 100)[1]
b2 = max_sum_quadratic([0] * 200)[1] / max_sum_quadratic([0] * 100)[1]
b3 = max_sum_linear([0] * 200)[1] / max_sum_linear([0] * 100)[1]
print(f'   n 从 100 翻到 200:三版的步数分别变成 {b1:.1f} 倍、{b2:.1f} 倍、{b3:.1f} 倍')
   n = 100 时三个版本给出的答案一致吗: True(都是 24)

   规模     O(n^3) 步数     O(n^2) 步数     O(n) 步数
       50         22,100          1,275           50
      100        171,700          5,050          100
      200      1,353,400         20,100          200
      400     10,746,800         80,200          400
   n 从 100 翻到 200:三版的步数分别变成 7.9 倍、4.0 倍、2.0 倍

最后的倍数就是复杂度的指纹。 规模翻一倍,三次方版涨约 8 倍(量到 7.9 倍),平方版涨约 4 倍(量到 4.0 倍),线性版涨 2 倍(量到 2.0 倍)。

这不是巧合,是定义:O(n^3) 里的 3 就是说「n 翻倍、步数翻 2^3 倍」。所以你量出来的倍数,反过来能告诉你这段代码是什么复杂度——比自己数循环层数可靠,因为循环不一定规整。

顺带看一件很值钱的事:n = 100 时三个版本都是 24,答案一样。区别只在步数——171{,}700 对 5{,}050 对 100。正确性上它们等价,取舍全在能不能跑得完。

范围决定你用哪一版

上表是步数,换成时间更直观。下面这张是同一批函数在本机跑出来的本机实测秒数:

nO(n^3)O(n^2)O(n)
500.0004 s0.0000 s0.000003 s
1000.0029 s0.0001 s0.000003 s
2000.0212 s0.0004 s0.000008 s
4000.1737 s0.0018 s0.000015 s
8001.5165 s0.0074 s0.000031 s

n = 800 时三次方版要 1.5 秒——已经超了。而它离 10^5 还差着两个数量级。

按复杂度把 n = 800 这一行外推(每翻倍:三次方涨 8 倍、平方涨 4 倍、线性涨 2 倍):

nO(n^3) 约O(n^2) 约O(n) 约
1,0003.0 s0.01 s0.00004 s
10,0003,007 s1.1 s0.0004 s
100,0003,006,977 s113 s0.0036 s
1,000,0003.0 \times 10^9 s11,335 s0.036 s

n = 10^5 那一行是最该看的一行:

所以同一道最大子段和:

题目没变,范围变了,答案就该换写法。 这也解释了省赛为什么爱出「同一道题、不同数据范围」的一组题——那是在考你会不会读范围。

多组测试的陷阱

有一种情况会让「范围反查」失灵,而且非常常见:多组测试数据。

n = 1000 这个范围,按上面的表看,平方级随便写(10^6 步,跑一下 0.01 秒)。但如果题面还有一句「共 10^5 组测试数据」呢?

n = 1000
per_case = max_sum_quadratic([(-1) ** i * (i % 17) for i in range(n)])[1]
print(f'   n = {n} 时,O(n^2) 一组的步数: {per_case:,}')
print()
print('   组数          总步数')
for cases in (1, 10, 100, 1000, 100000):
    print(f'   {cases:>8,}   {per_case * cases:>18,}')
print()
print(f'   一组的 n = {n} 看着很小,10^5 组一乘就是 {per_case * 100000:,} 步。')
   n = 1000 时,O(n^2) 一组的步数: 500,500

   组数          总步数
          1              500,500
         10            5,005,000
        100           50,050,000
      1,000          500,500,000
    100,000       50,050,000,000

   一组的 n = 1000 看着很小,10^5 组一乘就是 50,050,000,000 步。

一组的 n = 1000 看着很小,10^5 组一乘就是 5 \times 10^{10} 步。

拿尺子一除:5 \times 10^{10} \div (5 \times 10^7) = 1000 秒。判分给每道题通常只有一两秒。

本机实测同一件事:n = 1000 时平方版跑一组要 0.0120 秒,10^5 组就是 1200 秒——跟上面的估算对得上。

所以读范围时要读的是「一组的 n」乘「组数」的那个总量,不是单看 n。题目写「n \le 1000,T \le 10^5」的时候,真正的规模是 10^8。

问法也在提示算法

范围之外,问法是第二条线索。同一个数据规模下,「求最大值」「求方案数」「问可不可达」指向的算法族完全不同。

下面这张表把学过的都收进来了:

问法长什么样多半是
「最多能选几个」「最少要几个」且能证明局部最优不吃亏贪心
「让最大值最小」「让最小值最大」二分答案
「第 k 大」「动态地反复取最值」堆
「分成几个连通块」「这两个点通不通」并查集
「最少走几步」「能不能到达」图搜索中的 BFS
「有多少种走法」「把所有方案列出来」递归回溯 / DFS
「区间和」「多次询问同一批数据」前缀和
「有序数组里找一对」「原地去重」双指针
「方案数」「最优值且前后选择互相牵制」动态规划

这张表是线索,不是判决。第 27 课实测过一个反例:题面里出现「重量」这个词,关键词表会把「合并果子」判成背包,而那题的正解是堆。问法只用来缩小方向,最终要靠自己把题意读懂、把小样例手推一遍。

一张可查的题型表

把两条线索合起来用,就成了一个先范围、后问法的查表流程:

  1. 读范围,算出复杂度上限。 上限是 O(n \log n) 还是 O(n^2)?这一步就能划掉一大半候选。
  2. 读问法,缩小到两三个算法族。 用上一节那张表。
  3. 看候选里哪几个的复杂度在上限之内。 都行的,挑实现最简单的那个。
  4. 写之前先想清楚怎么验。 上一课那套:小数据对拍、反例最小化。

第 3 步里藏着一个很多人会做反的选择:几个候选都跑得完的时候,要挑最好写的那个,不是最快的那个。

原因是,写错的代价远大于慢一点点的代价。一个 O(n \log n) 的正确解,和一个「理论上 O(n) 但边界漏了」的解,前者拿满分,后者拿零分——而复杂的写法边界就是更多。

这一课用到的判断

上手做 只看范围与问法,先猜再验

这一课是概念课,动手部分不配 lab,落在纸面上和编辑器里。第一件事是量自己的尺子,这段先跑:

import time


def loop_cost(n):
    s = 0
    for i in range(n):
        s += i
    return s


for n in (10 ** 5, 10 ** 6, 10 ** 7, 10 ** 8):
    t0 = time.perf_counter()
    loop_cost(n)
    dt = time.perf_counter() - t0
    print(f'   n = {n:>11,}   用时 {dt:6.3f} s   每次循环 {dt / n * 1e9:5.1f} ns')

这一段的输出不要抄进作业里——它是计时,每次跑都不一样。你要记下来的是「我这台机器每秒大约多少次」这一个数,写在纸片上,以后卡线时用它。别抄课件里的数,量自己的。

剩下三件:

  1. 收集十道做过的题,只看范围与问法。 把题面遮住,只留数据范围和最后一问,写下候选算法族和理由(要写出复杂度估算)。写完再揭开对照当时怎么做的,把判断错的题单独记下来,标出是漏了哪条线索。
  2. 给同一道题换三个范围。 挑一道你会的题(比如最大子段和),分别假设 n \le 100、n \le 3000、n \le 10^5,写出三个版本并实测耗时,确认三版答案一致、而只有最后一版能在大范围上活下来。
  3. 算一次多组测试的总量。 找一道有 T 组数据的题,把「一组的规模乘组数」算出来,对照尺子判断你原来的写法到底能不能过。这一步是最容易发现自己以前一直在蒙的地方。
没看懂?随时问 把看不懂的段落(或报错)原样复制回会话,说一句哪里不懂就行,我只解释这一段,不打断你的进度。