前言

数塔问题,又称数字三角形、数字金字塔问题。数塔问题是多维动态规划问题中一类常见且重要的题型,其变种众多,难度遍布从低到高,掌握该类型题目的算法思维,对于攻克许多多维动态规划的问题有很大帮助。

当然你可能已经发现过我以前发布过的博客:教你彻底学会动态规划——入门篇 中已经详细讲解了数字三角形,当然那篇文章很好,不过由于数字三角形问题变种题较多,然后博主想要复习一下基础算法故记录一下数字三角形(朝夕 的文章讲解)并延伸介绍变种问题。

一、数塔问题原型

1.1 问题描述

  1. 7
  2. 3 8
  3. 8 1 0
  4. 2 7 4 4
  5. 4 5 2 6 5

有一个多行的数塔,数塔上有若干数字。问从数塔的最高点到底部,在所有的路径中,经过的数字的和最大为多少?

如上图,是一个5行的数塔,其中7—3—8—7—5的路径经过数字和最大,为30。

1.2 解法思路

面对数塔问题,使用贪心算法显然是行不通的,比如给的样例,如果使用贪心算法,那选择的路径应当是7—8—1—7—5,其经过数字和只有28,并不是最大。而用深搜DFS很容易算出时间复杂度为 \(O(2^n)\)(因为每个数字都有向左下和右下两种选择),行数一多必定超时。

所以,数塔问题需要使用动态规划算法。

①我们可以从上往下遍历。

可以发现,要想经过一个数字,只能从左上角或右上角的数字往下到达。

所以显然,经过任一数字A时,路径所经过的数字最大和——是这个数字A左上方的数字B以及右上方的数字C两个数字中,所能达到的数字最大和中较大的那一个,再加上该数字A。

故状态转移方程为: $$dp[i][j] = max(dp[i - 1][j],d[i - 1][j - 1]) + num[i][j]$$

其中i,j表示行数和列数,dp表示储存的最大和,num表示位置上的数字。

\(dp[i - 1][j]\) 表示左上角,\(dp[i - 1][j -1]\)表示右上角。

以样例来说明:在经过第三行的数字1时,我们先看它左上角的数字3和右上角的数字8其能达到的最大和。3显然只有7—3一条路径,故最大和是10;8显然也只有7—8一条路径,其最大和是15;两者中较大的是15,故经过1所能达到的最大和是15+1=16。

这样一步步向下遍历,最后经过每一个位置所能达到的最大和都求出来了,只要从最底下的一行里寻找最大值并输出即可。

②我们也可以从下往上遍历。

一条路径不管是从上往下走还是从下往上走,其经过的数字和都是一样的,所以这题完全可以变成求——从最底层到最高点所经过的最大数字和。

其写法与顺序遍历是一样的,只是状态转移时,变成从该数字的左下角和右下角来取max了。逆序遍历的写法相比于顺序遍历优点在于:少了最后一步求最后一行max的过程,可以直接输出最高点所储存的值。

1.3 代码实现

  1. // Author : RioTian
  2. // Time : 21/01/21
  3. // #include <bits/stdc++.h>
  4. #include <algorithm>
  5. #include <iostream>
  6. using namespace std;
  7. const int N = 1e3 + 10;
  8. int dp[N][N], num[N][N];
  9. int main() {
  10. ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr);
  11. int n;
  12. cin >> n; //输入数塔行数
  13. for (int i = 1; i <= n; ++i)
  14. for (int j = 1; j <= i; ++j)
  15. cin >> num[i][j]; //输入数塔数据,注意i和j要从1开始,防止数组越界
  16. for (int i = 1; i <= n; ++i)
  17. for (int j = 1; j <= i; ++j)
  18. dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - 1]) + num[i][j];
  19. //经过该数字的最大和,为左上角和右上角中的max,再加上该数字
  20. int ans = 0;
  21. for (int i = 1; i <= n; i++)
  22. ans = max(ans, dp[n][i]); //从最后一行中找到最大数
  23. cout << ans << endl;
  24. return 0;
  25. }

