
AtCoder Beginner Contest 454 复盘
比赛目录:
8.3--454
题目列表:AtCoder Beginner Contest 454 讨论记录:D 题 · E 题 · F 题 · G 题
排版与复盘结构参考:笔记/AtCoder Beginner Contest 452/ABC452.md
完成情况
| 题目 | 学习归属 | 核心知识点 | 复杂度 |
|---|---|---|---|
| A - Closed interval | 独立 AC | 闭区间计数 | |
| B - Mapping | 独立 AC | 频次数组、单射与满射 | |
| C - Straw Millionaire | 独立 AC;先 TLE,后改成栈遍历 | 有向图可达性、DFS/BFS | |
| D - (xx) | 赛时想到规范化,但未写完;看题解后独立默写 | 规范形、栈式字符串消除 | $O( |
| E - LRUD Moving | 赛时 DFS 导致 MLE/TLE;看题解、抄写后独立默写 | 棋盘染色、构造、前后缀拼接 | |
| F - Make it Palindrome 2 | AI 题解、抄写后独立默写 | 对称差、差分、配对操作、排序贪心 | |
| G - Mode in the Subtree | 因未学习树上 DSU,战略性放弃改错 | DSU on Tree、轻重儿子、欧拉序 | 官方为 |
本场最值得保留的结论
- C 题不是反复应用规则,而是从物品 出发求有向图可达点。 一旦某个点第一次可达,就把它压入栈继续扩展;每个点和每条边只需处理常数次。
- D 题的“标准统一化”方向是对的。 当双向操作互为逆操作时,可以寻找唯一规范形,把可达性问题转化成规范形相等。
- E 题先判存在性,再做构造。 染色给出
N为偶数且空格为白格的充要条件;s1从起点正向剥离,s2从终点反向剥离。 - F 题最难的不是代码,而是连续三次等价转化: 原数组 到对称差 ,区间操作到差分 的两点操作,再到固定选取数量后的排序贪心。
- G 题当前不应强行改代码。 在没有系统学习树上 DSU 前,只保留问题转化、数据维护目标和学习路线,避免把 AI 代码误当成已经掌握的模板。
A - Closed interval
题意与思路
求闭区间 中整数的数量。两端都包含,因此答案为
代码
#include <bits/stdc++.h>
using namespace std;
void solve()
{
int L,R;
cin>>L>>R;
cout<<R-L+1<<"\n";
}
int main()
{
ios::sync_with_stdio(0);cin.tie(0);cout.tie(0);
solve();
return 0;
}
时间复杂度为 ,空间复杂度为 。
B - Mapping
题意
个人各自穿着编号为 的衣服,衣服种类编号为 到 。分别判断:
- 是否所有人的衣服种类两两不同;
- 是否每种衣服都至少有一个人穿。
思路
令 表示衣服 出现的次数。
- 所有人穿不同衣服,当且仅当所有 ,即最大频次不超过 ;
- 每种衣服都有人穿,当且仅当所有 ,即最小频次不小于 。
从映射角度看,第一问在判断是否为单射,第二问在判断是否为满射。
代码
#include <bits/stdc++.h>
using namespace std;
const int N=110;
int cnt[N];
void solve()
{
int n,m;
cin>>n>>m;
for(int i=1;i<=n;i++)
{
int f;cin>>f;
cnt[f]++;
}
int max_cnt=0,min_cnt=INT_MAX;
for(int i=1;i<=m;i++)
{
if(cnt[i]>max_cnt) max_cnt=cnt[i];
if(cnt[i]<min_cnt) min_cnt=cnt[i];
}
cout<<(max_cnt>1?"No":"Yes")<<"\n";
cout<<(min_cnt==0?"No":"Yes")<<"\n";
}
int main()
{
ios::sync_with_stdio(0);cin.tie(0);cout.tie(0);
solve();
return 0;
}
时间复杂度为 ,空间复杂度为 。
C - Straw Millionaire
题意
初始只有物品 。若已经拥有物品 ,就可以把它交给第 个朋友并得到物品 。求最终可能获得多少种物品。
赛时失败路径:反复扫描所有规则
最初把已经拥有的物品放进 set,然后不断扫描全部 条规则;只要本轮得到新物品,就再扫描一轮。
问题在于,一轮可能只新增一个物品,最坏要进行 轮,每轮检查 条边,总复杂度可达
在 时必然 TLE。
正确转化:有向图可达性
把每条规则 看成一条有向边。初始拥有物品 ,问题就是:从节点 出发能够到达多少个节点?
维护已经到达的集合 s 和待扩展的栈 st:
- 节点第一次被发现时加入
s和st; - 从栈顶取出节点 ,枚举所有 ;
- 若 尚未访问,就标记并继续扩展。
这就是迭代版 DFS。这里“栈优化”不是对原反复扫描的小修小补,而是识别出了图遍历模型。
代码
#include <bits/stdc++.h>
using namespace std;
typedef pair<int, int> PII;
const int N = 3e5 + 10;
vector<set<int>> v(N);
void solve()
{
int n, m;
cin >> n >> m;
set<int> s;
s.insert(1);
for (int i = 1; i <= m; i++)
{
int a, b;
cin >> a >> b;
v[a].insert(b);
}
for(auto p:v[1]) s.insert(p);
if(s.size()==1)
{
cout<<"1"<<"\n";
return;
}
stack<int> st;
for(auto p:s) st.push(p);
while(!st.empty())
{
int b=st.top();
st.pop();
for(auto p:v[b])
{
if(s.count(p)) continue;
s.insert(p);
st.push(p);
}
}
cout<<s.size()<<"\n";
}
int main()
{
ios::sync_with_stdio(0);cin.tie(0);cout.tie(0);
solve();
return 0;
}
若邻接表和访问标记都用普通 vector/布尔数组,可以严格做到 ;当前代码中的 set 会带来对数因子,但仍能通过。
易错点
- 不要因为题面以“不断交换物品”叙述,就真的按轮模拟规则。
- 可重复使用已经得到的物品,所以无需考虑物品被“交出去后消失”;这里只关心某种物品是否曾经可获得。
- 邻接边重复不影响可达性。使用
set去重不是必需条件。
D - (xx)
题意
字符串只包含 (、x、),可以任意次进行互逆操作:
(xx) -> xx
xx -> (xx)
判断能否把 变成 。所有测试中字符串总长度不超过 。
赛时思路
赛时已经想到“把 全部统一成标准形式”,方向正确,但在识别栈尾模式和删除区间的实现上没有完成。
规范形
定义 :不断执行 (xx) -> xx,直到不存在 (xx),所得字符串称为 的规范形。
规范形与消除顺序无关。原因是任意两个当前存在的 (xx) 互不重叠,先消除其中一个不会破坏另一个;结合字符串长度严格下降,可以归纳得到最终结果唯一。
于是有
- 若 ,先把 化简到公共规范形,再逆向执行 的步骤即可到达 ;
- 每次正向或逆向操作都不改变规范形,因此可达的两个字符串规范形必然相等。
线性计算规范形
从左到右扫描字符,把当前字符追加到 ret。若最后四个字符恰好是 (xx),就删去这四个字符,再追加 xx。
这里官方代码使用一次 if 就够:替换后字符串以 x 结尾,而 (xx) 必须以 ) 结尾,因此替换不可能立刻在栈尾再次产生新的 (xx)。下一次读入字符后再检查即可。
substr 与迭代器
ret.substr(ret.size()-4,4)
中的 ret.size()-4 是下标;而 ret.end()-4 是迭代器。二者指向逻辑上的同一位置,但不能混用:
ret.substr(ret.size()-4,4); // substr 接收下标和长度
ret.erase(ret.end()-4,ret.end()); // erase 的区间重载接收迭代器
必须先判断 ret.size()>=4,否则无符号的 size_t 执行 size()-4 会下溢。
代码(看题解后独立默写)
#include <bits/stdc++.h>
using namespace std;
void solve()
{
string A,B;
cin>>A>>B;
auto simplify=[&](string s)
{
string ret;
for(auto& ch:s)
{
ret+=ch;
if(ret.size()>=4&&ret.substr(ret.size()-4,4)=="(xx)")
{
ret.erase(ret.end()-4,ret.end());
ret+="xx";
}
}
return ret;
};
cout<<(simplify(A)==simplify(B)?"Yes":"No")<<"\n";
}
int main()
{
ios::sync_with_stdio(0);cin.tie(0);cout.tie(0);
int T;cin>>T;
while(T--) solve();
return 0;
}
每个字符入栈一次,每次消除使长度减少 ,总时间复杂度为 ,空间复杂度为 。
E - LRUD Moving
题意
在 棋盘上,从 出发,恰好移动 步到达 ,期间不重复访问格子,并访问除 外的所有格子。判断是否可行,并构造一条路径。
赛时失败路径:DFS 搜索哈密顿路径
赛时代码枚举每一步的四个方向,并把整条字符串按值传入递归。这个搜索空间是指数级的;同时最大递归深度接近 ,每层还可能复制长度为 的字符串,因此 TLE、MLE 都不可避免。
这题不是搜索题,而是“存在性判定 + 直接构造”。
第一步:棋盘染色判定存在性
按 的奇偶性染色:偶数为黑格,奇数为白格。每移动一步颜色必然改变。
路径共访问
个格子,起点和终点都是黑格。
- 若 为奇数,则 为偶数。黑白交替且首格为黑时,第偶数个格子应为白,与终点为黑矛盾;
- 若 为偶数,原棋盘黑白格各有 个。合法路径首尾为黑,路径上的黑格比白格多一个,因此唯一不访问的格子必须是白格,即 为奇数。
所以答案为 Yes 的充要条件是
第二步:从两端缩减
满足条件后,把问题不断缩成更小的矩形。
行缩减
一段
R...(N-1 次)...R D L...(N-1 次)...L D
会蛇形走完当前最上方两行,并停到下一行的左端。
- 若禁入点不在最上方两行,即 1-based 下
a>2,可以从起点端执行该段,加入s1,并令a-=2; - 否则从终点端反向剥掉底部两行,把反转后的片段存进
s2。
列缩减
行缩减结束后只剩两行。片段 DRUR 走完左侧两列并进入下一列。
- 若禁入点不在最左两列,即
b>2,加入s1并令b-=2; - 否则从终点端处理右侧两列,把反转片段加入
s2。
为什么 s2 要反转存储、逆序拼接
s1 记录从起点出发的正向片段。s2 记录从终点往回剥离时对应的后缀片段,因此有两层顺序关系:
- 单个片段要反转后存入,使其成为最终答案方向上的字符序列;
- 从终点依次剥离的片段,在正向路径中出现顺序相反,所以最后要倒序拼接整个
s2。
最终答案为
第三步:处理最后的
缩减结束后,禁入点只能是当前 中的一个白格:
- 若为 ,走
DR; - 若为 ,走
RD。
最关键的边界错误
1-based 代码必须写
if(a>2)
if(b>2)
不能写成 >=2。当 a==2 时禁入点就在要扫过的第二行;当 b==2 时禁入点就在要扫过的第二列。这里正是最终 WA 的原因,也是源码中 ////base-1必须为严格大于 的重点。
另一个注释笔误是“ 必须为偶数”。实际上路径访问 个格子且首尾同为黑格,因此它必须为奇数,所以 必须为偶数。
代码(注释最完整的 copy1.cpp)
#include <bits/stdc++.h>
using namespace std;
void solve()
{
int n,a,b;
cin>>n>>a>>b;
//染色思想,我们令i+j为偶数时为黑色,i+j为奇数时为白色
//N^2-1必须为奇数,则N必须为偶数
//路线为黑 白黑 白黑 白黑.......
//经过画图,我们会经过N^2-1个格子,只有一个格子我们不会经过
//所以,这个a+b必须为奇数(白格子)
if(n&1||(a+b)%2==0)
{
cout<<"No"<<"\n";
return;
}
////s1记录从起点顺着来的路线
////s2为从终点逆着来的路线
///两者逐渐往(a,b)所在的那两行两列逼近缩减
////最终只留下一个2x2的方格,用if特判即可
vector<string> s1,s2;
int half=n/2;//我们以两行为一次操作
//行的缩减,执行half-1次
//把(a,b)那两行单独空出来处理
for(int i=1;i<half;i++)
{
string s=string(n-1,'R')+'D'+string(n-1,'L')+'D';
////if(a>=2)base-1必须为严格大于!!!!!!!!
if(a>2)
{
s1.push_back(s);
a-=2;
}
else
{
reverse(s.begin(),s.end());
s2.push_back(s);
}
}
//列的缩减
//把(a,b)所处的那两列单独拎出来处理
for(int i=1;i<half;i++)
{
string s="DRUR";
////if(b>=2)
if(b>2)
{
s1.push_back(s);
b-=2;
}
else
{
reverse(s.begin(),s.end());
s2.push_back(s);
}
}
//最后只剩下2×2的方格了
if(a==1&&b==2) s1.push_back("DR");
else s1.push_back("RD");
string ans;
for(auto& str:s1) ans+=str;
for(int i=(int)s2.size()-1;i>=0;i--) ans+=s2[i];
cout<<"Yes\n"<<ans<<"\n";
}
int main()
{
ios::sync_with_stdio(0);cin.tie(0);cout.tie(0);
int T;cin>>T;
while(T--) solve();
return 0;
}
代码保持了原注释;其中关于 奇偶性的注释应按上文修正理解。构造输出本身长度为 ,所以时间、空间复杂度均为 。
F - Make it Palindrome 2
题意
每次选择区间 ,把其中所有元素加 后对 取模。求把数组变成回文数组所需的最少操作次数。
这题代码不长,但思路要经过多层严格等价转化。
第一步:只保留左右配对差
令
数组为回文,当且仅当所有 。当 为奇数时,中间元素只与自己对应,完全不用处理,所以必须使用 half=n/2,不能向上取整。
跨越数组中点的操作可以消去对称重叠部分,等价改写为不跨中点的操作。因此:
- 在左半边进行一次区间加,对应 的某段区间加 ;
- 在右半边进行一次区间加,对应 的镜像区间减 。
于是原问题等价为:对 的任意区间进行 或 ,使其全部归零。
第二步:双哨兵差分
补两个零边界:
定义长度 的差分数组
这两个边界不能省略。例如 全为常数 时,内部差分虽然为零,但完整的 是
并不是全零。首尾差分保存了“对整段 操作”的信息。
对 的区间 加 ,等价于
区间减 时符号相反。因此问题变为:每次选择两个不同位置,使其中一个加 、另一个减 ,最终让 全部归零。
任意一对位置都能映射回合法区间:若加法位置在左,就对应 的区间加;若加法位置在右,就对应 的区间减。位置顺序不是额外限制。
第三步:每个 选择一种归零方向
设 。要把 变成零,有两种有效方式:
- 对它做 次减法;
- 对它做 次加法,使它到达 。
设集合 中的位置选择减法,其余位置选择加法。记
所需减法数为
所需加法数为
由于一次操作同时贡献一次加法和一次减法,官方推导得到操作数
由首尾哨兵差分的定义, 一定是 的倍数。
第四步:为什么排序取前 个
固定 时,公式只与 有关,因此应选 中最小的 个数。
当
时,多选一个元素会使 增加至多 ,但惩罚项会减少 ,总答案严格下降;越过该位置后惩罚项不再下降,而 只会不减。
因此最优选择数量为
答案就是排序后最小的 个 之和。
这里不是“均值不等式”,更准确地说是一个离散平衡点与边际变化:平衡点之前每多选一个都变优,之后再选不会变优。
下标错误链
half必须是n/2。奇数长度的中间元素无需配对;B[0]和B[half+1]都是零哨兵;C必须计算到half+1,否则漏掉右边界差分;- 有效长度是
len=half+1; - 排序区间是
sort(C.begin()+1,C.end()),不能误写成两个相同的迭代器; sumC和答案可能达到 ,需要long long。
代码(注释最完整的 copy1.cpp)
//思路:转为为Bi数组,求Bi全为0时的最小操作次数,再把Bi转化为差分数组
////对区间加减常数k-->秒想差分数组
//然后求令整个差分数组Ci为0的最小操作次数即可
//将操作次数分类为加和减
//由均值不等式可以知道,当加法的操作次数等于减法的操作次数时,总的操作次数最少
#include <bits/stdc++.h>
using namespace std;
#define int long long
void solve()
{
int n,m;
cin>>n>>m;
vector<int> a(n+1);
for(int i=1;i<=n;i++) cin>>a[i];
////int half=(n+1)/2;奇数的话中间那个数完全不用管,靠!!!!!!,我sb了
int half=n/2;//只看左右配对数即可
////vector<int> B(half+1);
vector<int> B(half+2);
for(int i=1;i<=half;i++)
{
//B[0]和B[half+1]为哨兵位
B[i]=(a[i]-a[n+1-i]+m)%m;
}
//创建差分数组
vector<int> C(half+2);
////for(int i=1;i<=half;i++)
for(int i=1;i<=half+1;i++)
{
C[i]=(B[i]-B[i-1]+m)%m;
}
int sumC=0;
int len=half+1;////C数组的有效长度
for(int i=1;i<=len;i++) sumC+=C[i];
int k=len-sumC/m;
sort(C.begin()+1,C.end());
int ans=0;
for(int i=1;i<=k;i++) ans+=C[i];
cout<<ans<<"\n";
}
signed main()
{
ios::sync_with_stdio(0);cin.tie(0);cout.tie(0);
int T;cin>>T;
while(T--) solve();
return 0;
}
时间复杂度为 ,空间复杂度为 。
G - Mode in the Subtree
当前学习归属
这题的 AC 代码来自 AI。由于树上 DSU 尚未学习,本场战略性放弃改错是合理的;下面只记录已经核验的问题模型和知识地图,不把代码当成已掌握内容。
题目要求维护什么
对每个节点 ,统计其子树内每种颜色的出现次数 ,求
以及达到最大频次的颜色数量
最后计算
真正困难之处是 :普通 unordered_map 小并大虽然复杂度看似可以,但常数和内存都过重,递归 DFS 也可能爆栈。
官方 DSU on Tree 框架
对每个节点选择子树最大的儿子作为重儿子,其余为轻儿子:
- 先递归处理轻儿子,得到答案后清空其统计表;
- 再处理重儿子,并保留其统计表;
- 用欧拉序区间把所有轻子树重新加入重儿子的表;
- 加入当前节点,此时表中恰好是当前整棵子树;
- 读取当前最大频次和达到该频次的颜色数。
需要维护三个数组/变量:
cnt[color] 当前表中该颜色的出现次数
freq[t] 当前恰好出现 t 次的颜色数量
mx 当前最大出现次数
由于颜色编号不超过 ,可以用连续数组代替哈希表。每个节点只会在根到它的路径经过轻边时被重复加入,而轻边数量为 ,因此总复杂度为 ,空间复杂度为 。
后续学习路线
- 先学习子树大小、重儿子与“经过一条轻边后子树大小至少减半”的证明;
- 用递归版 DSU on Tree 完成一题普通规模的子树颜色众数;
- 再学习欧拉序把子树压成连续区间;
- 最后回到本题,理解为什么要用数组邻接表、显式事件栈和按需清空来控制常数与递归深度。
在完成以上专题前,不建议背本题五千余字节的 AI 实现。
错因分类
1. 模型识别
- C:把图可达性写成多轮规则模拟;
- E:把有规律的棋盘构造写成指数级 DFS。
2. 下标与边界
- D:
size()-4是下标,end()-4是迭代器,且必须先检查长度; - E:1-based 下必须用
a>2、b>2; - F:奇数中点不配对,双哨兵和最后一项差分都不能漏。
3. 证明与实现脱节
- D:想到了规范化,但没有把“检查栈尾四字符并替换”写出来;
- F:大致理解公式后,仍在数组长度、有效区间和排序端点上连续出错。
4. 前置知识不足
- G:树上 DSU 尚未学习,不适合直接对超大规模优化代码做改错。
可执行训练项
- 图遍历辨识: 找三道“获得物品/技能/权限”的题,强制画成有向图后再写 BFS 或 DFS。
- 字符串栈消除: 独立实现三种固定模式消除,分别练习
substr、迭代器区间和手动尾部比较。 - 构造题验证器: 对 E 的答案检查长度、边界、是否撞禁入点、是否重复访问、终点是否正确;构造题必须形成自动验证习惯。
- 差分完整性: 每次写差分先明确原数组有效区间和两侧哨兵,再写出差分有效区间,不凭感觉分配数组长度。
- F 题重推: 不看笔记,从 四步重新推导一次,并解释为什么 。
- G 题暂缓: 完成一题基础 DSU on Tree 后,再回来逐段解释 AC 代码;在此之前不做机械默写。
核验记录
- 已核对官方 A-G 题面与约束;
- 已按消息顺序读完 D、E、F、G 四段 DeepSeek 分享对话;
- 已完整读取 ABC452 复盘作为排版与质量基准;
- 已读取比赛目录内全部 C++ 版本、需求文件、
////上下文,以及 E 题构造图和相关动画/验证材料; - A-G 本笔记所依据的代码均使用 GCC 14.2、C++17 编译通过;
- A-G 官方样例均通过,G 题额外核对了全部三个官方样例;
- E 题两套本地验证器各检查 108 个构造用例,均为 108/108 通过。
