树的直径与LCA

树的直径与LCA

树的直径

定义:设\(dis[i,j]\)表示\(i,j\)在树中的距离,则树的直径(\(diameter\),本文简记\(dia\))\(dia=dis[u,v](\forall i,j,dis[i,j]\le dis[u,v])\),通俗的讲,树的直径是树中最长的一条链
性质:

  1. 一棵树可能有不止一个直径
  2. 一棵树的直径有唯一的中点
  3. 我们称树的不同直径的公共边为必须边
  4. 树的所有必须边构成一条链
    关于性质4的证明:
    反证法,我们假设必须边构成两条链
    由必须边和树的直径的定义,这两条链一定在树的一条直径\(F\)上,那么设这两条链中间部分路径为\(T\),\(F\)上两条链中间为\(S\),则有\(val(T)=val(S)\),而由树的直径的定义,\(val(S)\)最大且唯一,以此类推n条链的情况,可知假设不成立,原命题成立

树的直径的求法
1.DP法,使用较少,掌握BFS就可以了

void dp(int x) {
    v[x] = 1;
    for (int i = head[x]; i; i = Next[i]) {
        int y = ver[i];
        if (v[y]) continue;
        dp(y);
        ans = max(ans, d[x] + d[y] + edge[i]);
        d[x] = max(d[x], d[y] + edge[i]);
    }
}

2.DFS/BFS法
概述,我们先任选一个节点,假设1,求出所有节点到1的距离d,然后找到d值最大的节点p,再求出所有节点到p的距离d1,d1值最大的节点q,p->q的路径就是树的直径
证明:我们p节点就是树的最深的一端,然后以p为根再求出q,我们就相当于求出了树的两个最深的端点,连起来就是答案,证明限于篇幅,感性理解就行
对于这个求d数组的过程,使用DFS/BFS都可以,一般来讲BFS已经足够,DFS就略去,反正也一样的道理

tot=1;
int bfs(int t){
	memset(d,-1,sizeof d);
	d[t]=0;
	queue<int>q;q.push(t);
	while(q.size()){
		int u=q.front();q.pop();
		for(int i=head[u];i;i=nxt[i]){
			int v=ver[i];
			if(d[v]!=-1)continue;
			lst[v]=i;
			d[v]=d[u]+cost[i];
			q.push(v);
		}
	}
	int p=-1;
	for(int i=1;i<=n;i++)if(p==-1||s[p]<s[i])p=i;
	return p;
}
//主函数内调用
dfs(1);
int p=find();
dfs(p);
int q=find();
while(p!=q){  
    b[++cnt]=q;
    q=ver[lst[q]^1];
}
b[++cnt]=p;
reverse(b+1,b+cnt+1);
//求树的直径整个链,因为倒着回来,所以在讲究顺序的时候需要翻转

例题1树网的核

题目描述

\(T=(V,E,W)\) 是一个无圈且连通的无向图(也称为无根树),每条边都有正整数的权,我们称 \(T\) 为树网(treenetwork),其中 \(V\)\(E\) 分别表示结点与边的集合,\(W\) 表示各边长度的集合,并设 \(T\)\(n\) 个结点。

路径:树网中任何两结点 \(a\)\(b\) 都存在唯一的一条简单路径,用 \(d(a, b)\) 表示以 \(a, b\) 为端点的路径的长度,它是该路径上各边长度之和。我们称
\(d(a, b)\)\(a, b\) 两结点间的距离。

\(D(v, P)=\min\{d(v, u)\}\), \(u\) 为路径 \(P\) 上的结点。

树网的直径:树网中最长的路径成为树网的直径。对于给定的树网 \(T\),直径不一定是唯一的,但可以证明:各直径的中点(不一定恰好是某个结点,可能在某条边的内部)是唯一的,我们称该点为树网的中心。

偏心距 \(\mathrm{ECC}(F)\):树网 \(T\) 中距路径 \(F\) 最远的结点到路径 \(F\) 的距离,即

\(\mathrm{ECC}(F)=\max\{D(v, F),v \in V\}\)

任务:对于给定的树网 \(T=(V, E, W)\) 和非负整数 \(s\),求一个路径 \(F\),他是某直径上的一段路径(该路径两端均为树网中的结点),其长度不超过 \(s\)(可以等于 \(s\)),使偏心距 \(\mathrm{ECC}(F)\) 最小。我们称这个路径为树网 \(T=(V, E, W)\) 的核(Core)。必要时,\(F\) 可以退化为某个结点。一般来说,在上述定义下,核不一定只有一个,但最小偏心距是唯一的。

下面的图给出了树网的一个实例。图中,\(A-B\)\(A-C\) 是两条直径,长度均为 \(20\)。点 \(W\) 是树网的中心,\(EF\) 边的长度为 \(5\)。如果指定 \(s=11\),则树网的核为路径DEFG(也可以取为路径DEF),偏心距为 \(8\)。如果指定 \(s=0\)(或 \(s=1\)\(s=2\)),则树网的核为结点 \(F\),偏心距为 \(12\)

输入格式

\(n\) 行。

\(1\) 行,两个正整数 \(n\)\(s\),中间用一个空格隔开。其中 \(n\) 为树网结点的个数,\(s\) 为树网的核的长度的上界。设结点编号以此为 \(1,2\dots,n\)

从第 \(2\) 行到第 \(n\) 行,每行给出 \(3\) 个用空格隔开的正整数 \(u, v, w\),依次表示每一条边的两个端点编号和长度。例如,2 4 7 表示连接结点 \(2\)\(4\) 的边的长度为 \(7\)

输出格式

一个非负整数,为指定意义下的最小偏心距。

分析

仔细观察题目,我们可以得到如下性质

  1. 对于两条路径\(S1,S2\),若\(S1\in S2\),则\(ECC(S2)\le ECC(S1)\),对于这个性质,我们可以得到一个很强的推论,即在长度不超过s的情况下,路径越长越好
  2. 最小偏心距具有单调性
  3. 不同直径求出来的最小偏心距相同,证明:不同直径有唯一中点,那么对于两条不同直径,我们可以通过组合变成四条直径,也就是把原本两条直径按中点砍成两半一共四个链,此时一定存在两组链长度相等,根据直径的最长性,不同组上的链上的点u的D值就在另一组的两条链的末端都满足,进一步可以扩展到n条链,得证
    于是我们得到一个类似DP的算法
    先求出树的直径,设直径上的点集为\(u_1\sim u_t\),d数组也可以被我们\(O(n)dfs\)预处理出来,表示不经过直径上的其他节点在树上的最远距离,则将题目所给偏心距公式可写为
    \(u_i,u_j\)为两个端点的树网的核的偏心距为

\[\max_{1\le i\le j\le t,dis(i,j)\le s}\left(\max_{i\le k \le j}\lbrace u_k\rbrace,dis(u_1,u_i),dis(u_j,u_t)\right) \]

由于\(i\le j,i,j\)都是单调递增的,满足使用单调队列优化的性质,于是我们采用单调队列对式子\(\max_{i\le k\le j}\lbrace u_k \rbrace\)进行优化,就可以做到\(O(n)\)
但我们还可以进一步进行优化,由于直径的最长性,任何从直径上的点\(u_i,u_j\)分叉出去的子树,对于\(u_j\)的距离不可能比\(dis(u_i,u_j)\)更大,否则直径就该换得了,于是\(\max_{i\le k\le j}\lbrace u_k \rbrace可化为\max_{1\le k\le t}\lbrace u_k \rbrace\),于是原式可变为

