AtCoder Beginner Contest 448 复盘笔记

比赛链接:AtCoder Beginner Contest 448
原始代码:7.20 目录
整理日期:2026-07-21

总结

题目核心知识点本次情况复杂度
A - chmin模拟、维护前缀最小值独立 ACO(N)O(N)
B - Pepper Addiction模拟、按种类维护余量独立 ACO(N+M)O(N+M)
C - Except and Min排序、候选集、利用 K≤5K\le5独立 AC,赛后纠正复杂度理解O(Nlog⁡N+∑K2)O(N\log N+\sum K^2)
D - Integer-duplicated Path树上 DFS、路径计数、回溯、坐标压缩赛时写了一半;看题解后复写O(Nlog⁡N)O(N\log N)
E - Simple Division商的取模、游程编码、快速幂、扩大模数消除除法AI 题解后复写O(∑log⁡li)O(\sum\log l_i)
F - Authentic Traveling Salesman Problem构造、分块、蛇形排序AI 题解后复写O(Nlog⁡N)O(N\log N)
G - Conquest零和博弈、凸包、黄金分割搜索战略性放弃改错参考实现约 O(Nlog⁡N)O(N\log N) / 组

这场最值得保留的经验:

  1. 小约束不只决定复杂度,也决定“只看少量候选”。C 中每次至多删除 5 个球,所以答案一定在全局前 6 小中。
  2. 树上根到当前点的路径状态,适合用 DFS 的进入/退出事件维护;不要为每个点复制整条路径。
  3. 模意义下不能随便除法。E 通过把模数从 LL 扩大到 9L9L,把“除以 9”变回普通整数除法。
  4. 构造题不要求最优时,先证明一个有余量的上界。F 的分块蛇形路线比求真正 TSP 简单得多。

A - chmin

题意

给定 XX 和序列 AA。从左到右处理:若 Ai<XA_i<X,输出 1 并令 X=AiX=A_i;否则输出 0。

思路

完全按题意模拟即可。处理到第 ii 项之前,XX 就是初始值与 A1,…,Ai−1A_1,\ldots,A_{i-1} 的最小值。

注意比较是严格小于;相等时不能更新。

我的代码

#include <bits/stdc++.h>
using namespace std;
int n,x;

int main()
{
    cin>>n>>x;
    for(int i=1;i<=n;i++)
    {
        int val;cin>>val;
        if(val<x)
        {
            cout << "1" << "\n";
            x=val;
        }
        else cout<<"0"<<"\n";
    }

    return 0;
}

B - Pepper Addiction

题意

餐厅有 MM 种辣椒,第 jj 种剩余 CjC_j 克。第 ii 道菜只能使用第 AiA_i 种辣椒,且最多放 BiB_i 克。求所有菜最多能放多少辣椒。

思路

不同种类互不影响。处理一道菜时,能放的最大量就是

min⁡(Bi,CAi).\min(B_i,C_{A_i}).

放得越多不会损失后续总收益:同一种辣椒无论分给哪道菜,每克对答案的贡献都相同。因此直接扣减库存即可。

我的代码

//纯模拟
#include <bits/stdc++.h>
using namespace std;
const int N=1e3+10;
int C[N];//每种辣椒的总g数
int A[N];//每道菜需要哪种辣椒
int B[N];//在第i到菜上使用Ai这种辣椒,最多使用多少g
int n,m;
int ans;

void solve()
{
    cin>>n>>m;
    for(int i=1;i<=m;i++) cin>>C[i];
    for(int i=1;i<=n;i++) cin>>A[i]>>B[i];

    for(int i=1;i<=n;i++)
    {
        int idx=A[i];
        //贪心
        int minVal=min(B[i],C[idx]);//不是B[idx]
        ans+=minVal;
        C[idx]-=minVal;
    }

    cout<<ans<<"\n";
}

int main()
{
    ios::sync_with_stdio(0);cin.tie(0);cout.tie(0);

    solve();
    return 0;
}

易错点正如代码注释:菜的上限是 B[i],辣椒库存才按种类取 C[idx]。


C - Except and Min

题意

有 NN 个球,第 ii 个球写着 AiA_i。每次询问临时移除至多 K≤5K\le5 个球,输出剩余球上的最小值,随后把球放回。

核心观察

先把 (数值, 原下标) 排序。一次询问最多排除 5 个不同下标,因此排序后的前 K+1K+1 个球中,至少有一个没有被移除。

