2024集训D6总结
集训D6总结
讲课内容
主要是树上DP , DS维护 , 已经一些构造型题 , 以讲题为主 . 没必要单独总结 .
训练记录
P2515
裸拓扑序问题 , 套路地直接缩点 , 从 0 向所有连边变成一颗外向树 , 然后直接树上背包 .
虽然自己写的还是比较轻松的 . 不过看起来确实比较考验代码能力 .
CF494D
题目中的 \(S(v)\) 其实就是 \(v\) 子树 , 然后发现问题变成了一个从 \(u\) 到 \(v\) 的子树内所有点的 \(dis^2\) . 考虑简单情况 , \(u\) 不在 \(v\) 子树内 , \(u\) 与 \(x\) 的 \(lca\) 确定为 \(v\) , 可以通过预处理子树深度和 , 深度和平方解决 .
而在子树内发现 \(lca\) 是从 \(u\) 到 \(v\) 的一条链 , 要求的是
对每个点维护子树内与 \(lca\)的 \(dep_v-2\times dep_lca\) 和与平方和 , 链上差分即可做出 .
本身不算难的一道题 , 同样 , 只要始终保持思路清晰 , 不写到 "神秘化" , 就可以较为容易地解决 .
ARC101E
发现正着做很难从 \(n^3\) 优化到 \(n^2\) , 而且配对这个限制本身很难避免单开一维来记录子树内没匹配的点 .
于是考虑反着做 , 用容斥 . 发现考虑钦定某些边没有覆盖 , 其余的随意 , 相当于把原来的树分成若干的连通块 , 每个连通块内相当于 \(sz!!\) 的任意匹配 .
因为对 \(sz\) 进行 dp , 可以进行经典的树上背包 \(n^2\) , 容斥系数就是最平凡的一类 .
这道题比较有思维含量 , 想到容斥以及双阶乘处理 , 虽然思路连贯 , 但是对基本功的要求还是有的 .
CF1100F
昨天的数据结构题 . 一本通 (或者别的哪里) 见过原题 .
整体思路类似于 (拟阵) 贪心 , 扫描线扫 \(r\) , 对所有情况统一维护线性基 , 记录每个元素在原序列的下标 .
希望在查询 \(l\) 时组合出的数最大 , 就要尽可能让下标 \(I\) 大的元素放在高位上 , 这对于线性基来说并不难 .
具体地 , 在插入时 , 只要当前能插入 , 下标比这一位原来的数大 , 就替换这个数继续向后跑 . 因为线性基的性质保证正确性 . 同时根据贪心可以保证最大化 .
查询时 , 直接选择所有下标 \(\ge l\) 的即可 .
这时一道线性基题 , 根据问题还是可以找到思路的 .
loj6669
经典的交互构造方案题 . 这一类题肯定优先把最直接的信息读取出来 , 比如这一题可以直接读取 \(dep_i\) , 然后考虑如何在 $n\log n $ 时间内确定其余点 .
没有具体方向 , 用数据范围猜测一下做法 . 一个方向是对 \(\log n\) 个点考虑 \(O(n)\) 的查询来确定所有点 , 但是在极端情况下树高为 \(\log n\) 时 , 从 \(\log\) 个点出发查询始终有两个点没有本质区别 , 因此不可行 .
另一个就是确定每一个点用 $\log n $ 次. 发现这个过程类似于一个链查询 , 只不过是从链顶开始跳 , 可以用 \(1\) 次操作确定 \(x\) 在任意一条链上的子树位置 .
结合范围 , 直接使用重链剖分 , 从上向下跳链即可做到 .
非常有趣的一道题 , 作为一道构造方案题 , 问题本身并不很强调找性质 , 反而很好地利用了重链剖分这一算法 . 这可以提醒在构造题中找 熟悉方法 的重要性 .
IOI2020 链接擎天树
与上一题不同 , 更接近找性质的一个题 .
先手玩 , 可以发现 \(b_{i,j}=3\) 的情况显然不合法 , 于是简化到 \(b_{i,j}=0/1/2\) .
从简单开始想起 , 不考虑 \(2\) , 那么只需要判断给出的 \(b_{i,j}=1\) 产生的连通块是否与 \(b_{i,j}=0\) 的条件矛盾 , 用并查集就可以完成 , 构造只需要把连通块连成任意的树 .
考虑 \(b_{i,j}=2\) 显然路径中间是恰好构成了一个环 , 再次手玩可以发现整个连通块不应该有第二个环 , 否则必然会出现 \(b_{i,j}=4\) 的情况 .
因此就是构造一个基环树森林 , \(b_{i,j}=1\) 的必须在同一颗子树上 , 而 \(=2\) 的必须不在同一子树上 , 注意子树个数 \(=2\) 时也无法完成 , 因为构不成环 .
剩下的就是简单构造了 .
虽然找性质 , 但是性质并不算藏得很深 , 关键还是手玩 , 而且简化掉不合法的无关情况 , 剩下的就是并查集判断了 ..
WC2018 通道
去年也做过这个题 , 不过是用随机化水过的 , 代码还大部分借鉴了题解 ; 今年再遇到 , 花了不到一晚上 , 总算把正确的做法给写出来了 .
这道题本身没有什么思维难度 , 就是直接在三棵树上找 \(dis_{u,v}\) 和最大值 .
-
第一棵树用边分治 , 每次把两侧的边染成不同颜色供后面匹配 , 同时转化不同颜色的 \(dis(u,v)=dep1_u+dep1_v\) . 用边分是为了保证两个颜色匹配容易做 . 点分配合Huffman树也可以做 , 但是太毒瘤了 .
-
第二棵树直接建出虚树 , 如果只考虑这两颗树 , 那么类似于 暴力写挂 这道题 , 直接在LCA处匹配左右子树不同颜色的最大值 .
有第三课树也同理 , 还是考虑 \(LCA\) 合并左右子树 , 这时考察的是来自不同子树 , 颜色不同 的两个点 的 \(dep1_u+dep1_v+dep2_u+dep2_v+dis3(u,v)-dep2_{LCA}\times 2\) , 把后面的 $LCA $提出来 , 就变成了第三棵树上只关于 \(u,v\) 的问题 .
-
第三棵树我们有经典的直径引理 , 考虑上式相当于把 \(dep1_u+dep2_u\) 挂在了 \(u\) 点下面 , 然后求这些挂点构成的某个点集的直径 , 我们只需枚举直径端点 , 合并点集时枚举 \(6\) 对点 , 更新答案时枚举来自不同点集的 \(4\) 个点 , 就保证了最大化 .
至此思路就结束了 , 考虑写的过程 , 还是很体现了一些树题 , 尤其是需要大量模板类东西维护的树题的代码技巧 :
- 三棵树之间仅仅用 \(dep1 , dep2\) 虚树点集等等沟通 , 第三棵树更是只需提供 $dis(u,v) $ , 因此善用
namespace
, 把函数写的 模块化 一些 , 调试时先对每一个模块是否正常工作进行研究 . - 保持良好代码习惯 , 写之前做好
int/ll
区分 . LCA 就正常用 \(nlogn - O1\) 写法就行 , 这减少了常数 , 也易于实现 . 边分治正常用链式前向星存图 , 做好rebuild
, 边分治本身并不难写 . 注意在考虑路径时不要忘记分治边本身 . 这也保证不会让代码 "神秘化" . - 保持自信 . 在大码量题中一定对调试好的东西有自信 . 集中精力去调试复杂的 , 核心的 , 与模板差别大的部分 . 不要草木皆兵 .
总而言之 , 这道题类似于一个模板集合 , 但是确实很考验对树的基本东西的应用 . 写出来不管怎么说还是很有成就感 .
#include<bits/stdc++.h>
#define file(x) freopen(#x ".in","r",stdin),freopen(#x ".out","w",stdout)
#define ll long long
#define INF 0x7fffffff
#define INF64 1e18
using namespace std;
constexpr int N=1e5+5;
int n;
ll d1[N*2],d2[N];
int c[N*2],b[N*2],bn;
ll res;
bitset<N*2> inq;
namespace Tree3{
vector<pair<int,ll > > e[N];
int fd[N*2][25],od,pos[N],dfn[N],rnk[N],tot,lg[N*2];
ll dep[N];
void dfs1(int u,int fa){
dfn[u]=++tot;rnk[tot]=u;fd[++od][0]=dfn[u];pos[u]=od;
for(auto [v,w]:e[u]){
if(v==fa) continue;
dep[v]=dep[u]+w;
dfs1(v,u);
fd[++od][0]=dfn[u];
}
}
int LCA(int x,int y){
x=pos[x],y=pos[y];
if(x>y) swap(x,y);
int t=lg[y-x+1];
return rnk[min(fd[x][t],fd[y-(1<<t)+1][t])];
}
void init(){
for(int i=1;i<n;i++){
int u,v;ll w;cin>>u>>v>>w;
e[u].push_back({v,w});
e[v].push_back({u,w});
}
dfs1(1,1);
for(int j=1;(1<<j)<=od;j++)
for(int i=1;i+(1<<j)-1<=od;i++)
fd[i][j]=min(fd[i][j-1],fd[i+(1<<(j-1))][j-1]);
lg[0]=lg[1]=0;
for(int i=2;i<=od;i++) lg[i]=lg[i/2]+1;
}
ll dis(int u,int v){
return dep[u]+dep[v]-2*dep[LCA(u,v)];
}
}
namespace Tree2{
vector<pair<int,ll > > e[N];
int fd[N*2][25],od,pos[N],dfn[N],rnk[N],tot,lg[N*2];
void dfs1(int u,int fa){
dfn[u]=++tot;rnk[tot]=u;fd[++od][0]=dfn[u];pos[u]=od;
for(auto [v,w]:e[u]){
if(v==fa) continue;
d2[v]=d2[u]+w;
dfs1(v,u);
fd[++od][0]=dfn[u];
}
}
int LCA(int x,int y){
x=pos[x],y=pos[y];
if(x>y) swap(x,y);
int t=lg[y-x+1];
return rnk[min(fd[x][t],fd[y-(1<<t)+1][t])];
}
void init(){
for(int i=1;i<n;i++){
int u,v;ll w;cin>>u>>v>>w;
e[u].push_back({v,w});
e[v].push_back({u,w});
}
dfs1(1,1);
for(int j=1;(1<<j)<=od;j++)
for(int i=1;i+(1<<j)-1<=od;i++)
fd[i][j]=min(fd[i][j-1],fd[i+(1<<(j-1))][j-1]);
lg[0]=lg[1]=0;
for(int i=2;i<=od;i++) lg[i]=lg[i/2]+1;
}
pair<int,int> f[N][2];
ll calc(int x,int y){
if(x==0||y==0) return 0;
return d1[x]+d1[y]+d2[x]+d2[y]+Tree3::dis(x,y);
}
pair<int,int> merge(pair<int,int> x,pair<int,int> y){
if(x==make_pair(0,0)) return y;
if(y==make_pair(0,0)) return x;
pair<int,int> tmp[6];
tmp[0]=x;tmp[1]=y;
tmp[2]={x.first,y.first};
tmp[3]={x.second,y.first};
tmp[4]={x.first,y.second};
tmp[5]={x.second,y.second};
sort(tmp,tmp+6,[](pair<int,int> xx,pair<int,int> yy){
return calc(xx.first,xx.second)>calc(yy.first,yy.second) ;
});
return tmp[0];
}
void solve(ll w){
sort(b+1,b+bn+1,[](int x,int y){return dfn[x]<dfn[y];});
int tmp=bn;
for(int i=1;i<tmp;i++) b[++bn]=LCA(b[i],b[i+1]);
sort(b+1,b+bn+1,[](int x,int y){return dfn[x]<dfn[y];});
bn=unique(b+1,b+bn+1)-b-1;
for(int i=1;i<=bn;i++) {
f[b[i]][0]=f[b[i]][1]={0,0};
if(inq[b[i]]) f[b[i]][c[b[i]]]={b[i],0};
}
for(int i=bn;i>=1;i--){
int u=b[i];
if(i==1) continue;
int fa=LCA(b[i],b[i-1]);
res=max(res,max({
calc(f[fa][0].first,f[u][1].first),
calc(f[fa][0].first,f[u][1].second),
calc(f[fa][0].second,f[u][1].first),
calc(f[fa][0].second,f[u][1].second),
calc(f[fa][1].first,f[u][0].first),
calc(f[fa][1].first,f[u][0].second),
calc(f[fa][1].second,f[u][0].first),
calc(f[fa][1].second,f[u][0].second)
})-d2[fa]*2+w);
f[fa][0]=merge(f[fa][0],f[u][0]);
f[fa][1]=merge(f[fa][1],f[u][1]);
}
for(int i=1;i<=bn;i++) inq[b[i]]=0;
}
}
namespace Tree1{
struct node{
int u,v;ll w;int nxt;
}te[N*4],e[N*4];
int headt[N*2],head[N*2],tt=1,tot=1,pn;
bitset<N*4> vis;
void addt(int u,int v,ll w){
te[++tt]={u,v,w,headt[u]};headt[u]=tt;
te[++tt]={v,u,w,headt[v]};headt[v]=tt;
}
void add(int u,int v,ll w){
e[++tot]={u,v,w,head[u]};head[u]=tot;
e[++tot]={v,u,w,head[v]};head[v]=tot;
}
void build(int u,int fa){
int last=0;
for(int i=headt[u];i;i=te[i].nxt){
auto [u,v,w,nxt]=te[i];
if(v==fa) continue;
if(last==0){
last=u;
}
else{
add(last,++pn,0);
last=pn;
}
add(last,v,w);
build(v,u);
}
}
int sz[N*2];
void getrt(int u,int fa,int siz,int &rt){
sz[u]=1;
for(int i=head[u];i;i=e[i].nxt){
if(vis[i]) continue;
auto [u,v,w,nxt]=e[i];
if(v==fa) continue;
getrt(v,u,siz,rt);
sz[u]+=sz[v];
if(!rt) rt=i;
else if(max(sz[v],siz-sz[v])<max(sz[e[rt].v],siz-sz[e[rt].v])) rt=i;
}
}
void dfs(int u,int fa,int tag){
sz[u]=1;
if(u<=n){
b[++bn]=u;c[u]=tag;
inq[u]=1;
}
for(int i=head[u];i;i=e[i].nxt){
if(vis[i]) continue;
auto [u,v,w,nxt]=e[i];
if(v==fa) continue;
d1[v]=d1[u]+w;
dfs(v,u,tag);
sz[u]+=sz[v];
}
}
void solve(int x){
if(sz[x]==1) return;
int rt=0;
getrt(x,x,sz[x],rt);
int u=e[rt].u,v=e[rt].v;
d1[u]=d1[v]=0;bn=0;
vis[rt]=vis[rt^1]=1;
dfs(u,u,0);
dfs(v,v,1);
Tree2::solve(e[rt].w);
solve(u);solve(v);
}
void init(){
pn=n;
for(int i=1;i<n;i++){
int u,v;ll w;
cin>>u>>v>>w;
addt(u,v,w);
}
build(1,1);
sz[1]=n;
}
void main(){
solve(1);
}
}
int main(){
ios::sync_with_stdio(false);
cin.tie(0);cout.tie(0);
cin>>n;
Tree1::init();
Tree2::init();
Tree3::init();
Tree1::main();
cout<<res;
}
总结
最近的 \(OI\) 赛制重视对树的考察 , 因为很多东西放在树上可以有很灵活的解决方式 , 同时还考验对树的基本理解 . 因此要学会适可而止地找性质 , 快速抓取关键思路用来做题 , 然后把精力用在良好的实现上 .
同时要重视代码习惯 , 树题有时思路不清晰时虽然可做 , 但是产生一些边界讨论 , 这在某些情况下是致命的 . 良好地梳理思维 , 简明 , 易于调试的实现也是树题基本功的重要部分 .