\[\max_{1\le i\le j\le t,dis(i,j)\le s}\left(\max_{1\le k\le t}\lbrace u_k \rbrace,dis(u_1,u_i),dis(u_j,u_t)\right) \]

至于\(i,j\)的枚举,采用双指针扫描法可以在\(O(n)\)的复杂度搞出来
综上,我们得到了一个\(O(n)\)的优秀算法(据说NOIP原题数据范围\(n\le 300\))

int ver[1000005],nxt[1000005],head[500005],cost[1000005],tot=1,d[1000005],f[1000005],pre[1000005],vis[1000005],n,s,mx=0xcfcfcfcf,ans=0x3f3f3f3f;
int t[1000005],num,sum[1000005],b[1000005],a[1000005];
void add(int u,int v,int w){
	nxt[++tot]=head[u],ver[tot]=v,cost[tot]=w,head[u]=tot;
}
queue<int>p;//新词:窥屏
int bfs(int s){
	memset(d,-1,sizeof d);
	p.push(s);d[s]=0;
	while(p.size()){
		int u=p.front();p.pop();
		for(int i=head[u];i;i=nxt[i]){
			int v=ver[i];
			if(d[v]!=-1)continue; 
			d[v]=d[u]+cost[i];
			pre[v]=i;
			p.push(v);
		}
	}
	int q=-1;
	for(int i=1;i<=n;i++)if(q==-1||d[q]<d[i])q=i;
	return q; 
}
void dfs(int u){
	vis[u]=1;
	for(int i=head[u];i;i=nxt[i]){
		int v=ver[i];
		if(vis[v])continue;
		dfs(v);
		f[u]=max(f[u],f[v]+cost[i]);
	}
}
int main(){
	scanf("%d%d",&n,&s);
	for(int i=1;i<n;i++){
		int u,v,w;
		scanf("%d%d%d",&u,&v,&w);
		add(u,v,w);
		add(v,u,w);
	}
	int p=bfs(1);
	int q=bfs(p);
	while(p!=q){
		a[++num]=q;
		b[num+1]=cost[pre[q]];
		q=ver[pre[q]^1];
	}
	a[++num]=p;
	for(int i=1;i<=num;i++)vis[a[i]]=1;
	for(int i=1;i<=num;i++){
		dfs(a[i]);
		mx=max(mx,f[a[i]]);
		sum[i]=sum[i-1]+b[i];
	}
	for(int i=1,j=1;i<=num;i++){
		while(j<num&&sum[j+1]-sum[i]<=s)j++;
		ans=min(ans,max(mx,max(sum[i],sum[num]-sum[j])));
	}
	printf("%d\n",ans);
	return 0;
}

例题2直径

题目描述

小Q最近学习了一些图论知识。根据课本,有如下定义。树:无回路且连通的无向图,每条边都有正整数的权值来表示其长度。如果一棵树有\(N\)个节点,可以证明其有且仅有\(N-1\) 条边。

路径:一棵树上,任意两个节点之间最多有一条简单路径。我们用 \(dis(a,b)\)表示点\(a\)和点\(b\)的路径上各边长度之和。称\(dis(a,b)\)\(a、b\)两个节点间的距离。

直径:一棵树上,最长的路径为树的直径。树的直径可能不是唯一的。

现在小Q想知道,对于给定的一棵树,其直径的长度是多少,以及有多少条边满足所有的直径都经过该边。

输入格式

第一行包含一个整数N,表示节点数。 接下来N-1行,每行三个整数a, b, c ,表示点 a和点b之间有一条长度为c的无向边。

输出格式

共两行。第一行一个整数,表示直径的长度。第二行一个整数,表示被所有直径经过的边的数量。

分析

我们上文提到的树的性质,第一问就不说了,板子。此题实际上就是让我们求必须边的数量,由必须边的性质:由所有的必须边组成一条链
于是我们的问题变成了如何在直径上找到这样一条链
直接寻找必须边比较复杂,我们可以采用容斥思想,找到所有的非必须边
设这棵树的点集为\(V\),我们定义一条直径上的点为\(u_1,u_2,…u_t\),组成集合\(D\),设\(d[u_i]\)表示点\(u_i\)不经过直径上的其他点所能在树中达到的最远距离,即\(d[u_i]=\max_{k\in(V-D)}(dis(u_i,k))\)
关于d的求法,与上题的dfs函数无二,复杂度\(O(n)\)
那么显而易见的,一条链\(u_i->u_j(i<j)\)是非必须边,当且仅当满足:\(dis(u_1,u_j)=d[u_j]或dis(u_t,u_j)=d[u_j]\),所以\(i\)这一维可以固定为1或t
于是我们可以用两个指针\(l,r\)分别从两端扫描,最后\(r-l\)就是答案

#include<bits/stdc++.h>
using namespace std;
#define int long long
int vis[2000005],lst[2000005],d[2000005],s[2000005],b[5000005],cnt,c[5000005],n;
int head[2000005],ver[5000005],nxt[5000005],cost[5000005],tot=1,e[1000005];
void add(int u,int v,int w){
	nxt[++tot]=head[u],ver[tot]=v,cost[tot]=w,head[u]=tot;
}
int bfs(int t){
	memset(s,-1,sizeof s);
	s[t]=0;
	queue<int>q;q.push(t);
	while(q.size()){
		int u=q.front();q.pop();
		for(int i=head[u];i;i=nxt[i]){
			int v=ver[i];
			if(s[v]!=-1)continue;
			lst[v]=i;
			s[v]=s[u]+cost[i];
			q.push(v);
		}
	}
	int p=-1;
	for(int i=1;i<=n;i++)if(p==-1||s[p]<s[i])p=i;
	return p;
}
void dfs(int u){
	vis[u]=1;
	for(int i=head[u];i;i=nxt[i]){
		int v=ver[i];
		if(vis[v])continue;
		dfs(v);
		d[u]=max(d[u],d[v]+cost[i]);
	}
}
int init(){
	int p=bfs(1);
	int q=bfs(p);
	printf("%lld\n",s[q]);
	while(p!=q){
		b[++cnt]=q;
		q=ver[lst[q]^1];
	}
	b[++cnt]=p;
	reverse(b+1,b+cnt+1);
	memset(vis,0,sizeof vis);
	for(int i=1;i<=cnt;i++)vis[b[i]]=1;
	for(int i=1;i<=cnt;i++){
		dfs(b[i]);
	}
	int l=1,r=cnt,cur=0; 
    for(int i=1;i<=cnt;i++){
        if(cur==d[b[i]])l=i;
        if(i<cnt)cur+=cost[lst[b[i+1]]];
    }
    cur=0; 
    for(int i=cnt;i>0;i--){
        if(cur==d[b[i]])r=i;
        if(i>0)cur+=cost[lst[b[i]]];
    }
	return r-l;
}
signed main(){
	//freopen("dia1.in","r",stdin);
	scanf("%lld",&n);
	for(int i=1;i<n;i++){
		int u,v,w;
		scanf("%lld%lld%lld",&u,&v,&w);
		add(u,v,w);
		add(v,u,w); 
	}
	printf("%lld",init());
}

最近公共祖先(LCA,Least Common Ancestors)