所以从小到大枚举候选时,最多检查 K+1≤6K+1\le6 个元素,并不是真的每次扫描 NN 个元素。

这里要特别注意:我的代码虽然写的是 for(int j=1;j<=n;j++),但找到第一个未删除的球后会立即 break。因为最多只有 KK 个球被删除,所以 j 不可能超过 K+1K+1。

也就是说,这份代码已经隐式利用了 K≤5K\le5。循环上界写成 nn 只是不够直观,并不代表它真的会遍历 nn 次。

复杂度为什么能过

对每个候选,用长度为 KK 的数组判断其下标是否被删除。每次询问最多检查 K+1K+1 个候选,因此为

O(K(K+1))=O(K2).O(K(K+1))=O(K^2).

总复杂度为

O(Nlog⁡N+∑K2),O\left(N\log N+\sum K^2\right),

又因为 K≤5K\le5,所以 K2≤5KK^2\le5K,结合题目的 ∑K≤4×105\sum K\le4\times10^5,查询部分至多是常数倍的 ∑K\sum K,完全足够。

有人会把查询复杂度写成 O(K)O(K),这是把 K≤5K\le5 当作常数后的简写。若严格统计我的两层小循环,每个查询应写成 O(K2)O(K^2);两种写法都能通过,但后者更准确。

本次踩坑

赛时代码中最关键的注释是:

////一定要注意,在这里定义B[N]的时间复杂度为O(n)
////一定要先考虑在外层定义!!!!!!!!!!

这里需要纠正一个认识:未初始化的局部数组 int B[N]; 通常只是调整栈指针,声明本身不是 O(N)O(N) 初始化。真正的问题是它会为只需 5 个元素的数据占用约 1.2 MB1.2\text{ MB} 栈空间,既浪费又有栈溢出风险。最合适的写法是长度 5 的小数组;像原代码一样放到全局也能避开栈空间问题。

更精确地区分:

  • int B[N];:不初始化元素,声明本身通常按 O(1)O(1) 看待;
  • int B[N] = {};:需要把整个数组清零,是 O(N)O(N);
  • vector<int> B(N);:会构造并初始化 NN 个元素,也是 O(N)O(N);
  • int B[6];:本题真正需要的大小,放在查询内部也完全合理。

另外,局部数组离开作用域后失效并不是本题风险,因为每个询问都会重新读入 B1,…,BKB_1,\ldots,B_K。全局数组默认清零也没有帮助,因为算法只访问本次刚读入的前 KK 项。

对 AI 对话的复盘

对话记录:DeepSeek 对话

对话前半段正确指出了“未初始化局部数组不是 O(N)O(N)”。但后半段把我的查询误判成 O(NK)O(NK),进而认为代码只是因为测试数据弱才 AC,这个结论不成立。

误判的原因是只看到了 for(j=1;j<=n;j++) 的表面上界,却漏掉了内部的提前退出。最坏情况下,前 KK 个候选恰好全部被删,第 K+1K+1 个候选一定未被删,因此实际执行次数满足

j≤K+1≤6.j\le K+1\le6.

DeepSeek 给出的“优化代码”只是把这个事实显式写成 pos++,与我的代码本质相同。它仍要对至多 K+1K+1 个候选逐一扫描 KK 个删除下标,严格复杂度仍是 O(K2)O(K^2),而不是回答中声称的 O(K)O(K)。

53ms 与 66ms 的差距也不能用来判断渐进复杂度,更不能证明“反复分配 1.2 MB 导致 TLE”。编译器通常会统一安排函数栈帧,同一循环的局部数组也会复用同一片栈地址;未初始化时不会每轮写满 1.2 MB。

这次最重要的经验是:复杂度要按真正执行的迭代次数分析,不能只机械相乘源代码里的循环上界。 break、单调性和题目约束都可能把一个表面上的 O(N)O(N) 循环压到 O(K)O(K)。

更清楚的等价写法

下面的版本没有改变算法,只把 j 的真实上界和 B 的真实大小直接写出来:

while(q--)
{
    int k;cin>>k;
    int B[6];
    for(int i=1;i<=k;i++) cin>>B[i];

    for(int j=1;j<=k+1;j++)
    {
        int idx=v[j].second;
        int flag=1;
        for(int i=1;i<=k;i++)
        {
            if(idx!=B[i]) continue;
            flag=0;
            break;
        }
        if(flag)
        {
            cout<<v[j].first<<"\n";
            break;
        }
    }
}

