1. ABC447竞赛题解:从算法思路到代码实现
最近参加了AtCoder Beginner Contest 447(ABC447),遇到了几道很有意思的题目。作为算法竞赛爱好者,我想分享一下其中A、C、E、F四道题的解题思路和实现细节。这些题目涵盖了基础逻辑判断、字符串处理、图论和树形DP等典型算法题型,对提升编程思维很有帮助。
在本文中,我将逐题分析题目要求、解题思路和代码实现,特别会重点讲解E题和F题中涉及的并查集优化和树形DP技巧。这些内容不仅适用于竞赛准备,对于日常算法学习也有参考价值。我会尽量用通俗易懂的方式解释复杂概念,并提供可复用的代码模板。
2. A题解析:基础逻辑判断
2.1 题目理解与数学建模
A题题目链接:A - Adjacent Chairs
题目大意:有N把椅子排成一圈,需要安排M个人坐下,要求任意两个相邻的椅子不能同时有人。我们需要判断给定的N和M是否能满足这个条件。
这道题的核心在于将实际问题转化为数学模型。考虑最坏的情况:为了最大化利用椅子,我们会交替安排人和空椅子。这样每安排一个人,至少需要"占用"两个椅子的位置(自己和旁边的一个空位)。因此,M个人至少需要2M-1把椅子才能满足不相邻的条件。
数学表达式为:N ≥ 2M - 1
2.2 代码实现与优化
基于上述分析,代码实现非常简单:
cpp复制#include<bits/stdc++.h>
using namespace std;
int main(){
int n,m;
cin>>n>>m;
cout<<(n>=2*m-1?"Yes":"No");
return 0;
}
这段代码的时间复杂度是O(1),直接通过条件判断输出结果。几个值得注意的点:
- 使用三元运算符简化条件输出
- 直接进行数学比较而不需要任何循环
- 题目保证输入的N和M都是正整数,所以不需要额外处理边界情况
提示:在竞赛中,简单的题目往往考察基本编程能力和对题意的准确理解。A题虽然简单,但如果理解错误题意(比如忽略椅子是环形排列),可能会导致错误答案。
3. C题解析:字符串处理与双指针技巧
3.1 问题分析与算法选择
C题题目链接:C - A-String
题目给定两个字符串S和T,只包含字符'A'。允许的操作是在S的任意位置添加或删除一个'A'。要求计算将S转换为T所需的最少操作次数。
观察题目特点:
- 字符串只包含'A'字符
- 操作只有添加或删除'A'
- 需要最小化操作次数
这种情况下,双指针算法是最佳选择。我们可以同时遍历两个字符串,根据当前位置字符的匹配情况决定操作。
3.2 双指针算法详解
算法思路:
- 初始化两个指针i和j分别指向S和T的开头
- 如果S[i] == T[j],两个指针都前进
- 如果不等,看哪个字符串当前字符是'A':
- 如果是S[i]='A',说明需要删除S中的这个'A'(i++,操作数++)
- 如果是T[j]='A',说明需要在S中添加'A'(j++,操作数++)
- 如果两个字符都不为'A',说明无法转换(输出-1)
cpp复制#include<bits/stdc++.h>
using namespace std;
char s[300005];
char t[300005];
int main(){
scanf("%s%s",s+1,t+1);
int n=strlen(s+1);
int m=strlen(t+1);
int i=1,j=1,cnt=0;
while(i<=n||j<=m){
if(s[i]==t[j]){
++i,++j;
}else if(s[i]=='A'){
while(s[i]=='A'&&s[i]!=t[j])
++i,++cnt;
}else if(t[j]=='A'){
while(t[j]=='A'&&t[j]!=s[i])
++j,++cnt;
}else{
cnt=-1;
break;
}
}
cout<<cnt;
return 0;
}
3.3 复杂度分析与边界处理
时间复杂度:O(|S| + |T|),因为每个字符最多被处理一次。
需要注意的边界情况:
- 字符串长度可能不同
- 需要处理两个指针都到达末尾的情况
- 使用s+1和t+1的索引方式是为了方便处理字符串(个人习惯)
经验分享:在字符串处理问题中,双指针是非常高效的技巧。关键是明确指针移动的条件和顺序,以及处理好边界条件。
4. E题解析:图论与并查集应用
4.1 题目理解与关键观察
E题题目链接:E - Complete Binary Tree
题目给出一个N个节点的树和M条边,要求计算删除某些边后,剩下的图仍然是连通图的情况下,删除边的权值和最大是多少。每条边的权值是2^i(i是边的编号)。
关键观察点:
- 边的权值是2的幂次方,这意味着编号大的边权值远大于前面所有边的和
- 要最大化删除边的权值和,应该优先保留小权值的边,尽可能删除大权值的边
- 最终图必须连通(即连通块数量为1)
4.2 并查集与贪心算法实现
解题步骤:
- 将边按编号从大到小排序
- 使用并查集维护连通性
- 对于每条边,判断是否可以删除(即删除后图仍然连通)
- 如果可以删除,则累加其权值
cpp复制#include<bits/stdc++.h>
#define P 998244353
using namespace std;
int u[200005],v[200005];
int fa[200005];
int pw[200005];
int get(int x){
return x==fa[x]?x:fa[x]=get(fa[x]);
}
int main(){
int n,m;
pw[0]=1;
cin>>n>>m;
for(int i=1;i<=n;i++)fa[i]=i;
for(int i=1;i<=m;i++){
cin>>u[i]>>v[i];
pw[i]=pw[i-1]*2%P;
}
int d=n,i=m;
ll ans=0;
while(i>=1){
int x=get(u[i]);
int y=get(v[i]);
if(x==y||(x!=y&&d>2)){
if(x!=y)fa[y]=x,--d;
}else ans=(ans+pw[i])%P;
--i;
}
cout<<ans;
return 0;
}
4.3 算法优化与数学原理
算法时间复杂度:O(M α(N)),其中α是反阿克曼函数,通常认为是很小的常数。
数学原理:
- 焦梓辰微定理:(∑_{i<x} 2^i) + 1 = 2^x
- 这意味着编号大的边的权值比所有小编号边权值之和还大,因此贪心策略成立
实现细节:
- 预处理2的幂次模数
- 并查集使用路径压缩优化
- 从大到小枚举边
- 维护当前连通块数量d
注意事项:在模数运算题目中,要提前计算好需要的幂次,并注意取模的位置。并查集的路径压缩能显著提高效率。
5. F题解析:树形DP与复杂条件分析
5.1 题目理解与蜈蚣图定义
F题题目链接:F - Centipedes' Invasion
题目给出树的定义和蜈蚣图的定义(一种特殊的树结构),要求在一棵树中找到最长的蜈蚣子图。
蜈蚣图的定义:
- 有一条主路径(脊柱)
- 主路径上的每个节点可以有若干"脚"(分支)
- 分支长度必须为1(即直接连接的叶子节点)
5.2 树形DP设计与实现
解题思路:
- 使用树形DP计算每个节点作为蜈蚣端点时的最大长度
- 分情况讨论节点的子节点数量
- 维护全局最大值
定义状态:
- f[x]:以x为端点的最长蜈蚣长度
- g[x]:经过x的最长蜈蚣长度
状态转移方程:
cpp复制f[x] = {
max{f[y]+1} (size_x ≥ 3)
1 (size_x = 2)
0 (size_x ≤ 1)
}
g[x] = {
max{f[y1]+f[y2]+1} (size_x ≥ 3)
max{f[y]+1} (size_x = 2)
1 (size_x = 1)
0 (size_x < 1)
}
对于根节点需要特殊处理(因为根没有父节点)。
cpp复制#include<bits/stdc++.h>
using namespace std;
const int N=200005;
vector<int> v[N];
int f[N];
bool cmp(int a,int b){return a>b;}
int ans=-1e9;
void dfs(int x,int fa){
int son=0,g;
vector<int> k;
for(int i=0;i<v[x].size();i++){
int y=v[x][i];
if(y==fa)continue;
dfs(y,x);++son;
k.push_back(f[y]);
}
sort(k.begin(),k.end(),cmp);
if(son>=3) f[x]=k[0]+1;
else if(son==2) f[x]=1;
else f[x]=0;
if((x==1&&son>=4)||(x!=1&&son>=3)) g=k[0]+k[1]+1;
else if((x==1&&son>=3)||(x!=1&&son>=2)) g=k[0]+1;
else if((x==1&&son>=2)||(x!=1&&son>=1)) g=1;
else g=0;
ans=max(ans,g);
return;
}
void solve(){
int n;
cin>>n;
ans=-1e9;
for(int i=1;i<=n;i++)v[i].clear();
for(int i=1,u,vt;i<n;i++){
cin>>u>>vt;
v[u].push_back(vt);
v[vt].push_back(u);
}
dfs(1,-1);
printf("%d\n",ans);
}
int main(){
int T;
scanf("%d",&T);
while(T--){
solve();
}
return 0;
}
5.3 实现细节与多组数据处理
关键实现细节:
- 使用邻接表存储树结构
- DFS遍历实现树形DP
- 对子节点的f值排序方便取最大值和次大值
- 分情况讨论普通节点和根节点
- 处理多组数据时需要清空全局变量
时间复杂度:O(N log N)(因为排序操作)
踩坑提醒:在多组数据的题目中,一定要记得初始化全局变量和容器。我最初因为没有清空vector而WA了几次。另外,树形DP中根节点的特殊情况容易被忽视,需要特别注意。
6. 竞赛经验与算法学习建议
通过这次ABC447的几道题目,我总结了以下几点算法竞赛经验:
-
基础题目要稳:像A题这样的基础题目虽然简单,但必须快速准确地完成,为后面的难题节省时间。
-
经典算法要熟:双指针、并查集、树形DP等都是高频考点,需要熟练掌握其模板和应用场景。
-
数学思维很重要:E题的贪心策略基于对2的幂次性质的深刻理解,平时要注意培养数学直觉。
-
边界条件要全面:F题中对根节点的特殊处理展示了全面考虑问题的重要性。
-
代码模板要准备:并查集、树遍历等常用算法可以准备模板,但也要理解其原理才能灵活应用。
对于想要提高算法能力的同学,我建议:
- 定期参加比赛积累经验
- 赛后补题理解优秀解法
- 分类整理常见算法题型
- 重视代码实现的质量和效率
算法学习是一个长期过程,需要耐心和坚持。每次比赛后认真总结,把不会的题目搞懂,这样水平就会逐步提高。