二、数塔问题变种

2.1 矩形数塔

以【洛谷P1508:Likecloud-吃、吃、吃】为例。

2.1.1 问题描述

有一m行,n列(n为奇数)的数字矩阵(数字中存在部分负数)。

以最后一行的正中间下方为出发点,每次移动可以选择向前方、左前方、右前方移动,问从出发点一直到矩阵的另一侧,所经过的最大数字和为多少。

2.1.2 样例

  1. 6 7
  2. 16 4 3 12 6 0 3
  3. 4 -5 6 7 0 0 2
  4. 6 0 -1 -2 3 6 8
  5. 5 3 4 0 0 -2 7
  6. -1 7 4 0 7 -5 6
  7. 0 -1 3 4 12 4 2
  8. S

如上,是一个6行7列的数字矩阵,出发点为最后一行数字4的下方'S'。第一次移动可以选择移动到最后一行3、4、12中一个,若选择移动到4,则第二次移动可以选择移动到倒二行的4、0、7。

该矩阵从出发点移动到矩阵的另一侧,所经过的最大数字和为41。

2.1.3 解题思路

与数塔问题的思路基本一致。

不过该题循环时要从倒二行开始循环到第一行。(最后一行只有三个可到达点,故可初始化后直接跳过)

且状态转移多了一个可选项,状态转移方程如下:

\[dp[i][j] = max(dp[i + 1,j + 1],dp[i + 1,j - 1],dp[i + 1,j]) + num[i][j]
\]

另外需要注意:矩阵中存在负数,故dp数组初始化时需要初始化为绝对值较大的负数,防止转移过程中由于访问到矩阵边界外而出现问题。(也可以在矩阵的边界特殊处理)

2.1.4 代码实现

  1. // Author : RioTian
  2. // Time : 21/01/21
  3. #include <bits/stdc++.h>
  4. using namespace std;
  5. typedef long long ll;
  6. const int N = 1e3 + 10;
  7. int dp[N][N], a[N][N];
  8. int main() {
  9. // freopen("in.txt", "r", stdin);
  10. ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr);
  11. int n, m;
  12. while (cin >> m >> n) {
  13. memset(dp, -9999, sizeof(dp)); //有个坑点,这里应该设置 -9999而不是-1
  14. for (int i = 1; i <= m; ++i)
  15. for (int j = 1; j <= n; ++j)
  16. cin >> a[i][j];
  17. dp[m][n / 2 + 1] = a[m][n / 2 + 1];
  18. dp[m][n / 2] = a[m][n / 2];
  19. dp[m][n / 2 + 2] = a[m][n / 2 + 2];
  20. for (int i = m - 1; i > 0; --i)
  21. for (int j = 1; j <= n; ++j)
  22. dp[i][j] =
  23. max(max(dp[i + 1][j + 1], dp[i + 1][j - 1]), dp[i + 1][j]) +
  24. a[i][j];
  25. int ans = 0;
  26. for (int i = 1; i <= n; ++i)
  27. ans = max(ans, dp[1][i]);
  28. cout << ans << endl;
  29. }
  30. }

2.2 以时间作为一个维度的数塔

以【HDU 1176 免费馅饼】为例。

这道题在 KB 的DP系列也出现过 : KB题集

2.2.1 问题描述

有一条10米长的小路,以小路起点为x轴的原点,小路终点为x=10,则共有x=0~10共计11个坐标点。(如下图)

接下来的n行每行有两个整数x、T,表示一个馅饼将在第T秒掉落在坐标x上。

同一秒在同一点上可能掉落有多个馅饼。

初始时你站在x=5上,每秒可移动1m,最多可接到所在位置1m范围内某一点上的馅饼。

比如你站在x=5上,就可以接到x=4、5、6其中一点上的所有馅饼。

问你最多可接到多少个馅饼。

2.2.2 样例

6 (表示有6个馅饼)

5 1(在第1s,有一个馅饼掉落在x=5上)