这种写法的价值主要是让正确性和复杂度一眼可见,而不是把一个错误算法优化成正确算法。

我的代码

#include <bits/stdc++.h>
using namespace std;
typedef pair<int,int> PII;
const int N=3e5+10;
int n,q;
int a[N];
PII v[N];
int B[N];

bool cmp(const PII& p1,const PII& p2)
{
    return p1.first<p2.first;
}

void solve()
{
    cin>>n>>q;
    for(int i=1;i<=n;i++)
    {
        cin >> a[i];
        v[i]={a[i],i};
    }

    //先预处理出最小值,然后看哪些最小值
    sort(v+1,v+1+n,cmp);

    while(q--)
    {
        int k;cin>>k;
        ////一定要注意,在这里定义B[N]的时间复杂度为O(n)
        ////一定要先考虑在外层定义!!!!!!!!!!
        for(int i=1;i<=k;i++) cin>>B[i];

        for(int j=1;j<=n;j++)
        {
            auto it=v[j];
            int idx=it.second;
            int flag=1;
            for(int i=1;i<=k;i++)
            {
                if(idx!=B[i]) continue;
                flag=0;
                break;
            }
            if(flag)
            {
                cout<<it.first<<"\n";
                break;
            }
        }
    }
}

int main()
{
    ios::sync_with_stdio(0);cin.tie(0);cout.tie(0);
    solve();
    return 0;
}

D - Integer-duplicated Path

题意

给定一棵以 1 为根的树,每个点有一个整数 AiA_i。对每个点 kk,判断从 1 到 kk 的简单路径上是否有两个不同顶点写着相同的数。

我的赛时尝试为什么不行

我的尝试为每个点保存整条路径,并用两重循环检查重复值,存在几个根本问题:

  1. check(path) 最坏是 O(路径长度2)O(\text{路径长度}^2),链形树会退化到不可接受的复杂度;
  2. 给每个结点保存一份完整路径,链形树需要 O(N2)O(N^2) 空间;
  3. 调用子结点 DFS 前和 DFS 开头都 push_back(a[it]),同一结点值会被重复压入,可能直接误判;
  4. 树上从根到点的路径唯一,不需要为每个结点复制路径,只需维护 DFS 当前路径的状态。

正确状态

维护:

  • cnt[x]:当前根路径上,值 xx 出现了多少次;
  • duplicateCnt:当前路径上,出现次数至少为 2 的不同数值有多少种。

进入值为 xx 的结点时:

  • 若 cnt[x]==1,加入后它第一次成为重复值,duplicateCnt++;
  • 然后 cnt[x]++。

此时答案就是 duplicateCnt>0。

退出结点时回溯:先 cnt[x]--;若它重新变成 1,说明不再重复,duplicateCnt--。

由于 Ai≤109A_i\le10^9,先坐标压缩,才能用数组维护频次。

坐标压缩与 vector 细节

对话记录:D 题 DeepSeek 对话

vector<int> vals;
vals.reserve(n);

reserve(n) 只预留至少能容纳 nn 个元素的容量,不改变 size(),因此后面必须用 push_back,不能直接访问 vals[i]。不写也仍是正确的摊还 O(N)O(N),预留容量只是避免扩容和搬移旧元素。

它与下面两个操作不同:

  • resize(n):把当前大小改成 nn,尽量保留原元素,新增的 int 初始化为 0;
  • assign(n,0):丢弃原内容,重新放入 nn 个 0。

排序并去重后,vals 保存所有不同的原值:

sort(vals.begin(),vals.end());
vals.erase(unique(vals.begin(),vals.end()),vals.end());

unique 只把重复元素移到逻辑末尾并返回新末尾,真正缩短容器的是后面的 erase。由于 A[i]A[i] 一定存在于 vals 中,

B[i]=lower_bound(vals.begin(),vals.end(),A[i])-vals.begin();

得到的就是 A[i]A[i] 在去重数组中的 0-based(从 0 开始)编号。压缩只改变标签,不改变“两个值是否相等”,所以不会影响本题判断。

cnt 与 dup 的精确定义

cnt[id] 是当前根路径上,压缩编号 id 出现的次数。dup 不是“重复元素的总个数”,而是出现次数至少为 2 的不同数值种类数。