定义:对于节点\(x,y\),若节点\(z\)既是\(x\)的祖先,也是\(y\)的祖先,则称\(z\)\(x,y\)的公共祖先,\(LCA(x,y)\)就是\(x,y\)的公共祖先里深度最大的那一个
性质: \(LCA(x,y)\)\(x\)\(y\)的简单路径上深度最小的节点

\(LCA\)的方法有很多,朴素就不说了

在线做法:

  1. 树链剖分\(LCA\),预处理\(O(n)\),查询一次\(O(\log n)\)
  2. \(ST\)\(LCA\),预处理\(O(n\log n)\),查询\(O(1)\)
  3. 倍增\(LCA\),这里我们详细讲述这个,预处理\(O(n\log n),\)查询\(O(\log n)\)
  4. 其实\(RMQ\)问题\(ST\)做法还有优化,可以把时间复杂度优化至\(O(n)\),叫约束\(RMQ\),因为它满足相邻两个数最多差1,但代码实现太过复杂,常数也较大,对于\(10^6\)及以下的数据甚至不如树剖倍增,而\(10^6\)以上的数据就只得\(O(n)\)做了
    我们谈谈三种做法的优劣,只不过我们只详细讲倍增,其他两种只做了解
    在空间上,\(ST\)只需要一个\(2n\)级别的数组,然后需要一个\(2n\log MAX_N\)的f数组
    倍增需要一个队列,一个长度为\(n\)\(dep\)数组,一个\(2n\log MAX_N\)的f数组,总的和\(ST\)相差无几
    树剖需要\(son\)\(f\)\(dep\)等数组,但是空间复杂度严格\(O(n)\)
    从严格时间复杂度来说,我们假设询问次数与n同级
    ST>树剖>倍增
    在一般情况下,实际效率是树剖约等于倍增>ST
    倍增树剖常数极小,ST有点大
    但是树剖很容易写丑,倍增就那样
    实际从严格理论上,我记得某集训队大佬的一篇论文里有严格证明一般情况下树剖常数是倍增的\(\frac{1}{2}\)
    于是综合考量,树剖最优秀,代码也很短

1. 树链剖分LCA

思路:先预处理链之类的,然后对于两个点不断跳链直到跳到一条链上,此时深度较小的节点就是LCA

struct edge{
    int to,ne;
}e[1000005];
int n,m,s,ecnt,head[500005],dep[500005],siz[500005],son[500005],top[500005],f[500005];
void add(int x,int y){
    e[++ecnt].to=y;
    e[ecnt].ne=head[x];
    head[x]=ecnt;
}
void dfs1(int x){
    siz[x]=1;
    dep[x]=dep[f[x]]+1;
    for(int i=head[x];i;i=e[i].ne){
        int dd=e[i].to;
        if(dd==f[x])continue;
        f[dd]=x;
        dfs1(dd);
        siz[x]+=siz[dd];
        if(!son[x]||siz[son[x]]<siz[dd])
            son[x]=dd;
    }
}
void dfs2(int x,int tv){
    top[x]=tv;
    if(son[x])dfs2(son[x],tv);
    for(int i=head[x];i;i=e[i].ne){
        int dd=e[i].to;
        if(dd==f[x]||dd==son[x])continue;
        dfs2(dd,dd);
    }
}
int main(){
    scanf("%d%d%d",&n,&m,&s);
    for(int i=1;i<n;++i){
        int x,y;
        scanf("%d%d",&x,&y);
        add(x,y);
        add(y,x);
    }
    dfs1(s);
    dfs2(s,s);
    for(int i=1;i<=m;++i){
        int x,y;
        scanf("%d%d",&x,&y);
        while(top[x]!=top[y]){
            if(dep[top[x]]>=dep[top[y]])x=f[top[x]];
            else y=f[top[y]];
        }
        printf("%d\n",dep[x]<dep[y]?x:y);
    }
}

2. ST表

使用欧拉序,欧拉序是指在深度优先遍历整棵树的是时候,节点刚递归进入的时候标记,退出的时候再标记,这样就可以有一个性质,节点\(u\)的子树全部在序列两个u之间,于是我们就可以把倍增里左边的x和右边的y所构成的区间里求深度最小的节点,这个节点就是LCA(x,y)

3. 倍增LCA

\(f[i][k]\)表示节点\(i\)\(2^k\)级祖先,有\(f[i][k]=f[f[i][k-1]][k-1]\),初值\(f[i][0]=fa[i]\)
DP的顺序我们需要知道\(i\)节点的所有祖先,即\(i\)的所有祖先都被\(DP\)后再\(DPi\)节点,这种以深度为顺序的,就是广度优先遍历,于是我们可以使用\(BFS\)来实现这个\(DP\)过程

queue<int>q;
void bfs(int rt){
	q.push(rt);
	dep[rt]=1;
	while(q.size()){
		int u=q.front();q.pop();
		for(int i=head[u];i;i=nxt[i]){
			int v=ver[i];
			if(dep[v])continue;
			dep[v]=dep[u]+1;
			f[v][0]=u;
			for(int i=1;i<=t;i++){//t=log2(n)向上取整的结果
				f[v][i]=f[f[v][i-1]][i-1];
			}
			q.push(v);
		}
	}
} 

然后对于查询过程,我们先将\(x,y\)调整至同一深度,这里默认最初\(dep[y]>dep[x]\),即我们将y不断上拉,直到\(dep[y]=dep[x]\)此时有两种情况,即\(x\)本就是\(y\)的祖先,此时\(x=y\)直接返回即可,或者是我们同时将两个节点向上拉

int lca(int x,int y){
	if(dep[x]>dep[y])swap(x,y);
	for(int i=t;i>=0;i--)if(dep[f[y][i]]>=dep[x])y=f[y][i];
	if(x==y)return x;
	for(int i=t;i>=0;i--)if(f[x][i]!=f[y][i])x=f[x][i],y=f[y][i];
	return f[x][0];
}

离线做法(tarjan算法)

前言:stO tarjan Orz,tarjan是真牛
时间复杂度:\(O(n+m)\)
在任意时刻,深度优先遍历的节点分为三类

  1. 已经完全结束了回溯的节点,标记2
  2. 已经访问但未回溯,标记1
  3. 尚未访问到的节点,标记0

对于一个正在访问的节点\(x\),它的祖先的标记一定都是1,那么对于一个已经访问完成的节点\(y\),\(y\)向上走,走到的第一个标记为1的节点就是\(LCA(x,y)\)
对于这个过程,我们可以使用并查集进行优化,当一个节点\(x\)获得了2的标记时,我们将\(y\)\(fa(y)\)合并为一个集合(此时\(fa(y)\)肯定是集合的代表元,且\(fa(y)\)的标记为1)
这样我们执行\(find(y)\)查询代表元的时候,实际上就是查找到了第一个\(y\)的祖先中标记为1的节点,即\(LCA(x,y)\)

