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

  • 相关阅读:
    再谈数据湖3.0:降本增效背后的创新原动力
    基于开源PolarDBX打造中正智能身份认证业务数据基座
    开源数据库PolarDB为什么能捕获娃哈哈的心?
    PolarDB开源未来将有哪些新动向?阿里云数据库开源负责人来解答
    谈谈PolarDBX在读写分离场景的实践
    科普达人丨一图看懂块存储&云盘
    科普达人丨一图看懂阿里云ECS
    网络编程框架Netty
    结构型模式
    网络编程框架Netty1
  • 原文地址:https://www.cnblogs.com/jinkun113/p/12723563.html
Copyright © 2020-2023  润新知