例如当前路径的值为 [1,2,1,2][1,2,1,2],则 1 和 2 都重复,dup=2;若为 [1,1,1][1,1,1],虽然有三个 1,dup 仍为 1。

只在频次跨过临界值 1 与 2 时修改 dup:

  • 进入前 cnt[id]==1:加入后从 1 变 2,dup++;
  • 退出后 cnt[id]==1:删除前是 2,删除后变 1,dup--。

这样 dup>0 就能 O(1)O(1) 判断当前路径是否存在重复值,不必每到一个结点都扫描整个 cnt。

为什么使用进入/退出事件

NN 最大为 2×1052\times10^5,递归 DFS 在链形树上可能爆栈。栈式 DFS 给每个结点安排两种事件:

  • state=0:进入结点,把它加入当前路径;
  • state=1:退出结点,撤销它对当前路径的影响。

这等价于递归 DFS 的“递归前加入、递归后回溯”。

Entry 三个字段的含义是:

  • u:当前结点;
  • p:父结点,用于在无向树中跳过回边;
  • state:0 表示进入,1 表示退出。

递归写法与栈式写法可以逐行对应:

递归 DFS栈式 DFS
调用 dfs(u,p)压入 {u,p,0}
函数开头加入当前结点处理进入事件
调用 dfs(v,u)压入 {v,u,0}
所有孩子返回后回溯处理 {u,p,1} 退出事件

关键是先压当前结点的退出事件,再压孩子的进入事件。栈是后进先出,孩子会先处理;所有孩子及其退出事件完成后,才轮到父结点退出,恰好模拟递归调用栈。

对话中的其他易错点

  1. 我的赛时路径方案会重复压入子结点:调用前 push_back(a[it]) 一次,进入 dfs(it) 后又压一次,却只弹出一次,路径状态会被破坏。
  2. memos[pos]=path 保存的是一份独立拷贝,不是引用;真正的问题是链形树上总拷贝量与总存储量均可达 O(N2)O(N^2)。
  3. 原尝试没有任何给 ans[pos] 赋 1 的有效判重逻辑,所以以 ans[pos] 为条件的剪枝不会触发。
  4. 在部分 GNU/Linux 环境中,<bits/stdc++.h> 间接声明了 POSIX 函数 dup(int)。全局变量 int dup 可能与它冲突;函数内局部变量通常没问题,全局版本可改名为 duplicateCnt。
  5. 递归版本更直观,但链形树深度可达 2×1052\times10^5,存在系统调用栈溢出风险,因此最终保留显式栈版本。

复写代码(保留我的风格)

#include <bits/stdc++.h>
using namespace std;

struct Entry
{
    //u,u的父亲,u的状态
    //state: 0为进栈事件,1为出栈事件
    int u,p,state;
};

void solve()
{
    int n;cin>>n;
    vector<int> A(n+1);
    vector<int> vals;
    vals.reserve(n);

    for(int i=1;i<=n;i++)
    {
        cin>>A[i];
        vals.push_back(A[i]);
    }

    //坐标压缩
    //排序+元素去重
    sort(vals.begin(),vals.end());
    vals.erase(unique(vals.begin(),vals.end()),vals.end());
    vector<int> B(n+1);
    for(int i=1;i<=n;i++)
    {
        B[i]=lower_bound(vals.begin(),vals.end(),A[i])-vals.begin();
    }

    //n-1条边
    vector<int> g[n+1];
    for(int i=1;i<n;i++)
    {
        int u,v;cin>>u>>v;
        g[u].push_back(v),g[v].push_back(u);
    }

    int M=vals.size();
    vector<int> cnt(M);
    int dup=0;
    vector<char> ans(n+1);

    //开始栈式dfs
    vector<Entry> st;
    st.push_back({1,0,0});

    while(!st.empty())
    {
        auto it=st.back();
        st.pop_back();
        int u=it.u,p=it.p;

        if(it.state==0)
        {
            //把u压入路径
            int id=B[u];
            int old=cnt[id];
            if(old==1) dup++;
            cnt[id]++;

            ////不是ans[id],id是Entry类型的!!!!!!!!!!!!!
            ans[u]=(dup>0);
            //压入出栈事件
            st.push_back({u,p,1});

            //把子节点压入栈中
            for(auto v:g[u])
            {
                if(v==p) continue;
                st.push_back({v,u,0});
            }
        }
        else
        {
            //出栈事件
            int id=B[u];
            cnt[id]--;
            if(cnt[id]==1) dup--;
        }
    }

    for(int i=1;i<=n;i++)
    {
        cout<<(ans[i]?"Yes":"No")<<"\n";
    }
}

