[算法学习笔记] 换根dp

upd:2024/7/20 更新了部分较难的内容。

换根 dp 一般不会指定根节点,并且根节点的变化会对一些值进行改变。因此我们需要转移根。

换根 dp一般需要预处理一下一个节点的值,然后对于任意节点开始树上dp转移。

所以我们常用两次 dfs,第一次 dfs预处理,第二次 dfs为树上 dp。

一般比较套路。

接下来会给出一个典型例题。

典例1:Luogu P3478

题目链接:Luogu P3478

Description

给定一个 \(n\) 个点的树,请求出一个结点,使得以这个结点为根时,所有结点的深度之和最大。
一个结点的深度之定义为该节点到根的简单路径上边的数量。

Analysis

Analysis 我们发现本题没有给定 root,而且 root 之间的转移会影响每个节点到根的简单路径上的边的数量。

那么这种变化之间有什么关联呢?

我们发现对于一条边 \(u-v\) ,其中 \(v\) 是儿子。如果从 \(u\)\(v\),那么
\(v\)\(v\) 的儿子深度都会 \(-1\),反之 \(v\) 上面的节点深度都会 \(+1\)

这就是转移式!借鉴先前求树的重心经验,对于 \(v\) 上面的部分,用 \(n-num_v-1\) 即可。

我们需要预处理每个节点的深度,以及每个节点下面有多少个儿子。然后转移即可。

形式化地,设 \(num_k\) 表示以 \(k\) 为父节点,其下面的儿子个数。\(dep_k\) 表示 \(k\) 的深度。则有:

\(f_k=f_{now}-num_v+(n-num_v)=f_{now}-2\times num_v+n\)\(now-v\) 表示一条边)

换根 dp 一般转移直接转移,因为显然当 root 确定时答案是显然确定的!最后求以哪个节点作为 root 最大即可。

Code

#include <iostream>
#include <cstdio>
#include <algorithm>
#include <vector>
#define int long long
using namespace std;
const int N = 1000100;
vector <int> Edge[N];
int n;
int dep[N],sz[N];
int f[N];
void dfs1(int now,int fa)
{
    int u = now;
    sz[u] = 1;
    dep[u] = dep[fa] + 1;
    for(int i=0;i<Edge[now].size();i++)
    {
        int v = Edge[now][i];
        if(v == fa) continue;
        dfs1(v,now);
        sz[now] += sz[v];
    }
}
void dfs2(int now,int fa)
{
    for(int i=0;i<Edge[now].size();i++)
    {
        int v = Edge[now][i];
        if(v == fa) continue;
        f[v] = f[now]-2*sz[v]+n;
        dfs2(v,now);
    }
}
signed main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    cin>>n;
    for(int i=1;i<n;i++)
    {
        int u,v;
        cin>>u>>v;
        Edge[u].push_back(v);
        Edge[v].push_back(u);
    }
    dfs1(1,-1);
    dfs2(1,-1);
    int maxn = -1,maxn_num;
    for(int i=1;i<=n;i++)
    {
        if(f[i] > maxn)
        {
            maxn = f[i];
            maxn_num = i;
        }
    }
    cout<<maxn_num<<endl;
    return 0;
}

典例2:求树的中心

Problem

Description

给定一棵树,树中包含 n 个结点(编号1~n)和 n−1 条无向边,每条边都有一个权值。

请你在树中找到一个点,使得该点到树中其他结点的最远距离最近。

Analysis

Analysis

一种求解方法是求树的直径,详见算法学习笔记 树的常用操作

下面介绍换根 dp 的实现。

\(f_i\) 表示以 \(i\) 为树的中心的答案。我们想到对于 \(i\),她要找距离其他节点最远的距离,是不是可以向下找和向上找?向下找非常容易,dfs 预处理即可。向上无法直接处理。

我们可以转化,对于一条边 \(u-v\) ,其中 \(u\) 是父亲。是不是可以用 \(u\) 向下所能走到的最远距离来更新?

此时就会出现一个问题,如果 \(u\) 向下走到的最远距离是由 \(v\) 更新来的。此时若直接更新则走回头路,这在树上是不允许的。

处理此类问题也非常简单,我们在预处理每个节点 \(u\) 向下走到的最远距离时同时记录一个“次大值”。需要注意次大值和最大值不能由一个儿子更新而来。这样,如果一个节点 \(u\) 向下走到的最远距离是由 \(v\) 更新来的,我们就用 \(v\) 的次大值更新即可。显然,\(v\) 的最大值和次大值不可能由同一个节点更新来的。