// Tarjan算法离线求LCA (模板题:HDOJ2586)
const int SIZE = 50010;
int ver[2 * SIZE], Next[2 * SIZE], edge[2 * SIZE], head[SIZE];
int fa[SIZE], d[SIZE], v[SIZE], lca[SIZE], ans[SIZE];
vector<int> query[SIZE], query_id[SIZE];
int T, n, m, tot, t;
void add(int x, int y, int z) {
	ver[++tot] = y; edge[tot] = z; Next[tot] = head[x]; head[x] = tot;
}
void add_query(int x, int y, int id) {
	query[x].push_back(y), query_id[x].push_back(id);
	query[y].push_back(x), query_id[y].push_back(id);
}
int get(int x) {
	if (x == fa[x]) return x;
	return fa[x] = get(fa[x]);
}
void tarjan(int x) {
	v[x] = 1;
	for (int i = head[x]; i; i = Next[i]) {
		int y = ver[i];
		if (v[y]) continue;
		d[y] = d[x] + edge[i];
		tarjan(y);
		fa[y] = x;
	}
	for (int i = 0; i < query[x].size(); i++) {
		int y = query[x][i];
		int id = query_id[x][i];
		if (v[y] == 2) {
			int lca = get(y);
			ans[id] = min(ans[id], d[x] + d[y] - 2 * d[lca]);
		}
	}
	v[x] = 2;
}
int main() {
	cin >> T;
	while (T--) {
		cin >> n >> m;
		for (int i = 1; i <= n; i++) {
			head[i] = 0;
			query[i].clear(), query_id[i].clear();
			fa[i] = i, v[i] = 0;
		}
		tot = 0;
		for (int i = 1; i < n; i++) {
			int x, y, z;
			scanf("%d%d%d", &x, &y, &z);
			add(x, y, z), add(y, x, z);
		}
		for (int i = 1; i <= m; i++) {
			int x, y;
			scanf("%d%d", &x, &y);
			if (x == y) ans[i] = 0;
			else {
				add_query(x, y, i);
				ans[i] = 1 << 30;
			}
		}
		tarjan(1);
		for (int i = 1; i <= m; i++) printf("%d\n", ans[i]);
	}
}

树上差分

在前缀和与差分中,我们实现了序列上的区间修改,单点查询问题,现在我们要对这个思想运用到树中,实现\(x-y\)的路径上修改,单点查询,这种操作被称为树上差分
原来的前缀和变成了子树和,区间操作对应路径操作

树上差分的两种形式

1.点权形式,将\(u->v\)的路径上的点权全部加上\(d\),我们的操作是将\(val[x]+=d,val[y]+=d,val[LCA(x,y)]-=d,val[fa(LCA(x,y))]-=d\)
这种操作的本质是\(LCA(x,y)\)也需要进行操作
2.边权形式,将\(u->v\)的路径上的边权全部加上\(d\),我们的操作是将\(val[x]+=d,val[y]+=d,val[LCA(x,y)]-=2*d\)
这种操作的本质是我们在操作的时候默认边权下放到了点权,这就使得\(LCA(u,v)->fa(LCA(u,v))\)的边权下放到了\(LCA(u,v)\),一个也不能统计到,于是需要减去两倍的\(d\)

例题1雨天的尾巴

深绘里一直很讨厌雨天。
灼热的天气穿透了前半个夏天,后来一场大雨和随之而来的洪水,浇灭了一切。
虽然深绘里家乡的小村落对洪水有着顽固的抵抗力,但也倒了几座老房子,几棵老树被连根拔起,以及田地里的粮食被弄得一片狼藉。
无奈的深绘里和村民们只好等待救济粮来维生。
不过救济粮的发放方式很特别。首先村落里的一共有 \(n\) 座房屋,并形成一个树状结构。然后救济粮分 \(m\) 次发放,每次选择两个房屋 \((x,~y)\),然后对于 \(x\)\(y\) 的路径上(含 \(x\)\(y\))每座房子里发放一袋 \(z\) 类型的救济粮。

然后深绘里想知道,当所有的救济粮发放完毕后,每座房子里存放的最多的是哪种救济粮。

输入格式

输入的第一行是两个用空格隔开的正整数,分别代表房屋的个数 \(n\) 和救济粮发放的次数 \(m\)

\(2\) 到 第 \(n\) 行,每行有两个用空格隔开的整数 \(a,~b\),代表存在一条连接房屋 \(a\)\(b\) 的边。

\((n + 1)\) 到第 \((n + m)\) 行,每行有三个用空格隔开的整数 \(x,~y,~z\),代表一次救济粮的发放是从 \(x\)\(y\) 路径上的每栋房子发放了一袋 \(z\) 类型的救济粮。

输出格式

输出 \(n\) 行,每行一个整数,第 \(i\) 行的整数代表 \(i\) 号房屋存放最多的救济粮的种类,如果有多种救济粮都是存放最多的,输出种类编号最小的一种。

如果某座房屋没有救济粮,则输出 \(0\)

提示

  • 对于 \(20\%\) 的数据,保证 \(n, m \leq 100\)
  • 对于 \(50\%\) 的数据,保证 \(n, m \leq 2 \times 10^3\)
  • 对于 \(100\%\) 测试数据,保证 \(1 \leq n, m \leq 10^5\)\(1 \leq a,b,x,y \leq n\)\(1 \leq z \leq 10^5\)

对于这道题,我们需要查询每个位置上的救济粮的最大值,于是我们就需要统计每个位置所有的救济粮数量,朴素的思想是开一个计数数组\(a\),\(a[i,j]\)表示节点i上存放节点j的数量,每一次在x到y的路径上朴素修改,我们就得到了一个时间复杂度\(O(nm)\),空间复杂度\(O(nm)\)优秀算法。考虑进行优化
优化1. 树上路径操作可以使用树上差分,具体的我们对于一条指令\((x,y,z)\),将\(a[x,z]++,a[y,z]++,a[lca(x,y),z]--,a[fa(lca(x,y)),z]--\),这样我们就成功地将修改复杂度降到了\(O(1)\),但时间复杂度没有实际变化(合并查询的时候仍然需要\(O(nm)\))

优化2. 针对优化1的继续优化,我们发现,合并查询的时候复杂度过高,而修改复杂度较低,我们就可以想办法均衡一下。这个均衡需要靠数据结构来实现,观察数据支持\(O((n+m)\log n)\),于是我们大胆猜测穿一个\(\log\)级别的数据结构来维护,很明显,线段树合并算法登场
详细的说,我们为了节省空间,先对z进行离散化,然后对于每一个节点都开一颗线段树存储,注意线段树用动态开点,这样我们的空间复杂度就会降低到\(O(m\log n)\),原因是线段树上修改一个叶子节点需要\(\lceil\log n\rceil\)个节点,总共修改\(O(m)\)次,并且线段树合并算法时间复杂度也只有\(O(n\log n)\)

