[知识点] 4.4 动态规划进阶模型——树形/DAG/数位DP

总目录 > 4 动态规划 > 4.4 树形 / DAG / 数位 DP

前言

本文主要介绍动态规划的集中进阶模型——树形 DP、DAG 上的 DP、数位 DP。不同于之前的基础模型的一点是,它们的状态并非单纯地建立在线性关系或者二维空间上,而是在树结构,图,数位上进行转移,相对之下可能更麻烦,但其实也是换汤不换药的。

子目录列表

1、树形 DP

2、DAG 上的 DP

3、数位 DP

4.4 树形 / DAG / 数位 DP

1、树形 DP

顾名思义,在树结构上进行动态规划,没有太多概念可言,直接以一道经典的例题引入好了。

【没有上司的舞会】某大学有 n 个职员,编号为 1 ~ n 。他们之间有从属关系,也就是说他们的关系就像一棵以校长为根的树,父结点就是子结点的直接上司。现在有个周年庆宴会,宴会每邀请来一个职员都会增加一定的快乐指数 ai,但是呢,如果某个职员的上司来参加舞会了,那么这个职员就无论如何也不肯来参加舞会了。所以,请你编程计算,邀请哪些职员可以使快乐指数最大,求最大的快乐指数。

在树状结构找子问题其实还是挺方便的,所有节点只和其子节点和唯一父节点相连,利用这点性质来切入:假设以 i 为根的树为一棵子树,如果 i 参加舞会,则其所有子节点均不能参加;如果不参加,则子节点可参加可不参加;再向其所有儿子节点递归,以此类推。

状态定义:f[i] 表示 “以 i 为根节点的子树可以获得的最大快乐指数”;

由于对于任何一个节点又存在两个状态——参加与不参加,所以还需要增加一维 0 / 1,表示是否参加舞会。

状态转移:f[i][0] = ∑ max(f[j][0], f[j][1]);

     f[i][1] = ∑ f[j][0] + a[i],j 表示 i 的儿子节点。

代码:

 1 #include <bits/stdc++.h>
 2 using namespace std;
 3 
 4 #define MAXN 6005
 5 
 6 struct Edge {
 7     int v, nxt;
 8 } e[MAXN];
 9 
10 int n, o, u, v, tot, f[MAXN][2], nr[MAXN], h[MAXN];
11 
12 void addEdge(int u, int v) {
13     e[++o] = (Edge) {v, h[u]}, h[u] = o;
14 }
15 
16 void dfs(int o, int fa) {
17     for (int x = h[o]; x; x = e[x].nxt) {
18         int v = e[x].v;
19         if (v == fa) continue;
20         dfs(v, o);
21         f[o][1] += f[v][0];
22         f[o][0] += max(f[v][0], f[v][1]);
23     }
24 }
25 
26 int main() {
27     cin >> n;
28     for (int i = 1; i <= n; i++) cin >> f[i][1];
29     for (int i = 1; i <= n; i++)
30         cin >> u >> v, addEdge(v, u), nr[u] = 1;
31     for (int i = 1; i <= n; i++) {
32         if (nr[i]) continue;
33         dfs(i, 0);
34         cout << max(f[i][0], f[i][1]);
35         break;
36     }
37     return 0;
38 } 

2、DAG 上的 DP

DAG 即有向无环图。同树形 DP,只是一种改变模型而本质不变的 DP。同样通过例题引入。

【巴比伦塔】有 n (n <= 30) 种砖块,已知三条边长,每种都有无穷多个。要求选一些立方体摞成一根尽量高的柱子(每个砖块可以自行选择一条边作为高),使得每个砖块的底面长宽分别严格小于它下方砖块的底面长宽,求塔的最大高度。 

砖块有三个量,可以自由选取其中两个作为长和宽进行转移,另一个则作为高度。这样的状态本身是不好直接进行转移的,可以转换一下形式。

题目看似和图没有关系。可以参考上面提到的树形 DP —— 上司和下属构成了一种树形的关系,对于任何一个节点只和它的上司和它的下属有直接关系。而本题中,也可以来找找砖块之间的关系——长宽必须严格小于下方砖块长宽,首先我们默认长比宽值更大,则当且仅当一个砖块长和宽分别小于另一个的长和宽,可以作为另一个的上方砖块,这也就构成了两者之间的关系。