4 1(在第1s,有一个馅饼掉落在x=4上)

6 1

7 2(在第2s,有一个馅饼掉落在x=7上)

7 2(同1s可能有多个馅饼掉落在同一点上)

8 3

样例中最多可接到4个馅饼。

其中一种接法是:第1s接住x=5的一个馅饼,第2s移动到x=6,接住x=7上的两个馅饼,第3s移动到x=7,接住x=8的一个馅饼,共计4个馅饼。

2.2.3 解题思路

本质上还是数塔问题,不过此时“行数”这一维度变成了“时间”。

以样例来说明:可以理解为在第一行的4、5、6三列的数字为1,第二行的第7列数字为2,第8列数字为1。然后出发点在第0行的第5列。每次移动可选择往下,左下,右下三种方式。

所以解法基本一致,解题时注意题目的附加条件即可。

2.2.4 代码实现

  1. // Author : RioTian
  2. // Time : 21/01/21
  3. #include <bits/stdc++.h>
  4. using namespace std;
  5. typedef long long ll;
  6. const int N = 1e5 + 50;
  7. int dp[N][15], a[N][15];
  8. int main() {
  9. // freopen("in.txt", "r", stdin);
  10. // ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr);
  11. int n;
  12. while (cin >> n && n) {
  13. int maxn = 0;
  14. memset(dp, 0, sizeof dp), memset(a, 0, sizeof a);
  15. int x, y, e = 0;
  16. for (int i = 1; i <= n; ++i) {
  17. cin >> x >> y;
  18. a[y][++x]++, e = max(e, y);
  19. }
  20. for (int i = e; i >= 0; --i)
  21. for (int j = 1; j <= 11; ++j)
  22. dp[i][j] =
  23. max({dp[i + 1][j - 1], dp[i + 1][j], dp[i + 1][j + 1]}) +
  24. a[i][j];
  25. cout << dp[0][6] << endl;
  26. }
  27. }

2.3双线程取数(四维DP)

2.3.1 例1:【洛谷P1004:方格取数】

2.3.1.1 问题描述

有一个N×N的方格图,在某些方格内放入正整数,其他方格则放入0。

某人从方格图左上角出发,只能选择向下或向右行走,直到走到右下角,过程中他可以取走方格内的数(取完后变成0)。这样连续走两次,问取出的数的和最大是多少?

2.3.1.2 样例

  1. A
  2. 0 0 0 0 0 0 0 0
  3. 0 0 13 0 0 6 0 0
  4. 0 0 0 0 7 0 0 0
  5. 0 0 0 14 0 0 0 0
  6. 0 21 0 0 0 4 0 0
  7. 0 0 15 0 0 0 0 0
  8. 0 14 0 0 0 0 0 0
  9. 0 0 0 0 0 0 0 0
  10. B

如上,这是一个8×8的方格图,第一次走,取走13、14、4,第二次走,取走21、15,最大和为67。

注:方格内的数是按照a b c的格式给的,a代表行数,b代表列数,c代表值。

如2 3 13代表第二行第三列的值是13。

2.3.1.3 解题思路

如果只是单线程,也就是只走一次,那这题就和矩形数塔没什么差别,建立二维数组 \(dp[i,j]\) 用于储存走到(i,j)时的最大和即可。

但这题是双线程,所以我们需要建立四维数组 \(dp[i,j,k,l]\) 用于储存第一次走到(i,j),第二次走到(k,l)时,所取得的最大和。

根据题意,行走时只能往下或往右走,所以此时就存在四种情况来到达(i,j)和(k,l):

第一次走往下走,第二次走往下走;——(i-1,j)、(k-1,l)

第一次走往下走,第二次走往右走;——(i-1,j)、(k,l-1)

第一次走往右走,第二次走往下走;——(i,j-1)、(k-1,l)

第一次走往右走,第二次走往右走;——(i,j-1)、(k,l-1)

也就是说:当前状态可能来源于四种之前的状态的转移

所以,状态转移方程如下:

\[dp[i,j,k,l] = max(dp[i - 1,j,k - 1,l],dp[i - 1,j,k,l - 1],dp[i,j - 1,k -1,l],dp[i,j-1,k,l-1])
\]

但还需要注意!根据题意,在第一次走时取走的数会变成0,则第二次走如果还经过相同的地方,那就只能取到0了,所以这里还需要特判:

  1. if (i == k && j == l) dp[i][j][k][l] -= num[i][j];

上面代码的意思是,当两次走到同一个位置时,只能取走一次方格内的值,由于状态转移方程里取了两次,所以这里特判时需要减去一次。

2.3.1.4 代码实现

  1. // Author : RioTian
  2. // Time : 21/01/21
  3. #include <bits/stdc++.h>
  4. using namespace std;
  5. typedef long long ll;
  6. const int N = 1e5 + 50;
  7. int f[12][12][12][12], a[12][12], n, x, y, z;
  8. int main() {
  9. cin >> n >> x >> y >> z;
  10. while (x != 0 || y != 0 || z != 0) {
  11. a[x][y] = z;
  12. cin >> x >> y >> z;
  13. }
  14. for (int i = 1; i <= n; i++) {
  15. for (int j = 1; j <= n; j++) {
  16. for (int k = 1; k <= n; k++) {
  17. for (int l = 1; l <= n; l++) {
  18. f[i][j][k][l] =
  19. max(max(f[i - 1][j][k - 1][l], f[i - 1][j][k][l - 1]),
  20. max(f[i][j - 1][k - 1][l], f[i][j - 1][k][l - 1])) +
  21. a[i][j] + a[k][l];
  22. if (i == k && l == j)
  23. f[i][j][k][l] -= a[i][j];
  24. }
  25. }
  26. }
  27. }
  28. cout << f[n][n][n][n];
  29. return 0;
  30. }

2.3.2 例2:【洛谷P1006:传纸条】

2.3.2.1 问题描述

有一m行n列的数字矩阵,寻找两条不重复的路径从左上角到达右下角,求两次取的数的和的最大值。

注:本题相比例1多了一个要求——两次走的路径不可重复,也就是 \(i\ !=k,j\ !=l\)

注2:起点和终点储存的值都是0。

2.3.2.2 解题思路

具体思路与上一例基本相似,只是由于多出的要求使得路径不可重复,所以不能再向上面那题一样去特判,而是在循环过程中就不能让路径重复。

这里的办法就是写for语句时让l从j+1开始循环。

为什么这样可以避免路径重复?

因为在循环过程中,l永远没法等于j,也就是走的时候不可能走到同一个坐标上,那就满足了题意。

2.3.2.3 代码实现

  1. // Author : RioTian
  2. // Time : 21/01/21
  3. #include <bits/stdc++.h>
  4. using namespace std;
  5. typedef long long ll;
  6. const int N = 1e5 + 50;
  7. using namespace std;
  8. int num[60][60];
  9. int dp[60][60][60][60];
  10. int main() {
  11. int m, n;
  12. while (scanf("%d%d", &m, &n) != EOF) {
  13. memset(dp, 0, sizeof(dp));
  14. for (int i = 1; i <= m; i++)
  15. for (int j = 1; j <= n; j++)
  16. scanf("%d", &num[i][j]); //输入矩阵
  17. for (int i = 1; i <= m; i++)
  18. for (int j = 1; j <= n; j++)
  19. for (int k = 1; k <= m; k++)
  20. for (int l = j + 1; l <= n; l++) //让l从j+1开始
  21. {
  22. dp[i][j][k][l] = max(max(dp[i - 1][j][k - 1][l],
  23. dp[i][j - 1][k][l - 1]),
  24. max(dp[i - 1][j][k][l - 1],
  25. dp[i][j - 1][k - 1][l])) +
  26. num[i][j] + num[k][l];
  27. } //由于坐标不会重复,无需再特判
  28. printf("%d\n",
  29. dp[m][n - 1][m - 1][n]); //需要注意此时输出的答案是在哪里的值
  30. //因为终点和起点储存的值都是0,所以才能这样,否则还需要加上一次终点的值
  31. }
  32. return 0;
  33. }

