0030 · 交卷之前你要检查什么

交卷之前你要检查什么

本节目标:能在提交前用独立暴力解对拍、跑完边界数据并逐条核对答案格式。

过样例不等于过题

到这一课,你会的算法已经够用了。真正让你丢分的往往不是不会做,而是交上去之后才发现:多组数据之间的状态没清空、答案该取模忘了取、数组开小了一格。这些错误有一个共同点——在题面给的样例上全都不会暴露。

样例通常是出题人挑出来的「正常」数据:规模小、情况普通。它能验的只有「你的代码能不能跑起来」,验不了边界、验不了退化、验不了多组之间的相互影响。

所以「提交前自检」不该靠感觉,要变成一份固定流程。这一课就把这个流程拆开,逐条量给你看它到底能挡住什么。

对拍器不是护身符

第 15 课教过对拍:写一个暴力解,造随机数据,两边跑,不一样就说明有一边错了。这是最有力的自检手段。

但它有一个致命前提——参考解必须真的独立于候选解。 如果你照着自己的思路再写一遍「参考解」,那两版会一起错,对拍全过,你得到一个假通过。

看一个具体的例子。题目求最大子段和,候选解写成了这样:

import random


def cand(a):
    """候选解:Kadane,但 best 从 0 起步。"""
    best = cur = 0
    for x in a:
        cur = max(0, cur + x)
        best = max(best, cur)
    return best


def ref_same_idea(a):
    """「参考解」——同一个错思路,只是行文不同。"""
    cur = 0
    ans = 0
    for x in a:
        cur += x
        if cur < 0:
            cur = 0
        if cur > ans:
            ans = cur
    return ans


def ref_brute(a):
    """独立暴力:枚举所有子段,一个不漏。"""
    best = None
    for i in range(len(a)):
        s = 0
        for j in range(i, len(a)):
            s += a[j]
            if best is None or s > best:
                best = s
    return best


random.seed(1)
TRIALS = 500
same_bad = 0
indep_bad = 0
for _ in range(TRIALS):
    n = random.randint(1, 8)
    a = [random.randint(-9, 9) for _ in range(n)]
    c = cand(a)
    if c != ref_same_idea(a):
        same_bad += 1
    if c != ref_brute(a):
        indep_bad += 1
print(f'   {TRIALS} 组随机数据(取值 -9..9,n 到 8):')
print(f'      与「同一个思路的参考解」对拍,不一致的: {same_bad:>3} 组')
print(f'      与「独立暴力解」对拍,不一致的        : {indep_bad:>3} 组')

random.seed(1)
first = None
for _ in range(TRIALS):
    n = random.randint(1, 8)
    a = [random.randint(-9, 9) for _ in range(n)]
    if cand(a) != ref_brute(a):
        first = (a, cand(a), ref_brute(a))
        break
print(f'   独立暴力抓到的第一个反例:数据 {first[0]}  '
      f'候选解给 {first[1]}  暴力解给 {first[2]}')
   500 组随机数据(取值 -9..9,n 到 8):
      与「同一个思路的参考解」对拍,不一致的:   0 组
      与「独立暴力解」对拍,不一致的        :  58 组
   独立暴力抓到的第一个反例:数据 [-7]  候选解给 0  暴力解给 -7

500 组数据,同一个思路的参考解一组都没抓到;换成独立暴力,抓到 58 组。

那个 bug 是 best 从 0 起步,所以面对全是负数的数据它会交回 0——而正确答案是那个最大的负数。ref_same_idea 和 cand 都犯了这个错,所以它们永远一致。

结论:参考解要尽量用「另一种办法」写。 求最值就用暴力枚举,计数就用组合公式或者小规模手推,图的问题就用矩阵小数据直接模拟。跟候选解思路不同的参考解才叫参考解,只是想换一种写法的,是对拍器里的装饰品。

生成器的范围要对

对拍器的第二个失效点更隐蔽:数据造得不对,等于没测。

上面那个 bug 只在「全部是负数」时才出现。很多人写生成器时会不自觉地绕开这种情况——比如为了让数据「好看一点」,加一句「至少有一个正数」:

def gen_narrow(n, rng):
    """只造「至少有一个正数」的数据——很多人的生成器就是这样写的。"""
    while True:
        a = [rng.randint(-9, 9) for _ in range(n)]
        if max(a) > 0:
            return a


