數塔問題,又稱數字三角形、數字金字塔問題。數塔問題是多維動態規劃問題中一類常見且重要的題型,其變種衆多,難度遍及從低到高,掌握該類型題目的算法思惟,對於攻克許多多維動態規劃的問題有很大幫助。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; }