0031 · 实验四:一次完整的赛前模拟

实验四:一次完整的赛前模拟

本节目标:能限时独立完成省赛难度真题,并讲清所用算法为何正确。

这次要做出什么

前面几课把零件和工具都备齐了:算法族(第 16 到 26 课)、看规模挑算法(第 28 课)、常数优化(第 29 课)、提交前自检(第 30 课)。但考场上不是一题一题分开考的——是在时间压力下连着选题、决策、写码、自检。

这一课不教新算法,做一次完整的赛前模拟。产出两样东西:

  1. 一份限时解题记录:按省赛节奏(4 小时、5 道题)实际走一遍,记录每道题的读题时间、定下的算法、写完的时刻、自检结果、最终得分。
  2. 一份讲解稿:对你做出来的每道题,讲清三件事——它为什么可以这么解、复杂度是多少、以及多大的数据规模会把这种方法淘汰掉。

第 2 样是这一课的重点。做出来不等于会做;讲不出「为什么正确」的解法,换一道题你就用不上了。

限时里最先做的判断

一坐下不要打开第一题就写。先花五到八分钟通读全部题目,对每道题做三件事:

  1. 读数据范围,算出复杂度上限。 这是第 28 课那套:n \le 10^5 就只能上 O(n \log n),n \le 10^3 才容得下平方级。
  2. 读问法,缩到两三个算法族。 「让最大值最小」指向二分答案,「第 k 大」指向堆,「方案数」指向动态规划。
  3. 估自己的完成时间。 这一步最难也最值钱——估错时间的代价比选错算法还大,下一节会量给你看。

这三步做完,你手上就有一张「这五道题分别大概要多久、值多少分」的表。选题顺序就是照着这张表定的。

「估完成时间」有一个必须承认的事实:你的估计一定不准。 所以估计的时候要给自己留余量——估 40 分钟的题按 60 分钟算。下面几节所有数字都是在这个前提下算的。

选题顺序真的决定得分

「选题顺序能有多大影响」听起来是个没法量的问题,但它其实可以建模。把一场比赛抽象成:5 道题,每道题有一个耗时和一个分值,总时长 240 分钟,做完一题才拿分——一题做不完,那题白费,后面的时间也没了。

拿 2000 场随机比赛分别试四种常见的选题顺序:

import random

T = 240
LEVELS = [
    ('简单甲', 25, 45, 20, 30),
    ('简单乙', 25, 45, 20, 30),
    ('中等', 50, 90, 40, 60),
    ('偏难', 100, 160, 70, 100),
    ('超难', 150, 400, 80, 130),
]
N = len(LEVELS)


def gen_case(rng):
    """造一场五题的比赛;题面顺序随机打乱,不代表难度顺序。"""
    ps = [(rng.randint(t_lo, t_hi), rng.randint(v_lo, v_hi))
          for _, t_lo, t_hi, v_lo, v_hi in LEVELS]
    rng.shuffle(ps)
    return ps


def play(order, case, budget=T):
    """按 order 依次做:做完一题才拿分;一题做不完,那题白费,后面的时间也没了。"""
    used = got = 0
    done = []
    stuck = None
    for i in order:
        t, v = case[i]
        if used + t <= budget:
            used += t
            got += v
            done.append(i)
        else:
            stuck = (i, t, v, budget - used)
            break
    return got, used, done, stuck


def best_subset(case, budget=T):
    """枚举所有「时间装得下」的子集,取分值最高的。这一步就是 0/1 背包。"""
    best, bsub = -1, None
    for mask in range(1 << len(case)):
        tt = vv = 0
        sub = []
        for i in range(len(case)):
            if mask >> i & 1:
                tt += case[i][0]
                vv += case[i][1]
                sub.append(i)
        if tt <= budget and vv > best:
            best, bsub = vv, sub
    return best, bsub


STRATS = [
    ('按题面顺序', lambda c: list(range(N))),
    ('先易后难(耗时升序)', lambda c: sorted(range(N), key=lambda i: c[i][0])),
    ('先难后易(分值降序)', lambda c: sorted(range(N), key=lambda i: -c[i][1])),
    ('性价比(分值/耗时降序)', lambda c: sorted(range(N), key=lambda i: -c[i][1] / c[i][0])),
]

