
AtCoder Beginner Contest 448 复盘笔记
比赛链接:AtCoder Beginner Contest 448
原始代码:7.20目录
整理日期:2026-07-21
总结
| 题目 | 核心知识点 | 本次情况 | 复杂度 |
|---|---|---|---|
| A - chmin | 模拟、维护前缀最小值 | 独立 AC | |
| B - Pepper Addiction | 模拟、按种类维护余量 | 独立 AC | |
| C - Except and Min | 排序、候选集、利用 | 独立 AC,赛后纠正复杂度理解 | |
| D - Integer-duplicated Path | 树上 DFS、路径计数、回溯、坐标压缩 | 赛时写了一半;看题解后复写 | |
| E - Simple Division | 商的取模、游程编码、快速幂、扩大模数消除除法 | AI 题解后复写 | |
| F - Authentic Traveling Salesman Problem | 构造、分块、蛇形排序 | AI 题解后复写 | |
| G - Conquest | 零和博弈、凸包、黄金分割搜索 | 战略性放弃改错 | 参考实现约 / 组 |
这场最值得保留的经验:
- 小约束不只决定复杂度,也决定“只看少量候选”。C 中每次至多删除 5 个球,所以答案一定在全局前 6 小中。
- 树上根到当前点的路径状态,适合用 DFS 的进入/退出事件维护;不要为每个点复制整条路径。
- 模意义下不能随便除法。E 通过把模数从 扩大到 ,把“除以 9”变回普通整数除法。
- 构造题不要求最优时,先证明一个有余量的上界。F 的分块蛇形路线比求真正 TSP 简单得多。
A - chmin
题意
给定 和序列 。从左到右处理:若 ,输出 1 并令 ;否则输出 0。
思路
完全按题意模拟即可。处理到第 项之前, 就是初始值与 的最小值。
注意比较是严格小于;相等时不能更新。
我的代码
#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
题意
餐厅有 种辣椒,第 种剩余 克。第 道菜只能使用第 种辣椒,且最多放 克。求所有菜最多能放多少辣椒。
思路
不同种类互不影响。处理一道菜时,能放的最大量就是
放得越多不会损失后续总收益:同一种辣椒无论分给哪道菜,每克对答案的贡献都相同。因此直接扣减库存即可。
我的代码
//纯模拟
#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
题意
有 个球,第 个球写着 。每次询问临时移除至多 个球,输出剩余球上的最小值,随后把球放回。
核心观察
先把 (数值, 原下标) 排序。一次询问最多排除 5 个不同下标,因此排序后的前 个球中,至少有一个没有被移除。
所以从小到大枚举候选时,最多检查 个元素,并不是真的每次扫描 个元素。
这里要特别注意:我的代码虽然写的是 for(int j=1;j<=n;j++),但找到第一个未删除的球后会立即 break。因为最多只有 个球被删除,所以 j 不可能超过 。
也就是说,这份代码已经隐式利用了 。循环上界写成 只是不够直观,并不代表它真的会遍历 次。
复杂度为什么能过
对每个候选,用长度为 的数组判断其下标是否被删除。每次询问最多检查 个候选,因此为
总复杂度为
又因为 ,所以 ,结合题目的 ,查询部分至多是常数倍的 ,完全足够。
有人会把查询复杂度写成 ,这是把 当作常数后的简写。若严格统计我的两层小循环,每个查询应写成 ;两种写法都能通过,但后者更准确。
本次踩坑
赛时代码中最关键的注释是:
////一定要注意,在这里定义B[N]的时间复杂度为O(n)
////一定要先考虑在外层定义!!!!!!!!!!
这里需要纠正一个认识:未初始化的局部数组 int B[N]; 通常只是调整栈指针,声明本身不是 初始化。真正的问题是它会为只需 5 个元素的数据占用约 栈空间,既浪费又有栈溢出风险。最合适的写法是长度 5 的小数组;像原代码一样放到全局也能避开栈空间问题。
更精确地区分:
int B[N];:不初始化元素,声明本身通常按 看待;int B[N] = {};:需要把整个数组清零,是 ;vector<int> B(N);:会构造并初始化 个元素,也是 ;int B[6];:本题真正需要的大小,放在查询内部也完全合理。
另外,局部数组离开作用域后失效并不是本题风险,因为每个询问都会重新读入 。全局数组默认清零也没有帮助,因为算法只访问本次刚读入的前 项。
对 AI 对话的复盘
对话记录:DeepSeek 对话
对话前半段正确指出了“未初始化局部数组不是 ”。但后半段把我的查询误判成 ,进而认为代码只是因为测试数据弱才 AC,这个结论不成立。
误判的原因是只看到了 for(j=1;j<=n;j++) 的表面上界,却漏掉了内部的提前退出。最坏情况下,前 个候选恰好全部被删,第 个候选一定未被删,因此实际执行次数满足
DeepSeek 给出的“优化代码”只是把这个事实显式写成 pos++,与我的代码本质相同。它仍要对至多 个候选逐一扫描 个删除下标,严格复杂度仍是 ,而不是回答中声称的 。
53ms 与 66ms 的差距也不能用来判断渐进复杂度,更不能证明“反复分配 1.2 MB 导致 TLE”。编译器通常会统一安排函数栈帧,同一循环的局部数组也会复用同一片栈地址;未初始化时不会每轮写满 1.2 MB。
这次最重要的经验是:复杂度要按真正执行的迭代次数分析,不能只机械相乘源代码里的循环上界。 break、单调性和题目约束都可能把一个表面上的 循环压到 。
更清楚的等价写法
下面的版本没有改变算法,只把 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 为根的树,每个点有一个整数 。对每个点 ,判断从 1 到 的简单路径上是否有两个不同顶点写着相同的数。
我的赛时尝试为什么不行
我的尝试为每个点保存整条路径,并用两重循环检查重复值,存在几个根本问题:
check(path)最坏是 ,链形树会退化到不可接受的复杂度;- 给每个结点保存一份完整路径,链形树需要 空间;
- 调用子结点 DFS 前和 DFS 开头都
push_back(a[it]),同一结点值会被重复压入,可能直接误判; - 树上从根到点的路径唯一,不需要为每个结点复制路径,只需维护 DFS 当前路径的状态。
正确状态
维护:
cnt[x]:当前根路径上,值 出现了多少次;duplicateCnt:当前路径上,出现次数至少为 2 的不同数值有多少种。
进入值为 的结点时:
- 若
cnt[x]==1,加入后它第一次成为重复值,duplicateCnt++; - 然后
cnt[x]++。
此时答案就是 duplicateCnt>0。
退出结点时回溯:先 cnt[x]--;若它重新变成 1,说明不再重复,duplicateCnt--。
由于 ,先坐标压缩,才能用数组维护频次。
坐标压缩与 vector 细节
对话记录:D 题 DeepSeek 对话
vector<int> vals;
vals.reserve(n);
reserve(n) 只预留至少能容纳 个元素的容量,不改变 size(),因此后面必须用 push_back,不能直接访问 vals[i]。不写也仍是正确的摊还 ,预留容量只是避免扩容和搬移旧元素。
它与下面两个操作不同:
resize(n):把当前大小改成 ,尽量保留原元素,新增的int初始化为 0;assign(n,0):丢弃原内容,重新放入 个 0。
排序并去重后,vals 保存所有不同的原值:
sort(vals.begin(),vals.end());
vals.erase(unique(vals.begin(),vals.end()),vals.end());
unique 只把重复元素移到逻辑末尾并返回新末尾,真正缩短容器的是后面的 erase。由于 一定存在于 vals 中,
B[i]=lower_bound(vals.begin(),vals.end(),A[i])-vals.begin();
得到的就是 在去重数组中的 0-based(从 0 开始)编号。压缩只改变标签,不改变“两个值是否相等”,所以不会影响本题判断。
cnt 与 dup 的精确定义
cnt[id] 是当前根路径上,压缩编号 id 出现的次数。dup 不是“重复元素的总个数”,而是出现次数至少为 2 的不同数值种类数。
例如当前路径的值为 ,则 1 和 2 都重复,dup=2;若为 ,虽然有三个 1,dup 仍为 1。
只在频次跨过临界值 1 与 2 时修改 dup:
- 进入前
cnt[id]==1:加入后从 1 变 2,dup++; - 退出后
cnt[id]==1:删除前是 2,删除后变 1,dup--。
这样 dup>0 就能 判断当前路径是否存在重复值,不必每到一个结点都扫描整个 cnt。
为什么使用进入/退出事件
最大为 ,递归 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} 退出事件 |
关键是先压当前结点的退出事件,再压孩子的进入事件。栈是后进先出,孩子会先处理;所有孩子及其退出事件完成后,才轮到父结点退出,恰好模拟递归调用栈。
对话中的其他易错点
- 我的赛时路径方案会重复压入子结点:调用前
push_back(a[it])一次,进入dfs(it)后又压一次,却只弹出一次,路径状态会被破坏。 memos[pos]=path保存的是一份独立拷贝,不是引用;真正的问题是链形树上总拷贝量与总存储量均可达 。- 原尝试没有任何给
ans[pos]赋 1 的有效判重逻辑,所以以ans[pos]为条件的剪枝不会触发。 - 在部分 GNU/Linux 环境中,
<bits/stdc++.h>间接声明了 POSIX 函数dup(int)。全局变量int dup可能与它冲突;函数内局部变量通常没问题,全局版本可改名为duplicateCnt。 - 递归版本更直观,但链形树深度可达 ,存在系统调用栈溢出风险,因此最终保留显式栈版本。
复写代码(保留我的风格)
#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
题意
用“数字 连续出现 次”的游程编码给出,总位数可能极大。求
核心推导
令 、。将 写成 ,其中 ,可以证明
因此只需从左到右维护 。
追加 个数字 时:
但 在模 下不一定有逆元。令
则 一定是 9 的倍数,并且
所以每组分别快速幂计算 和 。
完整证明、易错点和代码见单独修订的 E 题解析。
F - Authentic Traveling Salesman Problem
题意
平面上有 个点。从地点 1 出发,每个地点恰好访问一次并回到 1。曼哈顿距离总和不超过 即可,不要求最短。
构造:竖条分块 + 蛇形排序
对话记录:F 题 DeepSeek 对话
将横坐标按宽度 分成若干竖条:
先按块编号递增排序。同一块内:偶数块按 的一个方向排,奇数块反向排。这样路线在相邻竖条间蛇形前进,避免每换一块都从顶部跳回底部。
设坐标范围宽度为 。按照官方题解,路线长度可以严格放宽为:
各部分含义如下:
- 条带内纵向移动:每条至多 ,约有 条,共至多 ;
- 条带内横向移动:相邻访问点的横坐标差小于 ,至多发生 次,共至多 ;
- 跨越相邻条带:单次横向距离至多 ,约跨越 次,共至多 ;
- 最后一个点返回起点:曼哈顿距离至多 。
对话中曾把“当前条带最右端到下一条带最左端”说成距离 ,这个描述是错的:两者靠近同一边界,距离接近 0。真正的最坏情况是当前条带最左端到下一条带最右端,跨度才接近 。你当时的质疑是正确的。
由均值不等式,前两个主项满足
取等时
,因此代码取 2e7/sqrt(n)。在极限 时,上界约为
为什么旋转不改变路线
蛇形排序得到的是一个环。若原访问顺序是
从其中任意结点开始书写,环上使用的边完全相同,总长度自然不变。因此只需循环左移,让地点 1 成为第一个输出。
base-1(从 1 开始编号)的正确公式是:
ans[i]=idx[(pos1+i-2)%n+1];
其中先减 1 把位置转成 base-0,取模后再加 1 转回来。输出与样例不同不代表旋转错误;构造题只要求输出任意合法环。
特判如何判断答案
这类答案不唯一的题使用 special judge(特判程序)。评测不会枚举所有合法路线,只检查本次输出:
- 是否恰好是 的一个排列;
- 第一个地点是否为 1;
- 按输出顺序并加上末点回到 1 的距离后,总长度是否不超过 。
因此,样例输出只是一个示例,不是唯一标准答案。
易错点
- 判断蛇形方向要看块编号
bi&1,不是点编号i&1; base-1循环左移公式是(pos1+i-2)%n+1;- 题目隐含最后一个输出地点还要回到地点 1;排序构造分析的是一个环。
- 第 0 条带的奇偶性由
X[i]/B决定,与数组采用 base-0 还是 base-1 无关; - 所有条带的升降方向整体反过来仍是蛇形构造,但不能让同一条带内的不同点分别按点编号奇偶选择方向,否则比较器失去统一的条带顺序。
复写代码(保留我的风格)
#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(战略性放弃)
题意轮廓
这是一个多阶段零和博弈:双方同时各禁用对方一副牌,再在剩余牌中用混合策略选牌对战。要求双方采用最大化最坏收益的策略时,高桥的胜率。
参考代码在做什么
精简参考实现仍组合了多层高级工具:
- 对三列胜率两两组合,转成二维点集;
- 用上凸包保留可能成为最优混合策略的点;
- 在凸包边上用黄金分割搜索最大化两项收益的较小值;
- 只对决定全局最优的少数特殊牌重新计算“被禁用后”的答案;
- 最外层再对三种组合的混合权重做嵌套黄金分割搜索。
它要求同时理解 minimax(极小化极大)、二维凸包、混合策略几何化和连续优化。以本场复盘收益衡量,继续逐行改错的成本远高于巩固 D–F,因此战略性放弃是合理决策。
留给以后的前置知识
若以后重做,建议按以下顺序补齐:
- 零和博弈与混合策略的几何意义;
- 为什么劣于凸包的点永远不会成为最优策略;
- 禁掉一个候选后,为何多数答案不变,只需重算少数支撑点;
- 凹函数上的黄金分割搜索及误差控制。
本次不保留“千行天书”,也不把尚未真正掌握的参考代码伪装成自己的题解。
赛后训练清单
- 不看代码重新写一次 D 的栈式 DFS,重点默写进入/退出事件与回溯顺序。
- 手推一个两组游程的小例子,完整算出 E 中的 、、。
- 重新证明 F 的路线长度上界,并写熟
base-1循环左移公式。 - G 暂不改错;先补零和博弈和凸包优化的前置知识。
