【笔记】树形DP
树形DP
前言
- 与树或图的生成树相关的动态规划。
- 以每棵子树为子结构,在父亲节点合并,注意树具有天然的子结构,这是很优美的很利于 \(dp\) 的。
- 巧妙利用 \(Bfs\) 或 \(Dfs\) 序,可以优化问题,或得到好的解决方法。
- 可以与树上的数据结构相结合。
- 树形 \(Dp\) 的时间复杂度要认真计算,部分问题可以均摊复杂度分析。
- 一般设 \(f[u]\) 表示 \(u\) 子树的最优价值或者是说方案数,或者 \(f[u][k]\) 表示 \(u\) 子树附加信息为 \(k\) 的最优值,往往是通过考虑子树根节点的情况进行转移。
- 树形 \(dp\),在树结构上,求最优值,求方案等 \(dp\) 问题,就可以考虑是树
形 \(dp\)。 - 当然也有可能是点分治或者是分析性质的贪心题。但是树形 \(dp\) 绝对是一个很好的思考方向。
树上最大独立集
【没有上司的舞会】
【\(Description\)】
给你一颗大小为 \(n\) 的树,求这棵树最大独立子集是多少。
最大独立子集指的是一个最大的点集
\(n \leq 6 \times 10 ^ 3\)
【\(solution\)】
\(dp[i][0/1]\) 表示做完了 \(i\) 的子树,\(i\) 是是否选的最大独立子集,即可直接转移。
对于当前节点 \(i\) ,如果不选 \(i\) ,那么他的儿子可以选也可以不选。
如果选 \(i\) ,那么他的儿子一定不能选。
【\(Code\)】
#include <bits/stdc++.h>
#define ull unsigned long long
#define ll long long
#define M 10010
#define N 7010
#define INF 0x3f3f3f3f
using namespace std;
int n,m,num;
struct node{
int next;
int to;
int le;
}e[M];
int head[M];
int f[N][2];
int du[M];
inline int read()
{
int s = 0, f = 0;char ch = getchar();
while (!isdigit(ch)) f |= ch == '-', ch = getchar();
while (isdigit(ch)) s = s * 10 + (ch ^ 48), ch = getchar();
return f ? -s : s;
}
void print(int x)
{
if (x < 0) putchar('-'), x = -x;
if (x > 9) print(x / 10);
putchar(x % 10 + 48);
}
void add(int from,int to)
{
e[++num].next=head[from];
e[num].to=to;
head[from]=num;
}
void dfs(int now)
{
f[now][0]=0;
f[now][1]=e[now].le;
for(int i=head[now];i;i=e[i].next) {
int y=e[i].to;
dfs(y);
f[now][0]+=max(f[y][0],f[y][1]);
f[now][1]+=f[y][0];
}
}
signed main()
{
n = read();
for(int i=1;i<=n;i++) e[i].le=read();
for(int i=1;i<n;i++) {
int x,y;
x=read();
y=read();
add(y,x);
du[x]=1;
}
for(int i=1;i<=n;i++) {
if(!du[i]){
dfs(i);
printf("%d",max(f[i][0],f[i][1]));
return 0;
}
}
}
【树的直径】
\(Cow \ \ Marathon\)
【\(description\)】
给你一颗节点为 \(n\) 的树,让你求这棵树的直径是多少,也就是求最长的两个点之间的距离。\(n \leq 10 ^ 5\) 。
【\(Solution\)】
-
设 \(f[i]\) 表示 \(i\) 这个点到子树里面的最远点是多远的,然后对于每一个点 \(u\)
求出求出以这个点为根的最远路径距离,直接找 \({f[son_i]+edge_i}\) 的前两大值加起来即可。然后再在每一个点构成的答案里面取最大值就是全局的最优值了 -
随便找一个点 \(bfs\) 求它的最远点,设为 \(x\) ,再从 \(x\) 跑一遍 \(bfs\) ,求 \(x\) 最远点 \(y\) ,则 \((x,y)\) 就是一个直径了。
-
考虑离每一个点最远的点肯定是直径的其中一个端点。
【\(Tree\ chain\ problem\)】
【\(description\)】
给定一颗有 \(n\) 个节点的树,以及 \(m\) 条树链,其中第 \(i\) 条树链的价值为 \(w_i\) ,请选择一些没有公共点的树链,使得价值和最大。
\(1 \leq n,m \leq 10 ^ 3\)
【\(solution\)】
考虑树形 \(DP\),设 \(f[x]\) 为以 \(x\) 为根的子树内选取不相交树链的价值和的最大值,
枚举一条 \(LCA\) 为 \(x\) 的链 \((u,v,w)\) ,那么当前方案的价值为 : \(w+\) 去除 \(u\) 到 \(v\) 路径上的点后深度最小的点的 \(f\) 的和。
复杂度 \(O(M \times N)\)
树链剖分优化可以做到 \(O(M \times log(N)^2)\) 。
【三色二叉树】
【\(description\)】
给出了一棵二叉树,点数为 \(n\) ,然后要求每个点和其左儿子和其右儿子三者两两之间颜色互不相同,求最多能有多少点能被染成绿色。
\(n \leq 10 ^ 5\)
【\(solution\)】
(\(f[i][0]\) ,\(f[i][1]\),\(f[i][2]\)) , (\(g[i][0]\) ,\(g[i][1]\),\(g[i][2]\)) 分别表示根是绿红蓝三种颜色时的最多绿色 / 最少的数量,转移的时候只要保证上面的约束就行。
对于一颗完全二叉树,他的根节点和他的两个子节点必然是不同的颜色,根据抽屉原理,三个点染两种颜色必然不符合题意,所以考虑从子节点往上 \(DP\) ,对于每次转移,要比较如何染色使其最优。
记者给叶结点附初始值 \(f[i][0] = g[i][0] = 1\)
【Code】
#include <iostream>
#include <cstdio>
#include <cmath>
#include <cstring>
#include <algorithm>
using namespace std;
const int Maxk = 7e5 + 10;
char s[Maxk];
int f[Maxk][3];
//f[i][0] ,f[i][1],f[i][2]分别表示根是绿红蓝三种颜色时的最多/最少绿色的数量
int g[Maxk][3];
int n,cnt = 1;
int son[Maxk][2];
inline int read()
{
int s = 0, f = 0;char ch = getchar();
while (!isdigit(ch)) f |= ch == '-', ch = getchar();
while (isdigit(ch)) s = s * 10 + (ch ^ 48), ch = getchar();
return f ? -s : s;
}
int Max(int x,int y) {return x > y ? x : y;}
int Min(int x,int y) {return x < y ? x : y;}
void build(int i)//建树
{
int now = s[i] - '0';
if(now == 0) return;//如果这个字符为 0 则没有子节点
else if(now == 1) son[i][0] = ++ cnt,build(cnt);//有左子节点
else if(now == 2) son[i][0] = ++ cnt,build(cnt),son[i][1] = ++ cnt,build(cnt);//有右子节点
}
void dfs_max(int i)
{
int l = son[i][0],r = son[i][1];
if(l) dfs_max(l);
if(r) dfs_max(r);
if(!l && !r) f[i][0] = g[i][0] = 1, f[i][1] = f[i][2] = g[i][1] = g[i][2] = 0;
f[i][0] = Max(f[l][1] + f[r][2],f[l][2] + f[r][1]) + 1;
f[i][1] = Max(f[l][0] + f[r][2],f[l][2] + f[r][0]);
f[i][2] = Max(f[l][0] + f[r][1],f[l][1] + f[r][0]);
g[i][0] = Min(g[l][1] + g[r][2],g[l][2] + g[r][1]) + 1;
g[i][1] = Min(g[l][0] + g[r][2],g[l][2] + g[r][0]);
g[i][2] = Min(g[l][0] + g[r][1],g[l][1] + g[r][0]);
}
signed main()
{
scanf("%s",s + 1);
n = strlen(s + 1);
build(1);
dfs_max(1);
cout << Max(f[1][0],Max(f[1][1],f[1][2])) << " ";
cout << Min(g[1][0],Min(g[1][1],g[1][2]));
return 0;
}
【\(BZOJ \ \ 4711\)】
【\(description\)】
给出一棵树,每个点上都有 \(W_i\) 的矿石,现在可以在一些点上设立仓库,每设立一个仓库,都要花费 \(k\) 的代价。最后每个点的代价如下计算,如果离他最近的点距离为 \(dis\) ,则代价为 \(W_i \times f(dis)\) 。最小化总代价。树边长度都为 \(1\) 。
\(n \leq 200\)
【\(solution\)】
考虑 \(f[i][j]\) 表示 \(i\) 的子树内所有点都确定了往哪送,并且 \(i\) 送到 \(j\) 号点,并且 \(j\) 号点现在还未建立仓库(准确来说是 \(j\) 号点 \(k\) 的代价还没计入 \(dp\) 值内)的最小代价。
考虑转移,首先 \(f[i][j]\) 要加上从 \(i\) 到 \(j\) 的代价,然后考虑 \(i\) 的每一个儿子 \(i'\),如
果 \(i'\)也运到 \(j\),那么这个子树的代价就是\(f[i'][j]\)。如果 \(i'\) 运到一个不等于 \(j\) 的点 \(j'\) ,那么 \(j'\) 一定在以 \(i'\) 为根的子树里,这样每个被建立的仓库只会被算一次。
其实就是在 \(j\) 号点控制的联通块的根出记上 \(k\) 的花费以保证这个点 \(k\) 的花费被记到 \(dp\) 值恰好一次。
最终答案为 \(\min(f[1][i]) + k\)。
复杂度 \(O(N^3)\) 状态平方,转移是线性的。
树上背包
【树上背包简化版】
【\(Description\)】
给出一棵 \(n\) 个点的有根树,每个节点都是一个物品,具有价值 \(V_i\),如果一个物品要被选择,那么它的父亲必须被选择。
求选择至多 \(m\) 个物品的最大价值和。
\(n \leq 10 ^ 3\)
【\(Solution\)】
\(f[i][j]\) 表示在以 \(i\) 为根子树中选择, \(i\) 强制选择,选择 \(j\) 个点的最大价值,转移时每次将一个孩子暴力合并到父亲上,合并就枚举这个孩子内部选择了多少点即可。
就是枚举 \(son\) 内选了多少点。
我们按照一般的分析复杂度的方式的话是:状态数 $N^2 \times $转移复杂度 \(N\) ,总复杂度是 \(O(N ^ 3)\)。
实际上我们考虑每次合并的时候相当于是一组点对数量的复杂度,总的
来看的话就是 \(n\) 个点点对的数量,均摊复杂度 \(O(N^2)\)。
【树上背包】
【\(Description\)】
给出一棵 \(n\) 个点的有根树,每个节点都是一个物品,具有价值 \(V_i\) 和重量 \(W_i\),如果一个物品要被选择,那么它的父亲必须被选择。
求限制重量 \(m\) 内的最大价值和。
\(n , m \leq 10 ^ 3\)
【\(Solution\)】
这里不是选点的数量而是重量,所以这里的朴素做法是 \(O(n^3)\)。
\(f[i][j]\) 表示在以 \(i\) 为根子树中选择, \(i\) 强制选择,重量为 \(j\) 的最大价值,转移时每次将一个孩子暴力合并到父亲上,合并就枚举这个孩子内部选择了多少的重量即可。
就是枚举 \(son\) 内用了多少重量。
注意我们这里两个一维数组的背包合并是 \(n^2\) 的,所以慢。
但一个一维数组和一个单独的物品合并是线性的。
我们可以在 \(DFS\) 序上做 \(DP\)
在 \(dfs\) 序上 \(DP\),如果不选一个点,则跳过代表他的子树的 \(dfs\) 上连续一段。
设 \(f[i][j]\) 表示 \(dfs\) 序上第 \(i\) 个点到第 \(n\) 个点,选了 \(j\) 的重量得到的最大的价值是多少。\(i\) 可以选也可以不选。不选的话就要跳过整个子树。
设 \(T[i]\) 表示 \(dfs\) 序为 \(i\) 的点标号。
不选:
选:
两种情况取最大值即可。
另一个奇妙的方法
不是每次将孩子与自己合并,我们直接把 \(dp\) 数组复制再传给孩子,再从孩子递归下去,最后原来自己的 \(DP\) 数组和孩子的 \(DP\) 数组直接在对应重量的价值中取 \(\max\)。
以下是步骤:
- 我们现在在 \(u\) 节点,对 \(u\) 节点的 \(dp\) 数组中加入 \(u\) 点的物品。
- 做 \(dp[i] = \max\{dp[i],dp[i-w[u]]+v[u]\}\)操作。
- \(Dpson\) 数组 = \(dp\) 数组。
- 递归计算 \(son\) 的 \(dp\) 值,传入的参数是 \(dpson\) 数组。
- 回溯回 \(u\) 点。
- 对每一个 \(i\) 做 \(dp[i] = \max\{dpson[i],dp[i]\}\)。
【选课】
【\(Description\)】
在大学里每个学生,为了达到一定的学分,必须从很多课程里选择一些课程来学习,在课程里有些课程必须在某些课程之前学习,如高等数学总是在其它课程之前学习。
现在有 \(N\) 门功课,每门课有个学分,每门课有一门或没有直接先修课(若课程 \(a\) 是课程 \(b\) 的先修课即只有学完了课程 \(a\),才能学习课程 \(b\))。一个学生要从这些课程里选择 \(M\) 门课程学习,问他能获得的最大学分是多少?
\(1 \leq n , m \leq 300\)
【\(Solution\)】
因为数据范围比较小,可以采用 \(O(n ^ 3)\) 的做法。
由于这是一个由多棵树组成的森林,我们可以建造一个超级源点 \(0\), \(0\) 是所有人的父亲,且学分是 \(0\),所以可以选择的课程数目加一,但是得分不变,仍符合题意。
\(f[i][j]\) 表示在以 \(i\) 为根子树中选择,\(i\) 强制选择,选择 \(j\) 个点的最大价值,转移时每次将一个孩子暴力合并到父亲上。
从叶结点开始找,每次枚举子节点里面选择了多少点。
【\(Code\)】
#include <bits/stdc++.h>
#define LL long long
using namespace std;
const int Maxk = 300 + 10;
int n,m;
int f[Maxk][Maxk];
vector<int> e[Maxk << 2];
//f[i][j]表示在以i为根子树中选择,i强制选择,选择j个点的最大价值
inline int read()
{
int s = 0, f = 0;char ch = getchar();
while (!isdigit(ch)) f |= ch == '-', ch = getchar();
while (isdigit(ch)) s = s * 10 + (ch ^ 48), ch = getchar();
return f ? -s : s;
}
void work(int now)
{
for(int i = 0;i < e[now].size();i ++) work(e[now][i]);//优先处理子节点,不断向下递归。
for(int i = 0;i < e[now].size();i ++) {
int y = e[now][i];
for(int j = m;j > 0; -- j) {
for(int k = 0;k < j;k ++) {
f[now][j] = max(f[now][j],f[now][j - k] + f[y][k]);//枚举子树内选多少点。
}
}
}
}
signed main()
{
n = read(),m = read();
++ m;//建立超级根节点,所以这个根节点必须选,
for(int i = 1;i <= n;i ++) {
int x,y;
x = read(),y = read();
f[i][1] = y;
e[x].push_back(i);
}
work(0);
printf("%d",f[0][m]);
return 0;
}
换根树形 DP
树上有一个很经典的套路
我们看之前的都是从下往上推。
有一类题,是要先从下往上推,得到一些信息之后,再从上往下推下去得到最终答案
【经典题】
【\(Description\)】
一棵边带权值 \(n\) 个点的树,求每个点在树上的最远距离。
\(n \leq 10 ^ 5\)
【\(Solution\)】
法一 :
显然每个点的最远点一定在直径的两端其中一个。
两遍 \(bfs\) 求出直径 \((A,B)\) 后。然后求出 \(A\) 到所有点的距离, \(B\) 到所有点的距离,最后求出每一个点的最远距离即可。
法二 :
有些问题我们只需要处理子树的信息就可以计算出全部答案,但是还有一些问题需要知道父亲那一枝的信息。
这一类问题我们会做两遍 \(dfs\)。
- 一遍求出每个点子树内的信息。
- 另一遍从根往下遍历的时候计算维护出每个点父亲枝的信息。

