树论
树相关基础
一个没有固定根节点的树称为无根树。有几种等价的形式化表述
\(1.\)\(n\)个点\(n-1\)条边的无向连通图
\(2.\)无向无环的连通图
\(3.\)任意两个节点之间有且只有一条简单路径的无向图
\(4.\)任意边均为桥的连通图
\(5.\)没有圈,且在任意两点之间添加一条边后所得图仅含唯一一个圈
在无根树的基础上,选定一个结点作为根,可以转为有根树
\(dfs\)序表示对一棵树进行深度优先搜索时得到的节点序列。
\(dfn\)表示节点在\(dfs\)序中的位置,称为时间戳。
\(fa(i)\)表示\(i\)的父亲。
\(son(i)\)表示\(i\)的儿子。
\(anc(i)\)表示\(i\)的所有祖先。
\(sub(i)\)表示\(i\)子树的所有节点。
\(dist(i,j)\)表示\(i,j\)的树上距离。
\(lca(i,j)\)表示\(i,j\)的最近公共祖先。
树上子树的性质:
我们知道树上点数减边数等于\(1\),对于一个连通块,它是子树,当且仅当\(\sum d_i-edges*2=1\),即度数和减去边数的两倍等于\(1\)。
树的直径
\(DFS\)做法
以任意一点\(y\)为起点,\(dfs\)找到距离\(y\)最远的点\(z\),再以\(z\)为起点找到距离它最远的点\(p\),\(z\)到\(p\)即为树的直径。
int dis[maxn],c;
void dfs(int u,int fa){
for(int i=hd[u];i;i=e[i].nxt){
int v=e[i].to;
if(v==fa) continue;
dis[v]=dis[u]+1;
if(dis[v]>dis[c]) c=v;
dfs(v,u);
}
}
int main(){
dfs(1,0);
dis[c]=0;
dfs(c,0);
cout<<dis[c]<<endl;
return 0;
}
树形\(DP\)做法
每个节点作为子树的根向下所能延伸的最长长度\(d1\),与次长长度\(d2\),树的直径即为\(max(d1+d2)\)。树形DP的做法可以求存在负边权的树的直径。
int d1[maxn],d2[maxn],d;
void dfs(int u,int fa){
for(int i=hd[u];i;i=e[i].nxt){
int v=e[i].to;
if(v==fa) continue;
dfs(v,u);
int t=d1[v]+1;
if(t>d1[u]){
d2[u]=d1[u];
d1[u]=t;
}
else if(t>d2[u]){
d2[u]=t;
}
}
d=max(d,d1[u]+d2[u]);
}
最近公共祖先
倍增求\(LCA\)
从朴素的每次跳一步,优化为每次跳\(2^i\)步。
void pre(){
for(int j=1;j<=20;j++)
for(int i=1;i<=n;i++)
f[i][j]=f[f[i][j-1]][j-1];
}
void dfs(int u,int fa){
f[u][0]=fa;
dep[u]=dep[fa]+1;
for(int i=hd[u];i;i=e[i].nxt){
int v=e[i].to;
if(v==fa) continue;
dfs(v,u);
}
}
int lca(int x,int y){
if(dep[x]<dep[y]) swap(x,y);
for(int i=20;i>=0;i--){
if(dep[f[x][i]]>=dep[y]) x=f[x][i];
}
if(x==y) return x;
for(int i=20;i>=0;i--){
if(f[x][i]!=f[y][i]){
x=f[x][i],y=f[y][i];
}
}
return f[x][0];
}
int main(){
dfs(1,0);
pre();
return 0;
}
\(lca\)可以用于求树上简单路径的长度。\(dis(u,v)=dep(u)+dep(v)-2*dep(lca(u,v))\)。
(待施工)\(tarjan\)求\(LCA\)
树的重心
定义
如果在树上选择并删除某个节点,将这棵树分为若干子树,记录子树节点数的最大值,称为重量,使得这些子树重量最小的点即为树的重心。
性质
1.如果树的重心不唯一,则至多有两个重心,且这两个重心相邻
2.以树的重心为根时,所有子树的大小不超过整棵树大小的一半
3.树中所有点到某个点的距离和中,到重心的距离和最小,若重心有两个,则两个距离和相等
4.把两棵树通过一条边连起来,新树的重心在连接原来两棵树重心的路径上
5.在树上删除或添加一个叶子节点,重心最多移动一条边的距离
\(DFS\)求法
\(DFS\)先向下求每个子树的大小,然后总点数减去这个值得到向上的子树的大小,记录子树最大值(重量),利用重心的性质找到重心。
int siz[maxn],weight[maxn],centroid[maxn];
void dfs(int u,int fa){
siz[u]=1;
weight[u]=0;
for(int i=hd[u];i;i=e[i].nxt){
int v=e[i].to;
if(v==fa) continue;
dfs(v,u);//需要先向下搜索得到各个子树的大小
siz[u]+=siz[v];
weight[u]=max(weight[u],siz[v]);
}
weight[u]=max(weight[u],n-siz[u]);
if(weight[u]<=n/2){
centroid[centroid[0]!=0]=u;
}
}
最小生成树\((Minimum\ Spanning\ Tree,MST)\)
\(Kruskal\)算法
\(Kruskal\)是贪心算法,从小到大加边。需要我们用数据结构维护一个森林,用于查询两个点是否在一个集合内,并将这两个集合合并。这个操作用到了并查集。
证明:我们考虑反证法,如果在剩余边集内存在一条边权不大于生成树的边的边,那么我们把这条边加入树中,一定构成一个环,将比这条边边权大的边删除,得到的新生成树的权值和一定不大于原生成树。
bool cmp(edge x, edge y){
return x.w<y.w;
}
void kruskal(){
sort(e+1,e+m+1,cmp);
int cnt=0;
for(int i=1;i<=m;i++){
int u=e[i].u,v=e[i].v;
if(find(u)!=find(v)){
merge(u,v);
ans+=e[i].w;
cnt++;
}
if(cnt==n-1) break;
}
}
\(prim\)算法
该算法是从一个点开始,基本思想是加点,而不是\(kruskal\)那样加边。
每次找距离最小的点,就像\(dijkstra\)那样,最小距离可以用堆维护。
int dis[maxn],ans,vis[maxn];
typedef pair<int,int>pii;
priority_queue<pii,vector<pii>,greater<pii> >q;
void prim(){
memset(dis,0x3f,sizeof dis);
dis[1]=0;
q.push(make_pair(dis[1],1));
while(!q.empty()){
int u=q.top().second;
if(vis[u]) continue;
vis[u]=1;
ans+=q.top().first;
cnt++;
q.pop();
for(int i=hd[u];i;i=e[i].nxt){
int v=e[i].to,w=e[i].val;
if(dis[v]>w){
dis[v]=w;
q.push(make_pair(dis[v],v));
}
}
}
}
\(Kruskal\)重构树
算法引入
在\(Kruskal\)求最小生成树的过程中,我们将边按边权排序,若当前边\((u,v)\)不连通,那么我们把\((u,v)\)加入\(E\),并将\((u,v)\)连接,使用并查集维护连通性。
如果可以维护每条边连接的顺序,因为先加入的边边权更小,就可以快速刻画有边权限制时的图的连通性。
\(Kruskal\)重构树是在\(Kruskal\)的过程中建树,原图\(G\),对于每次要连接的边\((u,v)\),找到\(u,v\)对应集合\(U,V\),新建节点\(c\),将\(c\)设为\(U,V\)的父亲,并且在新图\(T\)中连接\((c,U),(c,V)\)。注意\(U,V\)不一定是原图节点。
通常将\(c\)的点权设为\(w_{u,v}\),为虚点设点权有利于解题。若原图\(G\)连通,则新图\(T\)的节点数为\(2n-1\),是一棵有根树,称为\(Kruskal\)重构树。
性质
原节点为原图节点,共\(n\)个。新节点为新建节点,共\(n-1\)个,代表了最小生成树的\(n-1\)条边。
\(Kruskal\)重构树\(T\)有许多性质:
\(1.\)\(T\)是一棵二叉树,对于部分问题,特殊重构树建法可以有效减少常数。
\(2.\)\(G\)的节点是\(T\)的叶子,原节点和重构树的叶子节点本质相同。
\(3.\)对于新点\(u\)和其祖先\(v\),有\(w_u\le w_v\)。
性质\(3\)是非常重要的性质,这说明深度越浅的边权大。原节点\(x\)经过\(\le d\)的权值的边可达的点,就等价于在\(T\)上\(x\)最浅的权值\(\le d\)的新节点\(a\)的子树中的所有点。一般倍增求解\(a\)。
换言之,对于原节点\(x\)倍增求得权值\(\le d\)的最浅祖先\(a\),\(a\)子树内所有叶子节点就是原图中保留边权\(\le d\)的边后,\(x\)所在连通块的所有点。
所以在题目存在限制形如“只经过权值不大于某个值的点或边”时,可以从\(Kruskal\)重构树入手,用来刻画存在边权限制时的图的连通情况。
\(Kruskal\)重构树求图上两点路径中最大权值的最小值,可以建最小生成树的重构树。求图上两点路径中最小权值的最大值,可以建最大生成树的重构树。
for(int i=1;i<=m;i++){
int u=find(a[i].u),v=find(a[i].v);
if(u!=v){
num++;
fa[u]=fa[v]=num;
w[num]=a[i].a;
G[u].push_back(num);G[num].push_back(u);
G[v].push_back(num);G[num].push_back(v);
}
}
模板:求路径最大权值的最小值,建最大生成树的重构树,将虚点的点权\(c\)设为\(w_{(u,v)}\)。深度最浅的点权最小,求\(lca(x,y)\)的点权就是\(x\)到 \(y\)路径中最大权值的最小值。
需要注意的是,我们是按虚点为根的,对于连通图或森林,都是先从\(tot\)做根往下\(dfs\)的。
#include<bits/stdc++.h>
using namespace std;
const int maxn=3e5+5;
const int N=1e5+5;
typedef long long ll;
int n,m,q,fa[N<<1],tot,hd[N<<1],cnt,f[N<<1][25],dep[N<<1],vis[N<<1],w[N<<1];
struct node{
int u,v,val;
}a[maxn];
struct edge{
int to,nxt,val;
}e[N<<2];
void add(int u,int v,int val){
e[++cnt].nxt=hd[u];
e[cnt].to=v;
e[cnt].val=val;
hd[u]=cnt;
}
int find(int x){
if(fa[x]==x) return x;
return fa[x]=find(fa[x]);
}
void merge(int u,int v){
fa[find(u)]=fa[find(v)]=tot;
}
bool cmp(node a,node b){
return a.val>b.val;
}
void dfs(int u,int fa){
vis[u]=1;
f[u][0]=fa;
dep[u]=dep[fa]+1;
for(int i=hd[u];i;i=e[i].nxt){
int v=e[i].to;
if(v==fa) continue;
dfs(v,u);
}
}
int lca(int x,int y){
if(dep[x]<dep[y]) swap(x,y);
for(int i=20;i>=0;i--){
if(dep[f[x][i]]>=dep[y]) x=f[x][i];
}
if(x==y) return x;
for(int i=20;i>=0;i--){
if(f[x][i]!=f[y][i]){
x=f[x][i],y=f[y][i];
}
}
return f[x][0];
}
int main(){
scanf("%d%d%d",&n,&m,&q);
tot=n;
for(int i=1;i<=2*n;i++) fa[i]=i;
for(int i=1;i<=m;i++){
scanf("%d%d%d",&a[i].u,&a[i].v,&a[i].val);
}
sort(a+1,a+m+1,cmp);
for(int i=1;i<=m;i++){
int u=a[i].u,v=a[i].v,val=a[i].val;
if(find(u)!=find(v)){
tot++;
add(tot,find(u),val);add(find(u),tot,val);
add(tot,find(v),val);add(find(v),tot,val);
merge(u,v);
w[tot]=val;
}
}
for(int i=tot;i;i--){
if(!vis[i]) dfs(i,0);
}
for(int j=1;j<=20;j++){
for(int i=1;i<=tot;i++){
f[i][j]=f[f[i][j-1]][j-1];
}
}
while(q--){
int x,y;
scanf("%d%d",&x,&y);
if(find(x)!=find(y)) puts("-1");
else{
printf("%d\n",w[lca(x,y)]);
}
}
return 0;
}
点权多叉重构树
\(Kruskal\)重构树还可以处理限制点权的情况。
方法一:将点权转化为边权,对边赋边权。若限制点权最大值,因为能经过\((u,v)\)要求\(w_u,w_v\)都不超过最大值限制,所以令\(w_{u,v}=max\{w_u,w_v\}\)。同样的,如果限制点权最小值,则令\(w_{u,v}=min\{w_u,w_v\}\)。
方法二:假设限制点权最大值,那么我们将节点按点权从小到大排序,对节点\(i\)遍历其所有出边\((i,u)\),如果\(u\)被遍历,这说明\(w_u\le w_i\),此时这条边的限制取到\(max(w_u,w_i)=w_i\),此时若\(i,u\)不连通,则从\(i\)向\(u\)的代表元连边。
这种做法对节点点权排序后枚举,相当于对边排序,边权即为\(max(w_i,w_u)\),不用建新点,常数小。
点分治
静态点分治
序列分治
研究树上问题,通常可以从对应的序列问题入手。
分治的时间复杂度有\(T(n)=2T(\frac n2)+O(n)->T(n)=O(nlogn)\)或\(T(n)=2T(\frac n2)+O(nlogn)->T(n)=O(nlog^2n)\)
尝试用分治法解决,对于当前区间\([l,r]\),中点\(m\)。要么\(i<j\le m\)或\(m<i<j\),要么\(i< m<j\),第一种情况可以转化为规模更小的子问题\([l,m],[m+1,r]\)。需要考虑的只有第二种情况。
首先对\(a\)求前缀和,将区间和转化为端点差分,记前缀和为\(s\),则原问题转化为:是否存在\(i\in [l,m-1],j\in [m+1,r]\),使得\(s_j-s_i=k\)。
将\(s_l-s_{m-1},s_{m+1}-s_r\)分别从小到大排序,然后按顺序枚举\(s_j\),并判断\(s_j-k\)是否在\(s_i\)中出现过,因为\(s_i\)有序且\(s_j-k\)单调不降,则可以维护指针\(i\)表示\(s_i\ge s_j-k\)最小的\(i\)。
总时间复杂度\(T(n)=2T(\frac n2)+O(nlogn)=O(nlog^2n)\)。
点分治
树链剖分
树上差分
为了引入树链剖分,首先我们考虑一个问题,将\(x,y\)路径上的点权都\(+d\)。可以用差分解决,记\(b_i\)为差分数组,\(val_i\)为点权。求\(val_k\)就等于以\(k\)为根的子树的所有\(b_i\)的和。按照这个差分与求和的关系考虑如何修改差分数组,\(b_x+=d,b_y+=d,b_{lca(x,y)}-=d,b_{f_{lca}}-=d\)。
修改完差分数组后,\(dfs\)求子树内差分数组和就是点权。
重链剖分
当需要修改\((x,y)\)路径权值时,树上差分就派不上用处了。而且如果修改操作后还有查询路径权值和的操作的话,上述方法就不够优秀了,这就需要树链剖分了。
定义
重儿子:父亲节点的所有儿子节点中,子树节点数最多(\(size\)最大)的节点。
轻儿子:父亲节点中除了重儿子的其他节点。
重边:父亲节点和重儿子连边。
轻边:父亲节点和轻儿子连边。
重链:由多条重边连接形成的链。
轻链:由多条轻边连接形成的链。
\(son(x)\)表示\(x\)的重儿子。\(top(x)\)表示\(x\)所处的重链的链头(深度最浅)。\(rnk(x)\)表示\(dfs\)序对应的节点编号,有\(rnk(dfn(x))=x\)。
性质
树上每个节点都属于且仅属于一条重链。
重链的链头不一定是重儿子,因为重边对于每个点都是有定义的。
所有的重链将一棵树完全剖分。
在剖分时重边优先遍历,重链内的\(dfs\)序是连续的,按\(dfn\)排序后的序列即剖分的链。因此我们可以用维护序列的数据结构(如线段树)方便地维护树上路径的信息。
一棵子树内的\(dfs\)序是连续。
当向下经过一条轻边时,所在子树大小至少除以二,所以对于树上任意一条路径,将其拆成从\(lca\)分别向两边向下,分别最多走\(O(logn)\)次。所以树上任意一条路径都能拆成最多\(O(logn)\)条链。
实现与应用
树链剖分主体是两个\(dfs\)。
\(dfs1\)用来求\(dep\)节点深度,\(fa\)节点的父亲,\(siz\)节点子树大小,\(son[x]\)表示\(x\)的重儿子。\(dfs2\)求\(top[x]\),表示\(x\)所在重链的链头。
\(dfs2\)是先\(dfs\)重儿子再\(dfs\)轻儿子,记录节点的\(dfs\)序,会发现以下特征。
\((1)\)每条重链内的点的\(dfs\)序都是有序的。
\((2)\)每棵子树内的节点的\(dfs\)序也都是有序的。
基于上述性质,我们可以用线段树处理重链。建一棵选段树,重链就相当于一段连续的区间,对重链信息的修改和查询用线段树处理即可,一条路径跨过多条重链,只要跳过重链之间的轻边再处理,跳重链的复杂度是\(O(logn)\)级别的,线段树处理的复杂度也是\(O(logn)\)级别的。
树链剖分求\(lca\)
\((1)\)\(x,y\)在同一条重链时,重链上的节点都是祖先和后代的关系,此时深度浅的就是\(lca\)。
\((2)x,y\)不在同一条重链上,链头更深的重链不停向上跳,直到位于同一条重链。
修改路径节点权值
修改\(x,y\)路径节点权值,其实就相当于查找\(lca(x,y)\)。从链头深度深的重链向上跳,每次将一条重链内连续的一段待修改的区间在线段树上修改,一直跳轻边并修改,直到\(x,y\)位于一条重链上,在线段树修改这一段区间。
查询路径节点权值和
和修改同理,每次在线段树上查询重链连续区间的权值和。
修改子树权值,查询子树权值和
因为每棵子树对应的\(dfs\)序也是连续的,在线段树上修改或查询这段区间即可。提前记录子树的\(dfs\)序的区间即可。
#include<bits/stdc++.h>
using namespace std;
const int maxn=1e5+5;
typedef long long ll;
int n,m,r,cnt,hd[maxn],dep[maxn],top[maxn],dfn[maxn],tim;
int rnk[maxn],fa[maxn],siz[maxn],son[maxn];
ll p,val[maxn];
int ls(int x){return x<<1;}
int rs(int x){return x<<1|1;}
struct edge{
int to,nxt;
}e[maxn<<1];
struct tree{
ll val,ad;
}t[maxn<<2];
void add(int u,int v){
e[++cnt].nxt=hd[u];
e[cnt].to=v;
hd[u]=cnt;
}
void dfs1(int x,int f){
dep[x]=dep[f]+1;
siz[x]=1;
fa[x]=f;
for(int i=hd[x];i;i=e[i].nxt){
int v=e[i].to;
if(v==f) continue;
dfs1(v,x);
siz[x]+=siz[v];
if(!son[x]||siz[son[x]]<siz[v]) son[x]=v; //求重儿子
}
}
void dfs2(int x,int topx){
dfn[x]=++tim;
rnk[tim]=x;
top[x]=topx;
if(!son[x]) return;//到了叶子节点return
dfs2(son[x],topx);//优先dfs重儿子
for(int i=hd[x];i;i=e[i].nxt){
int v=e[i].to;
if(v!=fa[x]&&v!=son[x])
dfs2(v,v);//任何一个轻儿子都有一条它作为链头的重链
}
}
void pushup(int rt){
t[rt].val=(t[ls(rt)].val+t[rs(rt)].val)%p;
}
void pushdown(int l,int r,int rt){
t[ls(rt)].ad+=t[rt].ad;
t[rs(rt)].ad+=t[rt].ad;
int mid=(l+r)>>1;
t[ls(rt)].val+=(mid-l+1)*t[rt].ad;
t[ls(rt)].val%=p;
t[rs(rt)].val+=(r-mid)*t[rt].ad;
t[rs(rt)].val%=p;
t[rt].ad=0;
}
void build(int l,int r,int rt){
if(l==r){
t[rt].val=val[rnk[l]];
return;
}
int mid=(l+r)>>1;
build(l,mid,ls(rt));
build(mid+1,r,rs(rt));
pushup(rt);
}
ll qur(int l,int r,int ql,int qr,int rt){
if(ql<=l&&qr>=r){
return t[rt].val%p;
}
pushdown(l,r,rt);
int mid=(l+r)>>1;
ll res=0;
if(ql<=mid) res+=qur(l,mid,ql,qr,ls(rt));
if(qr>mid) res+=qur(mid+1,r,ql,qr,rs(rt));
pushup(rt);
return res%p;
}
void upd(int l,int r,int ql,int qr,int rt,ll c){
if(ql<=l&&qr>=r){
t[rt].ad+=c;
t[rt].val+=c*(r-l+1);
t[rt].val%=p;
return;
}
pushdown(l,r,rt);
int mid=(l+r)>>1;
if(ql<=mid) upd(l,mid,ql,qr,ls(rt),c);
if(qr>mid) upd(mid+1,r,ql,qr,rs(rt),c);
pushup(rt);
}
int main(){
scanf("%d%d%d%lld",&n,&m,&r,&p);
for(int i=1;i<=n;i++)
scanf("%lld",&val[i]);
for(int i=1;i<n;i++){
int x,y;
scanf("%d%d",&x,&y);
add(x,y);add(y,x);
}
dfs1(r,0);
dfs2(r,r);
build(1,n,1);
while(m--){
int op;
scanf("%d",&op);
if(op==1){
int x,y;ll z;
scanf("%d%d%lld",&x,&y,&z);
z%=p;
while(top[x]!=top[y]){//不在一条链上
if(dep[top[x]]<=dep[top[y]]) swap(x,y);
upd(1,n,dfn[top[x]],dfn[x],1,z);//处理当前链
x=fa[top[x]];//链头深的向上跳
}
if(dep[x]>=dep[y]) swap(x,y);
upd(1,n,dfn[x],dfn[y],1,z);//最后在同一条链上
}
else if(op==2){
int x,y;
scanf("%d%d",&x,&y);
ll ans=0;
while(top[x]!=top[y]){
if(dep[top[x]]<=dep[top[y]]) swap(x,y);
ans+=qur(1,n,dfn[top[x]],dfn[x],1);
ans%=p;
x=fa[top[x]];
}
if(dep[x]>=dep[y]) swap(x,y);
ans+=qur(1,n,dfn[x],dfn[y],1);
ans%=p;
printf("%lld\n",ans);
}
else if(op==3){
int x;ll z;
scanf("%d%lld",&x,&z);
z%=p;
upd(1,n,dfn[x],dfn[x]+siz[x]-1,1,z);
}
else{
int x;
scanf("%d",&x);
ll ans=qur(1,n,dfn[x],dfn[x]+siz[x]-1,1);
printf("%lld\n",ans);
}
}
return 0;
}
树形\(DP\)
树上最大独立集
P1352 没有上司的舞会 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
最大独立集的定义为:不能取相邻节点的情况下,所选取点集的最大权值。
在树形\(DP\)更新时,要自底向上更新,因为我们更新根节点时需要其子树的值,这需要我们先搜索子树再返回更新根节点。
\(dp[u][1],dp[u][0]\)分别表示选或不选\(u\)点的最大权。
void dfs(int x,int fa){
for(int i=hd[x];i;i=e[i].nxt){
int v=e[i].to;
if(v==fa) continue;
dfs(v,x);
dp[x][0]+=max(dp[v][0],dp[v][1]);
dp[x][1]+=dp[v][0];
}
}
对于基环树的最大权独立集。
[P2607 ZJOI2008] 骑士 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
我们只需要断掉环上任意一边,变成树然后再树形\(DP\)求最大权独立集。
对于两种最大权独立集都要注意可能是森林,或者基环树森林,所以我们记\(vis\)表示是否遍历,从而求得每个连通块的答案。
带权最大路径和
树上节点有点权,定义路径权值为路径上点权和,求最大路径和。
我们设\(dp_i\)为\(i\)子树内的点\(v\)延伸到\(i\)的链的最大权值和。
#include <bits/stdc++.h>
using namespace std;
int t,n,dp[200005],ans;
vector<int>G[200005];
void dfs(int u,int fa){
int w=G[u].size()-2;
dp[u]=w;
ans=max(ans,w+2);
for(auto v:G[u]){
if(v==fa) continue;
dfs(v,u);
ans=max(ans,dp[u]+dp[v]+2);//此时dp[u]是从v子树外得到的最大值
dp[u]=max(dp[u],w+dp[v]);//判断是原子树更优还是v子树更优
}
}
void solve(){
cin>>n;
for(int i=1;i<=n;i++) G[i].clear();
ans=-1;
for(int i=1;i<=n;i++) dp[i]=0;
for(int i=1;i<n;i++){
int u,v;
cin>>u>>v;
G[u].push_back(v);
G[v].push_back(u);
}
dfs(1,0);
cout<<ans<<endl;
}
int main(){
cin>>t;
while (t--) {
solve();
}
return 0;
}
树的子树个数
P2796 Facer的程序 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
记\(f(u)\)为以\(u\)为根的子树数量,对于每个节点,可以选择子节点\(v\)的\(f(v)\)种形态,也可以不选。则\(f(u)=\prod_{v\in son(i)}(f(v)+1)\)。考虑所有子节点都不选,对于子树块的贡献是\(u\)节点自身。
最小点覆盖
P2016 战略游戏 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
对于图\(G(V,E)\),若\(V'\subseteq V\),且\(\forall e\in E\),满足\(e\)的至少一个端点在\(V'\)中,则称\(V'\)是一个点覆盖。
在图中选取尽可能少的点,使得图中每条边的端点都至少有一个点被选中。转化成例题的问题就是,在\(u\)点放置士兵,可以看守\((u,v),v\in son(u)\),求最少多少士兵可以看守完。
记\(f_{i,0|1}\)为最小点覆盖\(i\)的子树的数量,\(0\)表示\(i\)点不放置,\(1\)表示\(i\)点放置。
即\(u\)点不放,\(v\)点全要放。\(u\)点放,\(v\)点可放可不放。
最小支配集
[P2899 USACO08JAN] Cell Phone Network G - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
对于图\(G(V,E)\),若\(V'\subseteq V\),且\(\forall v\in (V/V'),\exist (u,v)\in E\),满足\(u\in V'\),则称\(V'\)是一个支配集。
对于图\(G(V,E)\),设\(V'\)是\(G\)的一个支配集,则对于图中任意一个顶点\(u\),不是属于\(V'\),就是与\(V'\)中的点相连。
在\(V'\)中除去任何元素后\(V'\)不再是支配集,则支配集\(V'\)是极小支配集。称\(G\)中所有支配集中顶点个数最少的支配集为最小支配集,最小支配集中的顶点个数称为支配数。
一个点可能自己就在支配集里,或者被父节点支配,或者被子节点支配,所以我们记\(dp_{i,0|1|2}\),\(0\)表示\(i\)属于支配集,\(1\)表示被父节点支配,\(2\)表示被子节点支配。
但是如果每个\(dp_{i,2}\)都是由\(dp_{son(i),2}\)更新,则没有点被支配,我们需要额外考虑,记\(p=min(dp_{son(i),0}-dp_{son(i),2})\),如果发现都是\(2\)更新的,就最后加上\(p\),就相当于将一个\(dp_{son(i),2}\)强制转换成\(dp_{son(i),0}\),并保证最小。
\(flag=0\)表示\(dp_{i,2}\)全由\(dp_{son(i),2}\)转移。\(flag=1\)表示\(dp_{i,2}\)中间由\(dp_{son(i),0}\)转移。
#include<bits/stdc++.h>
using namespace std;
const int maxn=1e5+5;
typedef long long ll;
const ll mod=1000000007;
int hd[maxn],cnt,d[maxn],n,t;
ll dp[maxn][3];
struct edge{
int u,to,nxt;
}e[maxn<<1];
void add(int u,int v){
e[++cnt].nxt=hd[u];
e[cnt].to=v;
e[cnt].u=u;
hd[u]=cnt;
}
void dfs(int u,int fa){
dp[u][0]=1;
int flag=0;
ll p=2147483647;
for(int i=hd[u];i;i=e[i].nxt){
int v=e[i].to;
if(v==fa) continue;
dfs(v,u);
dp[u][0]+=min(dp[v][0],min(dp[v][1],dp[v][2]));
dp[u][1]+=min(dp[v][0],dp[v][2]);
if(dp[v][0]<=dp[v][2]){
flag=1;
dp[u][2]+=dp[v][0];
}
else{
dp[u][2]+=dp[v][2];
p=min(p,dp[v][0]-dp[v][2]);
}
}
if(!flag) dp[u][2]+=p;
}
int main(){
cin>>n;
for(int i=1;i<n;i++){
int u,v;
scanf("%d%d",&u,&v);
add(u,v);
add(v,u);
}
dfs(1,0);
cout<<min(dp[1][0],dp[1][2])<<endl;
return 0;
}
基环树
定义
有\(n\)个点\(n\)条边的连通图,如果不保证联通,那么就变成基环树森林。如果我们把环中任意一边删除,就会得到一棵树;如果把这个环整个断掉,会得到森林。
内向树和外向树
如果基环树的每个点都有且仅有一条出边,那么我们定义为内向树。
如果基环树的每个点都有且仅有一条入边,那么我们定义为外向树。
基环树问题的两种处理方法
1.将环提出来,变成一个环挂上多棵子树的形态,然后对每棵子树操作,将信息合并到环上的节点,将基环树的问题变为处理环上问题。
2.将环上一条边删去,先对这棵树操作,再考虑信息合并。
(待施工)基环树直径
考虑基环树直径可能存在的几种形态。
1.直径完全被包含在广义根的子树中。
2.直径跨过了环。
基环树两点间距离
对于环上的点定义为基环树的广义根,以环上的每个点作为根,求其子树内点的信息。所以我们先找出环上的点,再以每个点为根做\(dfs\),处理出\(lca\)所需要的所有信息,记录上子树上的点属于哪棵子树。存在两种路径,
1:路径完全在环上点的子树内,直接做\(lca\)。
2.路径经过环,先求出\(dis(u,i),dis(v,j)\),\(i,j\)表示子树根,再加上环上最短路径。
void find(int x,int fa){
vis[x]=1;
if(flag) return;
for(int i=hd[x];i;i=e[i].nxt){
int v=e[i].to;
if(v==fa) continue;
if(vis[v]){
st=v;E=i;
flag=1;
return;
}
else find(v,x);
}
}
void viscircle(int u,int pre){
for(int i=hd[u];i;i=e[i].nxt){
int v=e[i].to;
if((i^1)==pre||i==pre) continue;
if(id[v]) return;
id[v]=++tot;
viscircle(v,i);
}
}
具体查询时,对于路径\(u->v\),分别找到\(u,v\)对应的根\(rtu,rtv\),由\(lca\)求出\(dis(u,rtu),dis(v,rtv)\),环上路径由根的顺序编号求出,\(min\{abs(id[rtu]-id[rtv]),abs(tot-id[rtu]-id[rtv])\}\)。
基环树的最大权独立集
每次找不同的基环树,删掉环中的一条边,设这条边的两端为\(u_i,v_i\),则最大权独立集取\(max\{dp[u_i][0],dp[v_i][0]\}\)。
以端点为根时,不取这个根,因为如果取这个根,另一端点可能不能取;将这个问题反过来想,需要两个端点不同时选,只需要每个端点为根时不选,剩下的端点随意,求一个最大权独立集即可。
有向图基环树:
//找环
int find(int x){//每次向父节点跳
vis[x]=1;//跳过即标记
int fa=f[x];
if(vis[fa]) return fa;//如果跳到一个曾经标记的点,说明这个点在环上
else return find(fa);
}
//求最大权独立集
void dfs(int x){
dp[x][0]=0;
dp[x][1]=a[x];//初始化
vis[x]=1;
for(int i=0;i<G[x].size();i++){
int v=G[x][i];
if(v==mk) continue;//因为假定删掉这条边了,所以从x点不能再回到mk
dfs(v);
dp[x][0]+=max(dp[v][0],dp[v][1]);
dp[x][1]+=dp[v][0];
}
}
1for(int i=1;i<=n;i++){
if(!vis[i]){//可能是基环树森林,每次找环和求最大权独立集时都将整个连通图内的点打上标记,所以未标记的即为另一连通图
mk=find(i);//找这张图内环上的点
ll res=0;
dfs(mk);
res=max(res,dp[mk][0]);//钦定断边的两端不选
mk=f[mk];//找mk的父节点,相当于断这条边
dfs(mk);
res=max(res,dp[mk][0]);
ans+=res;
}
}
无向基环树:
1.拓扑排序找环
基环树只有一个环,所以我们可以用拓扑找环。如果一张图里有多个环,这种方法会漏掉很多环。
//找环
queue<int>q;
for(int i=1;i<=n;i++) if(ind[i]==1) q.push(i);
void topofind(){
while(!q.empty()){
int u=q.front();
q.pop();
vis[u]=1;
for(int i=hd[u];i;i=e[i].nxt){
int v=e[i].to;
ind[v]--;
if(ind[v]==1) q.push(v);
}
}
for(int i=1;i<=n;i++) if(ind[i]>1&&vis[i]) circle[i]=cnt;
}
2.\(dfs\)方法找环
\(dfs\)记录两个状态,当前点\(u\),上一条边的编号\(pre\)。对于无向图,存边时正反边是相邻的,对于第\(i\)条边,\(i\ xor\ 1\)即为其反向边的编号。
记录\(vis\)数组,不搜索已搜索的点。记\(flag\)表示是否找到环,如果找到了就结束。
int x_1,x_2;
int flag=0;
void find(int x,int fa){
vis[x]=1;
if(flag) return;
for(int i=0;i<G[x].size();i++){
int v=G[x][i];
if(v==fa) continue;
if(vis[v]){
x_1=x;x_2=v;
flag=1;
return;
}
else find(v,x);
}
}
3.\(dfs\)记录时间戳找环
记录最早访问\(u\)点的时间戳\(dfn[u]\)。相当于将\(dfs(u,fa)\)中的\(fa\)状态减掉,改为用\(dfn\)的前后判断。
void get_loop(int u) {
dfn[u] = ++ idx;
for (int i = 0; i < G[u].size(); i ++) {
int v = G[u][i];
if(v == fa[u]) continue ;
if(dfn[v]) {
if(dfn[v] < dfn[u]) continue ;//v访问比u早,说明定根的情况下v是u的祖先,不能返祖
loop[++ cnt] = v;
for ( ; v != u; v = fa[v])
loop[++ cnt] = fa[v];
} else fa[v] = u, get_loop(v);
}
}
4.求最大权独立集
如果按\(dfs(u,fa)\)为状态的话,从环的某个点向其两侧出发,在这种\(dfs\)的状态上是不同的,但是仍会回到出发点,导致计算重复。解决这个问题有两个方法。
一是修改状态,\(dfs\)记录两个状态,当前点\(u\),上一条边的编号\(pre\)。
void dfs(int x,int pre)
{
dp[x][0]=0;
dp[x][1]=a[x];
for (int i=hd[x];i;i=e[i].nxt){
int v=e[i].to;
if ((i^1)==pre) continue;//双向边,不要走回去
if (i==E || (i^1)==E)//E为记录的断边编号,不走断边
continue;
dfs(v,i);
dp[x][1]+=dp[v][0];
dp[x][0]+=max(dp[v][1],dp[v][0]);
}
}
二是开\(vis\)数组,每次搜索前都清空,保证不搜索已搜过的点。
具体仍要记录断边的编号\(E\),不走断边。对于不走双向边回去,改为用\(vis\)数组判断。无向图\(dfs\ dp\)时需要\(vis\)判双向边,所以每次都要记得清空数组,或者开两个\(vis\)数组。

浙公网安备 33010602011771号