2.3.3 四维降三维的空间优化

在上面的两个例子中,由于开了四维数组,空间复杂度过于大了,一旦给的行列数稍大,就可能超出限定的内存,所以此时需要进一步地优化来降低空间复杂度。

四维降三维的思想如下:

我们可以发现,由于走的过程中只允许向右或向下走,所以每走一步不是行数加一就是列数加一。

故在两条路径的长度一样时(也就是走的步数一样多时)\(i + j = k + l= 步数 + 2\)

所以,我们可以开一个三维的数组 \(dp[n + m - 2,x_1,x_2]\)

其中第一维代表步数,m行n列的矩阵步数从0~n+m-2。

第二维和第三维分别表示两条路径的横坐标,只要知道了步数和横坐标,就可以通过计算得出纵坐标。

这样,空间复杂度就下降了。

代码实现读者可以自行尝试。

2.3.4 二维的空间优化

注:在看本步优化前,建议先学习背包问题一节。

在解决背包问题时,我们采用了滚动数组的方法使数组从二维降到了一维,这是因为背包问题中,我们只用得到上一“行”的数据。

同样的,本题中,由于只能向右或向下走,状态的转移也只用得到上一行和上一列(也就是上一步)的数据,故也可以使用滚动数组降维至二维。

2.3.4.1 代码实现

  1. // Author : RioTian
  2. // Time : 21/01/21
  3. #include <bits/stdc++.h>
  4. using namespace std;
  5. typedef long long ll;
  6. const int N = 1e5 + 50;
  7. using namespace std;
  8. int dp[200][200];
  9. int num[200][200];
  10. int main() {
  11. int n, m;
  12. scanf("%d%d", &n, &m);
  13. for (int i = 1; i <= n; i++)
  14. for (int j = 1; j <= m; j++)
  15. scanf("%d", &num[i][j]);
  16. for (int k = 1; k <= n + m - 2; k++) //步数,通过滚动数组降去了这一维
  17. for (int i = n; i >= 1; i--) //滚动数组需要倒序处理!!!
  18. for (int p = n; p > i; p--) // p>i是为了防止路径重复
  19. {
  20. dp[i][p] = max(max(dp[i][p], dp[i - 1][p - 1]),
  21. max(dp[i - 1][p], dp[i][p - 1]));
  22. dp[i][p] += num[i][k - i] + num[p][k - p];
  23. }
  24. printf("%d\n", dp[n - 1][n]);
  25. return 0;
  26. }