这个处理方法非常常见,可以记住。具体操作见下方代码。
形式化地,状态转移如下:

\(up_{v}=\max(up_u,d1_u)+w(d1_u \ne v)\)

\(up_{v}=\max(up_u,d2_u)+w(d1_u = v)\)

实现
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <vector>
using namespace std;
const int N = 100010;
const int INF = 0x3f3f3f3f;
typedef pair<int,int> PAIR;
int n;
vector <PAIR> Edge[N];
int d1[N],d2[N],s1[N],s2[N];
int up[N];
void dfs1(int now,int fa)
{
    for(int i=0;i<Edge[now].size();i++)
    {
        int v = Edge[now][i].first,w = Edge[now][i].second;
        if(v == fa) continue;
        dfs1(v,now);
        if(d1[v] + w >= d1[now])
        {
            d2[now] = d1[now];
            s2[now] = s1[now];
            d1[now] = d1[v] + w;
            s1[now] = v;
        }
        else if(d1[v] + w > d2[now])
        {
            d2[now] = d1[v] + w;
            s2[now] = v;
        }
    }
}
void dfs2(int now,int fa)
{
    for(int i=0;i<Edge[now].size();i++)
    {
        int v = Edge[now][i].first,w = Edge[now][i].second;
        if(v == fa) continue;
        int u = now;
        if(s1[now] == v)
        {
            up[v] = w + max(d2[u],up[u]);
        }
        else up[v] = w + max(d1[u],up[u]);
        dfs2(v,now);
    }
}
int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    cin>>n;
    for(int i=1;i<n;i++)
    {
        int a,b,c;
        cin>>a>>b>>c;
        Edge[a].push_back(PAIR(b,c));
        Edge[b].push_back(PAIR(a,c));
    }
    dfs1(1,-1);
    dfs2(1,-1);
    int res = INF;
    for(int i=1;i<=n;i++) res = min(res,max(up[i],d1[i]));
    cout<<res<<endl;
    return 0;
}

典例3:Luogu P2986

Problem

Analysis

Analysis

换根 dp 板子。

我们预处理出当 \(1\) 作为 root 的时候的不方便值。递归处理即可。同时在预处理递归的时候还需要处理出每个节点作为父亲其下面的儿子数量。这个回溯的时候更新即可。

需要注意哪怕是只求 \(1\) 作为 root,它还需要回溯的时候利用其他节点层层更新。

然后考虑转移,定义 \(f_i\) 表示当 \(i\) 作为根节点的不方便值。我们已经通过预处理计算出了 \(f_1\)\(num_i\)(以 \(i\) 为父节点其下面子节点数量)。我们可以画图看一下:
image

假设我们要从蓝色的点转移到红色的点。\(f_2\)\(f_1\) 有什么关系呢?

root 从 \(2\) 变成 \(1\),那么 \(2\) 下面的点,也就它的儿子,都少走了 \(w_{1,2}\),也就是 \(1-2\) 边的距离。简记为 \(w\)\(1\) 上面的点,都多走了 \(w\)。记 \(num_i\) 表示以 \(i\) 为父节点,它的儿子数量。则有:

\(f_v=f_u-num_v\times w+(Sum-num_v)\times w\)\(Sum\) 指一共有多少头奶牛)。

这个转移和上面的换根 dp 是类似的。

实现
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <vector>
#define int long long
#define INF 1e18
using namespace std;
typedef pair<int,int> PAIR;
const int N = 1000010;
int c[N],f[N],f1[N];
int sum[N];
vector <PAIR> Edge[N];
int n;
int Sum=0;
void dfs1(int now,int fa,int dist)
{
    sum[now] = c[now];
    for(int i=0;i<Edge[now].size();i++)
    {
        int v = Edge[now][i].first,w = Edge[now][i].second;
        if(v == fa) continue;
        dfs1(v,now,dist+w);
        sum[now] += sum[v];
        f1[now] += f1[v]+w*sum[v];
    }
}
void dfs2(int now,int fa)
{
    for(int i=0;i<Edge[now].size();i++)
    {
        int v = Edge[now][i].first,w = Edge[now][i].second,u = now;
        if(v == fa) continue;
        f[v] = f[u]+f1[u]-(f1[v]+sum[v]*w)+(sum[1]-sum[v])*w;
        dfs2(v,now);
    }
}
signed main()
{   
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    cin>>n;
    for(int i=1;i<=n;i++)
    {
        cin>>c[i];
        Sum += c[i];
    }
    for(int i=1;i<n;i++)
    {
        int u,v,w;
        cin>>u>>v>>w;
        Edge[u].push_back(PAIR(v,w));
        Edge[v].push_back(PAIR(u,w));
    //    Sum += w;
    }
    dfs1(1,0,0);
    dfs2(1,0);
    int minn = INF;
    for(int i=1;i<=n;i++)
    {
        minn = min(minn,f[i]+f1[i]);
    }
    cout<<minn<<endl;
    return 0;
}   

