树的直径与LCA
树的直径与LCA
树的直径
定义:设
性质:
- 一棵树可能有不止一个直径
- 一棵树的直径有唯一的中点
- 我们称树的不同直径的公共边为必须边
- 树的所有必须边构成一条链
关于性质4的证明:
反证法,我们假设必须边构成两条链
由必须边和树的直径的定义,这两条链一定在树的一条直径 上,那么设这两条链中间部分路径为 , 上两条链中间为 ,则有 ,而由树的直径的定义, 最大且唯一,以此类推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树网的核
题目描述
设 treenetwork
),其中
路径:树网中任何两结点
树网的直径:树网中最长的路径成为树网的直径。对于给定的树网
偏心距
任务:对于给定的树网 Core
)。必要时,
下面的图给出了树网的一个实例。图中,DEFG
(也可以取为路径DEF
),偏心距为
输入格式
共
第
从第 2 4 7
表示连接结点
输出格式
一个非负整数,为指定意义下的最小偏心距。
分析
仔细观察题目,我们可以得到如下性质
- 对于两条路径
,若 ,则 ,对于这个性质,我们可以得到一个很强的推论,即在长度不超过s的情况下,路径越长越好 - 最小偏心距具有单调性
- 不同直径求出来的最小偏心距相同,证明:不同直径有唯一中点,那么对于两条不同直径,我们可以通过组合变成四条直径,也就是把原本两条直径按中点砍成两半一共四个链,此时一定存在两组链长度相等,根据直径的最长性,不同组上的链上的点u的D值就在另一组的两条链的末端都满足,进一步可以扩展到n条链,得证
于是我们得到一个类似DP的算法
先求出树的直径,设直径上的点集为 ,d数组也可以被我们 预处理出来,表示不经过直径上的其他节点在树上的最远距离,则将题目所给偏心距公式可写为
以 为两个端点的树网的核的偏心距为
由于
但我们还可以进一步进行优化,由于直径的最长性,任何从直径上的点
至于
综上,我们得到了一个
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最近学习了一些图论知识。根据课本,有如下定义。树:无回路且连通的无向图,每条边都有正整数的权值来表示其长度。如果一棵树有
路径:一棵树上,任意两个节点之间最多有一条简单路径。我们用
直径:一棵树上,最长的路径为树的直径。树的直径可能不是唯一的。
现在小Q想知道,对于给定的一棵树,其直径的长度是多少,以及有多少条边满足所有的直径都经过该边。
输入格式
第一行包含一个整数N,表示节点数。 接下来N-1行,每行三个整数a, b, c ,表示点 a和点b之间有一条长度为c的无向边。
输出格式
共两行。第一行一个整数,表示直径的长度。第二行一个整数,表示被所有直径经过的边的数量。
分析
我们上文提到的树的性质,第一问就不说了,板子。此题实际上就是让我们求必须边的数量,由必须边的性质:由所有的必须边组成一条链
于是我们的问题变成了如何在直径上找到这样一条链
直接寻找必须边比较复杂,我们可以采用容斥思想,找到所有的非必须边
设这棵树的点集为
关于d的求法,与上题的dfs函数无二,复杂度
那么显而易见的,一条链
于是我们可以用两个指针
#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)
定义:对于节点
性质:
求
在线做法:
- 树链剖分
,预处理 ,查询一次 , 表 ,预处理 ,查询- 倍增
,这里我们详细讲述这个,预处理 查询 - 其实
问题 做法还有优化,可以把时间复杂度优化至 ,叫约束 ,因为它满足相邻两个数最多差1,但代码实现太过复杂,常数也较大,对于 及以下的数据甚至不如树剖倍增,而 以上的数据就只得 做了
我们谈谈三种做法的优劣,只不过我们只详细讲倍增,其他两种只做了解
在空间上, 只需要一个 级别的数组,然后需要一个 的f数组
倍增需要一个队列,一个长度为 的 数组,一个 的f数组,总的和 相差无几
树剖需要 , , 等数组,但是空间复杂度严格
从严格时间复杂度来说,我们假设询问次数与n同级
ST>树剖>倍增
在一般情况下,实际效率是树剖约等于倍增>ST
倍增树剖常数极小,ST有点大
但是树剖很容易写丑,倍增就那样
实际从严格理论上,我记得某集训队大佬的一篇论文里有严格证明一般情况下树剖常数是倍增的
于是综合考量,树剖最优秀,代码也很短
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表
使用欧拉序,欧拉序是指在深度优先遍历整棵树的是时候,节点刚递归进入的时候标记,退出的时候再标记,这样就可以有一个性质,节点
3. 倍增LCA
设
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);
}
}
}
然后对于查询过程,我们先将
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是真牛
时间复杂度:
在任意时刻,深度优先遍历的节点分为三类
- 已经完全结束了回溯的节点,标记2
- 已经访问但未回溯,标记1
- 尚未访问到的节点,标记0
对于一个正在访问的节点
对于这个过程,我们可以使用并查集进行优化,当一个节点
这样我们执行
// 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]);
}
}
树上差分
在前缀和与差分中,我们实现了序列上的区间修改,单点查询问题,现在我们要对这个思想运用到树中,实现
原来的前缀和变成了子树和,区间操作对应路径操作
树上差分的两种形式
1.点权形式,将
这种操作的本质是
2.边权形式,将
这种操作的本质是我们在操作的时候默认边权下放到了点权,这就使得
例题1雨天的尾巴
深绘里一直很讨厌雨天。
灼热的天气穿透了前半个夏天,后来一场大雨和随之而来的洪水,浇灭了一切。
虽然深绘里家乡的小村落对洪水有着顽固的抵抗力,但也倒了几座老房子,几棵老树被连根拔起,以及田地里的粮食被弄得一片狼藉。
无奈的深绘里和村民们只好等待救济粮来维生。
不过救济粮的发放方式很特别。首先村落里的一共有
然后深绘里想知道,当所有的救济粮发放完毕后,每座房子里存放的最多的是哪种救济粮。
输入格式
输入的第一行是两个用空格隔开的正整数,分别代表房屋的个数
第
第
输出格式
输出
如果某座房屋没有救济粮,则输出
提示
- 对于
的数据,保证 。 - 对于
的数据,保证 。 - 对于
测试数据,保证 , , 。
对于这道题,我们需要查询每个位置上的救济粮的最大值,于是我们就需要统计每个位置所有的救济粮数量,朴素的思想是开一个计数数组优秀算法。考虑进行优化
优化1. 树上路径操作可以使用树上差分,具体的我们对于一条指令
优化2. 针对优化1的继续优化,我们发现,合并查询的时候复杂度过高,而修改复杂度较低,我们就可以想办法均衡一下。这个均衡需要靠数据结构来实现,观察数据支持
详细的说,我们为了节省空间,先对z进行离散化,然后对于每一个节点都开一颗线段树存储,注意线段树用动态开点,这样我们的空间复杂度就会降低到
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;
}
关于动态开点线段树合并时间复杂度的简要证明
我们可以发现,线段树合并的时间与两棵树重合的节点相关,即最坏情况下也不会大于小的那颗树的节点个数,类似于启发式合并,我们之前也证明了至多会创建
总时间复杂度为
例题2天天爱跑步
题目描述
小c
同学认为跑步非常有趣,于是决定制作一款叫做《天天爱跑步》的游戏。《天天爱跑步》是一个养成类游戏,需要玩家每天按时上线,完成打卡任务。
这个游戏的地图可以看作一一棵包含
现在有
小c
想知道游戏的活跃度,所以在每个结点上都放置了一个观察员。在结点 小c
想知道每个观察员会观察到多少人?
注意:我们认为一个玩家到达自己的终点后该玩家就会结束游戏,他不能等待一 段时间后再被观察员观察到。 即对于把结点
输入格式
第一行有两个整数
接下来
接下来一行
接下来
对于所有的数据,保证
输出格式
输出
分析
首先
我们处理出所有点的深度记为
那么玩家
1.
因为这两个条件具有互斥性,所以我们可以分开统计贡献,下面以统计满足条件1的节点数量
我们对条件一进行变式得到
对于操作一样使用树上差分,设b为差分计数数组,则由于本题记录的是边权,于是对于路径
但题目最大数据点线段树,它死了启发我们需要一个更加高效的算法
我们发现,这道题具备区间减法性质,且每个点只问一个特殊值的数量,于是我们可以采用前缀和的思想方式,利用区间减法性质,进行“树上前缀和”
我们发现,由于我们采用树上差分的操作,使得我们在
然后我们开一个全局的计数数组
总得来说,这题最后的统计答案具备区间减法性质,这个性质一样可以扩展到树上,也即一段区间信息能由其他两段信息推出,我们就可以采用类似前缀和的方式进行优化,而上一题是
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:次小生成树
题意:给定一张无向连通图,求其严格次小生成树
分析
首先,常用思路是我们先求出最小生成树,然后尝试加边,毫无疑问,设我们对最小生成树加入一条边
首先由最小生成树的性质,原
但这样就完全正确了吗,注意,我们要求的是“严格”次小生成树,于是当图中
于是我们的问题就变成了如何求任意两点路径上的最大边权和严格次大边权。
考虑DP,朴素DP应该很容易写出状态转移方程,但复杂度无疑是
于是我们考虑优化,这个朴素的DP似乎不具有使用数据结构优化的前提,于是我们就只剩下一种优化方法,倍增
因为本题的DP满足区间加法和可拼凑性,满足倍增优化DP的前提
我们设
那么我们设
至于对于路径
时间复杂度
- 处理
数组和倍增 ,需要 的时间 - 处理
数组的动态规划,需要 的时间 - 枚举边找最小候选答案,需要
的时间
总时间复杂度为
#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 国有
H 国的首都爆发了一种危害性极高的传染病。当局为了控制疫情,不让疫情扩散到边境城市(叶子节点所表示的城市),决定动用军队在一些城市建立检查点,使得从首都到边境城市的每一条路径上都至少有一个检查点,边境城市也可以建立检查点。但特别要注意的是,首都是不能建立检查点的。
现在,在 H 国的一些城市中已经驻扎有军队,且一个城市可以驻扎多个军队。一支军队可以在有道路连接的城市间移动,并在除首都以外的任意一个城市建立检查点,且只能在一个城市建立检查点。一支军队经过一条道路从一个城市移动到另一个城市所需要的时间等于道路的长度(单位:小时)。
请问最少需要多少个小时才能控制疫情。注意:不同的军队可以同时移动。
分析
本题很明显满足单调性。使用贪心思想,一个军队很明显靠根节点越近越好。所以我们考虑二分答案,贪心判定
设二分的值为
的时间内不可以走到根节点的子节点- 可以走到根节点的子节点
很明显,第一类节点就尽全力向上走就可以了,走完之后我们设 是根节点的子节点集合,我们递归判断 ,是否已经控制了疫情,这一步我们可以把军队驻扎的点标记,若递归遇到标记节点之间返回1,否则递归子节点,当一个子节点返回false的时候就代表整个不行,设T是还有叶子节点没有被管辖的节点的集合
第二类节点有两个决策,一是留在原地不动,二是去支援T中节点,我们使用一个三元组来表示第二类节点, 分别表示编号为 的军队在子节点 的子树内,移动到 还剩下 的时间
这里有一个性质,即对于一个三元组 ,若 ,则这个军队就驻扎在 ,不需要移动了。道理很简单,若这个军队要出去驻扎,那么设它驻扎在节点 ,则有 ,若此时有另一个三元组 跨根节点驻扎在 (原来的走了,新的得来),则总路程为 ,所以三元组 也一定可以去驻扎在 ,这样还不如直接 就不动,然后让 去驻扎其他节点,因为 能驻扎的节点 都可以,它有更多的决策可能性,也更少浪费时间,具备决策包容性,所以对于一个三元组 ,若 ,则这个军队就驻扎在 ,不需要移动了。
于是我们可以再一次统计这样的三元组,把它们从 里面扔出去,只对剩下的进行讨论
这时候,我们把闲置的三元组按照 从小到大排序,把T中节点按照 从小到大排序,使用双指针扫描就可以得到答案
正确性很显然,把大的留在后面有更多可能性,由决策包容性可知成立
最后判断能否把 中节点合理分出去,就可以确定 的正确性了
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;
}
好的下面让我们来总结本节要点
知识点:
- 树的直径定义,最长性
- 树的直径中点唯一性
- 树的直径必须边组成唯一一条链,这条链的求法,双端收缩范围
- 树的偏心距及树网的核,贪心思想
- LCA的求法,树剖,倍增,tarjan
- LCA的性质,树上路径
- 树上差分的两种类型
- 利用区间加法性质,计数数组快速合并改为线段树合并算法
- 树上路径最大+严格次大边权的
求法,倍增优化
经典思想 - 双指针扫描法:从中间扩展,从两端收缩,从一端扫描匹配
- 贪心思想:邻项交换,决策包容性
- 比较两个不同决策找性质
- tarjan算法的离线标记思想
- 利用区间减法性质,以前缀和思想减少空间开销
- 时空平衡,减少空间开销,遇到实际元素量很少,但范围很大的时候使用vector时间换空间
- 次小生成树一题中的倍增优化DP思想
- 容斥原理,正面不行从反面入手
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!