int head[100050],ver[200500],nxt[200500],tot;//图 
int f[100005][25],dep[100005];//LCA
int ans[100005],n,m,T,root[100005],zmx,a[100005],b[100005],cnt;
struct edge{
	int x,y,z;
}que[100005];//question
struct node{
	int lc,rc,id,mx;
}t[5000000];
#define ls t[x].lc
#define rs t[x].rc
int new_node(){
	t[++cnt]={0,0,0,0};
	return cnt;
} 
void pushup(int x){
	t[x].id=t[ls].id,t[x].mx=t[ls].mx;
	if(t[x].mx<t[rs].mx)t[x].mx=t[rs].mx,t[x].id=t[rs].id;
}
void update(int L,int R,int xb,int d,int x){
	if(L==R){
		t[x].mx+=d;
		t[x].id=xb;
		return ;
	}
	int mid=L+R>>1;
	if(xb<=mid){
		if(!ls)ls=new_node();
		update(L,mid,xb,d,ls);
	}
	else {
		if(!rs)rs=new_node();
		update(mid+1,R,xb,d,rs);
	}
	pushup(x);
}
int merge(int p,int q,int l,int r){
	if(!p)return q;
	if(!q)return p;
	if(l==r){
		t[p].mx=t[q].mx+t[p].mx;
		return p;
	}
	int mid=l+r>>1;
	t[p].lc=merge(t[p].lc,t[q].lc,l,mid);
	t[p].rc=merge(t[p].rc,t[q].rc,mid+1,r);
	pushup(p);
	return p;
}
//以上线段树动态开点加合并
queue<int>q;
void bfs(){
	dep[1]=1;
	q.push(1);
	while(q.size()){
		int u=q.front();q.pop();
		for(int i=head[u];i;i=nxt[i]){
			int v=ver[i];
			if(dep[v])continue;
			dep[v]=dep[u]+1;
			f[v][0]=u;
			for(int i=1;i<=T;i++)f[v][i]=f[f[v][i-1]][i-1];
			q.push(v);
		}
	}
} 
int lca(int x,int y){
	if(dep[x]>dep[y])swap(x,y);
	for(int i=T;i>=0;i--)if(dep[f[y][i]]>=dep[x])y=f[y][i];
	if(x==y)return x;
	for(int i=T;i>=0;i--)if(f[y][i]!=f[x][i])x=f[x][i],y=f[y][i];
	return f[x][0];
}
//以上LCA
void dfs(int u){
	for(int i=head[u];i;i=nxt[i]){
		int v=ver[i];
		if(dep[v]>dep[u]){
			dfs(v);
			root[u]=merge(root[u],root[v],1,zmx);
		}
	}
	ans[u]=t[root[u]].mx?t[root[u]].id:0;
}
//以上统计答案
void change(int x,int y,int z){
	int fa=lca(x,y);
	update(1,zmx,z,1,root[x]);
	update(1,zmx,z,1,root[y]);
	update(1,zmx,z,-1,root[fa]);
	if(f[fa][0])update(1,zmx,z,-1,root[f[fa][0]]);
}
//修改操作
void add(int u,int v){
	nxt[++tot]=head[u];ver[tot]=v,head[u]=tot;
}
int main(){
//	freopen("1.in","r",stdin);
	scanf("%d%d",&n,&m);
	for(int i=1;i<=n;i++)root[i]=++cnt; 
	T=log(n)/log(2.0)+1;
	for(int i=1;i<n;i++){
		int u,v;
		scanf("%d%d",&u,&v);
		add(u,v);
		add(v,u);
	}	
	bfs();
	for(int i=1;i<=m;i++){
		scanf("%d%d%d",&que[i].x,&que[i].y,&que[i].z);
		a[i]=b[i]=que[i].z;
	}
	sort(a+1,a+m+1);
	zmx=unique(a+1,a+m+1)-a-1;
	for(int i=1;i<=m;i++){
		b[i]=lower_bound(a+1,a+zmx+1,b[i])-a; 
	}
	for(int i=1;i<=m;i++){
		change(que[i].x,que[i].y,b[i]);
	}
	dfs(1);
	for(int i=1;i<=n;i++){
		printf("%d\n",a[ans[i]]);
	}
	return 0;
} 

关于动态开点线段树合并时间复杂度的简要证明
我们可以发现,线段树合并的时间与两棵树重合的节点相关,即最坏情况下也不会大于小的那颗树的节点个数,类似于启发式合并,我们之前也证明了至多会创建\(O(m\log n)\)个节点,也即合并复杂度就是这个数
总时间复杂度为\(O((n+m)\log (n+m))\)

例题2天天爱跑步

题目描述

小c 同学认为跑步非常有趣,于是决定制作一款叫做《天天爱跑步》的游戏。《天天爱跑步》是一个养成类游戏,需要玩家每天按时上线,完成打卡任务。

这个游戏的地图可以看作一一棵包含 \(n\) 个结点和 \(n-1\) 条边的树,每条边连接两个结点,且任意两个结点存在一条路径互相可达。树上结点编号为从 \(1\)\(n\) 的连续正整数。

现在有 \(m\) 个玩家,第 \(i\) 个玩家的起点为 \(s_i\),终点为 \(t_i\)。每天打卡任务开始时,所有玩家在第 \(0\) 秒同时从自己的起点出发,以每秒跑一条边的速度,不间断地沿着最短路径向着自己的终点跑去,跑到终点后该玩家就算完成了打卡任务。 (由于地图是一棵树,所以每个人的路径是唯一的)

小c 想知道游戏的活跃度,所以在每个结点上都放置了一个观察员。在结点 \(j\) 的观察员会选择在第 \(w_j\) 秒观察玩家,一个玩家能被这个观察员观察到当且仅当该玩家在第 \(w_j\) 秒也正好到达了结点 \(j\)小c 想知道每个观察员会观察到多少人?

注意:我们认为一个玩家到达自己的终点后该玩家就会结束游戏,他不能等待一 段时间后再被观察员观察到。 即对于把结点 \(j\) 作为终点的玩家:若他在第 \(w_j\) 秒前到达终点,则在结点 \(j\) 的观察员不能观察到该玩家;若他正好在第 \(w_j\) 秒到达终点,则在结点 \(j\) 的观察员可以观察到这个玩家。

输入格式

第一行有两个整数 \(n\)\(m\)。其中 \(n\) 代表树的结点数量, 同时也是观察员的数量, \(m\) 代表玩家的数量。

接下来 \(n-1\) 行每行两个整数 \(u\)\(v\),表示结点 \(u\) 到结点 \(v\) 有一条边。

接下来一行 \(n\) 个整数,其中第 \(j\) 个整数为 \(w_j\) , 表示结点 \(j\) 出现观察员的时间。

接下来 \(m\) 行,每行两个整数 \(s_i\),和 \(t_i\),表示一个玩家的起点和终点。

对于所有的数据,保证 \(1\leq s_i,t_i\leq n, 0\leq w_j\leq n\)

输出格式

输出 \(1\)\(n\) 个整数,第 \(j\) 个整数表示结点 \(j\) 的观察员可以观察到多少人。

分析

首先\(s_i-t_i\)的路径上,观察员分为两类,一类是在\(s_i-lca(s_i,t_i)\)上的,一类是在\(lca(s_i,t_i)-t_i\)上的
我们处理出所有点的深度记为\(d\)
那么玩家\(i\)能够被观察员\(j\)观察到当且仅当满足以下两个条件之一
1.

\[d[s_i]-d[j]=w_j \]

\[d[s_i]+d[j]-2\times d[lca(s_i,t_i)]=w_j \]

因为这两个条件具有互斥性,所以我们可以分开统计贡献,下面以统计满足条件1的节点数量
我们对条件一进行变式得到\(d[s_i]=d[j]+w_j\),这样我们就分离了两个变量,那么下一步,我们尝试把它变成区间修改,类似上题雨天的尾巴,开一个计数数组\(a[i,j]\)(表示节点\(i\)\(a[i,j]个j\))判断,于是我们可以将\(s_i-lca(s_i,t_i)\)的路径上的点的\(a[u][d[s_i]](u\in s_i-lca(s_i,t_i))++\),条件2类似,将\(t_i-lca(s_i,t_i)\)上的点的\(a[u][d[s_i]-2\times d[lca(s_i,t_i)]](u\in s_i-lca(s_i,t_i))++\),答案就是

\[\sum_{i=1}^n\left(a[i][d[i]+w_i]+a[i][w_i-d[i]]\right) \]