典例4:Luogu P9584 城市

题目链接:Luogu P9584

Solution

一眼顶针,鉴定为换根 dp。

如果大家做过这道题的话,我们可以直接在这道题的基础上进行修改。(赛场我就是这么干的 qwq)

首先,如果忽略每次询问新建一条边,那这道题是不是就和上面那个一模一样?

每次询问加边也非常简单,运用 dp 的思想,考虑每次询问新增一条边的贡献。我们知道每次新增一条的边是从 \(k\)\(n+1\)。显然首先增加了以 \(k\) 为“根节点”的贡献(这里的“根节点”是以上面那个题为例)。由于我们保证 图上的每一个点都可以到达任意一个点。所以以每个点作为“根节点”的贡献都会加 \(w\)(每个点都可以到达 \(n+1\))。

上述描述可能有些许抽象,我们来画图举例。

在上图中,我们把 \(5\) 号节点作为 \(k\)。我们已经预处理加边之前以每个节点为“根节点”的贡献。新增这条边,首先从 \(8\) 可以到 \(5\),然后就是 \(5\) 的贡献。所以 \(ans+f_5\)。(\(ans\) 为每次询问加边之前,每个节点作为“根节点”的贡献和,\(f_5\) 表示以 \(5\) 作为“根节点”的贡献。我们已经通过换根 dp 求出)。

然后,由于从 \(8\) 走到 \(5\) 然后可以到达任意一个节点,而图上一共有 \(n\) 个节点(除了新加上的 \(n+1\) 号节点),每次走都会走一遍 \(w\),所以需要 \(ans+w\times n\)

这样就结束吗?怎么样例都过不了!仔细想想,由于是无向图,我们能从 \(8\) 走到图上任意一个节点,显然从图上任意一个节点都可以走到 \(8\)!上述更改都需要 \(\times 2\)

至于换根 dp 部分就可以愉快地套用这道题啦!由于比较板这里不再赘述,不明白换根 dp 如何处理的可以去看这道题的题解呢!

注意,本题还有一个坑点就是取模,在下文的代码部分会给出。

实现
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <vector>
#define int long long
#define INF 1e18
using namespace std;
const int mod =  998244353;
typedef pair<int,int> PAIR;
const int N = 1000010;
int c[N],f[N],f1[N];
int sum[N];
vector <PAIR> Edge[N];
long long n,q;
int Sum = 0;
void dfs1(int now,int fa,int dist)
{
    sum[now] = c[now]%mod;
    sum[now] %= mod;
    for(int i=0;i<Edge[now].size();i++)
    {
        int v = Edge[now][i].first%mod,w = Edge[now][i].second%mod;
        if(v == fa) continue;
        dfs1(v,now,dist+w%mod);
        sum[now] += sum[v]%mod;
        sum[now] %= mod;
        f1[now] += f1[v]+w%mod*sum[v]%mod;
        f1[now] %= mod;
    }
}
void dfs2(int now,int fa)
{
    for(int i=0;i<Edge[now].size();i++)
    {
        int v = Edge[now][i].first,w = Edge[now][i].second,u = now;
        if(v == fa) continue;
        f[v] = f[u]+f1[u]%mod-(f1[v]%mod+(sum[v]%mod)%mod*w%mod)+(sum[1]-sum[v])%mod*w;
        f[v] = (f[v]%mod+mod)%mod; //减法操作一定要这么处理,否则前面取模变小,后面数很大,做减法会出现负数。
        if(sum[1]-sum[v] < 0) 
        {
        	return;
		} 
        dfs2(v,now);
    }
}
signed main()
{   
//	freopen("input.txt","r",stdin); 
//	freopen("output.txt","w",stdout);
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    cin>>n>>q;
    for(int i=1;i<=n;i++)
    {
        c[i] = 1;
    }
    for(int i=1;i<n;i++)
    {
        long long u,v,w; 
        cin>>u>>v>>w;
        Edge[u].push_back(PAIR(v,w));
        Edge[v].push_back(PAIR(u,w));
        Sum += w%mod;
        Sum %= mod; 
    }
    dfs1(1,0,0);
    dfs2(1,0);
    int Sum1 = 0;
    for(int i=1;i<=n;i++)
    {
        Sum1 += (f[i]%mod+f1[i]%mod);
        Sum1 %= mod;
    }
    while(q--)
    {
        long long k,w;
        cin>>k>>w;
        __int128 t = (Sum1%mod + (f[k]+f1[k]%mod+w%mod*n)%mod*2%mod) % mod;
        long long ans2=(long long)t%mod;
        cout<<ans2<<endl;
    }
    return 0;
}   

