ACM | 動態規劃-數塔問題變種題型

前言

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

固然你可能已經發現過我之前發佈過的博客:教你完全學會動態規劃——入門篇 中已經詳細講解了數字三角形,固然那篇文章很好,不過因爲數字三角形問題變種題較多,而後博主想要複習一下基礎算法故記錄一下數字三角形(朝夕 的文章講解)並延伸介紹變種問題。ios

1、數塔問題原型

1.1 問題描述

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

有一個多行的數塔,數塔上有若干數字。問從數塔的最高點到底部,在全部的路徑中,通過的數字的和最大爲多少?
如上圖,是一個5行的數塔,其中7—3—8—7—5的路徑通過數字和最大,爲30。c++

1.2 解法思路

面對數塔問題,使用貪心算法顯然是行不通的,好比給的樣例,若是使用貪心算法,那選擇的路徑應當是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的過程,能夠直接輸出最高點所儲存的值。

1.3 代碼實現

// 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;
}

2、數塔問題變種

2.1 矩形數塔

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

2.1.1 問題描述

有一m行,n列(n爲奇數)的數字矩陣(數字中存在部分負數)。
以最後一行的正中間下方爲出發點,每次移動能夠選擇向前方、左前方、右前方移動,問從出發點一直到矩陣的另外一側,所通過的最大數字和爲多少。

2.1.2 樣例

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。

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 代碼實現

// 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;
    }
}

2.2 以時間做爲一個維度的數塔

以【HDU 1176 免費餡餅】爲例。

這道題在 KB 的DP系列也出現過 : KB題集

2.2.1 問題描述

有一條10米長的小路,以小路起點爲x軸的原點,小路終點爲x=10,則共有x=0~10共計11個座標點。(以下圖)

img

接下來的n行每行有兩個整數x、T,表示一個餡餅將在第T秒掉落在座標x上。
同一秒在同一點上可能掉落有多個餡餅。
初始時你站在x=5上,每秒可移動1m,最多可接到所在位置1m範圍內某一點上的餡餅。
好比你站在x=5上,就能夠接到x=四、五、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 解題思路

本質上仍是數塔問題,不過此時「行數」這一維度變成了「時間」。

以樣例來講明:能夠理解爲在第一行的四、五、6三列的數字爲1,第二行的第7列數字爲2,第8列數字爲1。而後出發點在第0行的第5列。每次移動可選擇往下,左下,右下三種方式。

因此解法基本一致,解題時注意題目的附加條件便可。

2.2.4 代碼實現

// 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;
    }
}

2.3雙線程取數(四維DP)

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

2.3.1.1 問題描述

有一個N×N的方格圖,在某些方格內放入正整數,其餘方格則放入0。
某人從方格圖左上角出發,只能選擇向下或向右行走,直到走到右下角,過程當中他能夠取走方格內的數(取完後變成0)。這樣連續走兩次,問取出的數的和最大是多少?

2.3.1.2 樣例

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。

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了,因此這裏還須要特判:

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

上面代碼的意思是,當兩次走到同一個位置時,只能取走一次方格內的值,因爲狀態轉移方程裏取了兩次,因此這裏特判時須要減去一次。

2.3.1.4 代碼實現

// 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;
}

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 代碼實現

// 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;
}

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 代碼實現

// 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;
}
相關文章
相關標籤/搜索