
AtCoder Beginner Contest 451 复盘笔记
比赛链接:AtCoder Beginner Contest 451
原始代码:7.27目录
整理日期:2026-07-30
总结
| 题目 | 核心知识点 | 本次情况 | 复杂度 |
|---|---|---|---|
| A - illegal | 字符串长度、取模 | 独立 AC | $O( |
| B - Personnel Change | 计数、差分思想 | 独立 AC | |
| C - Understory | multiset、upper_bound、区间删除 | 独立 AC | |
| D - Concat Power of 2 | 按位数 DP、字符串拼接、集合去重 | 独立推出阶段思路但未完成;题解后抄写并默写 | 约 |
| E - Tree Distance | 树距离矩阵、祖先判定、构造后验证 | AI 题解后抄写并默写 | |
| F - Make Bipartite 3 | 动态二分图、并查集、启发式合并 | AI 题解后抄写并默写 | |
| G - Minimum XOR Walk | 生成树异或、异或线性基、01-Trie | 阅读官方代码;因未学 Trie 战略性放弃改错 |
这场最值得保留的经验:
- D 题中“按十进制位数分阶段”方向是对的,真正困难的是不同拆分会生成同一个数,不能只用简单乘法统计数量。数据量允许时,直接构造全部状态更稳。
- E 题给的是树上两点距离矩阵,不是图的邻接矩阵。正确方向是从距离反推唯一的候选树,再完整验算,而不是跑 Floyd。
- F 题的
color0/color1是每个连通块内部的一组二分标签。不同块尚未合并时不需要讨论统一的“实际颜色”;加边时只需选择是否整体翻转一个块,使新边两端标签相反。 - 小集合向大集合合并不仅是一句优化口号。必须确保真正被移动的是较小连通块中的元素,否则复杂度保证会失效。
- G 题同时依赖异或线性基和 01-Trie。当前缺少前置知识时先记录问题转化,不强行背官方代码。
A - illegal
题意
若字符串长度是 的倍数,就违反法律并输出 Yes;否则输出 No。
约束为 。
思路
直接判断
我的代码
#include <bits/stdc++.h>
using namespace std;
void solve()
{
string s;cin>>s;
if(s.size()%5==0) cout<<"Yes"<<"\n";
else cout<<"No"<<"\n";
}
int main()
{
ios::sync_with_stdio(0);cin.tie(0);cout.tie(0);
solve();
return 0;
}
易错点
- 题目问的是“是否违法”,所以整除时输出
Yes。 s.size()返回无符号类型,但这里只与常数取模,不会产生问题。
B - Personnel Change
题意
有 名员工、 个部门。员工 本期属于 ,下期属于 。对每个部门 输出
思路
分别统计每个部门本期和下期的人数。每读入一名员工,就执行
最后逐个输出 nxt[i]-now[i]。
也可以只维护一个数组:离开原部门时减一,进入新部门时加一。
我的代码
#include <bits/stdc++.h>
using namespace std;
const int N=110;
int now[N],nxt[N];
int n,m;
void solve()
{
cin>>n>>m;
for(int i=1;i<=n;i++)
{
int a,b;
cin>>a>>b;
now[a]++,nxt[b]++;
}
for(int i=1;i<=m;i++)
{
cout<<nxt[i]-now[i]<<"\n";
}
}
int main()
{
ios::sync_with_stdio(0);cin.tie(0);cout.tie(0);
solve();
return 0;
}
复杂度
C - Understory
题意
花园初始没有树,共有 次操作:
1 h:加入一棵高度为 的树;2 h:删除所有高度不超过 的树。
每次操作后输出剩余树的数量。树高可以重复。
思路
需要维护一个有序、允许重复的集合,因此使用 multiset。
对删除操作,upper_bound(h) 返回第一个严格大于 的元素位置,所以区间
[s.begin(),s.upper_bound(h))
恰好是所有满足 的元素。直接删除这个半开区间即可。
我的代码
#include <bits/stdc++.h>
using namespace std;
void solve()
{
int Q;cin>>Q;
multiset<int> s;
while(Q--)
{
int ops,h;
cin>>ops>>h;
if(ops==1)
{
s.insert(h);
cout<<s.size()<<"\n";
}
else
{
////注意索引越界!!!!!!
if(s.empty())
{
cout<<0<<"\n";
continue;
}
////这里必须用upper_bound!!!!!!!!!!
//不能用lower_bound
auto it=s.upper_bound(h);
s.erase(s.begin(),it);
cout<<s.size()<<"\n";
}
}
}
int main()
{
ios::sync_with_stdio(0);cin.tie(0);cout.tie(0);
solve();
return 0;
}
为什么不能用 lower_bound
lower_bound(h):第一个 的位置,删除其前面只能删掉 ;upper_bound(h):第一个 的位置,删除其前面才能删掉 。
本题要求高度不超过 ,所以必须用 upper_bound。
multiset::erase(first,last) 的代价与查找和实际删除元素数有关。每棵树只会被插入一次、删除至多一次,因此总复杂度可写为
D - Concat Power of 2
本题独立想到了按十进制位数分阶段,但因为去重计数写不出而停止;之后看题解代码、抄写并独立默写。
题意
选择一个或多个 的幂,将它们的十进制字符串任意排列、允许重复地拼接起来,得到一个“好整数”。求第 小的好整数。题目保证答案不超过 。
例如:
我的初始思路
最初注意到可以按 划分位数阶段,并尝试只计算每一阶段的状态数,再二分定位第 个数所在阶段。这个方向与官方的“按位数递推”有共同点,但存在两个问题:
- 题目保证的是第 个好整数的数值不超过 ,不是 ;实际只有约 万个候选,完全可以全部生成。
- 不同切分方式可能生成同一个数。例如
因此不能写成简单的数量递推。只算个数仍然必须处理去重,而去重通常意味着保留足够多的实际状态。
原代码里的 calc(val) 若用于判断一个数能否拆成若干个 的幂字符串,可以写字符串划分 DP;但若从 枚举到 再调用它,复杂度仍不可接受。更好的策略是从合法状态出发直接生成。
按位数 DP
定义:
- :所有恰好为 位的 的幂;
- :所有恰好为 位的好整数;
- ,其中 只代表空串,不是一个好整数。
枚举最后拼接的 的幂长度 ,则
这个式子的核心是数位对齐:
- ,乘 后给末尾空出 位;
- ,恰好填满这 位;
- 枚举 ,等价于枚举最后一段的所有可能长度;
set对不同切分产生的相同整数去重。
当 时使用 ,生成只由一个 位的 的幂构成的好整数。因此循环必须写成 i<=k。
最后把 汇总,答案是 ans[n-1]。代码又执行了一次 sort,这样写没有问题,但在当前实现中其实不是必需的:不同位数的正整数天然按位数递增,而每个 X[k] 本身又是有序的 set。保留 sort 可以让“最终按数值取第 小”这一意图更直观,也不必依赖上述有序性。
默写代码
#include <bits/stdc++.h>
using namespace std;
#define int long long
void solve()
{
int n;cin>>n;
//按位存储2的幂
vector<int> pow2[10];
for(int i=0;;i++)
{
int p=1LL<<i;
if(p>1e9) break;
int len=to_string(p).size();
pow2[len].push_back(p);
}
//存储10的n次方
vector<int> pow10(10);
pow10[0]=1;
for(int i=1;i<10;i++) pow10[i]=pow10[i-1]*10;
vector<set<int>> X(10);
//X[i]:存储长度为i的好数
//X[0].push_back(0);-->完全错误的写法!!!!!!!
X[0].insert(0);
for(int k=1;k<10;k++)
{
for(int i=1;i<=k;i++)
{
for(auto x:X[k-i])
{
for(auto p:pow2[i])
{
int val=x*pow10[i]+p;
if(val<1e9) X[k].insert(val);
}
}
}
}
vector<int> ans;
for(int i=1;i<10;i++)
{
for(auto x:X[i]) ans.push_back(x);
}
//虽然set默认有序,但是放个sort在这更直观
sort(ans.begin(),ans.end());
cout<<ans[n-1]<<"\n";
}
signed main()
{
ios::sync_with_stdio(0);cin.tie(0);cout.tie(0);
solve();
return 0;
}
对话中的 MLE 复盘
最后一次错误提交被描述成 MLE,但根因不是集合真的占满了 ,而是越界导致未定义行为:
vector<set<int>> X(10);
for(int k=1;k<=10;k++) //访问了X[10]
合法下标只有 ,应写 k<10。此外,构造时写成 i<k 会漏掉单独一个 的幂,必须是 i<=k。
这里的经验是:评测显示 MLE 不代表根因一定是内存复杂度。发生数组或容器越界后,RE、MLE、WA 都可能只是未定义行为的外在表现。
速刷时再次确认的错误:为什么必须 set
速刷代码在这里专门标记了:
////这里必须用set来存储(去重)
vector<set<int>> good(10);
同一个整数可能有不同的拼接方式。例如 128 既可以看成一个单独的 的幂,也可以看成
其中 12 又来自 。因此递推枚举的是“所有生成方式”,而 X_k/good[k] 需要保存的是“所有不同的数”,两者不能混为一谈。若换成普通 vector 而不去重,最后的第 小会把同一个数重复计数。
复杂度
设最终生成的不同状态总数为 ,实际约为 。set 插入和最后排序均带对数因子,可概括为
E - Tree Distance
本题为 AI 题解后抄写,再独立默写。对话中最重要的转变是分清“距离矩阵”和“邻接矩阵”。
题意
输入 个顶点两两之间的距离 ,判断是否存在一棵正边权无向树,使树上任意两点的唯一路径长度都等于给定的 。
题目不是给图求最短路,而是给出完整距离表,要求判断它能否由一棵树产生。
为什么 Floyd 方向不对
输入的 已经是两点间距离,不是边权。若把所有 都当成边建完全图再跑 Floyd,检查到的只是这个完全图中的最短路性质,并没有构造出只有 条边的树。
树上两点间只有一条简单路径,所以这里应利用路径的可加性反推父子关系。
以 1 为根构造候选树
固定顶点 为根。对任意 ,若 位于根到 的路径上,则必有
反过来,在合法树距离中,满足该式的 就在根到 的路径上。由于所有边权均为正,这条祖先链上越靠近 的点, 越小。因此父节点为(该公式中的arg min为约束条件,返回值为下标j)
边权就是
在合法输入中,最小者不会并列:所有祖先位于同一条路径,正边权保证它们到 的距离严格不同。非法输入可能出现代数上的并列,此时任取一个候选,最后的完整验证会判掉。
为什么还要完整验证
局部满足祖先等式,只能构造出一个候选树,不能保证输入的每一项都一致。因此从每个根 root 做一次 DFS,算出候选树中的全部距离,并检查
是否对所有点对成立。
这是典型的“构造 + 验证”:构造阶段利用必要结构恢复唯一候选,验证阶段负责排除所有非法距离矩阵。
默写代码
#include <bits/stdc++.h>
using namespace std;
typedef pair<int,int> PII;
const int N=3e3+10;
int a[N][N];
int n;
int parent[N];
vector<PII> g[N];
void solve()
{
cin>>n;
for(int i=1;i<n;i++)
{
for(int j=i+1;j<=n;j++)
{
int w;cin>>w;
a[i][j]=a[j][i]=w;//存无向边
}
}
//确定2~n每个节点的父节点
//和从上往下,父节点是离子节点最近的
//利用a[1][i]=a[1][j]+a[j][i]
for(int i=2;i<=n;i++)
{
int fa=-1;
int fa_dist=INT_MAX;//初始化为无穷大
for(int j=1;j<=n;j++)
{
if(j==i) continue;
if(a[1][j]+a[j][i]!=a[1][i]) continue;//不是1~i路径上的
if(a[j][i]<fa_dist)
{
fa=j;
fa_dist=a[j][i];
}
}
//如果某个子节点不存在父节点
if(fa==-1)
{
cout<<"No"<<"\n";
return;
}
parent[i]=fa;
}
//利用每个点的父节点,将所有零散的点连成一棵树(邻接表)
for(int i=2;i<=n;i++)
{
int fa=parent[i];
int w=a[fa][i];
g[i].emplace_back(fa,w);//存边方便后续dfs取出来
g[fa].emplace_back(i,w);
}
//与已有的a[i]数组意义核对
//用dfs遍历1~n每个节点
for(int root=1;root<=n;root++)
{
vector<int> dist(n+1,-1);
stack<int> st;
st.push(root);
dist[root]=0;
//栈式dfs
while(!st.empty())
{
int u=st.top();
st.pop();
//求root到各个点的距离
//C++17结构化绑定
for(auto [v,w]:g[u])
{
//已经来过,直接continue
if(dist[v]!=-1) continue;
dist[v]=dist[u]+w;
////忘记入栈了 艹
st.push(v);////写掉了
}
}
//把dist[i]与a[roo][i]一一对照
for(int i=1;i<=n;i++)
{
if(dist[i]!=a[root][i])
{
cout<<"No"<<"\n";
return;
}
}
}
cout<<"Yes"<<"\n";
return;
}
int main()
{
ios::sync_with_stdio(0);cin.tie(0);cout.tie(0);
solve();
return 0;
}
默写时的错因
第一次默写 DFS 时只写了
dist[v]=dist[u]+w;
却漏掉 st.push(v)。这样只会处理根的直接邻居,无法继续访问更深的节点。以后写非递归 DFS 时,应把一次发现新点的完整动作绑定起来:
- 标记或写入距离;
- 入栈;
- 之后再由栈弹出并扩展。
速刷时暴露的三个边界错误
速刷版又把这三个地方明确标了出来:
-
读取上三角矩阵的循环边界:输入只有 的项,所以读取应写成
for(int i=1;i<n;i++)写成
i<=n虽然最后一轮没有新的j,但它不符合输入结构,也容易掩盖真正的边界。 -
每次 DFS 的距离数组必须初始化为
-1:-1同时表示“尚未访问”。如果使用未初始化数组,或没有为所有位置设为-1,就不能可靠地区分“未访问”和合法距离。 -
发现新点后必须入栈:
dist[v]=dist[u]+w; st.push(v);只更新
dist[v]而不push,遍历会在根的一层邻居后停止;这正是原默写代码中反复出现的错误。
复杂度
找父节点需要 ;候选树只有 条边,从每个点做一次 DFS 也是 :
F - Make Bipartite 3
本题为 AI 题解后抄写,再独立默写。代码选用注释最详细的
默写过一遍思路.cpp。
题意
初始有 个点、没有边。每次加入一条无向边后:
- 若当前图不是二分图,输出 ;
- 否则输出合法二染色中最少的黑点数量。
加边后不会删除边,因此一旦出现奇环,以后永远不是二分图。
静态问题:一个连通块贡献多少
一个连通二分图的二染色划分唯一到整体翻转。设两侧顶点数为 ,可以任选其中一侧作为黑色,所以该连通块的最小黑点数为
不同连通块之间可以独立翻转,因此全图答案为
这可以理解为连通块之间彼此独立的局部最优;合并两个块时,用
维护即可。
动态加边的两种情况
设 u,v 所在连通块根分别为 uu,vv。
已在同一个连通块
- 若二者标签不同,新边合法,划分不变;
- 若二者标签相同,新边与已有路径组成奇环,令
ans=-1。由于之后只加边,后续全部输出 。
位于不同连通块
两个块之间原本没有任何约束,所以它们各自的 标签方向可以独立选择:
- 若
u,v当前标签不同,直接把两块的同号标签合并; - 若
u,v当前标签相同,先整体翻转其中一个块,即交换该块的color0与color1,再按同号标签合并。
这样合并后 u,v 必然处于不同集合,新边合法。
“标准、标签、实际颜色”的严格解释
对话中围绕这个问题反复讨论,容易越说越绕。最简单且严格的理解是:
color0[root]和color1[root]只是当前连通块内部二分图的两侧,标签 本身没有黑白含义。- 两个连通块还没连接时,不存在必须统一的跨块“实际颜色标准”;两块都可以独立整体翻转。
- 新边第一次把两个块联系起来,此时我们选择一个相对方向:让新边两端落在不同标签中。
swap(color0[subRoot],color1[subRoot])是把子块整个二分划分翻转。它不破坏子块内部任何边的异色关系。- 完成对齐后,才把两块的
color0合并、color1合并,形成新连通块的一套统一标签。
因此 same_color 更准确的名字是 same_label。当两点来自不同块时,它不是在判断某种预先存在的全局黑白颜色,只是在决定合并前是否需要翻转一个块。
最终想通的视角:从合并后往前推
追加对话记录:F 题后续 DeepSeek 对话
昨天反复纠结的原因,是一直试图从前往后理解:合并前 A、B 两个连通块的 color0/color1 分别对应什么颜色,u,v 此时到底算不算同色。但两个块尚未联通,各自都能独立翻转,跨块比较“实际颜色”本来就没有固定意义。
更顺畅的理解方式是从合并后的要求往前倒推:
加入边 后,
u和v必须落在新连通块的两个不同标签集合中。
后面的集合合并固定采用
因此只需在执行这一步之前把 B 的标签方向对齐好:
合并前 u,v 的标签 | 是否翻转 B | 合并后的结果 |
|---|---|---|
| 不同 | 不翻转 | 分别进入 ,满足异色 |
| 相同 | 交换 | v 的标签取反,再分别进入 ,满足异色 |
所以“让 u,v 标签不同”不是在描述两个独立块合并前已有的全局颜色事实,而是在构造合并后的合法二染色。代码里的 is_same 决定的只是:为了让最终状态合法,合并前是否需要交换子块的两个状态集合。
为什么要小并大
若每次翻转和移动任意一个块,单次可能达到 ,总复杂度可能退化为 。
始终把较小连通块的元素移动到较大块后,每个顶点每被移动一次,所在块大小至少翻倍:
所以每个顶点最多被移动 次。使用 set::merge 时,每次节点转移还有平衡树操作的对数代价,因此 C++ 实现可保守记为
官方题解把“扫描较小块”的顶点总数记为 ;若集合合并操作按实现作更细分析,还需计入 set 的对数因子。
默写代码
#include <bits/stdc++.h>
using namespace std;
//用并查集来维护联通状态
class UnionSet
{
public:
vector<int> fa,sz;
UnionSet(int n):fa(n+1),sz(n+1)
{
for(int i=0;i<=n;i++)
{
fa[i]=i;
sz[i]=1;
}
}
int get(int x)
{
return fa[x]=(x==fa[x]?x:get(fa[x]));
}
int merge(int a,int b)
{
int aa=get(a),bb=get(b);
if(aa==bb) return bb;
if(sz[aa]>sz[bb])
{
fa[bb]=aa;
sz[aa]+=sz[bb];
return aa;
}
else
{
fa[aa]=bb;
sz[bb]+=sz[aa];
return bb;
}
}
};
void solve()
{
int n,q;
cin>>n>>q;
UnionSet uf(n);
//用set自动去重,避免一个元素多次加入导致size计算不准确
//color[root]:以root为根节点,与root联通的所有点集
//color0:颜色标签为0,color1:颜色标签为1
vector<set<int>> color0(n+1),color1(n+1);
//初始化
for(int i=1;i<=n;i++) color0[i].insert(i);//set要用insert
int ans=0;
while(q--)
{
int u,v;
cin>>u>>v;
if(ans==-1)
{
cout<<"-1"<<"\n";
continue;
}
int uu=uf.get(u),vv=uf.get(v);
//通过两者颜色标签是否为0来判断两者标签是否相同
bool u_is0=color0[uu].count(u);
bool v_is0=color0[vv].count(v);
bool is_same=(u_is0==v_is0);
//如果u,v本来就联通
if(uu==vv)
{
//如果颜色相同,则后续一直为非法状态,全输出-1即可
if(is_same) ans=-1;
cout<<ans<<"\n";//不相同就不影响黑点个数,依然还是ans
continue;
}
//如果u,v不联通
//我们先减去合并前两个联通块的黑点数,最后再加上合并后的黑点数
//局部贪心思想,取min,因为我大可以通过翻转颜色来使每个分散的联通块黑点个数最少,从而达到总体最小
ans-=min(color0[uu].size(),color1[uu].size());
ans-=min(color0[vv].size(),color1[vv].size());
//确定大小根-->小并大,set::merge效率更高
int mainRoot=uu,subRoot=vv;
int newRoot=uf.merge(uu,vv);
int oldRoot=(newRoot==mainRoot?subRoot:mainRoot);
//通过返回值来确定大小-->newRoo.size()t>oldRoot.size()
//若标签相同,则翻转
if(is_same)
{
swap(color0[oldRoot],color1[oldRoot]);
}
//然后直接调用set里面的merge方法来合并集合,而不是范围for+insert/erase
color0[newRoot].merge(color0[oldRoot]);
color1[newRoot].merge(color1[oldRoot]);
color0[oldRoot].clear();
color1[oldRoot].clear();
ans+=min(color0[newRoot].size(),color1[newRoot].size());
cout<<ans<<"\n";
}
}
int main()
{
ios::sync_with_stdio(0);cin.tie(0);cout.tie(0);
solve();
return 0;
}
速刷时的实现提醒:集合初始化也要保持不变量
速刷代码在初始化处写了:
//记得去重
vector<set<int>> color0(n+1),color1(n+1);
for(int i=1;i<=n;i++) color0[i].insert(i);//初始化
这里的重点不只是“容器选 set”,还包括初始化不变量:每个单点连通块的唯一顶点必须先进入 color0[i],color1[i] 为空。之后 count(u) 才能判断某点当前是否属于标签 0;如果漏初始化、把根下标写错,后面的 same_label 判断和答案贡献都会一起失效。
代码细节复盘
merge返回新根很重要,因为颜色集合必须挂到真实的新根上。oldRoot是并查集合并后失去根身份的旧根,也是需要移动颜色集合的块。- 注释
newRoot>oldRoot不准确:根的选择依据是sz,不是编号大小;代码本身没有依赖编号关系。 - 大小相等时模板让第二个根成为新根,这仍符合小并大,因为两块等大,移动任意一边都满足复杂度证明。
set::merge是 C++17 接口,编译时需要使用 C++17 或更新标准。- 对话中曾把一次 TLE 归因于多一次
get或少数几次自合并,这个解释证据不足。真正需要检查的是:每次是否只移动较小块、旧根集合是否正确清空、是否意外反复移动大块。
G - Minimum XOR Walk
本题只保留官方思路地图。当前尚未学习 01-Trie,也需要异或线性基作为前置,因此战略性放弃改错。
题意
给定带权连通无向图。一次 walk 的权值是经过边权的异或和,允许重复经过顶点和边。统计满足以下条件的点对 :从 到 的 walk 的最小可能异或值不超过 。
官方思路的四层转化
1. 固定生成树
令 为生成树上根 到顶点 的路径异或值。对任意边 ,定义环异或值
生成树边的 ;非树边的 表示沿生成树路径和该边绕一圈所能额外加入的异或量。
2. 环异或构成线性空间
所有环值张成异或线性空间 。任意从 到 的 walk 权值都可写为
因此最小 walk 权值为
3. 用异或基约简代表元
官方证明了这里的最小代表元映射满足
令
则点对条件化为
代码中通过
z=min(z,z^b);
依次用基向量约简,得到每个等价类的最小代表元。
4. 01-Trie 统计异或不超过 K 的点对
依次处理 。查询 Trie 中已有多少个 满足
再插入 。这样每个无序点对恰好统计一次。
官方代码模块地图
a[v]:生成树上根到 的路径异或;a[x]^a[y]^w:边 对应的环异或;basis:这些环异或张成空间的一组约简基;- 对每个
a[v]再执行min(z,z^b):求规范代表元 ; BinaryTrie::count_leq(K,z):统计已有值中与 异或后不超过 的数量。
为什么现在不继续改错
这题的关键前置不是语法,而是两个独立专题:
- 异或线性基:如何插入基、约简、证明可达异或空间;
- 01-Trie:如何按最高位到最低位统计 。
在这两个专题尚未学习时,修改官方代码容易变成背模板,无法解释核心不变量。当前先完成既定的 DP 后半段与线段树学习,再依次补 01-Trie、异或线性基,最后回看本题更合适。
另外,对话中 AI 最初给出的实现全 WA,随后对根因的解释也没有定位到确定差异。这再次说明:官方 AC 代码可以作为事实基准,但 AI 对 WA 原因的猜测必须通过反例或对拍验证,不能直接写入结论。
本场错因清单
题意与建模
- D:把“第 个好整数不超过 ”误读成了“”,因此过早采用只计数的阶段压缩。
- E:把树距离矩阵当成图的邻接矩阵,试图用 Floyd 验证最短路。
- F:把不同连通块的内部标签误认为预先存在统一的全局黑白标准。
边界与未定义行为
- C:删除 的元素必须使用
upper_bound(h)。 - D:
i<k会漏掉单个 的幂,应为i<=k。 - D:
X大小为 时访问X[10]越界;表面上的 MLE 实际可能来自未定义行为。 - D(速刷):同一个好整数可能由多种拼接方式生成,普通
vector会重复计数,必须使用set或等价方式去重。 - E(速刷):读取距离矩阵时外层只需枚举
i<n;每轮 DFS 都要把dist初始化为-1。 - E:非递归 DFS 写入距离后忘记把新点压栈。
- E(速刷):再次漏写
st.push(v),说明“更新距离 + 入栈”仍需作为一个不可拆分的遍历动作记忆。
数据结构不变量
- F:颜色集合必须始终挂在当前并查集根上。
- F(速刷):每个单点块必须初始化为
color0[i]={i}, color1[i]=empty,否则标签查询的不变量从一开始就不成立。 - F:合并后必须清空旧根集合,避免后续误用。
- F:启发式合并的复杂度依赖“移动较小块”这一事实,不能只看变量名叫
mainRoot/subRoot。 - G:线性基与 01-Trie 都有严格不变量,不能仅凭代码短就直接默写。
新知识专题
1. 构造后验证
当输入描述的是某个隐藏结构的全部观测值时,可以采用:
- 利用必要条件构造唯一或少量候选;
- 用原始定义完整验证候选。
E 题中,祖先距离等式负责构造父节点,全点对 DFS 距离负责验证。这种框架允许构造阶段对非法输入中的并列候选任取一个,因为最终验证会保证正确性。
2. 小并大
若要反复合并两个集合,始终把较小集合中的元素移动到较大集合。对任意元素,每次移动后所在集合大小至少翻倍,所以移动次数最多为
常见应用包括:
- DSU 上维护成员集合;
- 树上启发式合并;
- 合并颜色计数、频率表或有序集合。
3. 二分图的整体翻转自由度
连通二分图的二染色只有两种,它们互为整体翻转。若两个连通块尚无边相连,则两块可以分别选择翻不翻;新边加入时,这个自由度正好用于让两个端点异色。
这个性质也是带奇偶关系并查集的基础。以后学“扩展域并查集”或“带权并查集”时,可以把 F 题改写成只维护每个点到根的颜色异或关系,并单独维护每个根两侧的计数,从而不必保存所有顶点集合。
赛后训练清单
- 不看代码重写 C,并口述
lower_bound与upper_bound的区别。 - 用 手推 D 中两种不同拆分为什么需要
set去重。 - 不看代码写出 D 的递推式,并解释 为什么代表空串。
- 检查 D 的两个循环边界:
i<=k与汇总时k<10。 - 用四个点画一棵带权树,手算 E 中每个点的祖先候选和父节点。
- 独立重写 E 的非递归 DFS,把“写距离 + 入栈”作为一个完整动作。
- 用两个二分连通块手推 F 的四种端点标签组合,以及何时交换
color0/color1。 - 证明 F 中每个连通块对答案的贡献是 。
- 检查 F 的并查集返回值,确认颜色集合始终合并到真实新根。
- 完成当前 DP 后半段与线段树计划后,再学习 01-Trie、异或线性基并回看 G。
