Loading

树上问题

0. 树基础

详见 树基础

1. 树上简单问题

1.0 基础定义

  • \(son(u)\) 表示 \(u\) 的儿子节点。
  • \(LCA(x,y)\) 表示 \(x\)\(y\) 的最近公共祖先。
  • \(deep_x\) 表示节点 \(x\) 的深度。

1.1 树的重心

1.1.0 定义

树的重心也叫树的质心。它是这样的一个节点:对于一棵树 \(n\) 个节点的无根树,将无根树变为以它为根的有根树时,最大子树的结点数最小

换句话说,删除这个点后最大连通块(一定是树)的结点数最小。

1.1.1 性质

  1. 某个点是树的重心等价于它最大子树大小不大于整棵树大小的一半

  2. 至多有两个重心。如果树有两个重心,那么它们相邻。此时树一定有偶数个节点,且可以被划分为两个大小相等的分支,每个分支各自包含一个重心。

  3. 树中所有点到某个点的距离和中,到重心的距离和是最小的;如果有两个重心,那么到它们的距离和一样。反过来,距离和最小的点一定是重心。

  4. 往树上增加或减少一个叶子,如果原节点数是奇数,那么重心可能增加一个,原重心仍是重心;如果原节点数是偶数,重心可能减少一个,另一个重心仍是重心

  5. 把两棵树通过一条边相连得到一棵新的树,则新的重心在较大的一棵树一侧的连接点原重心之间的简单路径上。如果两棵树大小一样,则重心就是两个连接点。

1.1.2 求法

size[u] 表示以 \(u\) 为根的子树大小,\(v\in son(u)\)

显然把结点 \(u\) 删除后会出现两种连通块,一种是以 \(v\) 为根的子树,一种是在 \(u\) 上面的子树,他们的大小分别为 size[v] 以及 n-size[u]

根据性质1以及性质2可得以下代码。

void dfs(int u,int fa){
    int maxs=0; size[u]=1;
    for(int i=h[u];i;i=e[i].nxt){
        int v=e[i].v;
        if(v==fa) continue;
        dfs(v,u);
        size[u]+=size[v];
        maxs=max(maxs,size[v]);
    }
    maxs=max(maxs,n-size[u]);
    if(maxs<=n>>1) ctr[ctr[0]!=0]=u;
}

1.1.3 性质证明

算法学习笔记(72): 树的重心

1.1.4 习题

1.2 树的直径

1.2.0 定义

树上任意两节点之间最长的简单路径即为树的直径。

1.2.1 性质

  1. 直径两端点一定是两个叶子节点
  2. 距离任意点最远的点一定是直径的一个端点(边权非负)。
  3. 对于两棵树,如果第一棵树直径两端点为 \((u,v)\),第二棵树直径两端点为 \((x,y)\),用一条边将两棵树连接,那么新树的直径端点一定是 \(u,v,x,y\) 其中的两个点。
  4. 对于一棵树,如果在一个点的上接一个叶子节点,那么最多会改变直径的一个端点。
  5. 若一棵树存在多条直径,那么这些直径交于一点且交点是这些直径的中点

1.2.2 求法1

根据性质2,可以先做一次 DFS得到直径的任意的一个端点,然后在进行一次DFS得到另外一个端点。

这种求法在有负边权下将会失效。

核心代码如下:

void dfs(int u,int fa){ // c表示找到的端点
	for(int i=h[u];i;i=e[i].nxt){
		int v=e[i].v;
		if(v==fa) continue;
		dis[v]=dis[u]+e[i].w;
		if(dis[v]>dis[c]) c=v;
		dfs(v,u);
	}
}
int main(){
	scanf("%d",&n);
	for(int i=1,u,v;i<n;i++) scanf("%d%d",&u,&v),add(u,v),add(v,u);
	dfs(1,0),dis[s=c]=0;
    dfs(s,0),t=c; // s,t代表直径的两个端点
    return 0;
}