\(Dp[u][0]\) : 是向下第一长的。
\(Dp[u][1]\) : 是向下第二长的。
\(Dp[u][2]\) : 向上最长的长度。
每个点的答案就是以上三者最大的值。
总结
其实这种 \(dp\) 我的理解也称之为换根树形 \(dp\)。
在前面的题目当中,以 \(1\) 号点为根 \(dfs1\) 之后,我们就得到一号点(根)所需的所有信息。如果我们要求全局的答案,我们可以枚举每个根都这个算一遍,但复杂度就太高了。
我们发现如果我们知道了 \(1\) 号点作为根的所有信息,通过一些加加减减我们就可以得到1号点的孩子作为根所用的信息,我们现在就视一号点的孩子为根了,这就完成了一个换根操作。这个过程还是要用 \(dfs\) 来实现。
首先最完从下到上的 \(dp\) 之后,对根来说所需要计算答案的信息都足够了,而其他的点只有孩子枝的信息,缺少父亲枝的信息。
(因为所有从原始根出去的分支的信息都计算完了,但是其他的点的父亲一枝的信息是没有通过从下而上的 \(dp\) 计算出来的,我们第二遍 \(dfs\) 就是求每一个点父亲枝的信息)。
我们肯定是不能对每一个点作为根跑一遍 \(O(n)\) 的 \(dp\)。
所以我们第二遍 \(dfs\) 过程中就求出了每个点父亲枝的信息,这样该点所需计算答案的所需信息都有了。对应的算答案就好。
【摧毁树状图】
【\(Description\)】
【\(Solution\)】
【\(Code\)】
【基环树】
基环树,也是环套树,简单地讲就是树上在加一条边。
如果把那个环视为中心,可看成:有一个环,环上每个点都有一棵子树的形式。
因此,对基环树的处理两部分分别为对树处理和对环处理。