def gen_full(n, rng):
    """允许全负。"""
    return [rng.randint(-9, 9) for _ in range(n)]


for name, gen in (('只造有正数的', gen_narrow), ('允许全负的', gen_full)):
    rng = random.Random(2)
    caught = 0
    for _ in range(300):
        n = rng.randint(1, 8)
        a = gen(n, rng)
        if cand(a) != ref_brute(a):
            caught += 1
    print(f'   {name:<12}:300 组里抓到 bug 的有 {caught:>3} 组')
   只造有正数的      :300 组里抓到 bug 的有   0 组
   允许全负的       :300 组里抓到 bug 的有  33 组

同一个对拍器、同一个 bug、同样是 300 组数据——生成器多了一句 max(a) > 0,抓到数从 33 变成 0。这就是「测了个寂寞」:跑了三百组,一组都没碰到问题所在。

所以生成器要照着题面的取值域写,不是照着你心里舒服的范围写。题面说「-10^9 \le a_i \le 10^9」,生成器就得允许全负、允许全零、允许重复、允许取到端点。

范围有两个维度,两个都容易造窄:

第 19 课量过一个真实的例子:并查集不做路径压缩也不按秩合并,n = 20000 加 20 万次查询要 101.4 秒,而做了优化的不到 0.04 秒——差 2800 倍。但这两版在 n \le 100 的小数据上答案逐字一致。只测小数据,这类问题一个都抓不到。

边界清单

对拍用的是随机数据,但随机数据不太会撞上极端情况。所以除了对拍,还要有一份固定的边界清单,一条一条手动跑。四大类:

类别要造的数据常抓到的错
最小n = 1;只有一行;只有一组数据循环一次都不进;初值没设对
最大n 取题面上限;值取上限数组开小了;溢出;超时
重复全部元素相同;大量重复去重写错;哈希退化;「第一个」判成「任意一个」
退化全负、全零;已排序 / 完全逆序;链退化成一条线恰好是上一节那些 bug 的藏身处

「最小」那一类最值钱,因为它最容易被跳过。n = 1 时很多循环体一次都不执行,best 之类的初值就原样交回去了——本课块 1 抓到的 [-7] 就是这一类。

多组数据要清空

题面写「共 T 组数据」的时候,多了一个全新的出错面:组与组之间的状态。

凡是定义在循环外面的东西——全局变量、类属性、累加器、上一次的答案——都必须在新一组开始时清掉。漏一个,第一组对、第二组开始就错:

class Solver:
    def __init__(self):
        self.total = 0

    def reset(self):
        self.total = 0

    def answer(self, a):
        for x in a:
            self.total += x
        return self.total


CASES = [[1, 2, 3], [10, 20], [5]]
EXPECT = [6, 30, 5]

s = Solver()
leaky = [s.answer(a) for a in CASES]        # 三问之间没清空
s.reset()
clean = []
for a in CASES:
    s.reset()
    clean.append(s.answer(a))

print(f'   三组数据      : {CASES}')
print(f'   忘了清空      : {leaky}')
print(f'   每问都清空    : {clean}')
print(f'   正确答案      : {EXPECT}')
print(f'   忘了清空对吗: {leaky == EXPECT}    每问清空对吗: {clean == EXPECT}')
   三组数据      : [[1, 2, 3], [10, 20], [5]]
   忘了清空      : [6, 36, 41]
   每问都清空    : [6, 30, 5]
   正确答案      : [6, 30, 5]
   忘了清空对吗: False    每问清空对吗: True

结果从 [6, 30, 5] 变成 [6, 36, 41]——第一组是对的。所以如果你只拿样例的第一组数据试,什么都看不出来。

最省事的修法不是「记住要清空」,而是别把状态放在外面:把每组的处理写成一个函数,所有中间量都是局部变量,退出函数 naturally 就清掉了。要清的东西越少,忘清的机会越少。

取模与溢出

题面写「答案对 10^9 + 7 取模」的时候,最常见的错法是中途忘了取。而这件事在小数据上完全看不出来:

MOD = 10 ** 9 + 7


def fib_nomod(n):
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a


def fib_mod(n):
    a, b = 0, 1
    for _ in range(n):
        a, b = b, (a + b) % MOD
    return a