int main()
{
    ios::sync_with_stdio(0);cin.tie(0);cout.tie(0);
    solve();

    return 0;
}

E - Simple Division

题意

NN 用“数字 cic_i 连续出现 lil_i 次”的游程编码给出,总位数可能极大。求

⌊NM⌋ mod 10007.\left\lfloor\frac NM\right\rfloor\bmod10007.

核心推导

令 P=10007P=10007、L=MPL=MP。将 NN 写成 N=MQ+RN=MQ+R,其中 0≤R<M0\le R<M,可以证明

Q mod P=⌊N mod LM⌋.Q\bmod P=\left\lfloor\frac{N\bmod L}{M}\right\rfloor.

因此只需从左到右维护 r=N mod Lr=N\bmod L。

追加 ll 个数字 cc 时:

r′=(r×10l+c10l−19) mod L.r'=\left(r×10^l+c\frac{10^l-1}{9}\right)\bmod L.

但 99 在模 LL 下不一定有逆元。令

X=c(10l−1) mod (9L),X=c(10^l-1)\bmod(9L),

则 XX 一定是 9 的倍数,并且

c(10l−1)9 mod L=X9.\frac{c(10^l-1)}9\bmod L=\frac X9.

所以每组分别快速幂计算 10l mod L10^l\bmod L 和 10l mod 9L10^l\bmod9L。

完整证明、易错点和代码见单独修订的 E 题解析。


F - Authentic Traveling Salesman Problem

题意

平面上有 N≤6×104N\le6\times10^4 个点。从地点 1 出发,每个地点恰好访问一次并回到 1。曼哈顿距离总和不超过 101010^{10} 即可,不要求最短。

构造:竖条分块 + 蛇形排序

对话记录:F 题 DeepSeek 对话

将横坐标按宽度 BB 分成若干竖条:

block⁡(i)=⌊XiB⌋.\operatorname{block}(i)=\left\lfloor\frac{X_i}{B}\right\rfloor.

先按块编号递增排序。同一块内:偶数块按 YY 的一个方向排,奇数块反向排。这样路线在相邻竖条间蛇形前进,避免每换一块都从顶部跳回底部。

设坐标范围宽度为 W=2×107W=2\times10^7。按照官方题解,路线长度可以严格放宽为:

W2B+NB+4W.\frac{W^2}{B}+NB+4W.

各部分含义如下:

  1. 条带内纵向移动:每条至多 WW,约有 W/BW/B 条,共至多 W2/BW^2/B;
  2. 条带内横向移动:相邻访问点的横坐标差小于 BB,至多发生 NN 次,共至多 NBNB;
  3. 跨越相邻条带:单次横向距离至多 2B2B,约跨越 W/BW/B 次,共至多 2W2W;
  4. 最后一个点返回起点:曼哈顿距离至多 W+W=2WW+W=2W。

对话中曾把“当前条带最右端到下一条带最左端”说成距离 2B2B,这个描述是错的:两者靠近同一边界,距离接近 0。真正的最坏情况是当前条带最左端到下一条带最右端,跨度才接近 2B2B。你当时的质疑是正确的。

由均值不等式,前两个主项满足

W2B+NB≥2WN,\frac{W^2}{B}+NB\ge2W\sqrt N,

取等时

B≈WNB\approx\frac{W}{\sqrt N}

,因此代码取 2e7/sqrt(n)。在极限 N=6×104N=6\times10^4 时,上界约为

2WN+4W≈9.88×109<1010.2W\sqrt N+4W\approx9.88\times10^9<10^{10}.

为什么旋转不改变路线

蛇形排序得到的是一个环。若原访问顺序是

p1→p2→⋯→pN→p1,p_1\to p_2\to\cdots\to p_N\to p_1,

从其中任意结点开始书写,环上使用的边完全相同,总长度自然不变。因此只需循环左移,让地点 1 成为第一个输出。

base-1(从 1 开始编号)的正确公式是:

ans[i]=idx[(pos1+i-2)%n+1];