而对于任意一个砖块,存在 3 种不同状态,三条边均可作为高,故其实可以视作三个不同的砖块,并且固定高度;以此类推,所有砖块都是单向继承,而且不可能存在循环,因为长和宽必然是越来越小的,而且每个砖块的 3 种状态分别都只可能被使用一次,故 DAG 为最佳模型。

假设存在一个长宽高为 (10, 20, 30) 的砖块,将其拆分成 3 部分,构建的 DAG 如下图所示:

(INF, INF) 表示地面,地面无限大。无论砖块如何摆放,必然可以放置在地面上;当 (30, 20) 作为长和宽时,高度为 10,其上方还可以放置 (20, 10) 为长宽的砖块,故 DAG 中连一条有向边,其高度为 30;而 (20, 10) 上方不能放置任何砖块。故答案为 40。

做法有点和最短路相似了,只不过是找的是最长路。那状态和转移方程都很好定义了:

状态定义:f[i] 表示 “使用第 i 个砖块时能得到的最大高度”(此砖块指已经被拆分成 3 种状态的砖块);

状态转移:f[i] = max(f[i], f[j] + h[i]),其中 j 表示能从 i 到达的状态,h[i] 表示第 i 个砖块的高度。

代码:

 1 #include <bits/stdc++.h>
 2 using namespace std;
 3 
 4 #define MAXN 6005
 5 #define INF 1 << 30
 6 
 7 struct Edge {
 8     int v, w, nxt;
 9 } e[MAXN];
10 
11 int n, a[3], f[MAXN], l[MAXN], w[MAXN], h[MAXN], head[MAXN], ans, tot;
12 
13 void addEdge(int u, int v, int w) {
14     e[++tot] = (Edge) {v, w, head[u]}, head[u] = tot;
15 }
16 
17 void work(int o) {
18     for (int x = head[o]; x; x = e[x].nxt) {
19         int v = e[x].v;
20         if (f[v] < f[o] + e[x].w) {
21             ans = max(ans, f[v] = f[o] + e[x].w);
22             work(v);
23         }
24     }
25 }
26 
27 int main() {
28     cin >> n;
29     l[1] = w[1] = INF;
30     for (int i = 1; i <= n; i++) {
31         cin >> a[0] >> a[1] >> a[2];
32         for (int j = 0; j <= 2; j++) {
33             int o = i * 3 + j - 1;
34             h[o] = a[j];
35             for (int k = 0; k <= 2; k++)
36                 if (k != j) l[o] = max(l[o], a[k]);
37             w[o] = a[0] + a[1] + a[2] - h[o] - l[o];
38             addEdge(1, o, h[o]);
39         }
40     }
41     for (int i = 2; i <= n * 3 + 1; i++)
42         for (int j = 2; j <= n * 3 + 1; j++)
43             if (l[i] > l[j] && w[i] > w[j]) addEdge(i, j, h[j]);
44     work(1);
45     cout << ans;
46     return 0;
47 } 

(并非原例题的标程,只保留了核心功能)

最后还想再提一下:注意到不论是树形 DP 还是 DAG 上的 DP,一般都是采用递归的形式来完成,因为转移过程并非简单的线性或二维三维,for 循环难以满足要求。

3、数位 DP

数位 DP 的题面一般是在区间 [l, r] 中满足某种条件的数的个数有多少。比如:

【windy 数】给定一个区间 [l, r],求其中不含前导 0 且相邻两个数字相差至少为 2 的数字个数。

比如,22, 123, 777 不满足,9, 15, 26, 520 满足。

由于对枚举数字的要求是每一位上的数字相关,如果按平常的枚举方式效率显然低下,考虑从数位来切入。

但我们先不谈这道题,先来个更简单的例题:

【例题】给定一个区间 [l, r],求其中所有数字各个数位上 0 ~ 9 出现的次数。

① 随意选择