1.2.3 求法2

树形dp,设 \(d1[u],d2[u]\) 分别表示以 \(u\) 为根的子树下所能延伸的最长路和次长路。

显然最后的答案就是 \(max_{u=1}^n d1[u]+d2[u]\)

核心代码如下:

void dp(int u,int fa){
	for(int i=h[u];i;i=e[i].nxt){
		int v=e[i].v;
		if(v==fa) continue;
		dp(v,u);
		int t=d1[v]+e[i].w;
		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]);
}

1.2.4 例题

[APIO2010] 巡逻

因为这是一棵树,没有环,所以要巡逻一遍并回到起点,那么每条边肯定要经过两遍,所以不加边的答案是 \(2(n-1)\)

观察数据范围发现 \(k\le2\) ,所以考虑分类讨论:

  • \(k=1\) 时,因为这是一棵树,显然加一条边一定会出现一个环;因为是这是一个环,所以每条边只需要走一遍就可以最到起点

    所以我们就考虑让环尽量大就好了,显然就是给树的直径的两个端点连边。

    \(d\) 表示树的直径,那么最终答案为 \(2(n-1)-d+1\)

  • \(k=2\) 时,最终图中肯定会出现两个环,而这两个环重合的部分肯定要走两遍

    首先 \(k=1\) 情况下造出的环一定优,但是你再造一个环与其重合,这不就是破坏他人的劳动成果嘛,这样贡献是 \(-1\)

    所以呢,我们只要把第一条直径上边的边权改成 \(-1\) 再计算直径就好啦。

    这样可以保证两个环不重合,贡献也是对的。

    如果两次计算的直径长度分别为 \(d1,d2\) 那么最终答案就是 \(2(n-1)-d_1-d_2+2\)

tips:链式前向星找反边可以直接将编号异或 \(1\) (当然第一条边的编号为偶数)。

#include<iostream>
#include<algorithm>
using namespace std;
const int N=1e5+5;
int cnt=1,h[N];
struct Edge{
	int v,w,nxt;
}e[N<<1];
int n,k,d,c,s,t,d1[N],d2[N],dis[N];
inline void add(int u,int v){
	e[++cnt]={v,1,h[u]};
	h[u]=cnt;
}
void dp(int u,int fa){
	for(int i=h[u];i;i=e[i].nxt){
		int v=e[i].v;
		if(v==fa) continue;
		dp(v,u);
		int t=d1[v]+e[i].w;
		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]);
}
void dfs(int u,int fa){
	for(int i=h[u];i;i=e[i].nxt){
		int v=e[i].v;
		if(v==fa) continue;
		dis[v]=dis[u]+e[i].w;
		if(dis[v]>dis[c]) c=v;
		dfs(v,u);
	}
}
bool mark(int u,int fa){
	if(u==t) return 1;
	for(int i=h[u];i;i=e[i].nxt){
		int v=e[i].v;
		if(v==fa) continue;
		if(mark(v,u)) {
			e[i].w=-1;
			e[i^1].w=-1;
			return 1;
		}
	}
}
int main(){
	scanf("%d%d",&n,&k);
	for(int i=1,u,v;i<n;i++) scanf("%d%d",&u,&v),add(u,v),add(v,u);
	dfs(1,0);
	dis[s=c]=0,dfs(c,0),t=c;
	if(k==1){
		printf("%d",(n-1)*2-dis[t]+1);
		return 0;
	}
	mark(s,0);
	dp(1,0);
	printf("%d",(n-1)*2-dis[t]-d+2);
	return 0;
}

2. LCA

2.0 定义

  • 公共祖先:在一棵有根树上,若 \(F\) 同时是 \(x\)\(y\) 的祖先,那么就成 \(F\)\(x\)\(y\) 的公共祖先。
  • 最近公共祖先(LCA): 最近公共祖先是 \(x\)\(y\) 的深度最大的公共祖先。

2.1 求法

