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