其中先减 1 把位置转成 base-0,取模后再加 1 转回来。输出与样例不同不代表旋转错误;构造题只要求输出任意合法环。

特判如何判断答案

这类答案不唯一的题使用 special judge(特判程序)。评测不会枚举所有合法路线,只检查本次输出:

  1. 是否恰好是 1,2,…,N1,2,\ldots,N 的一个排列;
  2. 第一个地点是否为 1;
  3. 按输出顺序并加上末点回到 1 的距离后,总长度是否不超过 101010^{10}。

因此,样例输出只是一个示例,不是唯一标准答案。

易错点

  1. 判断蛇形方向要看块编号 bi&1,不是点编号 i&1;
  2. base-1 循环左移公式是 (pos1+i-2)%n+1;
  3. 题目隐含最后一个输出地点还要回到地点 1;排序构造分析的是一个环。
  4. 第 0 条带的奇偶性由 X[i]/B 决定,与数组采用 base-0 还是 base-1 无关;
  5. 所有条带的升降方向整体反过来仍是蛇形构造,但不能让同一条带内的不同点分别按点编号奇偶选择方向,否则比较器失去统一的条带顺序。

复写代码(保留我的风格)

#include <bits/stdc++.h>
using namespace std;
const int N=6e4+10;
int X[N],Y[N];
int idx[N];
int ans[N];

void solve()
{
    //读入
    int n;cin>>n;
    for(int i=1;i<=n;i++) cin>>X[i]>>Y[i];
    for(int i=1;i<=n;i++) idx[i]=i;

    //构建蛇形路线
    int B=(int)(2e7/sqrt(n));//为什么用sqrt(n)-->均值不等式求最小值
    sort(idx+1,idx+1+n,[&](int i,int j){
       int bi=X[i]/B;
       int bj=X[j]/B;
       if(bi!=bj) return bi<bj;
       ////严重错误!!!!!!!!!!!!!!
       ////是bi不是i!!!!!!!!!
       if(bi&1) return Y[i]<Y[j];
       return Y[i]>Y[j];
    });

    //寻找点1的位置
    int pos1=1;
    while(idx[pos1]!=1) pos1++;

    //循环左移-->以1为第一个输出
    for(int i=1;i<=n;i++)
    {
        //如果是base-0
        //ans[i]=idx[(pos+i)%n];
        ans[i]=idx[(pos1+i-2)%n+1];//base-1情况
    }

    for(int i=1;i<=n;i++) cout<<ans[i]<<" ";
    cout<<"\n";
}

int main()
{
    ios::sync_with_stdio(0);cin.tie(0);cout.tie(0);
    solve();

    return 0;
}

G - Conquest(战略性放弃)

题意轮廓

这是一个多阶段零和博弈:双方同时各禁用对方一副牌,再在剩余牌中用混合策略选牌对战。要求双方采用最大化最坏收益的策略时,高桥的胜率。

参考代码在做什么

精简参考实现仍组合了多层高级工具:

  1. 对三列胜率两两组合,转成二维点集;
  2. 用上凸包保留可能成为最优混合策略的点;
  3. 在凸包边上用黄金分割搜索最大化两项收益的较小值;
  4. 只对决定全局最优的少数特殊牌重新计算“被禁用后”的答案;
  5. 最外层再对三种组合的混合权重做嵌套黄金分割搜索。

它要求同时理解 minimax(极小化极大)、二维凸包、混合策略几何化和连续优化。以本场复盘收益衡量,继续逐行改错的成本远高于巩固 D–F,因此战略性放弃是合理决策。

留给以后的前置知识

若以后重做,建议按以下顺序补齐:

  1. 2×N2\times N 零和博弈与混合策略的几何意义;
  2. 为什么劣于凸包的点永远不会成为最优策略;
  3. 禁掉一个候选后,为何多数答案不变,只需重算少数支撑点;
  4. 凹函数上的黄金分割搜索及误差控制。

本次不保留“千行天书”,也不把尚未真正掌握的参考代码伪装成自己的题解。


赛后训练清单

  • 不看代码重新写一次 D 的栈式 DFS,重点默写进入/退出事件与回溯顺序。
  • 手推一个两组游程的小例子,完整算出 E 中的 LL、9L9L、rr。
  • 重新证明 F 的路线长度上界,并写熟 base-1 循环左移公式。
  • G 暂不改错;先补零和博弈和凸包优化的前置知识。