之前的 DP 都是在线性结构上进行的,实际上 DP 还可以在树上或者 DAG 上进行。本文将对这些内容进行简单的介绍。
更新日志
更新了少许杂题。
树形 DP
树形 DP 就是将在线性结构上的 DP 变到了树上。
概念
既然 DP 都长到树上去了,那么肯定有不一样的地方。
由于树固有的递归性质,树形 DP 一般都是递归进行的。在树形 DP 中,我们会选根节点为 DP 的开始,然后对于它的每棵子树进行递归,然后考虑转移。递归到了一个叶子节点,就可以进行初始化了。
基础例题
之所以大多数教程都直接上题,是因为这玩意没法讲!
之前说了树形 DP 就是在树上进行 DP,就是把状态的转移移到了树上。但这么说没人能听得懂,所以我们只能通过题目来学习,那么就来吧。
[Luogu P1352] 没有上司的舞会
某大学有 个职员,编号为 。他们之间有从属关系,也就是说他们的关系就像一棵以校长为根的树,父结点就是子结点的直接上司。现在有个周年庆宴会,宴会每邀请来一个职员都会增加一定的快乐指数 ,但是呢,如果某个职员的直接上司来参加舞会了,那么这个职员就无论如何也不肯来参加舞会了。计算邀请哪些职员可以使快乐指数最大,求最大的快乐指数。
根据刚才的讲解和在线性 DP 中的经验,我们可以设 为 的子树的最大的快乐指数。
但这样不行,我们需要知道 是否参加舞会,来判断 的上司是否能参加舞会。
那么根据在线性 DP 中讲过的“打不过就加入”,我们可以设 为 不参加舞会, 为 参加舞会。
那么根据题意,便有转移:
那么代码就很容易写出来了:
查看代码
#include <bits/stdc++.h>
using namespace std;
inline int read(void)
{
int x = 0, c = getchar(), f = 1;
while (!isdigit(c)) {if (c == '-') f = -1; c = getchar();}
while (isdigit(c)) x = (x<<3) + (x<<1) + (c^48), c = getchar();
return x * f;
}
int n;
int r[6005];
bool v[6005];
vector <int> son[6005];
int f[6005][2];
void dp(int o)
{
f[o][0] = 0; // 不参加,初始为 0
f[o][1] = r[o]; // 参加,初始为 r[o]
for (int i = 0; i < son[o].size(); ++i) // 遍历所有子树
{
int y = son[o][i];
dp(y); // 递归进行
f[o][0] += max(f[y][0], f[y][1]); // 可以参加或不参加
f[o][1] += f[y][0]; // 只能不参加
}
}
int main(void)
{
n = read();
for (int i = 1; i <= n; ++i) r[i] = read();
for (int i = 1; i < n; ++i)
{
int x = read(), y = read();
v[x] = 1;
son[y].push_back(x);
}
int root;
for (int i = 1; i <= n; ++i)
if (!v[i])
{
root = i; // 寻找根节点开始 DP
break;
}
dp(root);
cout << max(f[root][0], f[root][1]) << endl; // 要取最大值
return 0;
}
状态有 个,每个状态在转移时都会被考虑一次,因此时间复杂度为 。
树形 DP 还有另一种实现方式:以拓扑序自底向上迭代,速度比上述递归方法快一点,但实用性不高。感兴趣的读者可以自行了解。
[Luogu P2016] 战略游戏
的树。他要在这棵树的结点上放置最少数目的士兵,使得这些士兵能了望到所有的路。他要在这棵树的结点上放置最少数目的士兵,使得这些士兵能了望到所有的路。
这道题和上道很相似,但可以注意到这是一棵无根树。而想要做树形 DP,就必须有根。
那怎么办呢?转成有根树就行了。随便找一个结点作根,代码如下:
for (int i = 0; i < n; ++i)
{
int o = read(), k = read();
for (int j = 0; j < k; ++j)
{
int t = read();
G[o].push_back(t);
G[t].push_back(o);
}
}
v[0] = -1;
dfs(0);
这里的 数组代表 的父亲。dfs(o)
代表遍历节点 。
dfs
的过程如下:
void dfs(int o)
{
done[o] = 1;
for (int i = 0; i < G[o].size(); ++i)
{
if (done[G[o][i]] == 0)
{
son[o].push_back(G[o][i]);
v[G[o][i]] = o;
dfs(G[o][i]);
}
}
}
这里用了一个 done
数组来防止重复遍历,还有一种方式如下:
void dfs(int o, int fa)
{
for (int i = 0; i < G[o].size(); ++i)
if (G[o][i] != fa)
{
son[o].push_back(G[o][i]);
dfs(G[o][i], o);
}
}
为什么可以这样做呢?因为加的是无向边,来回遍历时才会造成重复遍历。
我们设 代表在 的位置上不放士兵, 代表在 的位置上放士兵,它们的子树所需要的最少士兵。
那么完整代码就很容易写出来了:
查看代码
#include <iostream>
#include <cstdio>
#include <vector>
using namespace std;
inline int read(void)
{
int x = 0, c = getchar();
while (!isdigit(c)) c = getchar();
while (isdigit(c)) x = (x<<3) + (x<<1) + (c^48), c = getchar();
return x;
}
int n;
int f[1505][2];
bool done[1505];
vector <int> son[1505];
vector <int> G[1505];
void dfs(int o, int fa)
{
done[o] = 1;
for (int i = 0; i < G[o].size(); ++i)
if (done[G[o][i]] == 0)
{
son[o].push_back(G[o][i]);
dfs(G[o][i], o);
}
}
void dp(int o)
{
f[o][0] = 0;
f[o][1] = 1;
for (int i = 0; i < son[o].size(); ++i)
{
int y = son[o][i];
dp(y);
f[o][0] += f[y][1]; // 必须放
f[o][1] += min(f[y][0], f[y][1]); // 可放可不放
}
}
int main(void)
{
n = read();
for (int i = 0; i < n; ++i)
{
int o = read(), k = read();
for (int j = 0; j < k; ++j)
{
int t = read();
G[o].push_back(t);
G[t].push_back(o);
}
}
dfs(0, -1);
dp(0);
cout << min(f[0][0], f[0][1]) << endl;
return 0;
}
[Luogu P1122] 最大子树和
这跟上一题很相似,但是我们可以不建树,直接在 DP 的时候判断是否来自 fa
即可。求最大子树和,只需要将正的子树加上即可。
查看代码
#include <iostream>
#include <cstdio>
#include <vector>
using namespace std;
int n;
int a[16005], f[16005];
vector <int> G[16005];
void dp(int o, int fa)
{
f[o] = a[o];
for (int i = 0; i < G[o].size(); ++i)
{
int y = G[o][i];
if (y == fa) continue;
dp(y, o);
if (f[y] > 0) f[o] += f[y];
}
}
int main(void)
{
scanf("%d", &n);
for (int i = 1; i <= n; ++i) scanf("%d", a + i);
for (int i = 1; i < n; ++i)
{
int u, v;
scanf("%d%d", &u, &v);
G[u].push_back(v);
G[v].push_back(u);
}
dp(1, 0);
int ans = -2e9;
for (int i = 1; i <= n; ++i)
ans = max(ans, f[i]);
printf("%d\n", ans);
return 0;
}
关于树形 DP 有一个问题:为什么我们可以随便选一个点作为根节点进行 DP 呢?这是因为在考虑子树的过程中,如果一个以 root
节点为根的答案会更好,它要么相当于一棵子树,要么相当于它的一个孙辈的子树。
当然也有例外,我们很快就会见到。
[ZJOI2007] 时态同步
个点的有根树,有边权,一次操作可以将某条边的边权 。求最少使用多少次操作,可以让所有叶子结点到根的距离相同。
很容易想到一个贪心做法:优先调整靠上的边。为什么?因为与其在叶子节点的边权都 ,不如直接在父亲节点的边 。这样,一个节点只需要调整它的子树,保证它的子树边权相同即可,剩下的交给它的父亲。
那我们记 为调整好 的子树的最少操作数。但是想要成功计算,我们还需要知道节点到叶子节点的距离,所以我们记 为调整后 到它的最底层的叶子节点的距离。
转移也不难。很容易得出 的转移:
怎么转?像这样:
什么意思?首先肯定要加上调整所有子树的代价,然后要开始调整这些子树。代价是多少?显然是 ,也就是当前的深度剪去叶子节点的深度再减去这条边的长度。
代码如下:
查看代码
#include <iostream>
#include <cstdio>
#include <vector>
#define pii pair<int, int>
#define Y first
#define W second
using namespace std;
using i64 = long long;
inline int read(void)
{
int x = 0, c = getchar();
while (!isdigit(c)) c = getchar();
while (isdigit(c)) x = (x<<3) + (x<<1) + (c^48), c = getchar();
return x;
}
int n, s;
i64 f[500005], g[500005];
vector <pii> son[500005];
struct edge
{
int from, to, dist;
edge(int u, int v, int d) :
from(u), to(v), dist(d) {}
};
vector <edge> edges;
vector <int> G[500005];
inline void addedge(int u, int v, int d)
{
edges.push_back(edge(u, v, d));
G[u].push_back(edges.size() - 1);
}
void dp(int x)
{
// 0 就是初始条件,叶子节点不需要调整
for (int i = 0; i < son[x].size(); ++i)
dp(son[x][i].Y); // 对儿子进行处理
for (int i = 0; i < son[x].size(); ++i)
g[x] = max(g[x], g[son[x][i].Y] + son[x][i].W); // g 的转移
for (int i = 0; i < son[x].size(); ++i)
f[x] += f[son[x][i].Y] + (g[x] - g[son[x][i].Y] - son[x][i].W); // f 的转移
}
void dfs(int o, int fa)
{
for (int i = 0; i < G[o].size(); ++i)
{
edge &e = edges[G[o][i]];
if (e.to != fa)
{
son[o].push_back(make_pair(e.to, e.dist));
dfs(e.to, o);
}
}
}
int main(void)
{
n = read(), s = read();
for (int i = 1; i < n; ++i)
{
int u = read(), v = read(), d = read();
addedge(u, v, d);
addedge(v, u, d);
}
dfs(s, -1); // 建树
dp(s);
printf("%lld\n", f[s]);
return 0;
}
[ZJOI2006] 三色二叉树
设 分别代表 节点染成绿色、红色、蓝色的绿色节点最多数。转移对于读到这的读者来说应该不是困难。
查看代码
#include <bits/stdc++.h>
using namespace std;
int n;
char s[500005];
int f[500005][3], g[500005][3]; // f 最多,g 最少,0~2: GRB 的绿色节点
void dp(int o)
{
if (s[o] == '0')
{
f[o][0] = g[o][0] = 1;
return;
}
int x, y;
dp(x = ++n);
if (s[o] == '1')
{
f[o][0] = max(f[x][1], f[x][2]) + 1;
f[o][1] = max(f[x][0], f[x][2]);
f[o][2] = max(f[x][0], f[x][1]);
g[o][0] = min(g[x][1], g[x][2]) + 1;
g[o][1] = min(g[x][0], g[x][2]);
g[o][2] = min(g[x][0], g[x][1]);
}
else
{
dp(y = ++n);
f[o][0] = max(f[x][1] + f[y][2], f[x][2] + f[y][1]) + 1;
f[o][1] = max(f[x][0] + f[y][2], f[x][2] + f[y][0]);
f[o][2] = max(f[x][0] + f[y][1], f[x][1] + f[y][0]);
g[o][0] = min(g[x][1] + g[y][2], g[x][2] + g[y][1]) + 1;
g[o][1] = min(g[x][0] + g[y][2], g[x][2] + g[y][0]);
g[o][2] = min(g[x][0] + g[y][1], g[x][1] + g[y][0]);
}
}
int main(void)
{
scanf("%s", s + 1);
dp(++n);
printf("%d %d\n", max({f[1][0], f[1][1], f[1][2]}), min({g[1][0], g[1][1], g[1][2]}));
return 0;
}
[UVa 12186] Another Crisis
状态的定义与转移对于读者来说应该已经不是困难,这里提供另一种实现。由于需要排序,所以将 dp
做成有返回值的函数,这样其实更类似于 dfs
。
查看代码
#include <bits/stdc++.h>
using namespace std;
int n, t, f[100005];
vector <int> son[100005];
int dp(int x)
{
if (son[x].empty()) return 1;
vector <int> a;
for (int i = 0; i < son[x].size(); ++i)
a.push_back(dp(son[x][i]));
sort(a.begin(), a.end());
int c = ceil(son[x].size() * t / 100.0);
int ans = 0;
for (int i = 0; i < c; ++i)
ans += a[i];
return ans;
}
int main(void)
{
while (scanf("%d%d", &n, &t) == 2 && n)
{
for (int i = 0; i <= n; ++i)
{
son[i].clear();
f[i] = 0;
}
for (int i = 1, x; i <= n; ++i)
{
scanf("%d", &x);
son[x].push_back(i);
}
printf("%d\n", dp(0));
}
return 0;
}
[UVa 1218] Perfect Service
这种题有属于自己的套路:
- 代表 是服务器,那么儿子随便;
- 代表 不是,但是父亲是,那么儿子都不是;
- 代表 和父亲都不是,但是有一个儿子是。
转移方程应该不难写出。
查看代码
#include <iostream>
#include <cstdio>
#include <vector>
using namespace std;
const int INF = 10001;
int n, f[10005][3];
vector <int> G[10005];
void dp(int x, int fa)
{
f[x][0] = 1, f[x][1] = 0, f[x][2] = INF;
int sum = 0;
for (auto y : G[x])
{
if (y == fa) continue;
dp(y, x);
f[x][0] += min(f[y][0], f[y][1]);
f[x][1] += f[y][2];
sum += f[y][2];
}
for (auto y : G[x])
if (y != fa) f[x][2] = min(f[x][2], sum - f[y][2] + f[y][0]);
}
int main(void)
{
while (n != -1 && scanf("%d", &n) == 1)
{
for (int i = 1; i <= n; ++i) G[i].clear();
for (int i = 1; i < n; ++i)
{
int u, v;
scanf("%d%d", &u, &v);
G[u].push_back(v);
G[v].push_back(u);
}
dp(1, 0);
printf("%d\n", min(f[1][0], f[1][2]));
scanf("%d", &n);
}
return 0;
}
树形背包
问题定义在树形结构上,依照子树设定子问题。常常用 表示子树 在状态限制 下的最优解。先递归求解子树的答案,再计算当前结点答案。
普通的背包,如 01 背包可以放到树上,而树形结构还可以用来解决依赖性背包。
普通背包 | [Luogu P2015] 二叉苹果树
有一棵苹果树,如果树枝有分叉,一定是分二叉(就是说没有只有一个儿子的结点)
这棵树共有 个结点(叶子点或者树枝分叉点),编号为 ,树根编号一定是 。
我们用一根树枝两端连接的结点的编号来描述一根树枝的位置。下面是一颗有 个树枝的树:
2 5
\ /
3 4
\ /
1
现在这颗树枝条太多了,需要剪枝。但是一些树枝上长有苹果。
给定需要保留的树枝数量,求出最多能留住多少苹果。
我们设 代表以 为根的子树,恰好保留 条边所能获得的最多苹果数。那么我们考虑左右子树(如果有),由于 这个状态是存在的,所以我们让 的体积代表什么都不选。
下面是代码,请仔细阅读。
#include <iostream>
#include <cstdio>
using namespace std;
int n, q;
int son[105][2], val[105][2];
int s[105]; // s[x] 为 x 及其子节点所含有的边数
int f[105][105];
void dp(int o)
{
int x = son[o][0], y = son[o][1];
if (!x) return;
dp(x), dp(y);
s[o] = s[x] + s[y] + 2; // +2 是连接左右子树用掉的
for (int i = -1; i <= s[x]; ++i)
for (int j = -1; j <= s[y]; ++j)
{
int vl = (i == -1 ? 0 : f[x][i] + val[o][0]); // -1 不选就是 0,选了就是儿子的能获得的苹果数值加上儿子上的苹果数(因为这一条边选了)
int vr = (j == -1 ? 0 : f[y][j] + val[o][1]);
f[o][i + j + 2] = max(f[o][i + j + 2], vl + vr); // i + j 是左子树和右子树用掉的边,+2 是当前节点连接左右子树用掉的边
}
}
int main(void)
{
scanf("%d%d", &n, &q);
for (int i = 1; i < n; ++i)
{
int x;
scanf("%d", &x);
int b = son[x][0] > 0; // 存在左子树就往右子树里读入
scanf("%d%d", &son[x][b], &val[x][b]); // 这里的树枝上的苹果送给儿子
}
dp(1);
printf("%d\n", f[1][q]);
return 0;
}
可以发现,其实这就是一个 01 背包问题,只不过跑到了树上。
依赖性背包 | [CTSC1997] 选课
在大学里每个学生,为了达到一定的学分,必须从很多课程里选择一些课程来学习,在课程里有些课程必须在某些课程之前学习,如高等数学总是在其它课程之前学习。现在有 门功课,每门课有个学分,每门课有一门或没有直接先修课(若课程 a 是课程 b 的先修课即只有学完了课程 a,才能学习课程 b)。一个学生要从这些课程里选择 门课程学习,问他能获得的最大学分是多少?
在《背包》中我们就讨论过这个问题,不过当时我们给出的方案是暴力枚举子集转换成分组背包,但是显然很慢。现在有了树,这类问题就变的好解了。
如果没有先修课的限制,这就是一个标准的 01 背包问题。由于每门课程的先修课只有一门,这就构成了一棵每门课都以自己的先修课为父亲的森林结构(因为可能会有多门课没有先修课)。既然如此,我们增设一个虚(chao)拟(ji)课(ba)程(ba),0 号节点,作为”实际上没有先修课的课程“。
设 表示在以 为根的子树中选 门课程能获得的最高学分。设它的子节点个数为 ,那么有 。当 时,必须选节点 ,那么有( 指课程 获得的学分, 指 的子节点):
也就是说,要在满足子节点所选的科目的综合为 的前提下,在子树中选课获得最大的得分。
现在想一想,这就是分组背包的处理方式!
可以这么理解。对于每个节点,有 个儿子,也就是有 组物品,每组物品都有 个(不足的用体积和价值都为 的物品来补齐),其中第 组的第 个物品体积为 ,价值为 。背包的总容量为 (因为当前节点会吃掉体积为 的容量)。
每组中至多只能选一个物品(难不成你还能同时选 和 吗 ),使得物品体积不超过 的前提下(根据之前背包中所推的原理,不需要取体积分别为 的最大值),物品价值最大(获得最多的学分)。然而我们的超级爸爸 号节点除外,因为它根本不需要被选修,背包总容积为 。
实现时我们可以装作有 个可选的物品,这样就不用理会超级爸爸了。下面是代码,请仔细阅读。
#include <bits/stdc++.h>
using namespace std;
int n, m, s[305], siz[305];
int f[305][305];
vector <int> son[305];
void dp(int x)
{
f[x][0] = 0; f[x][1] = s[x]; siz[x] = 1;
for (int y : son[x])
{
dp(y); // 递归求解每个物品的价值(每个儿子的价值)
for (int i = min(siz[x], m + 1); i >= 1; --i)
for (int j = min(siz[y], m + 1 - i); j >= max(1, i - siz[x]); --j)
f[x][i + j] = max(f[x][i + j], f[x][i] + f[y][j]);
siz[x] += siz[y];
}
}
int main(void)
{
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; ++i)
{
int x; scanf("%d", &x);
son[x].push_back(i);
s[i] = read();
}
memset(f, 0xff, sizeof(f)); // 求的是最大值
dp(0); // 从超级节点开始 dp
printf("%d\n", f[0][m + 1]); // 必选,所以答案只能是这一个
return 0;
}
树形背包中的上下界需要注意,需要卡死,注意不要遍历到无用的状态,一定是一个将子树合并到当前节点的过程,这样才能保证时间复杂度为 ,大致原理是:“每对节点只会恰好在 LCA 处合并一次”。
换根 DP
正常来讲,这道题怎么做?很显然,不能随便选一个点作为根节点,这样无法统计答案。如果枚举源点,那么每次都跑一个树形 DP 就可以解决了,但是时间不允许。但是不要紧,一种名为“二次扫描与换根法”的技巧可以只 DP 一次来统计答案,也被称之为换根 DP。在此之前,我们先把 次 DP 的转移方程写出来:
void dp(int x, int fa)
{
f[x] = 0;
for (int i = 0; i < G[x].size(); ++i)
{
int y = G[x][i].to, w = G[x][i].val;
if (y != fa)
{
dp(y, x);
if (deg[y] == 1) f[x] += w;
else f[x] += min(f[y], w);
}
}
}
我们任意选择一个节点作为 root 进行如上操作后,就可以开始换根了:
设 代表以 作为源点,流向整个水系,流量最大是多少。初始肯定是 。
如果 已经被求出,那么对于子节点 , 包含两个部分:
- 从 流入 的子树的流量,就是 ;
- 从 到父亲 然后继续流的流量。
这个 怎么求?还记得我们是怎么求解树的重心的吗?我们用整体的减去了局部的,就等于除了局部以外的内容了。
这里也是一样,像这样:
当 的父亲 是个度数为 的点时,它就是一个汇点,流量就是 ;当它不是一个汇点的时候,就等于以它父亲作为源点的整个水系的流量 ,减去从 的流量 ,同时还要将这个差与 取最小值。
#include <bits/stdc++.h>
using namespace std;
struct edge {
int to, val;
edge(int to = 0, int val = 0) :
to(to), val(val) {}
};
int n;
int f[200005], g[200005], deg[200005];
vector <edge> G[200005];
inline void addedge(int u, int v, int w) { G[u].push_back(edge(v, w)); }
void dp(int x, int fa) {
f[x] = 0;
for (int i = 0; i < G[x].size(); ++i) {
int y = G[x][i].to, w = G[x][i].val;
if (y != fa) {
dp(y, x);
if (deg[y] == 1) f[x] += w;
else f[x] += min(f[y], w);
}
}
}
void dfs(int x, int fa) {
for (int i = 0; i < G[x].size(); ++i) {
int y = G[x][i].to, w = G[x][i].val;
if (y != fa) {
if (deg[x] == 1) g[y] = f[y] + w; // 先计算好当前的 g,然后再遍历
else g[y] = f[y] + min(g[x] - min(f[y], w), w);
dfs(y, x);
}
}
}
void solve(void) {
scanf("%d", &n);
for (int i = 1; i <= n; ++i) G[i].clear();
memset(deg, 0, sizeof(deg));
memset(f, 0, sizeof(f));
memset(g, 0, sizeof(g));
for (int i = 1; i < n; ++i) {
int u, v, w;
scanf("%d%d%d", &u, &v, &w);
addedge(u, v, w), addedge(v, u, w);
deg[u]++, deg[v]++;
}
dp(1, -1);
g[1] = f[1];
dfs(1, -1);
int ans = 0;
for (int i = 1; i <= n; ++i)
ans = max(ans, g[i]);
printf("%d\n", ans);
}
int main(void) {
int T;
scanf("%d", &T);
while (T--) solve();
return 0;
}
也就是说,在换根的过程中,要搞明白代价少了什么,又多了什么。
图上 DP
我们说过,DP 需要有无后效性。所以一般我们只能在 DAG 上进行 DP(后面会学习到高斯消元可以进行后效性处理)。当然,不太复杂的问题,如单个环上的问题上的问题也是可以做的。或者,一般图的缩点之后也可以做。
DAG 上的 DP
你有没有想过 DP 的本质是什么?
简述
线性结构上的 DP 也好,树形结构上的 DP 也罢。它们都有“状态””决策”两个概念。状态对应图上的一个点,而决策对应图上的边。
你有没有发现什么?
如果 DP 的状态图长成下图这样,会发生什么?
想要求解状态 ,依赖于状态 ,而状态 依赖于状态 ,状态 又依赖于状态 !这成了个无限循环问题。
所以可以得出结论,DP 一般只适用于有向无环图(DAG),遍历顺序便是这个 DAG 的一个拓扑序。如果这个图是带环的,那么一般它就不能 DP。
一个问题可以 DP,是因为这个问题可以从小问题的解,推断出大问题的解。我们可以从初始状态的解,推出最终状态的解,从而解决问题。也就是说有这几条性质:
如果我们按以上方法绘图,那么立即就有几条性质:
- DP 的每一个状态都对应着一个点;
- 每种可能的转移方式,都对应着一条有向边;
- DP 的求解顺序,等同于这张图上的拓扑排序;
- 整张图必须是 DAG,否则不可能找到合适的求解顺序。
[Luogu P1613] 跑路
我们的目的就是建图,然后求最短路。令 代表存在一条 ,长度为 的边,这样的边就是可以 跑完的。那么若 ,则 。由于图的规模很小,求最短路时直接使用 Floyd 即可。
查看代码
#include <bits/stdc++.h>
using namespace std;
int n, m;
int f[55][55];
bool G[55][55][40];
int main(void) {
scanf("%d%d", &n, &m);
memset(f, 0x3f, sizeof f);
while (m--) {
int u, v;
scanf("%d%d", &u, &v);
f[u][v] = 1;
G[u][v][0] = true;
}
for (int l = 1; l <= 32; ++l)
for (int i = 1; i <= n; ++i)
for (int j = 1; j <= n; ++j)
for (int k = 1; k <= n; ++k)
if (G[i][j][l - 1] && G[j][k][l - 1]) {
G[i][k][l] = true;
f[i][k] = 1;
}
for (int k = 1; k <= n; ++k)
for (int i = 1; i <= n; ++i)
for (int j = 1; j <= n; ++j)
f[i][j] = min(f[i][j], f[i][k] + f[k][j]);
printf("%d\n", f[1][n]);
return 0;
}
环上 DP
可以考虑使用缩点解决。
Problemset
可能比较麻烦,但都没有什么难度。
树形 DP
基础的树形 DP,后面的题会稍微难一点。
[SDOI2006] 保安站岗
设 分别代表由父亲、自己和儿子来维护。需要注意的就是由儿子维护的,儿子要至少有一个是自己维护自己的。
查看代码
#include <bits/stdc++.h>
using namespace std;
int n;
int w[1505], f[1505][3];
vector <int> G[1505];
void dp(int x, int fa)
{
f[x][1] = w[x];
int flag = 0, minn = 1e9;
for (int i = 0; i < G[x].size(); ++i)
{
int y = G[x][i];
if (y != fa)
{
dp(y, x);
f[x][0] += min({f[y][1], f[y][2]});
f[x][1] += min({f[y][0], f[y][1], f[y][2]});
if (f[y][1] < f[y][2]) f[x][2] += f[y][1], flag = true;
else minn = min(minn, f[y][1] - f[y][2]), f[x][2] += f[y][2];
}
}
if (!flag) f[x][2] += minn;
}
int main(void)
{
scanf("%d", &n);
for (int i = 1; i <= n; ++i)
{
int p, t, x;
scanf("%d", &p);
scanf("%d%d", w + p, &t);
while (t--)
{
scanf("%d", &x);
G[p].push_back(x), G[x].push_back(p);
}
}
dp(1, 0);
printf("%d\n", min(f[1][1], f[1][2]));
return 0;
}
[CSP-S2019] 括号树
表示 到 的答案,再记 为第 个节点的贡献,如果扫描到当前一个 )
,就说明这个节点是有贡献的。维护一个记录左括号位置的栈,扫描到一个 )
就从栈中进行匹配,更新 ,其中 为弹出的栈顶的父亲,这样可以将父亲的贡献值也算上。如果是 (
就直接入栈。这时候就可以计算当前的答案:父亲节点的答案加上当前节点的贡献。接下来就可以递归计算儿子的贡献,然后要还原现场使得父亲的其它儿子可以正确计算:如果弹出过栈就要把这个再压回去,否则如果发现栈不是空的,就是压进去过元素,把它 pop 出来。
查看代码
#include <bits/stdc++.h>
using namespace std;
typedef long long i64;
int n, fa[500005];
i64 ans = 0, f[500005], g[500005]; // g[i] 表示节点 i 的贡献
char s[500005];
vector <int> son[500005];
stack <int> v;
void dp(int x)
{
int tmp = -1;
if (s[x] == ')')
{
if (!v.empty())
{
tmp = v.top();
g[x] = g[fa[tmp]] + 1;
v.pop();
}
}
else v.push(x);
f[x] = f[fa[x]] + g[x];
for (int i = 0; i < son[x].size(); ++i)
dp(son[x][i]);
if (tmp != -1) v.push(tmp); // 压回去
else if (!v.empty()) v.pop(); // 还原现场,将压入的 '(' pop 出来
}
int main(void)
{
scanf("%d%s", &n, s + 1);
for (int i = 2, x; i <= n; ++i)
scanf("%d", &x), son[x].push_back(i), fa[i] = x;
dp(1);
for (int i = 1; i <= n; ++i)
ans ^= f[i] * i;
printf("%lld\n", ans);
return 0;
}
[CF486D] Valid Sets
发现需要枚举点来统计信息,但是换根 DP 不是很好做,而且数据范围很小,所以考虑枚举每个点然后进行暴力 DP。
我们枚举每一个点,并令它是点权最大的点。设 代表包含 的子树的最大连通块数。如果儿子 的点权差大于了 不行,如果点权比 大也不行,相等的时候要判断一下点的编号,只能计算一个,因为枚举 的时候它还会被计算一遍。
转移也很简单,要乘上子树的大小 +1,设子树的大小为 ,对应选 。
查看代码
#include <bits/stdc++.h>
using namespace std;
typedef long long i64;
const int MOD = 1000000007;
int n, d;
int a[2005], f[2005];
vector <int> G[2005];
inline void addedge(int u, int v) { G[u].push_back(v); }
void dp(int x, int fa, int root) {
f[x] = 1;
for (int i = 0; i < G[x].size(); ++i) {
int y = G[x][i];
if (y == fa) continue;
if (a[y] > a[root] || (a[y] == a[root] && y < root)) continue;
if (a[root] - a[y] > d) continue;
dp(y, x, root);
f[x] = 1ll * f[x] * (f[y] + 1) % MOD;
}
}
int main(void) {
scanf("%d%d", &d, &n);
for (int i = 1; i <= n; ++i)
scanf("%d", a + i);
for (int i = 1, u, v; i < n; ++i) {
scanf("%d%d", &u, &v);
addedge(u, v), addedge(v, u);
}
int ans = 0;
for (int i = 1; i <= n; ++i) {
memset(f, 0, sizeof(f));
dp(i, 0, i);
ans = (ans + f[i]) % MOD;
}
printf("%d\n", ans);
return 0;
}
[国家集训队] 聪聪可可
设 代表距离 为 (模意义)的点数,按照类似于点分治的方式统计即可。
查看代码
#include <bits/stdc++.h>
using namespace std;
int gcd(int x, int y) {
if (y == 0) return x;
return gcd(y, x % y);
}
int n, ans, f[20005][3];
vector<pair<int, int>> G[20005];
int M(int x) { return (x % 3 + 3) % 3; }
void dfs(int x, int fa) {
f[x][0] = 1;
for (int i = 0; i < G[x].size(); ++i) {
int y = G[x][i].first, w = G[x][i].second;
if (y == fa) continue;
dfs(y, x);
for (int i = 0; i < 3; ++i) ans += f[y][i] * f[x][M(-i - w)] * 2;
for (int i = 0; i < 3; ++i) f[x][M(i + w)] += f[y][i];
}
}
int main(void) {
scanf("%d", &n);
for (int i = 1; i < n; ++i) {
int u, v, d; scanf("%d%d%d", &u, &v, &d);
G[u].emplace_back(make_pair(v, d));
G[v].emplace_back(make_pair(u, d));
}
dfs(1, 0);
ans += n; int full = n * n; int g = gcd(ans, full);
ans /= g, full /= g;
printf("%d/%d\n", ans, full);
return 0;
}
[HNOI2003] 消防局的设立
令 分别代表 覆盖到它的爷爷,它的父亲,它自己,它的儿子,它的孙子及其子树的最小代价,转移见代码。
查看代码
#include <bits/stdc++.h>
using namespace std;
int n;
int f[1005][5];
vector <int> G[1005];
inline void addedge(int u, int v) { G[u].push_back(v); }
void dp(int x, int fa)
{
int tot = 0, sum3 = 0, sum2 = 0;
for (int i = 0; i < G[x].size(); ++i)
{
int y = G[x][i];
if (y == fa) continue;
dp(y, x);
++tot;
sum3 += f[y][3]; // 记录儿子自行覆盖它们的儿子所在的子树
sum2 += f[y][2]; // 记录儿子自行覆盖它所在的子树
}
if (tot == 0)
{
f[x][0] = f[x][1] = f[x][2] = 1;
return;
}
f[x][0] = 1, f[x][1] = f[x][2] = 1e9; // 只有想要覆盖自己的爷爷是必须要自行执行的,初值为 1
for (int i = 0; i < G[x].size(); ++i)
{
int y = G[x][i];
if (y == fa) continue;
// 距离为 2 的点都覆盖了,只需要儿子覆盖它们的孙子即可
f[x][0] += f[y][4];
// f[x][1] 的由来:它有一个儿子(y)覆盖到了它的爷爷,可以覆盖它的兄弟,但是无法覆盖到它兄弟的子树(不含自己)
f[x][1] = min(f[x][1], f[y][0] + sum3 - f[y][3]);
// f[x][2] 的由来:它有一个儿子(y)覆盖到了它的父亲,但是它的兄弟无法覆盖
f[x][2] = min(f[x][2], f[y][1] + sum2 - f[y][2]);
// 要求它所有的儿子被覆盖,儿子需要覆盖自己和子树
f[x][3] += f[y][2];
// 要求它的孙子被覆盖,儿子需要覆盖它们的儿子即可
f[x][4] += f[y][3];
}
for (int i = 1; i < 5; ++i) f[x][i] = min(f[x][i], f[x][i - 1]);
}
int main(void)
{
scanf("%d", &n);
for (int i = 2, x; i <= n; ++i)
{
scanf("%d", &x);
addedge(i, x);
addedge(x, i);
}
dp(1, 0);
printf("%d\n", f[1][2]); // 答案是覆盖自己及子树
return 0;
}
树上背包
树上背包(分组,依赖性)的模型非常有用,而且类似于 的状态设计也可以算是广义的树形背包,一定要了解原理。
[Luogu P1273] 有线电视网
就是选课的翻版,设 代表以 为根的子树中,满足了 个客户的最大收益。然后直接 DP 做就行。注意最多能满足的客户个数。
查看代码
#include <iostream>
#include <cstdio>
#include <vector>
#include <cstring>
#define pii pair<int, int>
using namespace std;
int n, m, f[3005][3005]; // f[i][j] 以 i 为根的子树中,j 个客户转的最大收益
int M[3005];
vector <pii> G[3005];
int dp(int x) { // 返回观众的个数
f[x][0] = 0;
if (x > n - m) return 1;
int sum = 0;
for (int i = 0; i < G[x].size(); ++i) {
int y = G[x][i].first, w = G[x][i].second;
sum += dp(y);
for (int j = sum; j >= 0; --j)
for (int k = j; k >= 0; --k)
f[x][j] = max(f[x][j], f[x][j - k] + f[y][k] - w);
}
return sum;
}
int main(void) {
scanf("%d%d", &n, &m);
for (int i = 1; i <= n - m; ++i) {
int k;
scanf("%d", &k);
while (k--) {
int a, c;
scanf("%d%d", &a, &c);
G[i].push_back({a, c});
}
}
memset(f, 0xbf, sizeof(f));
for (int i = n - m + 1; i <= n; ++i) scanf("%d", &f[i][1]);
dp(1);
for (int i = m; i >= 0; --i)
if (f[1][i] >= 0) {
printf("%d\n", i);
break;
}
return 0;
}
[Luogu P1272] 重建道路
一场可怕的地震后,人们用 个牲口棚(编号 )重建了农夫 John 的牧场。由于人们没有时间建设多余的道路,所以现在从一个牲口棚到另一个牲口棚的道路是惟一的。因此,牧场运输系统可以被构建成一棵树。
John 想要知道另一次地震会造成多严重的破坏。有些道路一旦被毁坏,就会使一棵含有 个牲口棚的子树和剩余的牲口棚分离,John 想知道这些道路的最小数目。
设 代表以 为根,保留 个点拆掉的最小边数,而且 必须保留。
初始时 等于 点的度数,转移的时候按照树形背包的方式转移:
为什么是减 呢?因为 和 要连边,那么这条边就不用拆了。显然,这条边之前在 各被拆了一次,所以减去 。
查看代码
#include <bits/stdc++.h>
using namespace std;
int n, p, f[155][155]; // i 为根,保留 j 个点拆掉的最小边数
int siz[155];
vector <int> G[155];
void dp(int x, int fa) {
siz[x] = 1; f[x][1] = G[x].size();
for (int y : G[x])
if (y != fa) {
dp(y, x);
siz[x] += siz[y];
for (int i = siz[x]; i >= 0; --i) {
for (int j = i - 1; j >= 0; --j)
f[x][i] = min(f[x][i], f[x][i - j] + f[y][j] - 2);
}
}
}
int main(void) {
scanf("%d%d", &n, &p);
for (int i = 1; i < n; ++i) {
int u, v;
scanf("%d%d", &u, &v);
G[u].emplace_back(v);
G[v].emplace_back(u);
}
memset(f, 0x3f, sizeof(f));
dp(1, 0);
int ans = f[1][p];
for (int i = 2; i <= n; ++i)
ans = min(ans, f[i][p]);
printf("%d\n", ans);
return 0;
}
[HAOI2015] 树上染色
有一棵点数为 的树,树边有边权。给你一个在 之内的正整数 ,你要在这棵树中选择 个点,将其染成黑色,并将其他 的 个点染成白色。将所有点染色后,你会获得黑点两两之间的距离加上白点两两之间的距离的和的收益。问受益最大值是多少。
考虑每一条边的贡献,这样就可以统计了出一条边被经过了多少次。假设这条边连接的子树中有 个黑色点,那么经过次数就是 ,然后 只能选择一个,这就是分组背包!
那么设 代表以 为根,选择了 个子节点染成黑色的最大贡献。实现时有一个细节:应该把所有 都初始化为 ,代表是不合法的。然后令 时 ,因为只选 个黑点肯定合法。转移的时候倒序枚举,如果儿子的值是合法的就用树形背包的方式更新。特别的,与普通树形背包不一样, 的选择一定是要选 再选其它的,因为本来就要算上儿子点的全白贡献(即使一个黑点不选,也是有贡献的,体积为 但是价值不为 ,贡献必须计算)。所以转移的时候可以选择正序或者提前处理好。
查看代码
#include <bits/stdc++.h>
using namespace std;
typedef long long i64;
struct edge {
int v, d;
};
int n, m, siz[2005];
i64 f[2005][2005];
vector<edge> G[2005];
void dp(int x, int fa)
{
siz[x] = 1; f[x][0] = f[x][1] = 0;
for (int i = 0; i < G[x].size(); ++i) {
int y = G[x][i].v; i64 w = G[x][i].d;
if (y != fa) {
dp(y, x); siz[x] += siz[y];
for (int j = min(m, siz[x]); j >= 0; --j) {
if (f[x][j] != -1) f[x][j] += f[y][0] + w * siz[y] * (n - m - siz[y]);
for (int k = min(j, siz[y]); k >= 1; --k) {
if (f[x][j - k] == -1) continue;
i64 val = 1ll * k * (m - k) + 1ll * (siz[y] - k) * (n - m - (siz[y] - k));
f[x][j] = max(f[x][j], f[x][j - k] + f[y][k] + val * w);
}
}
}
}
}
int main(void)
{
memset(f, -1, sizeof(f));
scanf("%d%d", &n, &m);
for (int i = 1; i < n; ++i) {
int u, v, d;
scanf("%d%d%d", &u, &v, &d);
G[u].push_back({v, d});
G[v].push_back({u, d});
}
dp(1, 0);
printf("%lld\n", f[1][m]);
return 0;
}
换根 DP
同样,也不是很难。
[POI2008] STA-Station
换根 DP 的模板题。在换根的时候,我们需要知道子树大小和父亲 的答案,那么儿子 的答案相比父亲来讲,它所有的子树深度都减去 ,而不是它子树的深度都加上了 。
查看代码
#include <bits/stdc++.h>
using namespace std;
typedef long long i64;
int n;
int s[1000005], dep[1000005];
i64 f[1000005];
vector <int> G[1000005];
inline void addedge(int u, int v) {
G[u].push_back(v);
}
void dfs(int x, int fa) {
dep[x] = dep[fa] + 1, s[x] = 1;
for (int i = 0; i < G[x].size(); ++i) {
int y = G[x][i];
if (y != fa) dfs(y, x), s[x] += s[y];
}
}
void dp(int x, int fa) {
for (int i = 0; i < G[x].size(); ++i) {
int y = G[x][i];
if (y != fa) {
f[y] = f[x] - s[y] + (n - s[y]); // -s[y], +(n - s[y])
dp(y, x);
}
}
}
int main(void) {
scanf("%d", &n);
for (int i = 1, u, v; i < n; ++i) {
scanf("%d%d", &u, &v);
addedge(u, v);
addedge(v, u);
}
dfs(1, 0);
for (int i = 1; i <= n; ++i) f[1] += dep[i];
dp(1, 0);
i64 ans = 0;
int id = 0;
for (int i = 1; i <= n; ++i)
if (f[i] > ans) ans = f[i], id = i;
printf("%d\n", id);
return 0;
}
DAG 上的 DP
按照拓扑序转移。
[CF721C] The Journey
设 代表在 走过 个点的最短距离即可。
查看代码
#include <iostream>
#include <cstdio>
#include <vector>
#include <queue>
#include <cstring>
using namespace std;
const int INF = 0x3f3f3f3f;
int n, m, k, ans, in[5005];
int f[5005][5005];
int pre[5005][5005];
vector<pair<int, int>> G[5005];
void Kahn(void) {
queue<int> q;
memset(f, 0x3f, sizeof(f));
for (int i = 1; i <= n; ++i) if (in[i] == 0) q.push(i);
f[1][1] = 0;
while (!q.empty()) {
int u = q.front(); q.pop();
for (int i = 0; i < G[u].size(); ++i) {
int v = G[u][i].first, w = G[u][i].second;
for (int j = 1; j <= n; ++j) {
if (f[v][j + 1] > f[u][j] + w) {
f[v][j + 1] = f[u][j] + w;
pre[v][j + 1] = u;
}
if (f[n][j] <= k) ans = max(ans, j);
}
--in[v]; if (in[v] == 0) q.push(v);
}
}
}
int p[5005];
void dfs(int x, int t) {
p[t] = x;
if (t > 1) dfs(pre[x][t], t - 1);
}
int main(void) {
scanf("%d%d%d", &n, &m, &k);
while (m--) {
int u, v, d; scanf("%d%d%d", &u, &v, &d);
G[u].emplace_back(make_pair(v, d)); ++in[v];
}
Kahn();
printf("%d\n", ans);
dfs(n, ans);
for (int i = 1; i <= ans; ++i) printf("%d ", p[i]);
putchar('\n');
return 0;
}
杂题
补充一些题目。
[CF274B] Zero Tree
每次必须操作 节点的条件过于奇怪,设 分别代表这个节点应该加减多少,然后需要取子树中的最大值。
查看代码
#include <bits/stdc++.h>
using namespace std;
typedef long long i64;
int n, a[100005];
vector<int> G[100005];
i64 f[100005], g[100005]; // f[x] 加,g[x] 减
void dfs(int x, int fa) {
for (int y : G[x]) if (y != fa) {
dfs(y, x);
f[x] = max(f[x], f[y]);
g[x] = max(g[x], g[y]);
}
int k = a[x] + f[x] - g[x];
if (k > 0) g[x] += k;
else f[x] -= k;
}
int main(void) {
ios::sync_with_stdio(0);
cin >> n;
for (int i = 1; i < n; ++i) {
int u, v; cin >> u >> v;
G[u].emplace_back(v); G[v].emplace_back(u);
}
for (int i = 1; i <= n; ++i) cin >> a[i];
dfs(1, 0);
cout << f[1] + g[1] << "\n";
return 0;
}