基环树处理方法
- 断开环上一条边,枚举边端点的情况,然后当树来处理。
- 先把环上挂着的树的信息都计算完,然后转化为序列问题,或者说是环形的序列问题。
dfs 找环
基环树,环是关键,所以做这类题目我们首先得找到环。
找环的方式很多,这里讲解dfs找环。
对于 \(dfs\) 找环,我们就对这个基环树做 \(dfs\) 遍历。我们知道对于一个在图,它 \(dfs\) 树上的每一个返祖边 \((v \to u)\),和 \(dfs\) 树上构成的路径就会构成一个环。也就是我们只需要找到这个返祖边即可。

主函数调用时,要枚举每一个点。

因为有可能是个基环树森林。
这是很容易犯的一个坑:\(n\) 个点 \(n\) 条边不一定是个基环树,准确来讲是基环树森林!!

如果说我们要采用断开一条边,当成树来处理。我们不需要找出来整个环,只需要找一个在环上的边,按右图写法会简便很多。
基环内外向树
内向
首先它是一个有向图,它构成类似基环树的结构,有一个特点是每个点
都有且只有一个出度,并且环外的节点方向指向环内。
如果题目说满足每一个点都有一个唯一出度,则本质上就是给了我们一个 基环内向树森林(不只是一个基环树!!!!)。
任何一个点沿着唯一出边走都会走到环上。
利用这个性质可以随便选一个点再通过一个简单循环找到基环树的环。