先令 l = 0。十进制数本质在于,对于任何一个数位,持续累加,满十进一,而后开始下一个循环,继续累加,各个数字的出现显然存在规律。

当 r = 8 时,即 0 ~ 8 各出现 1 次,9 未出现;

当 r = 22 时,其中:0 ~ 9 中 0 ~ 9 各出现一次;10 ~ 19 中 1 出现 11 次,其他各出现一次;20 ~ 22 中 2 出现 4 次,其他各出现一次。综上,0 出现 3 次,1 出现 1 + 11 + 1 = 13 次,2 出现 1 + 1 + 4 = 6 次,其他各出现 3 次;

当 r = 123 时,其中:0 ~ 99 中,个位 0 ~ 9 各出现 10 次,十位 0 ~ 9 各出现 10 次,共 20 次,百位 0 出现 100 次(都是前导 0,具体后面解释);100 ~ 119 中,个位 + 十位 0 ~ 9 各出现 2 次,百位 1 出现 2 次;120 ~ 123 中略。

② 发现规律

我们发现,将一个数按照其每一个数位拆分成若干部分,统计起来会更有规律:

当 r = abcdef 时,拆分成 0 ~ (a-1)99999, a00000 ~ a(b-1)9999, ab0000 ~ ab(c-1)999, ..., abcd(e-1)9 ~ abcdef:

0 ~ (a-1)99999 中, 个/十/百/千/万 0 ~ 9 均出现 5 * 10 ^ 4 = 50000 次,十万位 0 ~ a - 1 各出现 10 ^ 5 = 100000 次;

a00000 ~ a(b-1)9999 中,个/十/百/千 0 ~ 9 均出现 4 * 10 ^ 3 = 4000 次,万位 0 ~ b - 1 各出现 10 ^ 4 = 10000 次,十万位 a 出现 (b-1)9999 + 1 = b0000 次;

以此类推。

③ 归纳规律

我们有发现,每一部分统计的时候,又可以拆分成若干部分,即完整的 0 ~ 9 循环,不完整的次高位,不变的最高若干位。(这些表述可能不太准确)

好的了,这个庞大的问题已经被拆的稀巴烂了,也就完成了 DP 的第一环节。

对了,前面提到了前导 0。前导 0 即数最前面的 0,本身没任何意义,例如 23 和 0023。前面所有的计算都把前导 0 算进去了,最后还要再删掉。删掉也不麻烦,比如:

还是 r = abcdef 中,十万位 0 全部都是前导 0,减去 10 ^ 5 = 100000 次;万位 0 当且仅当十万位也是 0 时也是前导 0,减去 10 ^ 4 = 10000 次,以此类推,减去 111111 次即可。

说实话看起来不太像 DP!

整个过程好像就是个暴力,只是思路需要很清晰——其实只是状态不太明晰罢了。

如果已经懂了上述过程,应该意识到了我们需要预处理一些数据,比如前面已经出现的 10, 100, 1000, ... 以及 1, 20, 300, 4000, 50000, ....,其实在递推这些数时本质就是 DP。

④ 回归 DP

状态定义:f[i] 表示 “所有位数不超过 i 位的数里每个数字出现的次数”;

状态转移:f[i] = f[i - 1] * 10 + 10 ^ (i - 1)。

即对应了上方描述的完整的 0 ~ 9 循环的次数;同样地,不完整的次高位即 f[i] / i。

⑤ 回归题目

最后还有个要注意的:在 ① 时我们先令了 l = 0,取消这个条件后,也没什么大问题——因为 [l, r] 的答案通过 [1, r] 和 [1, l - 1] 相减就可以得到。