ACM | 动态规划-数塔问题变种题型的更多相关文章

  1. c++ 动态规划(数塔)

    c++ 动态规划(dp) 题目描述 观察下面的数塔.写一个程序查找从最高点到底部任意位置结束的路径,使路径经过数字的和最大. 每一步可以从当前点走到左下角的点,也可以到达右下角的点. 输入 5 13 ...

  2. ACM 杭电HDU 2084 数塔 [解题报告]

    数塔 Time Limit: 1000/1000 MS (Java/Others)    Memory Limit: 32768/32768 K (Java/Others)Total Submissi ...

  3. HDU 2084 数塔(动态规划)

    数塔 http://acm.hdu.edu.cn/showproblem.php?pid=2084 Problem Description 在讲述DP算法的时候,一个经典的例子就是数塔问题,它是这样描 ...

  4. [ACM_动态规划] 数字三角形(数塔)

    递归方法解决数塔问题 状态转移方程:d[i][j]=a[i][j]+max{d[i+1][j],d[i+1][j+1]} 注意:1\d[i][j]表示从i,j出发的最大总和;2\变界值设为0;3\递归 ...

  5. ACM 数塔

    在讲述DP算法的时候,一个经典的例子就是数塔问题,它是这样描述的: 有如下所示的数塔,要求从顶层走到底层,若每一步只能走到相邻的结点,则经过的结点的数字之和最大是多少?  已经告诉你了,这是个DP的题 ...

  6. [ACM_动态规划] hdu 1176 免费馅饼 [变形数塔问题]

    Problem Description 都说天上不会掉馅饼,但有一天gameboy正走在回家的小径上,忽然天上掉下大把大把的馅饼.说来gameboy的人品实在是太好了,这馅饼别处都不掉,就掉落在他身旁 ...

  7. 数塔(hdoj 2084,动态规划递推)

    在讲述DP算法的时候,一个经典的例子就是数塔问题,它是这样描述的: 有如下所示的数塔,要求从顶层走到底层,若每一步只能走到相邻的结点,则经过的结点的数字之和最大是多少? 已经告诉你了,这是个DP的题目 ...

  8. 【DP专辑】ACM动态规划总结

    转载请注明出处,谢谢.   http://blog.csdn.net/cc_again?viewmode=list          ----------  Accagain  2014年5月15日 ...

  9. 数塔~~dp学习_1

    题目链接:http://acm.hdu.edu.cn/showproblem.php?pid=2084 数塔 Time Limit: 1000/1000 MS (Java/Others)    Mem ...

  10. Hdoj 2084.数塔 题解

    Problem Description 在讲述DP算法的时候,一个经典的例子就是数塔问题,它是这样描述的: 有如下所示的数塔,要求从顶层走到底层,若每一步只能走到相邻的结点,则经过的结点的数字之和最大 ...

随机推荐

  1. 【Javascript】什么是alert,alert怎么用

    alert()是javascript语言提供的一个警告框函数 他可以接受任意参数,参数就是警告框里的函数信息

  2. 有什么BI工具可以实现中国式报表?

    BI(Business Intelligence)工具是指用于帮助企业收集.分析.处理和展示数据的软件工具,以支持企业决策制定和业务运营优化的技术系统. 中国式报表在BI工具中的实现主要涉及到对中国商 ...

  3. Tensorflow2.0实现VGG13

    导入必要的库: import os import tensorflow as tf from tensorflow import keras from tensorflow.keras import ...

  4. 持续集成Jenkins

    一.简单慨念 持续集成(Continuous integration,简称 CI),随着近几年的发展,持续集成在项目中 得到了广泛的推广和应用. 软件集成就是用一种较好的方式,使多种软件的功能集成到一 ...

  5. Python——第一章:循环语句while——break和continue

    while True: content = input("请输入你要发送的内容(q结束):") print("发送内容:", content) 这样的代码会无限 ...

  6. 一个简单的Python暴力破解网站登录密码脚本

    目录: 关键代码解释 完整代码 方法一 运行结果 方法二 运行结果 测试靶机为DVWA,适合DVWA暴力破解模块的Low和Medium等级 关键代码解释 url指定url地址 url = " ...

  7. java实现一个录像大师

    java实现一个录像大师 javacv从入门到入土系列,发现了个好玩的东西,视频处理,于是我想搞了个屏幕录屏大师,这里我使用javafx进行页面显示. 依赖 <!-- 需要注意,javacv主要 ...

  8. QRCoder1.4.3生成二维码,不依赖System.Drawing,解决"未能找到类型或命名空间名QRCode","及ImageFormatPng仅在windows上受支持"

    生成二维码1(简单) 包引用: <PackageReference Include="QRCoder" Version="1.4.3" /> usi ...

  9. 开源云原生网关Linux Traefik本地部署结合内网穿透远程访问

      开源云原生网关Linux Traefik本地部署结合内网穿透远程访问 前言 Træfɪk 是一个云原生的新型的 HTTP 反向代理.负载均衡软件,能轻易的部署微服务.它支持多种后端 (Docker ...

  10. VSCode 终端选择文本自动复制

    Ctrl + , 打开设置 搜索 copyOnSelection,勾选即可 对应的 settings.json 如下 "terminal.integrated.copyOnSelection ...