树是特殊的无环连通图,有n个节点以及n - 1条边(可以是有向边也可以是无向边).
树形dp则是基于树这个数据结构的动态规划问题.
树分为有向树和无向树.有向树只要储存每个节点的子节点即可.而无向树则用邻接表的形式进行储存.注意无向树在遍历的时候遍历过的节点(状态)要打上标记,防止重复遍历.
在树上设计dp算法时,一般以节点从深到浅(子树从小到大)的顺序作为dp的"阶段".dp的状态表示中,一般第一维是节点编号(表示以该节点为根的子树).
我们有两种方式实现动态规划(对应两种遍历树的方式).分别是递归和拓扑序的形式(dfs和bfs,不过后者是按照从后到前的拓扑序的顺序进行遍历的,可以建立返图来进行遍历).在大多数情况下,第一种形式就已经足够.
我们主要讲一下递归是如何实现树形的动态规划的.对于每个节点x,先递归到它的每个子节点,在每个子节点上进行dp.在回溯时,从子节点进行状态转移.为什么要这么做呢?因为dp解决的问题是具有最优子结构和无后效性的,最优子结构对应着子树.如果我们从顶至下更新状态,就违背了无后效性这一条件(因为父节点会影响其子树).
接下来是几道经典的例题可以帮助读者更好地了解树形dp:
1.AcWing 285. 没有上司的舞会
我们以节点编号作为状态的第一维度,由于子节点选不选只跟父节点有关,于是我们以当前节点选不选作为状态的第二维度.在状态转移完成以后,我们只要比较根节点参加舞会时整颗子树的最大快乐指数之和以及根节点不参加时的最大快乐指数和即可,可以满足"最优子结构".
设F[x, 0]表示以x为根的子树中邀请一部分人参加舞会,x不参加舞会时,快乐指数总和的最大值.此时,x的子节点可以参会:
F[x, 1]则表示当前节点参加舞会时快乐指数总和的最大值,子节点不能参加,所以有:.
我们从根节点开始dp:
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N = 6010;
int f[N][2];
int h[N];
int n;
bool v[N];
vector<int> son[N];
void dp(int x){
f[x][0] = 0;
f[x][1] = h[x];
for (int i = 0; i < son[x].size(); ++i){
int y = son[x][i];
dp(y);
f[x][0] += max(f[y][0], f[y][1]);
f[x][1] += f[y][0];
}
}
signed main(){
cin >> n;
for (int i = 1; i <= n; ++i)
cin >> h[i];
for (int i = 1, a, b; i < n; ++i){
cin >> a >> b;
son[b].emplace_back(a);
v[a] = 1;
}
int root;
for (int i = 1; i <= n; ++i)
if (!v[i]){
root = i;
break;
}
dp(root);
cout << max(f[root][0], f[root][1]) << '\n';
}
2.AcWing 286. 选课(树形背包)
我们设计状态F[x, t]表示以x为根的树选取了t节课时选得的最多的学分.
有状态转移:
.
,我们关注这部分,这部分实际上就可以转化成一个分组背包模型.有p=|son(x)|个物品,每组物品有t - 1个,其中第i组物品的第j个物品的体积为j,价值为,背包的总容积为t - 1,我们要从每组中选取不超过1个物品(每个子节点y只能选择一个状态转移到x),物品的价值总和最大.注意x = 0的情况,此时不用添加score[x]
总结:
1.当存在森林时我们可以添加一个虚拟节点使得森林连成一棵树
2.分组背包模型的转化.
#include <bits/stdc++.h>
using namespace std;
const int N = 310;
int f[N][N], score[N];
int n, m;
vector<int> son[N];
void dp(int x){
//对于每层f[x][i],我们先进行分组背包,价值最大的所有子树的选择选上,最后再加上score[x]
/* 枚举i就是枚举p=|son[x]|组物品 */
for (int i = 0; i < son[x].size(); ++i){
int y = son[x][i];
dp(y);
/* 枚举体积t和每组物品中选哪个 */
for (int t = m; ~t; --t)
for (int j = 0; j <= t; ++j)
f[x][t] = max(f[x][t], f[x][t - j] + f[y][j]);
}
if (x)
for (int i = m; i > 0; --i)
f[x][i] = f[x][i - 1] + score[x];
}
signed main(){
cin >> n >> m;
for (int i = 1, x; i <= n; ++i){
cin >> x >> score[i];
son[x].emplace_back(i);
}
dp(0);
cout << f[0][m] << '\n';
}
3.AcWing 287. 积蓄程度(二次扫描与换根法)
我们首先考虑朴素做法,枚举每个点当源点,设D[x]为以x为根的子树的最大流量deg[x]为x的度数.有:
对于每个源点,我们都能用O(n)的复杂度算出最大的流量,纯暴力的话时间复杂度是O(n^2).我们使用二次扫描来将时间复杂度优化到O(n).
设F[x]为以x为源点的水系的最大流量.x的一个子节点是y,假设F[x]已经算好了,可以发现,y的流量等于其D[y]加上流向父节点的流量.F[root] = D[root]有:
实际做法就是,我们随机选取一个点作为根进行dp,再对该根进行dfs
#include <bits/stdc++.h>
using namespace std;
const int N = 2e5 + 10;
int n;
int h[N], edge[N * 2], ver[N * 2], ne[N * 2], tot;
bool v[N];
int deg[N];
int D[N];//D[x]表示以x为根的子树的最大流量
int F[N];//F[x]表示以x为源点的水系的最大流量
void add(int a, int b, int c){
edge[++tot] = c, ver[tot] = b, ne[tot] = h[a], h[a] = tot;
}
void dp(int x){
v[x] = 1;
D[x] = 0;
for (int i = h[x]; i; i = ne[i]){
int y = ver[i];
if (v[y])
continue;
dp(y);
if (deg[y] == 1)
D[x] += edge[i];
else
D[x] += min(edge[i], D[y]);
}
}
void dfs(int x){
v[x] = 1;
for (int i = h[x]; i; i = ne[i]){
int y = ver[i];
if (v[y])
continue;
if (deg[x] > 1 && deg[y] > 1)
F[y] = D[y] + min(F[x] - min(edge[i], D[y]), edge[i]);
else if (deg[x] == 1)
F[y] = D[y] + edge[i];
else
F[y] = D[y] + min(F[x] - edge[i], edge[i]);
dfs(y);
}
}
void init(){
memset(edge, 0, sizeof edge);
memset(h, 0, sizeof h);
memset(ver, 0, sizeof ver);
memset(ne, 0, sizeof ne);
memset(deg, 0, sizeof deg);
memset(v, 0, sizeof v);
tot = 0;
}
void solve(){
init();
cin >> n;
for (int i = 1, x, y, z; i < n; ++i){
cin >> x >> y >> z;
add(x, y, z);
add(y, x, z);
++deg[x], ++deg[y];
}
/* 第一次扫描 */
dp(1);
memset(v, 0, sizeof v);
/* 第二次扫描 */
F[1] = D[1];
dfs(1);
int ans = 0;
for (int i = 1; i <= n; ++i)
ans = max(ans, F[i]);
cout << ans << "\n";
}
signed main(){
ios::sync_with_stdio(false), cin.tie(0), cout.tie(0);
int T;
cin >> T;
while (T--)
solve();
}
练习:AcWing 325. 计算机
//第一次扫描(自底而上):
//D[x][0]表示当前x向下最远的计算机的距离,D[x][1]表示次远的距离,p[x]表示最远的计算机的路径途径的第一个点.
//D[x][0],D[x][1]可以用一次dfs求出来.
//第二次扫描(自顶而下):
//D[x][2]表示当前的计算机距离向上最远的距离.
//假设当前的节点是x,子节点是y
//1.若x的最远的路径途径的第一个点是y,那么D[y][2] = w(x, y) + max(D[x][1], D[x][2])
//2.若x的最远的路径途径的第一个点不是y,那么D[y][2] = w(x, y) + max(D[x][2], D[x][0])
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N = 1e4 + 10;
int n;
int D[N][4];
int h[N], ne[N * 2], ver[N * 2], edge[N * 2], tot;
bool v[N];
int p[N], S[N];
void add(int a, int b, int c){
edge[++tot] = c, ver[tot] = b, ne[tot] = h[a], h[a] = tot;
}
void init(){
tot = 0;
memset(h, 0, sizeof h);
memset(v, 0, sizeof v);
}
void dp(int x){
v[x] = 1;
D[x][0] = 0;
for (int i = h[x]; i; i = ne[i]){
int y = ver[i];
if (v[y])
continue;
dp(y);
//更新最远的路径长度和当前的节点到最远的计算机的路径的路径途径的第一个节点.
if (D[x][0] < edge[i] + D[y][0]){
D[x][1] = D[x][0];
D[x][0] = edge[i] + D[y][0];
p[x] = y;
}
//再更新次远的路径长度
else if (D[x][1] <= edge[i] + D[y][0])
D[x][1] = edge[i] + D[y][0];
}
}
void dfs(int x){
v[x] = 1;
for (int i = h[x]; i; i = ne[i]){
int y = ver[i];
if (v[y])
continue;
if (p[x] == y)
D[y][2] = edge[i] + max(D[x][1], D[x][2]);
else
D[y][2] = edge[i] + max(D[x][0], D[x][2]);
dfs(y);
}
S[x] = max(D[x][0], D[x][2]);
}
signed main(){
ios::sync_with_stdio(false), cin.tie(0), cout.tie(0);
while (cin >> n){
init();
for (int i = 2, x, z; i <= n; ++i){
cin >> x >> z;
add(x, i, z);
add(i, x, z);
}
dp(1);
memset(v, 0, sizeof v);
D[1][2] = 0;
dfs(1);
for (int i = 1; i <= n; ++i)
cout << S[i] << "\n";
}
}