数塔问题,又称数字三角形、数字金字塔问题。数塔问题是多维动态规划问题中一类常见且重要的题型,其变种众多,难度遍及从低到高,掌握该类型题目的算法思惟,对于攻克许多多维动态规划的问题有很大帮助。html
固然你可能已经发现过我之前发布过的博客:教你完全学会动态规划——入门篇 中已经详细讲解了数字三角形,固然那篇文章很好,不过因为数字三角形问题变种题较多,而后博主想要复习一下基础算法故记录一下数字三角形(朝夕 的文章讲解)并延伸介绍变种问题。ios
7 3 8 8 1 0 2 7 4 4 4 5 2 6 5
有一个多行的数塔,数塔上有若干数字。问从数塔的最高点到底部,在全部的路径中,通过的数字的和最大为多少?
如上图,是一个5行的数塔,其中7—3—8—7—5的路径通过数字和最大,为30。c++
面对数塔问题,使用贪心算法显然是行不通的,好比给的样例,若是使用贪心算法,那选择的路径应当是7—8—1—7—5,其通过数字和只有28,并非最大。而用深搜DFS很容易算出时间复杂度为 \(O(2^n)\)(由于每一个数字都有向左下和右下两种选择),行数一多一定超时。算法
因此,数塔问题须要使用动态规划算法。数组
①咱们能够从上往下遍历。学习
能够发现,要想通过一个数字,只能从左上角或右上角的数字往下到达。优化
因此显然,通过任一数字A时,路径所通过的数字最大和——是这个数字A左上方的数字B以及右上方的数字C两个数字中,所能达到的数字最大和中较大的那一个,再加上该数字A。spa
故状态转移方程为: $$dp[i][j] = max(dp[i - 1][j],d[i - 1][j - 1]) + num[i][j]$$线程
其中i,j
表示行数和列数,dp
表示储存的最大和,num
表示位置上的数字。code
\(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的过程,能够直接输出最高点所储存的值。
// Author : RioTian // Time : 21/01/21 // #include <bits/stdc++.h> #include <algorithm> #include <iostream> using namespace std; const int N = 1e3 + 10; int dp[N][N], num[N][N]; int main() { ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); int n; cin >> n; //输入数塔行数 for (int i = 1; i <= n; ++i) for (int j = 1; j <= i; ++j) cin >> num[i][j]; //输入数塔数据,注意i和j要从1开始,防止数组越界 for (int i = 1; i <= n; ++i) for (int j = 1; j <= i; ++j) dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - 1]) + num[i][j]; //通过该数字的最大和,为左上角和右上角中的max,再加上该数字 int ans = 0; for (int i = 1; i <= n; i++) ans = max(ans, dp[n][i]); //从最后一行中找到最大数 cout << ans << endl; return 0; }
以【洛谷P1508:Likecloud-吃、吃、吃】为例。
有一m行,n列(n为奇数)的数字矩阵(数字中存在部分负数)。
以最后一行的正中间下方为出发点,每次移动能够选择向前方、左前方、右前方移动,问从出发点一直到矩阵的另外一侧,所通过的最大数字和为多少。
6 7 16 4 3 12 6 0 3 4 -5 6 7 0 0 2 6 0 -1 -2 3 6 8 5 3 4 0 0 -2 7 -1 7 4 0 7 -5 6 0 -1 3 4 12 4 2 S
如上,是一个6行7列的数字矩阵,出发点为最后一行数字4的下方'S'。第一次移动能够选择移动到最后一行三、四、12中一个,若选择移动到4,则第二次移动能够选择移动到倒二行的四、0、7。
该矩阵从出发点移动到矩阵的另外一侧,所通过的最大数字和为41。
与数塔问题的思路基本一致。
不过该题循环时要从倒二行开始循环到第一行。(最后一行只有三个可到达点,故可初始化后直接跳过)
且状态转移多了一个可选项,状态转移方程以下:
另外须要注意:矩阵中存在负数,故dp数组初始化时须要初始化为绝对值较大的负数,防止转移过程当中因为访问到矩阵边界外而出现问题。(也能够在矩阵的边界特殊处理)
// Author : RioTian // Time : 21/01/21 #include <bits/stdc++.h> using namespace std; typedef long long ll; const int N = 1e3 + 10; int dp[N][N], a[N][N]; int main() { // freopen("in.txt", "r", stdin); ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); int n, m; while (cin >> m >> n) { memset(dp, -9999, sizeof(dp)); //有个坑点,这里应该设置 -9999而不是-1 for (int i = 1; i <= m; ++i) for (int j = 1; j <= n; ++j) cin >> a[i][j]; dp[m][n / 2 + 1] = a[m][n / 2 + 1]; dp[m][n / 2] = a[m][n / 2]; dp[m][n / 2 + 2] = a[m][n / 2 + 2]; for (int i = m - 1; i > 0; --i) for (int j = 1; j <= n; ++j) dp[i][j] = max(max(dp[i + 1][j + 1], dp[i + 1][j - 1]), dp[i + 1][j]) + a[i][j]; int ans = 0; for (int i = 1; i <= n; ++i) ans = max(ans, dp[1][i]); cout << ans << endl; } }
以【HDU 1176 免费馅饼】为例。
这道题在 KB 的DP系列也出现过 : KB题集
有一条10米长的小路,以小路起点为x轴的原点,小路终点为x=10,则共有x=0~10共计11个坐标点。(以下图)
接下来的n行每行有两个整数x、T,表示一个馅饼将在第T秒掉落在坐标x上。
同一秒在同一点上可能掉落有多个馅饼。
初始时你站在x=5上,每秒可移动1m,最多可接到所在位置1m范围内某一点上的馅饼。
好比你站在x=5上,就能够接到x=四、五、6其中一点上的全部馅饼。
问你最多可接到多少个馅饼。
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个馅饼。
本质上仍是数塔问题,不过此时“行数”这一维度变成了“时间”。
以样例来讲明:能够理解为在第一行的四、五、6三列的数字为1,第二行的第7列数字为2,第8列数字为1。而后出发点在第0行的第5列。每次移动可选择往下,左下,右下三种方式。
因此解法基本一致,解题时注意题目的附加条件便可。
// Author : RioTian // Time : 21/01/21 #include <bits/stdc++.h> using namespace std; typedef long long ll; const int N = 1e5 + 50; int dp[N][15], a[N][15]; int main() { // freopen("in.txt", "r", stdin); // ios::sync_with_stdio(false), cin.tie(nullptr), cout.tie(nullptr); int n; while (cin >> n && n) { int maxn = 0; memset(dp, 0, sizeof dp), memset(a, 0, sizeof a); int x, y, e = 0; for (int i = 1; i <= n; ++i) { cin >> x >> y; a[y][++x]++, e = max(e, y); } for (int i = e; i >= 0; --i) for (int j = 1; j <= 11; ++j) dp[i][j] = max({dp[i + 1][j - 1], dp[i + 1][j], dp[i + 1][j + 1]}) + a[i][j]; cout << dp[0][6] << endl; } }
有一个N×N的方格图,在某些方格内放入正整数,其余方格则放入0。
某人从方格图左上角出发,只能选择向下或向右行走,直到走到右下角,过程当中他能够取走方格内的数(取完后变成0)。这样连续走两次,问取出的数的和最大是多少?
A 0 0 0 0 0 0 0 0 0 0 13 0 0 6 0 0 0 0 0 0 7 0 0 0 0 0 0 14 0 0 0 0 0 21 0 0 0 4 0 0 0 0 15 0 0 0 0 0 0 14 0 0 0 0 0 0 0 0 0 0 0 0 0 0 B
如上,这是一个8×8的方格图,第一次走,取走1三、1四、4,第二次走,取走2一、15,最大和为67。
注:方格内的数是按照a b c的格式给的,a表明行数,b表明列数,c表明值。
如2 3 13表明第二行第三列的值是13。
若是只是单线程,也就是只走一次,那这题就和矩形数塔没什么差异,创建二维数组 \(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)
也就是说:当前状态可能来源于四种以前的状态的转移
因此,状态转移方程以下:
但还须要注意!根据题意,在第一次走时取走的数会变成0,则第二次走若是还通过相同的地方,那就只能取到0了,因此这里还须要特判:
if (i == k && j == l) dp[i][j][k][l] -= num[i][j];
上面代码的意思是,当两次走到同一个位置时,只能取走一次方格内的值,因为状态转移方程里取了两次,因此这里特判时须要减去一次。
// Author : RioTian // Time : 21/01/21 #include <bits/stdc++.h> using namespace std; typedef long long ll; const int N = 1e5 + 50; int f[12][12][12][12], a[12][12], n, x, y, z; int main() { cin >> n >> x >> y >> z; while (x != 0 || y != 0 || z != 0) { a[x][y] = z; cin >> x >> y >> z; } for (int i = 1; i <= n; i++) { for (int j = 1; j <= n; j++) { for (int k = 1; k <= n; k++) { for (int l = 1; l <= n; l++) { f[i][j][k][l] = max(max(f[i - 1][j][k - 1][l], f[i - 1][j][k][l - 1]), max(f[i][j - 1][k - 1][l], f[i][j - 1][k][l - 1])) + a[i][j] + a[k][l]; if (i == k && l == j) f[i][j][k][l] -= a[i][j]; } } } } cout << f[n][n][n][n]; return 0; }
有一m行n列的数字矩阵,寻找两条不重复的路径从左上角到达右下角,求两次取的数的和的最大值。
注:本题相比例1多了一个要求——两次走的路径不可重复,也就是 \(i\ !=k,j\ !=l\)
注2:起点和终点储存的值都是0。
具体思路与上一例基本类似,只是因为多出的要求使得路径不可重复,因此不能再向上面那题同样去特判,而是在循环过程当中就不能让路径重复。
这里的办法就是写for语句时让l从j+1开始循环。
为何这样能够避免路径重复?
由于在循环过程当中,l永远无法等于j,也就是走的时候不可能走到同一个坐标上,那就知足了题意。
// Author : RioTian // Time : 21/01/21 #include <bits/stdc++.h> using namespace std; typedef long long ll; const int N = 1e5 + 50; using namespace std; int num[60][60]; int dp[60][60][60][60]; int main() { int m, n; while (scanf("%d%d", &m, &n) != EOF) { memset(dp, 0, sizeof(dp)); for (int i = 1; i <= m; i++) for (int j = 1; j <= n; j++) scanf("%d", &num[i][j]); //输入矩阵 for (int i = 1; i <= m; i++) for (int j = 1; j <= n; j++) for (int k = 1; k <= m; k++) for (int l = j + 1; l <= n; l++) //让l从j+1开始 { dp[i][j][k][l] = max(max(dp[i - 1][j][k - 1][l], dp[i][j - 1][k][l - 1]), max(dp[i - 1][j][k][l - 1], dp[i][j - 1][k - 1][l])) + num[i][j] + num[k][l]; } //因为坐标不会重复,无需再特判 printf("%d\n", dp[m][n - 1][m - 1][n]); //须要注意此时输出的答案是在哪里的值 //由于终点和起点储存的值都是0,因此才能这样,不然还须要加上一次终点的值 } return 0; }
在上面的两个例子中,因为开了四维数组,空间复杂度过于大了,一旦给的行列数稍大,就可能超出限定的内存,因此此时须要进一步地优化来下降空间复杂度。
四维降三维的思想以下:
咱们能够发现,因为走的过程当中只容许向右或向下走,因此每走一步不是行数加一就是列数加一。
故在两条路径的长度同样时(也就是走的步数同样多时)\(i + j = k + l= 步数 + 2\)
因此,咱们能够开一个三维的数组 \(dp[n + m - 2,x_1,x_2]\)
其中第一维表明步数,m行n列的矩阵步数从0~n+m-2。
第二维和第三维分别表示两条路径的横坐标,只要知道了步数和横坐标,就能够经过计算得出纵坐标。
这样,空间复杂度就降低了。
代码实现读者能够自行尝试。
注:在看本步优化前,建议先学习背包问题一节。
在解决背包问题时,咱们采用了滚动数组的方法使数组从二维降到了一维,这是由于背包问题中,咱们只用获得上一“行”的数据。
一样的,本题中,因为只能向右或向下走,状态的转移也只用获得上一行和上一列(也就是上一步)的数据,故也可使用滚动数组降维至二维。
// Author : RioTian // Time : 21/01/21 #include <bits/stdc++.h> using namespace std; typedef long long ll; const int N = 1e5 + 50; using namespace std; int dp[200][200]; int num[200][200]; int main() { int n, m; scanf("%d%d", &n, &m); for (int i = 1; i <= n; i++) for (int j = 1; j <= m; j++) scanf("%d", &num[i][j]); for (int k = 1; k <= n + m - 2; k++) //步数,经过滚动数组降去了这一维 for (int i = n; i >= 1; i--) //滚动数组须要倒序处理!!! for (int p = n; p > i; p--) // p>i是为了防止路径重复 { dp[i][p] = max(max(dp[i][p], dp[i - 1][p - 1]), max(dp[i - 1][p], dp[i][p - 1])); dp[i][p] += num[i][k - i] + num[p][k - p]; } printf("%d\n", dp[n - 1][n]); return 0; }