外向
与基环内向树相反,它有且只有一个入度(基环内向树是出度),并且
并且由环指向环外。
可以把所有边反向后,变成基环内向树找快速找环。

【骑士】
【\(Description\)】
\(N\) 个人,每个人都有一个战斗力和一个讨厌的人(不是他本身),要求一个总战斗力最大的人的集合,满足集合内部两两不互相讨厌。
\(n \leq 10 ^ 5\)
【\(Solution\)】
把这个讨厌关系的图画出来,就是个基环内向树森林,然后我们要求最
大权独立集。
求最大独立集内向和外向和无向图毫无区别,都是相邻的不能选。
这里的基环树上有且仅有一个环,就是从任意环上一条边 \((u,v)\) 断开环,分两种情况,一种是选 \(u\),不选 \(v\) ,一种是选 \(v\) ,不选 \(u\) (其实还有一种是都不选),两种情况取最大值。转化成树的话,就是个树形 \(dp\)。
找环 \(dfs\) 找就好,或者从一个点顺着父亲一直走直到走到一个曾经走到过的点就找到一个环了。
【\(Code\)】
远古代码。。。
#include<cstdio>
#include<cmath>
#include<iostream>
#include<cstring>
#include<queue>
#include<algorithm>
#include<stdlib.h>
#include<time.h>
#include<map>
#include<vector>
#include<set>
#define ull unsigned long long
#define ll long long
#define M 1000010
#define N 1010
#define qaq cout<<"可行 QAQ"<<endl
#define INF 0x3f3f3f3f
using namespace std;
const int mod1 = 19260817;
const int mod2 = 19660813;
/*================================================*/
ll n,ans,sum,tot;
int num,l,r,root;
bool flag;
struct node{
int next;
int to;
}bian[M<<1];
int head[M];
int fat[M];
int w[M];
ll f[M][2];
int vis[M];
/*================================================*/
inline int read()
{
int s = 0, f = 0;char ch = getchar();
while (!isdigit(ch)) f |= ch == '-', ch = getchar();
while (isdigit(ch)) s = s * 10 + (ch ^ 48), ch = getchar();
return f ? -s : s;
}
void print(int x)
{
if (x < 0) putchar('-'), x = -x;
if (x > 9) print(x / 10);
putchar(x % 10 + 48);
}
void add(int from,int to)
{
bian[++num].to=to;
bian[num].next=head[from];
head[from]=num;
}
void dfs_tree(int now)
{
vis[now]=1;
f[now][0]=0;
f[now][1]=w[now];
for(int i=head[now];i;i=bian[i].next) {
int y=bian[i].to;
if(y==root){
f[y][1]=~INF;
continue;
}
dfs_tree(y);
f[now][1]+=f[y][0];
f[now][0]+=max(f[y][1],f[y][0]);
}
}
void dfs_ring(int x)
{
vis[x]=1;
root=x;
while(!vis[fat[root]]){
root=fat[root];
vis[root]=1;
}
dfs_tree(root);
sum=max(f[root][0],f[root][1]);
root=fat[root];
dfs_tree(root);
tot=max(f[root][0],f[root][1]);
ans+=max(sum,tot);
}
/*=================================================*/
signed main()
{
// freopen(".in","r",stdin);
// freopen(".out","w",stdout);
scanf("%lld",&n);
for(int i=1;i<=n;i++) {
int x;
scanf("%d%d",&w[i],&x);
add(x,i);
fat[i]=x;
}
for(int i=1;i<=n;i++) {
if(!vis[i]) {
dfs_ring(i);
}
}
printf("%lld",ans);
return 0;
}
【岛屿】
【\(Description\)】
求基环树森林里每棵基环树的直径之和。
\(n \leq 10 ^ 6\)
【\(Solution\)】
先找出环,很明显答案有两种情况。
- 在以一个环上节点为根的外向树的直径。
- 以两个环上节点分别为根的最大深度再加上两个节点在环上距离。
第一种情况就是之前讲的树形dp。
第二种情况要处理出以环上每个节点为根的最大深度 \(deep[i]\),环上的点重标号,选环上 \(1\) 号点作为基准点,求出 \(s[i]\) 表示 \(i\) 号点到 \(1\) 号点的距离,\(sum\) 为总的环长。
设我们找的两个环上节点是 \(i\) , \(j\) ,设答案为 \(Ans\)。
但如果暴力求是 \(n^2\) 的。并没有比最开始直接枚举快多少,考虑优化。
我们考虑把内部的一个 \(\min\) 去掉,式子能看起来更清晰一些。
考虑枚举选的两个点的后一个点 \(i\),然后求对于 \(i\),离 \(i\) 最远的 \(j\) 距离是多少,然后对于每一个 \(i\) 的答案求最大值就是整个基环树的直径了。
第一种情况 : \(s[j] \geq s[i]-sum/2\)
求 \(deep[j] - s[j]\) 的最大值即可,注意可选的 \(j\) 区间会移动,所以这里需要单调队列。
第二种情况 : \(s[j]< s[i]-sum/2\)
这个可行区间只会变大,不会缩小,所以直接记录 \(s[j]+deep[j]\) 的最大值即可。
【\(Code\)】
//你不会真觉着我写了代码了吧?
总结
- 树上背包问题,一种经典且常用的均摊复杂度分析的方式,很多时候一道看似复杂度超的题目就因此复杂度正确了。
- 基环树两个处理方法,断环为树,或处理树上信息后转化成环形的序列问题。

浙公网安备 33010602011771号