ROUNDS = 2000
rng = random.Random(20260301)
CASES = [gen_case(rng) for _ in range(ROUNDS)]
OPT = [best_subset(c)[0] for c in CASES]
opt_avg = sum(OPT) / ROUNDS
print(f'   {ROUNDS} 场模拟比赛:每场 5 题、总时长 {T} 分钟、题面顺序随机打乱')
print('   选题顺序                  平均得分   离上限还差   平均做完几道')
for name, key in STRATS:
    tot = done_tot = 0
    for c in CASES:
        g, _, done, _ = play(key(c), c)
        tot += g
        done_tot += len(done)
    avg = tot / ROUNDS
    print(f'   {name:<22} {avg:7.1f}     {opt_avg - avg:6.1f}      {done_tot / ROUNDS:5.2f} 道')
print(f'   {"上限(枚举可行的子集)":<22} {opt_avg:7.1f}     {0.0:6.1f}      '
      f'{sum(len(best_subset(c)[1]) for c in CASES) / ROUNDS:5.2f} 道')
   2000 场模拟比赛:每场 5 题、总时长 240 分钟、题面顺序随机打乱
   选题顺序                  平均得分   离上限还差   平均做完几道
   按题面顺序                     90.6       66.6       1.86 道
   先易后难(耗时升序)               109.8       47.4       3.11 道
   先难后易(分值降序)                47.6      109.6       0.46 道
   性价比(分值/耗时降序)             133.9       23.3       2.93 道
   上限(枚举可行的子集)              157.2        0.0       2.91 道

同一批题目,只换做题顺序,平均分从 47.6 到 133.9——差 2.8 倍。 这跟算法水平一点关系都没有,纯粹是顺序问题。

两条最值得记住的:

做完的题数不等于得分

看块 1 的最后两列,有一件事很反直觉:

顺序平均得分平均做完几道
先易后难109.83.11 道
上限157.22.91 道

「先易后难」做完的题最多,得分却不是最高。 最高分的做法平均少做 0.2 道题,反而多拿 47.4 分。

原因很直接:题目的分值不一样。 三道 30 分的容易题加起来 90 分,还不如一道 100 分的题。你埋头把三道小题做完,看着进度条很满,总分却落后。

所以考场上不要用「做完了几道」来判断自己发挥得好不好——那个指标会把你引向错误策略。

一题做不完的代价

上一节说「一道超长题会把时间全吃掉」。这件事的量级值得单独算:

rng = random.Random(7)
cases = lost = 0
for _ in range(2000):
    c = gen_case(rng)
    over = [i for i in range(N) if c[i][0] > T]
    if not over:
        continue
    bad = over[0]
    g_bad, _, _, _ = play([bad] + [i for i in range(N) if i != bad], c)
    g_opt, _ = best_subset(c)
    cases += 1
    lost += g_opt - g_bad
print(f'   {2000} 场里有 {cases} 场存在「单题耗时 > 总时长」的题')
print(f'   这些场次里,先啃那道题比最高分的做法平均少拿 {lost / cases:.1f} 分')
   2000 场里有 1243 场存在「单题耗时 > 总时长」的题
   这些场次里,先啃那道题比最高分的做法平均少拿 156.4 分

2000 场里有 1243 场存在一道「单题就超过总时长」的题——超过六成。也就是说,考场上你大概率会碰到一道做不完的题,这不是小概率事件。

而在这些场次里,先啃它的代价是平均少拿 156.4 分。因为它不只是让你少做一道题,而是让后面所有本来能做的题全部作废——时间已经用光了。

这条对应大纲里的第一个坑:把时间全花在一道难题上。 它的代价不是「少拿那一道题的分数」,而是「整场崩掉」。

判断一道题该不该啃,要在动手之前做。一旦开始写,你会投入十几分钟的沉没成本,那时候再放弃就更难了——这是人都会有的倾向,不靠意志力对抗,靠开工前就把规则定死:比如「超过预计时间 1.5 倍还没写出可运行版本,就立刻跳到下一题」。

选题顺序就是背包

现在来看为什么「性价比排序」还是差上限一截(133.9 对 157.2),以及真正的上限是怎么算出来的。

注意一件事:既然一题做不完就白费,那么你实际完成的题目一定构成一个时间装得下的子集。反过来,任何一个时间装得下的子集,你都能按某个顺序把它全部做完。

所以「选一个做题顺序」本质上就是「选一个时间装得下的题目子集」——而后者正是第 25 课的 0/1 背包:时间是容量,分值是价值,挑一个价值最大又装得下的组合。

拿块 3 那个具体例子验一下:

EX = [(200, 100), (40, 30), (40, 30), (40, 30), (30, 25)]
og, osub = best_subset(EX)
print(f'   五题(耗时, 分值)= {EX},总时长 {T} 分钟')
print(f'   分值最高的可行子集:第 {osub} 题,共 {og} 分')
for name, key in STRATS:
    g, u, done, stuck = play(key(EX), EX)
    if stuck:
        tag = f'卡在第 {stuck[0]} 题(只差 {stuck[3]} 分钟,那题值 {stuck[2]} 分)'
    else:
        tag = '时间刚好用尽'
    print(f'   {name:<22} 得分 {g:>4}   做完 {done}   {tag}')
   五题(耗时, 分值)= [(200, 100), (40, 30), (40, 30), (40, 30), (30, 25)],总时长 240 分钟
   分值最高的可行子集:第 [0, 1] 题,共 130 分
   按题面顺序                  得分  130   做完 [0, 1]   卡在第 2 题(只差 0 分钟,那题值 30 分)
   先易后难(耗时升序)             得分  115   做完 [4, 1, 2, 3]   卡在第 0 题(只差 90 分钟,那题值 100 分)
   先难后易(分值降序)             得分  130   做完 [0, 1]   卡在第 2 题(只差 0 分钟,那题值 30 分)
   性价比(分值/耗时降序)           得分  115   做完 [4, 1, 2, 3]   卡在第 0 题(只差 90 分钟,那题值 100 分)

这一局最优是 130 分,做法是「做第 0 题和第 1 题」。 注意看这个结局有多紧:200 + 40 = 240,正好把时间用完,一分钟不剩。

而「性价比排序」在这一局只拿 115 分——它按性价比从高到低做,先把三道 30 分的小题和那道 25 分的做完(115 分),轮到第 0 题时只剩 90 分钟,那道题要 200 分钟,只能放弃。它做出了4 道题,却比只做 2 道题的方案少 15 分。

结论:性价比排序是个不错的经验法则(块 1 里它 133.9 分,是四种里最高的),但它不是最优。 差别在于——性价比只看「每一分钟买到多少分」,而背包要看「装得下的组合里哪个总分最高」。前者是贪心,后者才对应问题本身的结构。第 23 课讲过贪心什么时候会失效,这里是同一个道理换了个场景。

考场上你当然不会去枚举 2^5 个子集(虽然五道题真枚举也就 32 种,心算都行)。实用的做法是:按性价比从高到低排,然后检查一遍「排在最后那道题,剩下的时间够不够」。 如果不够,就把它换掉,看看换成别的组合总分会不会更高。五道题的规模,这个检查手算就能做完。

自检时间要算进预算

第 30 课讲过「写对但来不及自检」是常见的失分点。这一课把它算进时间预算里看代价:

def run_check(reserve, p_wrong, seed=31337, rounds=4000):
    rng = random.Random(seed)
    tot = saved_tot = done_tot = 0
    for _ in range(rounds):
        c = gen_case(rng)
        order = sorted(range(N), key=lambda i: c[i][0])
        _, _, done, _ = play(order, c, budget=T - reserve)
        done_tot += len(done)
        eff = min(1.0, reserve / 30)          # 投入越多,救回率越接近上限
        got = saved = 0
        for i in done:
            v = c[i][1]
            if rng.random() < p_wrong:
                if rng.random() < 0.85 * eff:
                    got += v
                    saved += 1
            else:
                got += v
        tot += got
        saved_tot += saved
    return tot / rounds, saved_tot / rounds, done_tot / rounds


for pw in (0.28, 0.45):
    print(f'   假设做出来的题里有 {pw:.0%} 其实是错的,自检最多能救回 85%:')
    print('      预留自检   平均得分    平均救回几道   平均做完几道')
    for reserve in (0, 10, 20, 30, 40, 60):
        g, s, d = run_check(reserve, pw)
        print(f'      {reserve:>6} 分   {g:7.1f}      {s:6.2f}        {d:6.2f}')
   假设做出来的题里有 28% 其实是错的,自检最多能救回 85%:
      预留自检   平均得分    平均救回几道   平均做完几道
           0 分      78.6        0.00          3.10
          10 分      82.9        0.24          3.04
          20 分      88.8        0.47          3.01
          30 分      96.0        0.70          3.00
          40 分      95.9        0.70          3.00
          60 分      95.9        0.70          3.00
   假设做出来的题里有 45% 其实是错的,自检最多能救回 85%:
      预留自检   平均得分    平均救回几道   平均做完几道
           0 分      60.3        0.00          3.11
          10 分      69.9        0.38          3.04
          20 分      81.4        0.77          3.01
          30 分      93.3        1.14          3.00
          40 分      93.2        1.14          3.00
          60 分      93.2        1.14          3.00