对于操作一样使用树上差分,设b为差分计数数组,则由于本题记录的是边权,于是对于路径\(u-v\)我们只需要修改\(u,v,lca(u,v)\)三个节点即可,所以\(a[i,j]=\sum_{v\in son(i)}b[v][j]\),其中\(son\)代表子树节点集合
但题目最大数据点\(n,m\le 3\times 10^5\),采用上一题的线段树合并算法由于空间过大很容易\(MLE\)掉,当然就算空间过了常数也不行,于是线段树,它死了启发我们需要一个更加高效的算法
我们发现,这道题具备区间减法性质,且每个点只问一个特殊值的数量,于是我们可以采用前缀和的思想方式,利用区间减法性质,进行“树上前缀和”
我们发现,由于我们采用树上差分的操作,使得我们在\(b\)数组中至多只会有\(O(m)\)个值有意义,其余的值是冗余操作,于是我们为了节省空间,可以在每一个节点开一个\(vector\),存\(i,j\)表示在树中有\(j\)个值为\(i\),这样我们达到了一定的时空平衡。每一次树上差分操作只需要在\(vector\)后面插入一个节点即可,反正最多所有节点加起来也才\(O(m)\),这个插入的节点j只可能为-1/1,避免了在\(vector\)里再去查找\(i\),然后修改对应\(j\)的操作
然后我们开一个全局的计数数组\(c\)(一维),\(c_i\)代表目前值为\(i\)的有\(c_i\)个,我们可以通过一次深度优先遍历,在遍历到节点\(x\)的时候,我们开两个辅助变量\(cnt1,cnt2\),分别记录\(c[d[x]+w_x],c[w_x-d[x]]\)的值,之后我们就把当前节点的vector全部累加上c数组,继续遍历子节点,最后回溯的时候做一个类似前缀和的操作,\(ans[x]=c[d[x]+w_x]-cnt1+c[w_x-d[x]]-cnt2\),我们就可以得到子树中\(d[x]+w_x,w_x-d[x]\)的值的数量,注意\(w_x-d[x]\)需要平移数组
总得来说,这题最后的统计答案具备区间减法性质,这个性质一样可以扩展到树上,也即一段区间信息能由其他两段信息推出,我们就可以采用类似前缀和的方式进行优化,而上一题是\(\max\)操作,不满足区间减法性质,于是我们就不能够这样优化

const int MAXN = 6e5 + 10;
const int MAXM = 1e6 + 21000;
int n, m, cnt, s[MAXN], t[MAXN], lc[MAXN], lenth[MAXN];
int head[MAXN], dep[MAXN], fa[MAXN], son[MAXN], siz[MAXN], top[MAXN], val[MAXN], c[MAXN];
int nxt[MAXM], to[MAXM];
int start[MAXN], cntt[MAXM << 1], a[MAXN], len[MAXN], ans[MAXN];
vector<int> anc[MAXN], tail[MAXN];
void add(int x, int y) {
	cnt++;
	nxt[cnt] = head[x];
	head[x] = cnt;
	to[cnt] = y;
}
void dfs1(int u, int fat) {
	siz[u] = 1, fa[u] = fat, dep[u] = dep[fat] + 1;
	for (int i = head[u]; i; i = nxt[i]) {
		int v = to[i];
		if (v != fa[u]) {
			c[v] = c[u] + 1;
			dfs1(v, u);
			siz[u] += siz[v];
			if (siz[son[u]] < siz[v])
				son[u] = v;
		}
	}
}
void dfs2(int u, int tp) {
	top[u] = tp;
	if (!son[u])
		return;
	dfs2(son[u], tp);
	for (int i = head[u]; i; i = nxt[i]) {
		int v = to[i];
		if (v != son[u] && v != fa[u]) {
			dfs2(v, v);
		}
	}
}
int lca(int x, int y) {
	while (top[x] != top[y]) {
		if (dep[top[x]] < dep[top[y]])
			swap(x, y);
		x = fa[top[x]];
	}
	return dep[x] < dep[y] ? x : y;
}//树剖LCA
void dfs3(int u) {
	int xx = cntt[dep[u] + val[u]], yy = cntt[val[u] - dep[u] + MAXN];
	for (int i = head[u]; i; i = nxt[i]) {
		int v = to[i];
		if (v == fa[u])
			continue;
		dfs3(v);
	}
	cntt[dep[u]] += start[u];
	for (int i = 0; i < tail[u].size(); i++) {
		int v = tail[u][i];
		cntt[lenth[v] - dep[t[v]] + MAXN]++;
	}
	ans[u] += cntt[dep[u] + val[u]] - xx + cntt[val[u] - dep[u] + MAXN] - yy;
	for (int i = 0; i < anc[u].size(); i++) {
		int v = anc[u][i];
		cntt[dep[s[v]]]--, cntt[lenth[v] - dep[t[v]] + MAXN]--;
	}
	return;
}

int main() {
	scanf("%d%d",&n,&m);
	for (int i = 1; i < n; ++i) {
     int x,y;		
     scanf("%d%d",&x,&y);
		add(x, y), add(y, x);
	}
	for (int i = 1; i <= n; ++i)
		scanf("%d",&val[i]);
	dfs1(1, 0);
	dfs2(1, 1);
	for (int i = 1; i <= m; ++i) {
		scanf("%d%d",&s[i],&t[i]);
		lc[i] = lca(s[i], t[i]);
		lenth[i] = c[s[i]] + c[t[i]] - c[lc[i]] * 2;
		anc[lc[i]].push_back(i);
		tail[t[i]].push_back(i);
		start[s[i]]++;
		if (dep[s[i]] == dep[lc[i]] + val[lc[i]])
			ans[lc[i]]--;
	}
	dfs3(1);
	for (int i = 1; i <= n; ++i)
		cout << ans[i] << ' ';
	return 0;
}

LCA的综合运用

例题1:次小生成树

题意:给定一张无向连通图,求其严格次小生成树
\(n\le 10^5,m\le 3\times 10^5\)

分析