模板题链接 P3379

以下内容讨论如何求 \(LCA(x,y)(dep_x\ge dep_y)\)

2.1.1 倍增

求LCA有一个朴素的思想,分两步。

  1. \(x\) 往上跳,使得 \(deep_x=deep_y\)
  2. \(x\)\(y\) 同步跳,直到他们在节点 \(d\) 相遇,那么显然 \(LCA(x,y)=d\)

这个方法时间复杂度为 \(O(n)\) ,太慢了,我们考虑用倍增优化。

先来看步骤一怎么优化。

假设 \(x\) 跳到第 \(a\) 个祖先停下了,使得 \(x\)\(y\) 的深度相等。

我们不放把 \(a\) 拆成2进制来看,它可能是这样的:

\((11011111101010010)_2\)

我们发现它最多只有 \(\lfloor \log_2a \rfloor\)1

所以,我们可以考虑从最左边开始放 1(即加上它在十进制下的位权)。

而且不难发现,只要放下这个 1 后得出来的值不大于 \(a\) ,那么这个地方就一定是 1 ,否则为 0

我们 \(fa[u][k]\) 表示节点 \(u\)\(2^i\) 个祖宗,每次往上跳 \(2^i\) 次, 我们只需要看一下他跳上去的深度是否不大于 \(dep_y\) ,是就代表没跳过头,那么二进制下 \(a\) 这个位置就是 1 ,需要跳上去。

for(int i=18;~i;i--) if(dep[x]-(1<<i)>=dep[y]) x=fa[x][i];

现在来看步骤二怎么优化。

如果直接往 \(LCA(x,y)\) 上跳的话,因为我们不能判断有没有跳过头(是不是大于 \(a\),所以好像不能用倍增优化,怎么办呢?

注意到 \(LCA(x,y)\) 以上的节点都是 \(x\)\(y\) 的公共祖先,所以我们可以很容易的判断有没有跳过 \(son(LCA(x,y))\) ,那么解法就呼之欲出了。

for(int i=18;~i;i--) if(fa[x][i]!=fa[y][i]) x=fa[x][i],y=fa[y][i];

最后还有一个问题没解决,就是 \(fa\) 数组怎么求?显然有 \(fa[u][k]=fa[fa[u][k-1]][k-1]\),刚开始预处理一下就好了。

#include<bits/stdc++.h>
using namespace std;
const int N=5e5+5;
vector<int> e[N];
int n,m,s,fa[N][19],dep[N];
void dfs(int u,int f){ // 预处理
    fa[u][0]=f,dep[u]=dep[f]+1;
    for(int i=1;i<19;i++) fa[u][i]=fa[fa[u][i-1]][i-1];
    for(int v:e[u]) if(v!=f) dfs(v,u);
}
int lca(int x,int y){
    if(dep[x]<dep[y]) swap(x,y);
    for(int i=18;~i;i--) if(dep[x]-(1<<i)>=dep[y]) x=fa[x][i];
    if(x==y) return x; //记得判断一下
    for(int i=18;~i;i--) if(fa[x][i]!=fa[y][i]) x=fa[x][i],y=fa[y][i];
    return fa[x][0];
}
int main(){
    ios::sync_with_stdio(0),cin.tie(0),cout.tie(0);
    cin>>n>>m>>s;
    for(int i=1,u,v;i<n;i++) cin>>u>>v,e[u].push_back(v),e[v].push_back(u);
    dfs(s,0);
    for(int i=1,u,v;i<=m;i++) cin>>u>>v,cout<<lca(u,v)<<'\n';
    return 0;
}

在本题中倍增的时间复杂度为 \(O(n+m\log_2n)\) ,空间复杂度 \(O(n\log_2n)\)

2.1.2 Tarjan(到时候再填坑)

2.1.3 树链剖分

3. 参考资料

posted @ 2023-04-09 18:34  szy_dxf  阅读(76)  评论(0)    收藏  举报