print(f'   MOD = {MOD:,}')
print('   n        不取模(末 14 位)        取模后的值      两者相等吗')
for n in (10, 30, 50, 100, 1000, 10000):
    x, y = fib_nomod(n), fib_mod(n)
    tail = str(x)[-14:]
    print(f'   {n:>5}      …{tail:>14}   {y:>14}   {x == y}')
print(f'   不取模在 n = 10000 时的位数: {len(str(fib_nomod(10000)))} 位')
   MOD = 1,000,000,007
   n        不取模(末 14 位)        取模后的值      两者相等吗
      10      …            55               55   True
      30      …        832040           832040   True
      50      …   12586269025        586268941   False
     100      …48179261915075        687995182   False
    1000      …95166849228875        517691607   False
   10000      …10059947366875        271496360   False
   不取模在 n = 10000 时的位数: 2090 位

n \le 30 时两版一模一样,n = 50 开始分道扬镳。 因为第 50 个斐波那契数 12{,}586{,}269{,}025 第一次超过了 10^9 + 7。你拿小样例试,永远试不出来。

Python 的整数不会溢出(这是它的好处),所以「忘了取模」在 Python 里带来的不是报错,而是静默地交出一个错误的数——比 C++ 里溢出成一个负数更难发现。

答案格式

这一类错误跟算法无关,纯属输出没照题面写,但一样是零分。逐条对:

检查项常见错法
大小写题面要 Yes / No,交了 YES / yes
浮点精度题面要保留 2 位小数,直接 print(x) 打出 15 位
行尾空格每行末尾多一个空格(有的判题器严格逐字比)
最后换行有的判题要求末行有换行,有的不允许
多组之间的空行题面要每组之间空一行,或者绝对不能空行
多解题面说「输出任意一组合法方案」,你输出的一组不合法
调试输出交之前没删掉 print,标准输出多了一堆中间量

「多解」那一条要特别注意:题面写「任意一组」不等于不检查——判题器会验你给的那组是否合法,而不是跟你比对某一份标准答案。所以这类题的自检不是对拍,是写一个合法性检查器,拿它验自己输出的方案。

提交前清单

把上面几节压成一份可以照着做的清单:

  1. 样例逐字对。 题面给的每一组样例都跑一遍,输出与题面给的答案逐字符比,不是「看着差不多」。
  2. 边界四类跑一遍。 最小、最大、重复、退化,一条一条手动跑,看输出合不合理。
  3. 独立参考解对拍。 参考解必须换一种思路写。对拍不过就把反例缩到最小,找到根因再改代码。
  4. 生成器照题面写。 值域和规模两个维度都要盖到题面上限。
  5. 规模压到上限跑一次计时。 只看它有没有在时限内跑完,不看答案——这一步专门抓性能退化。
  6. 多组数据混着测。 至少造一组「第 1 组小、第 2 组大、第 3 组相等」的数据,专门看状态有没有串。
  7. 格式逐条核。 对着上一节那张表一条条过。
  8. 删掉调试输出。 用样例输入跑一遍,标准输出里只该有答案。

第 3 步和第 5 步是两条独立的证据:对拍证明「答案对」,计时证明「跑得完」。只做其中一个都不够——本课那个并查集的例子,两版答案逐字一致,但一版跑 101 秒。

这一课用到的判断

练习 用清单回查三道做错的题

这一课的动手部分落在 lab 里(任务与断言见 lab/0030-stress-test-and-review/,做完对照 lab/solutions/)。要做的是四件事。

  1. 把清单写出来。 照上面八条,写成一份你自己看得懂的清单(可以合并、可以加你自己的坑)。这份东西以后每次交卷前都过一遍。
  2. 用它回查三道以前做错的题。 每道题找出「当时漏的是清单里哪一条」,写下来。如果三道题都归到同一条上,那条就是你最该盯的地方。
  3. 写一个能抓到自己 bug 的对拍器。 挑一道你写对的题,故意把某一处改坏(比如把 best 初值从 -inf 改成 0),然后确认你的对拍器能抓到它。抓不到就说明你的生成器范围太窄,回去放宽再试。
  4. 压一次上限。 把某一道题的数据造到题面上限跑一次,记下耗时。如果超了,写下你打算换什么算法——那正是第 28、29 课的内容。
没看懂?随时问 把看不懂的段落(或报错)原样复制回会话,说一句哪里不懂就行,我只解释这一段,不打断你的进度。