首先,常用思路是我们先求出最小生成树,然后尝试加边,毫无疑问,设我们对最小生成树加入一条边\((u,v,w)\)就会导致原最小生成树上\(u-v\)的路径上会形成环,这时我们将环上断开一条边就得到了一个次小生成树的候选答案,下面我们思考应该断开怎样一条边
首先由最小生成树的性质,原\(u-v\)路径上的任何一条边的边权都应该小于或等于\(w\),那么我们比较两条边,边权为\(x_1,x_2\),设\((x_1<x_2\),那么断开\(x_1\)后,原最小生成树权值总和增加了\(w-x_1\),同理断开\(x_2\)后增加了\(w-x_2\),由于\(x_1<x_2\),所以\(w-x_2<w-x_1\),而我们所求生成树是严格次小的,于是我们应该使得增加的尽量小,于是断开\(x_2\)更优,使用数学归纳法易证,我们应该使用原\(u-v\)路径上最大的边断开,接上\((u,v,w)\)
但这样就完全正确了吗,注意,我们要求的是“严格”次小生成树,于是当图中\(u-v\)最大边与\(w\)相等的时候就无法使用,这启发我们再维护一个严格次大边
于是我们的问题就变成了如何求任意两点路径上的最大边权和严格次大边权。
考虑DP,朴素DP应该很容易写出状态转移方程,但复杂度无疑是\(O(nm)\)的,这不可能完成\(10^5\)级别的数据
于是我们考虑优化,这个朴素的DP似乎不具有使用数据结构优化的前提,于是我们就只剩下一种优化方法,倍增
因为本题的DP满足区间加法可拼凑性,满足倍增优化DP的前提
我们设\(f[x,k]\)表示\(x\)\(2^k\)倍祖先,这个可以\(O(n)\)倍增预处理
那么我们设\(g[x,k,0/1]\)表示\(x\)\(x\)\(2^k\)倍祖先的最大边权和严格次大边权,\(k=0\)时最大边权是自己,次大边权为负无穷,则有

\[g[x,k,0]=max(g[x,k-1,0],g[f[x,k-1],k-1,0]) \]

\[\left\{ \begin{aligned} g[x,k,1]&=\max(g[f[x,k-1],k-1,0],g[x,k-1,1])&(g[x,k-1,0]>g[f[x,k-1],k-1,0])\\ g[x,k,1]&=\max(g[x,k-1,0],g[f[x,k-1],k-1,1])&(g[x,k-1,0])<g[f[x,k-1],k-1,0])\\ g[x,k,1]&=\max(g[x,k-1,1],g[f[x,k-1],k-1,1])&(g[x,k-1,0]=g[f[x,k-1],k-1,0])\\ \end{aligned} \right. \]

至于对于路径\(u-v\)的最大边权和严格次大边权可以看作\(lca(u,v)\)\(u,v\)之间的连边按照处理\(g\)数组的方式处理出来,然后合并两条路径也按照\(g\)数组的处理方式来,就可以求出了,然后我们就可以\(O(\log n)\)求出一个严格次小生成树的候选答案
时间复杂度

  1. 处理\(f\)数组和倍增\(lca\),需要\(O(n\log n)\)的时间
  2. 处理\(g\)数组的动态规划,需要\(O(n\log n)\)的时间
  3. 枚举边找最小候选答案,需要\(O(m\log n)\)的时间
    总时间复杂度为\(O(n\log n+m\log n)=O((m+n)\log n)=O(m\log n)\)
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 3e5 + 7, M = 3e5 + 7, MM = 3e5 + 7;
const ll INF = 0x7ffffffffff;;
int n, m;
ll sum;
int cnt, head[MM], ver[MM], nex[MM], edge[MM];
int tree[MM], pre[N], ppre[N][23], depth[N], lg[N];
ll maxf[N][23], minf[N][23];
struct E {
	int from, to, w;
	E() {}
	E(int from, int to, int w) : from(from), to(to), w(w) {}
	bool operator < (const E& b)const {
		return w < b.w;
	}
}e[M];
void add(int x, int y, int w) {
	ver[++cnt] = y;
	nex[cnt] = head[x];
	edge[cnt] = w;
	head[x] = cnt;
}
int find(int x) {
	return x == pre[x] ? x : pre[x] = find(pre[x]);
}
void read() {
	scanf("%d%d", &n, &m);
	for (int i = 1; i <= m; i++)
		scanf("%d%d%d", &e[i].from, &e[i].to, &e[i].w);

	for (int i = 0; i < N; i++)
		pre[i] = i;
}
void work1() {
	sort(e + 1, e + m + 1);
	for (int i = 1; i <= m; i++) {
		int x = e[i].from, y = e[i].to, w = e[i].w;
		int fx = find(x), fy = find(y);
		if (fx != fy) {
			pre[fx] = fy;
			sum += w;
			add(x, y, w);
			add(y, x, w);
			tree[i] = 1;
		}
	}
}
void dfs(int f, int fa, int w) {
	depth[f] = depth[fa] + 1;
	ppre[f][0] = fa;
	minf[f][0] = -INF;
	maxf[f][0] = w;
	for (int i = 1; (1 << i) <= depth[f]; i++) {
		ppre[f][i] = ppre[ppre[f][i - 1]][i - 1];
		maxf[f][i] = max(maxf[f][i - 1], maxf[ppre[f][i - 1]][i - 1]);
		minf[f][i] = max(minf[f][i - 1], minf[ppre[f][i - 1]][i - 1]);//这里分清次小关系 
		if (maxf[f][i - 1] > maxf[ppre[f][i - 1]][i - 1]) minf[f][i] = max(minf[f][i], maxf[ppre[f][i - 1]][i - 1]);
		else if (maxf[f][i - 1] < maxf[ppre[f][i - 1]][i - 1]) minf[f][i] = max(minf[f][i], maxf[f][i - 1]);
	}

	for (int i = head[f]; i; i = nex[i]) {
		int y = ver[i], w = edge[i];
		if (y != fa) {
			dfs(y, f, w);
		}
	}
}
int lca(int x, int y) {
	if (depth[x] < depth[y]) swap(x, y);
	while (depth[x] > depth[y])
		x = ppre[x][lg[depth[x] - depth[y]] - 1];

	if (x == y) return x;
	for (int i = lg[depth[x]] - 1; i >= 0; i--) {
		if (ppre[x][i] != ppre[y][i])
			x = ppre[x][i], y = ppre[y][i];
	}

	return ppre[x][0];
}
ll qmax(int x, int y, int maxx) {
	ll ans = -INF;

	for (int i = lg[depth[x]] - 1; i >= 0; i--) {
		if (depth[ppre[x][i]] >= depth[y]) {
			if (maxx != maxf[x][i]) ans = max(ans, maxf[x][i]);
			else ans = max(ans, minf[x][i]);
			x = ppre[x][i];
		}
	}
	return ans;
}
void work2() {
	for (int i = 1; i <= n; i++)
		lg[i] = lg[i - 1] + (1 << lg[i - 1] == i);

	dfs(1, 0, 0);
	ll ans = INF;
	for (int i = 1; i <= m; i++) {
		if (tree[i]) continue;
		int x = e[i].from, y = e[i].to, w = e[i].w;
		int lc = lca(x, y);
		ll maxx = qmax(x, lc, w);
		ll maxv = qmax(y, lc, w);
		ans = min(ans, sum - max(maxx, maxv) + w);
	}
	printf("%lld\n", ans);
}
int main() {
	read();
	work1();
	work2();
	return 0;
}

例题2:疫情控制

题目描述

H 国有 \(n\) 个城市,这 \(n\) 个城市用 \(n-1\) 条双向道路相互连通构成一棵树,\(1\) 号城市是首都,也是树中的根节点。

H 国的首都爆发了一种危害性极高的传染病。当局为了控制疫情,不让疫情扩散到边境城市(叶子节点所表示的城市),决定动用军队在一些城市建立检查点,使得从首都到边境城市的每一条路径上都至少有一个检查点,边境城市也可以建立检查点。但特别要注意的是,首都是不能建立检查点的。

现在,在 H 国的一些城市中已经驻扎有军队,且一个城市可以驻扎多个军队。一支军队可以在有道路连接的城市间移动,并在除首都以外的任意一个城市建立检查点,且只能在一个城市建立检查点。一支军队经过一条道路从一个城市移动到另一个城市所需要的时间等于道路的长度(单位:小时)。

请问最少需要多少个小时才能控制疫情。注意:不同的军队可以同时移动。

分析

本题很明显满足单调性。使用贪心思想,一个军队很明显靠根节点越近越好。所以我们考虑二分答案,贪心判定
设二分的值为\(mid\),那么所有的军队分为两类

  1. \(mid\)的时间内不可以走到根节点的子节点
  2. 可以走到根节点的子节点
    很明显,第一类节点就尽全力向上走就可以了,走完之后我们设\(son(rt)\)是根节点的子节点集合,我们递归判断\(\forall s\in son(rt)\),是否已经控制了疫情,这一步我们可以把军队驻扎的点标记,若递归遇到标记节点之间返回1,否则递归子节点,当一个子节点返回false的时候就代表整个不行,设T是还有叶子节点没有被管辖的节点的集合
    第二类节点有两个决策,一是留在原地不动,二是去支援T中节点,我们使用一个三元组来表示第二类节点,\((x,y,z)\)分别表示编号为\(x\)的军队在子节点\(y\)的子树内,移动到\(y\)还剩下\(z\)的时间
    这里有一个性质,即对于一个三元组\((x,y,z)\),若\(z<2\times dis(rt,y)\),则这个军队就驻扎在\(y\),不需要移动了。道理很简单,若这个军队要出去驻扎,那么设它驻扎在节点\(i\),则有\(dis(rt,y)>dis(rt,i)\),若此时有另一个三元组\((x',y',z')\)跨根节点驻扎在\(y\)(原来的走了,新的得来),则总路程为\(dis(y,rt)+dis(rt,y')\),所以三元组\((x',y',z')\)也一定可以去驻扎在\(i\),这样还不如直接\((x,y,z)\)就不动,然后让\((x',y',z')\)去驻扎其他节点,因为\((x,y,z)\)能驻扎的节点\((x',y',z')\)都可以,它有更多的决策可能性,也更少浪费时间,具备决策包容性,所以对于一个三元组\((x,y,z)\),若\(z<2\times dis(rt,y)\),则这个军队就驻扎在\(y\),不需要移动了。
    于是我们可以再一次统计这样的三元组,把它们从\(T\)里面扔出去,只对剩下的进行讨论
    这时候,我们把闲置的三元组按照\(z-dis(y,rt)\)从小到大排序,把T中节点按照\(dis(rt,v)\)从小到大排序,使用双指针扫描就可以得到答案
    正确性很显然,把大的留在后面有更多可能性,由决策包容性可知成立
    最后判断能否把\(T\)中节点合理分出去,就可以确定\(mid\)的正确性了
int cnt, tot, sum, n, m, dep[50005], gap[50005], ans, mid, head[50005], dist[50005][30], f[50005][30], number[50005], dis, tie[100006], tot2;bool edn[50005];
pair<int, int>cup[50005];
bool vis[50005];
struct node {
	int v, nxt, w;
}e[1000005];
void add(int u, int v, int w) {
	++cnt;
	e[cnt].v = v, e[cnt].w = w, e[cnt].nxt = head[u], head[u] = cnt;
}
void bfs() {
	queue<int>q;
	q.push(1);
	int t = log2(n) + 1;
	dep[1] = 1;
	while (!q.empty()) {
		int u = q.front(); q.pop();
		for (int i = head[u]; i; i = e[i].nxt) {
			int v = e[i].v;
			if (dep[v])continue;
			dep[v] = dep[u] + 1;
			f[v][0] = u, dist[v][0] = e[i].w;
			for (int j = 1; j <= t; j++) {
				f[v][j] = f[f[v][j - 1]][j - 1];
				dist[v][j] = dist[v][j - 1] + dist[f[v][j - 1]][j - 1];
			}
			q.push(v);
		}
	}
}
bool dfs(int u) {
	bool vis2 = false;
	if (edn[u])return 1;
	for (int i = head[u]; i; i = e[i].nxt) {
		int v = e[i].v;
		if (dep[v] < dep[u])continue;
		vis2 = true;
		if (!dfs(v))return 0;
	}
	if (!vis2)return 0;
	return 1;
}
bool check() {
	memset(cup, 0, sizeof cup);
	memset(gap, 0, sizeof gap);
	memset(tie, 0, sizeof tie);
	memset(vis, 0, sizeof vis);
	memset(edn, 0, sizeof edn);
	int t = log2(n);
	int sum = 0;
	tot = dis = tot2 = 0;
	for (int i = 1; i <= m; i++) {
		int u = number[i];
		sum = 0;
		for (int i = t; i >= 0; i--) {
			if (f[u][i] > 1 && sum + dist[u][i] <= mid) {
				sum += dist[u][i];
				u = f[u][i];
			}
		}
		if (f[u][0] == 1 && sum + dist[u][0] <= mid) {
			cup[++tot].first = mid - (sum + dist[u][0]);
			cup[tot].second = u;
		}//还能走
		else {
			edn[u] = 1;//标记
		}
	}
	for (int i = head[1]; i; i = e[i].nxt) {
		int v = e[i].v;
		if (!dfs(v)) {
			vis[v] = 1;
		}
	}
	sort(cup + 1, cup + tot + 1);
	for (int i = 1; i <= tot; i++) {
		int time = cup[i].first;
		int u = cup[i].second;
		if (vis[u] && dist[u][0] > time) {
			vis[u] = 0;
		}
		else {
			tie[++dis] = time;
		}
	}
	for (int i = head[1]; i; i = e[i].nxt) {
		if (vis[e[i].v])gap[++tot2] = dist[e[i].v][0];
	}
	if (dis < tot2)return false;
	sort(tie + 1, tie + dis + 1);
	sort(gap + 1, gap + tot2 + 1);
	int l = 1, r = 1;//双指针扫描
	while (l <= dis && r <= tot2) {
		if (tie[l] >= gap[r]) {
			l++, r++;
		}
		else {
			l++;
		}
	}
	if (r > tot2) {
		return true;
	}
	return false;
}
int r;
int query() {
	int l = 0;
	while (l <= r) {
		mid = l + r >> 1;
		if (check())r = mid - 1;
		else l = mid + 1;
	}
	return l;
}
int main() {
	ios::sync_with_stdio(false);
	cin.tie(0);
	cin >> n;
	r = 0;
	for (int i = 1; i < n; i++) {
		int u,v , w;
		cin >> u >> v >> w;
		add(u, v, w);
		add(v, u, w);
		r += w;
	}
	bfs();
	cin >> m;
	for (int i = 1; i <= m; i++) {
		cin >> number[i];
	}
	cout << query();
	return 0;
}

好的下面让我们来总结本节要点
知识点:

  1. 树的直径定义,最长性
  2. 树的直径中点唯一性
  3. 树的直径必须边组成唯一一条链,这条链的求法,双端收缩范围
  4. 树的偏心距及树网的核,贪心思想
  5. LCA的求法,树剖,倍增,tarjan
  6. LCA的性质,树上路径
  7. 树上差分的两种类型
  8. 利用区间加法性质,计数数组快速合并改为线段树合并算法
  9. 树上路径最大+严格次大边权的\(O(n\log n)\)求法,倍增优化
    经典思想
  10. 双指针扫描法:从中间扩展,从两端收缩,从一端扫描匹配
  11. 贪心思想:邻项交换,决策包容性
  12. 比较两个不同决策找性质
  13. tarjan算法的离线标记思想
  14. 利用区间减法性质,以前缀和思想减少空间开销
  15. 时空平衡,减少空间开销,遇到实际元素量很少,但范围很大的时候使用vector时间换空间
  16. 次小生成树一题中的倍增优化DP思想
  17. 容斥原理,正面不行从反面入手
posted @ 2022-11-30 22:38  spdarkle  阅读(90)  评论(0编辑  收藏  举报