
AtCoder Beginner Contest 447 复盘笔记
比赛链接:AtCoder Beginner Contest 447
整理日期:2026-07-18
代码来源:7.17目录中的赛时代码、改错代码、题解复现代码与 A—F 速刷代码。
总结
| 题目 | 核心知识点 | 本次情况 | 复杂度 |
|---|---|---|---|
| A - Seats 2 | 贪心、上界 | 独立尝试 DFS / 记忆化 / DP,后来化简 | |
| B - mpp | 计数、模拟 | 独立完成,可以进一步精简 | |
| C - Insert and Erase A | 不变量、双指针、分段统计 | 独立双指针,因不变量判错 WA 1 点,后改 AC | |
| D - Take ABC 2 | 贪心、状态压缩式扫描 | 独立模拟 TLE;看题解后复现线性贪心 | |
| E - Divide Graph | 逆向贪心、并查集 | 看题解后复现并速刷 | |
| F - Centipede Graph | 树形 DP、最长链合并 | 看题解后复现并速刷 | / 组 |
| G - Div. 1 & Div. 2 | 前后缀 Top-K、线段树、离线更新 | 战略性放弃改错 |
这场最值得保留的三条经验:
- 先找操作无法改变的东西。C 题中,删除全部
A后的字符串就是不变量。 - 模拟超时时,不要只优化容器;要思考能否把“已经匹配到哪一步”压缩成少量计数。D 题只需记录未匹配的
A和AB。 - 权值满足“当前位大于所有低位之和”时,普通的数值最优化会变成按高位优先的字典序贪心。E 题就是这种结构。
A - Seats 2
题意
一排有 个座位,要安排 个人,且任意两个相邻座位不能同时坐人。判断是否可行。
约束:。
核心性质
为了放下尽量多的人,应当隔一个座位坐一个人,例如:
坐 空 坐 空 坐 ...
因此最多能坐的人数为
所以答案只有一个判断:
我的过程与问题
赛时从 DFS、记忆化和三维 DP 入手,其实把一道上界判断题做重了。
- 朴素 DFS 的状态树最多有指数级分支,会重复计算。
map<pair<vector<int>,int>,bool>把整段落座历史放进状态,状态过大;实际只需知道“上一位是否有人”。- 逆推 DP 中曾把转移固定写成
dp[n+1][...],而正确来源应为dp[pos+1][...]。 - 还曾无条件写
dp[pos][cnt][last]=1,等于抹掉刚计算出的ret。
如果练 DP,合理状态是:
但本题最终应优先选择 贪心。
参考代码(保留我的风格)
#include <bits/stdc++.h>
using namespace std;
int main()
{
int n,m;
cin>>n>>m;
int maxPeople=(n+1)/2;
if(maxPeople>=m) cout<<"Yes"<<"\n";
else cout<<"No"<<"\n";
return 0;
}
易错点
- 奇数个座位时不能写成
n/2,应写(n+1)/2。 - 题目只问是否存在方案,不需要真的构造座位安排。
B - mpp
题意
给定只含小写英文字母的字符串 。删除所有出现次数达到最大值的字符;若多个字符并列最多,则全部删除。其余字符保持原顺序输出。
约束:。
思路
先用 cnt[26] 统计每个字母的出现次数,再求最大频率
最后重新遍历原字符串。字符 it 只有在
时才输出。重新遍历原串天然保证相对顺序不变。
我的过程与优化
独立代码先找出所有待删除字符,再用 vis 标记每个位置。逻辑正确,但有两层不必要的处理:
- 不必构造
del;频率数组已经能直接判断一个字符是否应删除。 - 不必构造
vis;第二次扫描时直接输出即可。
参考代码(保留我的风格)
#include <bits/stdc++.h>
using namespace std;
int main()
{
string s;
cin>>s;
int cnt[26]={0};
for(auto it:s) cnt[it-'a']++;
int maxVal=*max_element(cnt,cnt+26);
for(auto it:s)
{
if(cnt[it-'a']!=maxVal) cout<<it;
}
cout<<"\n";
return 0;
}
易错点
- 并列最大频率的字符要全部删除,不能只删一个。
- 输出必须保持原顺序,不能按字母表顺序输出。
- 答案可以是空串,此时仍输出换行。
C - Insert and Erase A
题意
给定大写字符串 。每次只能在 中插入一个 A,或删除一个 A。问能否把 变成 ;若能,求最少操作数,否则输出 -1。
约束:。
第一关键点:操作不变量
操作只能增删 A,所以所有非 A 字符本身及其相对顺序都无法改变。
定义 表示从字符串 中删除全部 A 后得到的字符串,则可行的充要条件为
这里必须比较字符串,而不能只比较每种非 A 字符的数量。
例如:
S = BAC
T = CAB
二者各字符计数相同,但非 A 序列分别为 BC 和 CB,无论怎样增删 A 都不能互相转化。
这正是最初双指针 WA 一个点的根因:只比较了非 A 字符数量,没有检查相对顺序。
此外,字符均为大写字母。若用数组计数,下标必须写 S[i]-'A',不能写 S[i]-'a',否则会出现负下标越界。
第二关键点:答案为何是每段 A 数量差
设两串共同的非 A 骨架为
每个非 A 字符之间,包括字符串首尾,都对应一段 A。若 在第 段分别有 个 A,那么这一段至少需要
次增删,并且这样操作一定能做到。因此最少操作数是
分段双指针
两个指针分别扫描 :
- 统计当前位置开始的连续
A数量cntAS、cntAT。 - 将差值的绝对值加入答案。
- 若两串都结束,则处理完成。
- 若只有一串结束,说明非
A骨架长度不同,输出-1。 - 若下一对非
A字符不同,说明骨架顺序不同,输出-1。 - 跳过这一对相同的非
A字符,继续处理下一段。
参考代码(保留我的风格)
#include <iostream>
using namespace std;
int main()
{
string S,T;
cin>>S>>T;
int n=S.size(),m=T.size();
int p1=0,p2=0;
int ans=0;
while(p1<n||p2<m)
{
int cntAS=0,cntAT=0;
while(p1<n&&S[p1]=='A')
{
cntAS++;
p1++;
}
while(p2<m&&T[p2]=='A')
{
cntAT++;
p2++;
}
if(p1==n&&p2==m)
{
ans+=abs(cntAS-cntAT);
break;
}
if(p1==n||p2==m)
{
cout<<"-1"<<"\n";
return 0;
}
if(S[p1]!=T[p2])
{
cout<<"-1"<<"\n";
return 0;
}
ans+=abs(cntAS-cntAT);
p1++,p2++;
}
cout<<ans<<"\n";
return 0;
}
复杂度
两个指针都只向右移动:
易错点
- “各非
A字符数量相同”不是充分条件,必须比较非A序列。 while(p1<n||p2<m)内每次访问字符前都要判断边界。- 别忘了在匹配一对非
A字符后执行p1++,p2++。
D - Take ABC 2
题意
字符串只含 A、B、C。一次操作选择下标 ,满足字符依次为 A、B、C,然后删除它们。求最多能操作多少次。
约束:。
赛时思路为什么 TLE
最初做法枚举 A,再向后找未使用的 B,再找未使用的 C。即使使用 vis 或预存三类字符下标,本质仍在反复向后搜索,最坏复杂度接近
而 可达 ,必然超时。20 AC / 15 TLE 和 22 AC / 13 TLE 说明优化了常数或部分数据,却没有改变复杂度量级。
正解:扫描时维护匹配进度
从左到右扫描,只需要两个计数:
cntA:已经出现、但尚未与B匹配的A数量;cntAB:已经匹配成AB、但尚未等待到C的数量。
遇到不同字符时:
A:加入候选,cntA++;B:若有可用A,消耗一个A,形成一个AB;C:若有可用AB,消耗一个AB,完成一次操作。
为什么局部贪心正确
遇到 B 时,只要左边有未匹配的 A,立刻配对不会让未来更差:所有未来的 C 都在这个 B 右侧,而保留某个更早的 A 没有额外价值。
同理,遇到 C 时立刻消耗一个已有 AB 也不会损失后续方案。每次匹配都只取当前字符左侧已经合法形成的前缀,因此始终满足下标顺序 。
参考代码(保留我的风格)
#include <bits/stdc++.h>
using namespace std;
int main()
{
string s;cin>>s;
int cntA=0,cntAB=0,ans=0;
for(auto it:s)
{
if(it=='A') cntA++;
else if(it=='B')
{
if(cntA>=1)
{
cntAB++;
cntA--;
}
}
else
{
if(cntAB>=1)
{
ans++;
cntAB--;
}
}
}
cout<<ans<<"\n";
return 0;
}
复杂度
可迁移经验
当题目要求按顺序选出一个短模式(如 ABC),并且每个字符只能使用一次时,可以尝试维护各匹配阶段的数量,而不是枚举具体下标。
E - Divide Graph
本题赛时不会,以下为看题解后复现与速刷所得。
题意
给定一个连通无向简单图,边按输入顺序编号为 ,第 条边的删除代价为 。删除若干边,使图恰好变成两个连通块,求最小删除总代价。最终只对答案取模 。
约束:(并满足题目中的图边数约束)。
权值的特殊性质
对任意 ,有
因此一条编号更大的边,比所有编号更小的边代价之和还大。最小化删除总代价时,决策优先级是:
- 尽量不删除编号最大的边;
- 再尽量不删除次大的边;
- 依此类推。
注意:题目最小化的是真实整数代价,不能拿取模后的数比较大小。取模只用于输出答案。
从“删边”改看成“留边”
从空图开始,按边编号从大到小考虑是否保留。
- 当前边两端已连通:保留它只会成环,不改变连通块数量,当然无需删除。
- 两端不连通,且当前连通块数
components>2:保留它并合并两个连通块。 - 两端不连通,且
components==2:再保留就会使整张图连通,违反“恰好两个连通块”,所以必须删除,并把 加入答案。
这个过程就是逆向 Kruskal 风格的贪心,并查集负责判断两端是否已经连通。
正确性要点
按编号从大到小处理时,当前边的删除代价大于所有尚未处理边的总代价。只要保留当前边仍有可能最终得到两个连通块,就一定应保留;任何为了删除它而保留若干低编号边的方案都会更贵。
当只剩两个连通块时,连接二者的边必须删掉;同一连通块内部的边无需删除,因为它不会减少连通块数量。
参考代码(保留我的风格)
#include <bits/stdc++.h>
using namespace std;
#define int long long
typedef pair<int,int> PII;
const int mod=998244353;
class UnionSet{
public:
vector<int> fa,size;
UnionSet(int n):fa(n+1),size(n+1)
{
for(int i=0;i<=n;i++)
{
fa[i]=i;
size[i]=1;
}
}
int get(int x)
{
return fa[x]=(x==fa[x]?x:get(fa[x]));
}
void merge(int a,int b)
{
int aa=get(a),bb=get(b);
if(aa==bb) return;
fa[aa]=bb;
size[bb]+=size[aa];
}
};
signed main()
{
int n,m;
cin>>n>>m;
vector<PII> edges(m+1);
vector<int> pow2(m+1);
pow2[0]=1;
for(int i=1;i<=m;i++)
{
int u,v;
cin>>u>>v;
edges[i]={u,v};
pow2[i]=(pow2[i-1]*2)%mod;
}
UnionSet uf(n);
int components=n;
int ans=0;
for(int i=m;i>=1;i--)
{
int u=edges[i].first;
int v=edges[i].second;
int uu=uf.get(u),vv=uf.get(v);
//if(u!=v)
//是uu!=vv,不是u!=v!!!!!!!!!!!!!!!
if(uu!=vv)
{
if(components>2)
{
uf.merge(uu,vv);
components--;
}
else ans=(ans+pow2[i])%mod;
}
}
cout<<ans<<"\n";
return 0;
}
复杂度
并查集使用路径压缩:
易错点
- 必须从
m到1逆序枚举边。 - 初始连通块数量是
n;只有真正合并两个不同集合时才减一。 components==2时只删除连接两个不同连通块的边;块内成环边不用删。- 预处理的是 ,其中
pow2[0]=1。
F - Centipede Graph
本题赛时不会,以下为看题解后复现与速刷所得。
题意
长度为 的蜈蚣图有一条包含 个点的主链,主链上的每个点各连接两个只属于它的叶子,因此总点数为 。给定一棵树,求其中作为子图出现的最大蜈蚣长度。共有 组测试,所有测试的 之和不超过 。
结构观察
蜈蚣主链上的点需要:
- 两条边连接自己的两个叶子;
- 若是主链内部点,还需要两条主链边,总度数至少为 ;
- 若是主链端点,只需要一条主链边,总度数至少为 ;
- 当 时,唯一主链点只需连接两个叶子,总度数至少为 。
由于只要求“子图”而不是“诱导子图”,原树上多余的边可以不选。
DP 定义
把树任意定根。定义
这里默认主链还可以通过 的父边继续向上,因此:
son>=3:除父边外, 至少有三个孩子;可拿两个孩子当叶子,再选一个孩子方向延伸主链,故dp[u]=mx+1。son==2:两个孩子只能当叶子, 可以作为这条向下链的端点,故dp[u]=1。son<=1:连两个叶子都凑不齐,故dp[u]=0。
其中 mx 是所有子节点 dp[v] 的最大值。
用每个点作为拼接中心更新答案
设 mx,sx 为子节点中最大、次大的 dp:
deg[u]==2:可以单独构成长度 的蜈蚣。deg[u]==3: 作为主链端点,向一个孩子方向延伸,答案候选为mx+1。deg[u]>=4: 可作为主链内部点,拼接两个孩子方向,答案候选为mx+sx+1。
“向父亲方向延伸”的答案不会漏掉,因为整条主链总有一个相对当前根深度最小的点;在这个点处,主链的其余部分都落在它的孩子方向中。
参考代码(保留我的风格)
#include <bits/stdc++.h>
using namespace std;
const int N=2e5+10;
vector<int> g[N];//邻接表
int deg[N];
int ans;
int dp[N];
void dfs(int u,int fa)
{
int mx=-1,sx=-1;
int son=0;
for(auto v:g[u])
{
if(v==fa) continue;
son++;
dfs(v,u);
int val=dp[v];
if(val>mx) sx=mx,mx=val;
else if(val>sx) sx=val;
}
if(son>=3) dp[u]=mx+1;
else if(son==2) dp[u]=1;
else dp[u]=0;
int total_deg=deg[u];
if(total_deg==2) ans=max(ans,1);
else if(total_deg==3)
{
if(mx!=-1) ans=max(ans,mx+1);
}
else if(total_deg>=4)
{
if(mx!=-1&&sx!=-1) ans=max(ans,mx+sx+1);
}
}
void solve()
{
int n;cin>>n;
for(int i=1;i<=n;i++) g[i].clear();
fill(deg,deg+1+n,0);
for(int i=1;i<n;i++)
{
int u,v;
cin>>u>>v;
g[u].push_back(v);
g[v].push_back(u);
deg[u]++,deg[v]++;
}
ans=0;
dfs(1,0);
cout<<ans<<"\n";
}
int main()
{
int q;cin>>q;
while(q--) solve();
return 0;
}
复杂度
每条树边只访问常数次:
每组如此;所有测试总复杂度为 。
易错点
- 树有 条边,读边循环不能写成 次。
- 多组测试必须清空邻接表和
deg;ans也要重新置零。 son不包含父节点,而deg[u]包含父节点,两者不能混用。son==2时是dp[u]=1,不是dp[u]=mx:两个孩子都必须留给叶子。- 代码注释中的
mx,sx应称为“最大、次大”,不是“最小、次小”。 - 递归深度最坏可达 ;若平台栈空间严格,可改为迭代 DFS 加逆序处理。
G - Div. 1 & Div. 2
本题按当前学习进度战略性放弃改错:DP 尚未完全学完,线段树为零基础。这里不强行背代码,只记录题目结构和未来回看入口。
题意
有 道候选题,第 道题难度为 、类型为 、兴趣值为 。按难度递增选择六题
其中:
- Div. 2 使用 ,四题类型两两不同;
- Div. 1 使用 ,四题类型两两不同。
求六题兴趣值之和的最大值;不存在合法选择则输出 -1。
难点拆解
两场比赛共享中间两题 。固定它们后,需要:
- 在 左侧选两个题,类型彼此不同,且都不能等于 ;
- 在 右侧选两个题,类型彼此不同,且都不能等于 。
暴力枚举六个下标显然不可行。即便只枚举 ,仍有 对,需要进一步把“某个区间内排除少量类型后的最大两项和”压缩成可快速查询的信息。
题解代码的主线(只建立地图)
本地题解采用以下结构:
L[i]:前缀中按兴趣值保存类型互异的 Top 4。R[i]:后缀中按兴趣值保存类型互异的 Top 4。- 固定 时,把 与左侧可选的最佳两题合并为 。
- 枚举 的类型,临时排除该类型对各个 的影响。
- 用线段树在区间 中查询类型互异的 Top 4 候选。
- 再结合 右侧的 Top 候选完成答案更新,之后回滚临时修改。
为什么只保留很小的 Top-K:每次查询至多排除共享题的少数几个类型。若前几名冲突,继续往后看有限个候选就足够;不需要保存整个区间的所有题。
当前阶段的学习顺序
不建议现在直接背 248 行题解。更合理的顺序是:
- 学会线段树的单点修改与区间查询。
- 理解幺半群:节点保存什么信息、两个节点如何
op合并、单位元e是什么。 - 单独练习“区间内不同颜色 / 类型的 Top-K”问题。
- 再看前后缀 Top-K 与离线按类型修改。
- 最后回到本题,逐段验证
L/R、change、SegTree和答案拼接。
暂不改错的理由
本题同时要求线段树、Top-K 状态合并、按类型离线修改与回滚。基础尚未补齐时,逐行改错很容易变成记实现而不理解状态。当前把 A—F 的贪心、并查集和树形 DP 吃透,收益更高。
本场错因清单
复杂度
- A:没有先推最大容量,过早使用 DFS / DP。
- D:用三层搜索模拟选下标,没有根据 的数据范围反推必须接近 。
不变量
- C:只检查了非
A字符计数,漏掉“非A字符相对顺序不变”。
数组下标
- C:大写字母应减
'A';减'a'会产生负下标。
DP 状态和转移
- A:把
pos+1错写成固定的n+1,并且没有把ret写回状态。 - F:要区分孩子数量
son与无根树中的总度数deg[u]。
多组测试
- F:邻接表、度数与全局答案都必须清空。
后续复习建议
- 短期重写:不看代码重写 C、D,重点口述不变量和贪心正确性。
- 并查集巩固:找两道“逆序加边”题,熟悉把删边问题转成加边问题。
- 树形 DP 巩固:练习树上最长链、树的直径、选最大与次大子树贡献。
- 线段树前置:先掌握单点修改、区间和 / 最大值,再学习自定义节点合并。
- 最后回看 G:能独立写通用线段树后,再拆解 Top-K 幺半群和离线回滚。
复习时最好能独立回答:
- A 为什么是 ?
- C 为什么删除所有
A后的字符串必须完全相同? - D 的
cntA、cntAB分别代表什么? - E 为什么必须逆序处理边?
- F 为什么
son==2时dp[u]=1?
能不看代码说明这五点,才算真正把本场核心内容消化下来。