代码:

 1 #include <bits/stdc++.h>
 2 using namespace std;
 3 
 4 #define MAXN 10
 5 
 6 int l, r, a[MAXN], f[MAXN], ansl[MAXN], ansr[MAXN];
 7 
 8 void work(int o, int *ans) {
 9     int len = 0, bit = o;
10     while (o) a[++len] = o % 10, o /= 10;
11     for (int i = len; i >= 1; i--) {
12         for (int j = 0; j <= 9; j++) ans[j] += f[i - 1] * a[i];
13         for (int j = 0; j < a[i]; j++) ans[j] += f[i] / i; 
14         bit -= a[i] * f[i] / i, ans[a[i]] += bit + 1, ans[0] -= f[i] / i; 
15     }
16 }
17 
18 int main() {
19     f[1] = 1;
20     for (int i = 2; i <= 8; i++) f[i] = f[i - 1] * 10 + pow(10, i - 1);
21     cin >> l >> r;
22     work(l - 1, ansl), work(r, ansr);
23     for (int i = 0; i <= 9; i++)
24         cout << ansr[i] - ansl[i] << ' ';
25     return 0;
26 } 

相比前面的思路,代码里有一些精简,比如:

a00000 ~ a(b-1)9999 中,个/十/百/千 0 ~ 9 均出现 4 * 10 ^ 3 = 4000 次,万位 0 ~ b - 1 各出现 10 ^ 4 = 10000 次,十万位 a 出现 (b-1)9999 + 1 = b0000 次

其实后面几部分中,十万位 a 会分别出现 c000 + d00 + e0 + f 次,共计 bcdef 次,那么直接在 ans[a[i]] += bit + 1 这句话中一次性减完了。

⑥ 再谈数位 DP

看到这里感觉这类 DP 好像和前面的比好像有点不太一样,甚至有点怀疑这是不是 DP,那么再回过头看看前面那道例题【windy 数】。

状态定义:f[i][j] 表示 “位数为 i 且最高位为 j 时的方案数”;

此处省略大量的找规律环节,直接来到:

当 r = abcdef 时,拆分成少于 6 位,等于 6 位且不是 a 开头,等于 6 位且是 a 开头三部分:

0 ~ 99999 中,分别按 1, 2, 3, 4, 5 位数累加,即 f[1][1..9] + f[2][1..9] + ... f[5][1..9];

100000 ~ (a-1)9999 中,方案数为 f[6][1..a - 1];

a00000 ~ abcdef 中,再次按 5, 4, 3, 2, 1 位数逐层拆分,具体略。

最后一个问题是,如何递推出 f 数组。对数的筛选只和数位和数位之间的相对大小有关,任意一个数位的大小限制只和相邻位置的数位有关,而我们的状态数组里记录了最高位,则扩展位数的时候直接往前扩,即可得到多 1 位的结果:

状态转移:f[i][j] = ∑ f[i - 1][k],abs(j, k) >= 2。

代码:

 1 #include <bits/stdc++.h>
 2 using namespace std;
 3 
 4 #define MAXN 15
 5 
 6 int l, r, a[MAXN], f[MAXN][MAXN];
 7 
 8 int work(int o) {
 9     int len = 0, res = 0;
10     while (o) a[++len] = o % 10, o /= 10;
11     for (int i = 1; i < len; i++)
12         for (int j = 1; j <= 9; j++)
13             res += f[i][j];
14     for (int j = 1; j < a[len]; j++)
15         res += f[len][j];
16     for (int i = len - 1; i >= 1; i--) {
17         for (int j = 0; j < a[i]; j++)
18             if (abs(j - a[i + 1]) >= 2)
19                 res += f[i][j];
20         if (abs(a[i] - a[i + 1]) < 2) break;
21     }
22     return res;
23 }
24 
25 int main() {
26     for (int i = 0; i <= 9; i++) f[1][i] = 1;
27     for (int i = 2; i <= 10; i++)
28         for (int j = 0; j <= 9; j++)
29             for (int k = 0; k <= 9; k++)
30                 if (abs(j - k) >= 2)
31                     f[i][j] += f[i - 1][k];
32     cin >> l >> r;
33     cout << work(r + 1) - work(l);
34     return 0;
35 } 

注意,work(x) 实际求得的是 x - 1 内的方案数,故最终答案应该为 work(r + 1) - work(l)。

⑦ 总结

数位 DP 不论从题面,到状态定义,到求解过程其实还是挺有特点的。由于规律往往是适用于所有数,且为了方便,状态数组一般都是预处理出来的,再根据所求区间进行计算。

原文地址:https://www.cnblogs.com/jinkun113/p/12723563.html