留出 30 分钟自检,平均多做不完 0.1 道题,却多拿 17 到 33 分。

注意曲线的形状:从 0 到 30 分钟一直在涨,30 分钟往后就平了(40 分、60 分都是 96.0)。这是因为模型里自检的收益在 30 分钟就饱和了——再多留只是白白少做题。

所以「自检要留多久」这个问题有实际答案:留到收益饱和的那个点就够,不要预留过多。 具体留多久取决于题目难度和你的出错率,但这个形状是通用的——先陡后平。

怎么算过

这一课的产物是两份材料,判分看四件事:

  1. 限时是真的限时。 4 小时做完 5 道题,有开始与结束的时间记录。中途可以卡住,但不能停表去查资料、不能事后补做。
  2. 解题记录完整。 每道题都要记:读题用时、定下的算法与理由(要提到数据范围)、写完的时刻、自检做了什么、最后得了几分、丢分丢在哪。
  3. 讲解稿讲清三件事。 对做出来的每题:为什么这个方法是对的、复杂度是多少、多大的规模会把它淘汰掉。第三件是重点——说不出这条,说明你只是记住了这道题。
  4. 至少有一道题是「放弃得对」的。 记录里要能看出你在某道题上做出了放弃的决定,并说清依据(比如「预计耗时超过剩余预算」)。这一条是判断你会不会选题的直接证据。

怎么跑

在 lab 里按这个顺序走:

# 1) 拿题:lab 里给了 5 道省赛难度的题面与样例数据
# 2) 计时开始,4 小时,中途不要停表
# 3) 按你自己的选题顺序做,边做边填解题记录模板
# 4) 时间到,立刻停手(哪怕某题只差一行)
# 5) 用 lab 的判分脚本对答案,算出得分
# 6) 写讲解稿
# 7) 回头复盘:如果换一种选题顺序,这一场能多拿几分?用手算

第 7 步是这一课最有价值的一步:你不是在复盘算法,是在复盘决策。

上手做 四件事按这个顺序做

这一课的动手部分落在 lab 里(任务与断言见 lab/0031-lab-04-spaced-repetition-drill/,做完对照 lab/solutions/)。要做的是四件事。

  1. 先通读、先列表,再动手。 打开五道题,只做第 2 节那三步(读范围、读问法、估时间),把表填出来。这一步不许写代码,15 分钟内完成。
  2. 定顺序,并写下理由。 照着上一步的表定出做题顺序,写清为什么这么排——要提到「预计耗时」与「分值」两方面。写完就不再改(中途可以放弃某题,但顺序不许重排,这是为了让你看到自己判断的准确度)。
  3. 限时 4 小时走完。 边做边填记录。卡住超过预计时间 1.5 倍的题,按你自己定的规则放弃,并在记录里写清楚。
  4. 写讲解稿并复盘。 讲解稿讲三件事(为什么对、复杂度、什么规模会淘汰它)。复盘时算一遍:如果按性价比排序,这一场能多拿几分? 用手算,不用写程序。

自查清单

最可能卡在哪

卡壳顺序

  1. 先确认时间是真的限时。超时了这场模拟就没意义了,重来。
  2. 顺序定了之后不许重排——这一条是纪律,不是建议。重排了就看不出你的判断准不准。
  3. 某道题卡住,先看是不是数据范围读错了(第 28 课那套)。范围读错会导致算法选错,那是根本性的。
  4. 算法选对了但写不出来,看是不是边界没想清。用小数据手推一遍,别硬想。
  5. 到了放弃规则触发的时间点,立刻跳。跳之前把记录填完,别等到最后凭记忆补。
  6. 讲解稿写不下去,说明那道题你其实没真懂——回课件对应那一课重看。

做完之后

这一场模拟的结果不是分数,是一份关于你自己的数据:

把这份数据带到第 33 课的综合实战里去——那一课是这套流程的最后一次完整演练。

没看懂?随时问 把看不懂的段落(或报错)原样复制回会话,说一句哪里不懂就行,我只解释这一段,不打断你的进度。