典例5:医院设置

Problem

Description

image

Analysis

Analysis

本题数据范围较小,可以通过 Floyd 预处理任意两点距离然后暴力计算通过。这里主要介绍换根 dp 做法。

我们可以预处理算出以 1 为 root 时的答案,这很简单,dfs 扫一遍就可以。在 dfs 的同时我们算出 每个节点下的节点个数,以备状态转移使用。

dfs 预处理代码如下。

dfs预处理
void dfs(int u,int fa,int dist) 
{
	for(auto v:Edge[u]) 
	{
		if(v == fa) continue; 
		sum += dist*num[v]; // sum  即为以 1 为 root 时的答案
		dfs(v,u,dist+1); 
		num[u] += num[v]; // 同时算出每个点下方节点个数,以备换根
	}	
}

到现在,我们已经预处理了以 1 为 root 时的答案以及每个节点下方的节点数量(准确来说是“病人”数量)。

考虑转移。

image

转移非常典,对于边 \(u,v\),当 root 从 \(u\) 转移到 \(v\) 时,\(v\) 下方的病人都不需要走一段路,\(v\) 上方的病人需要多走一段路。状态转移方程如下:

\(f_v=f_u-\sum_v+(Sum-sum_v)=f_u-2sum_v+Sum\)

Explanation: \(Sum-sum_v\) 指节点 \(v\) 上方的节点数量,我们在输入每个节点信息的时候算出了总人数 \(Sum\)。这是换根 dp 的常见做法。

(实际上这个转移方程太典了,一眼秒了,读者可以借本题了解换根 dp 的常用处理方法)。

至此,我们在 \(O(n)\) 的复杂度下就可以处理出以每个节点为 root 的答案。取 minn 即可。

这可以说是最简单的换根 dp,难度远远不如上面的题目。

Code
/*
换根 dp
预处理出 以 1 为 root时的答案 然后换根 
*/
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <vector>
using namespace std;
const int N = 10010;
const int INF = 0x3f3f3f3f;
int num[N]; 
int rem[N];
vector <int> Edge[N];
int mapp[N][N];
int n;
int sum = 0;
int minn = INF;
int population = 0;
void dfs(int u,int fa,int dist) 
{
	for(auto v:Edge[u])
	{
		if(v == fa) continue;
		sum += dist*num[v];
		dfs(v,u,dist+1);
		num[u] += num[v];
	}	
}
void solve(int u,int fa,int ans)
{
	for(auto v:Edge[u])
	{
		if(v == fa) continue;
		int t = ans;
		t = t-num[v]+(population-num[v]);;
		minn = min(minn,t);
		solve(v,u,t);
	}
}
int main()
{
	freopen("input.txt","r",stdin);
	ios::sync_with_stdio(false);
	cin.tie(0);
	cout.tie(0);
	cin>>n;
	for(int i=1;i<=n;i++)
	{
		int v1,v2;
		cin>>num[i]>>v1>>v2;
		rem[i] = num[i];
		population += num[i]; // population 即为总人数
		Edge[i].push_back(v1);
		Edge[i].push_back(v2);
	}
	dfs(1,0,1); // 预处理
	minn = min(minn,sum);
	solve(1,0,sum); // 换根转移
	cout<<minn<<endl;
}

典例6 CF543D

Source

Description
posted @   SXqwq  阅读(143)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!
点击右上角即可分享
微信分享提示