1. 程式人生 > >leetcode與資料結構---動態規劃總結(一)

leetcode與資料結構---動態規劃總結(一)

這幾天一直在做leetcode上關於動態規劃方面的題目,雖然大二下的演算法設計課上較為詳細的講過動態規劃,奈何遇到新穎的題目或者稍加背景的題目立刻就原形畢露不知題目所云了。動態規劃算是較難的一個專題了,但只要找到遞推關係其最終的程式碼又相當簡便。現在把這幾天做過的題目整理總結一下,畢竟只求做題數量不求掌握精髓最終也沒法提升自己的能力的。

從簡單入手

先從簡單的題目入手吧,代表題目:64.最小路徑和120.三角形最小路徑和62.不同路徑63.不同路徑II

以64題和62題為例,我們先看64題的的題目描述:


給定一個包含非負整數的 m x n 網格,請找出一條從左上角到右下角的路徑,使得路徑上的數字總和為最小。

說明:每次只能向下或者向右移動一步。

示例:

輸入:
[
  [1,3,1],
  [1,5,1],
  [4,2,1]
]
輸出: 7
解釋: 因為路徑 1→3→1→1→1 的總和最小。

題目中說到路徑從左下角到右下角,言下之意即為從路徑的一個節點到下一個節點,只能向下走或者向右走,可以看出這是一個非常明顯的暗示了。如下圖:

          (i-1,j)
            |
            v
(i,j-1)-> (i, j)           

我們想要到達下一個節點(i,j),那我們是選擇從節點(i, j-1)走過來呢還是從節點(i-1,j)

走過來呢?既然題目要求路徑和最小,那麼我們肯定選擇上節點對應路徑值最小的作為中間節點。於是可以寫出如下的遞推式:

d p ( i ,
j ) = m i n ( d p ( i , j 1 ) , d p ( i 1 , j ) ) + v ( i , j )

當然還有一些小細節要處理,當節點位於 i=0( j=0)的時候,是不可能從該節點的左邊(該節點的上邊走過來的),此時只有一種選擇,因此要對這兩種情況單獨處理。整理一下,可得如下程式碼:

class Solution {
public:
    int minPathSum(vector<vector<int>>& grid) {
        int h = grid.size();
        int w = grid[0].size();
        for (int i=0; i<h; ++i){
            for (int j=0; j<w;++j){
                if (i==0 && j==0)
                    continue;
                if (i==0){
                    grid[0][j] += grid[0][j-1]; continue;
                }
                if (j==0){
                    grid[i][0] += grid[i-1][0]; continue;
                }
                grid[i][j] += min(grid[i-1][j], grid[i][j-1]);
            }
        }
        return grid[h-1][w-1];
    }
};

120題三角形最短路徑和與該題類似,基本採用同樣的方法。那麼我們再來看看62題,62題的題目描述如下:


一個機器人位於一個 m x n 網格的左上角 (起始點在下圖中標記為“Start” )。

機器人每次只能向下或者向右移動一步。機器人試圖達到網格的右下角(在下圖中標記為“Finish”)。

問總共有多少條不同的路徑?

img

例如,上圖是一個7 x 3 的網格。有多少可能的路徑?

說明mn 的值均不超過 100。

示例 1:

輸入: m = 3, n = 2
輸出: 3
解釋:
從左上角開始,總共有 3 條路徑可以到達右下角。
1. 向右 -> 向右 -> 向下
2. 向右 -> 向下 -> 向右
3. 向下 -> 向右 -> 向右

和64題大同小異,只是這次需要求解的是多少種可能的路徑,同樣我們注意到走到節點(i,j)有兩種走法,一種是通過(i-1, j)從左邊走過來,一種是通過(i, j-1)從上面走下來。於是這便提示我們建立一個二維陣列dp(m, n),用於儲存到達節點(i, j)時的路徑數量。那麼可以寫出如下的遞推關係:

d p ( i , j ) = d p ( i 1 , j ) + d p ( i , j 1 )
同理,我們需要注意對邊緣的處理,即 i=0j=0的情況,邊緣只有一條路徑。經過如上分析以後我們可以寫出如下程式碼:

class Solution {
public:
    int uniquePaths(int m, int n) {
        vector<vector<int>> num(m, vector<int>(n, 0));
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (i == 0 || j == 0) {
                    num[i][j] = 1;
                    continue;
                }                   
                num[i][j] = num[i][j - 1] + num[i-1][j];
            }
        }
        return num[m - 1][n - 1];
    }
};

提升下難度

以上題目型別都以圖網路作為背景,涉及到圖網路除了採用圖的相應演算法外動態規劃也是很好的解決方式,而以上題目動態規劃的意圖比較明顯,也比較容易寫出遞推關係。下面題目在以上題目的基礎上加深了難度,需要一定的技巧,代表題目:174.地下城遊戲741.摘櫻桃

我們先來看看174的題目描述:


一些惡魔抓住了公主(P)並將她關在了地下城的右下角。地下城是由 M x N 個房間組成的二維網格。我們英勇的騎士(K)最初被安置在左上角的房間裡,他必須穿過地下城並通過對抗惡魔來拯救公主。

騎士的初始健康點數為一個正整數。如果他的健康點數在某一時刻降至 0 或以下,他會立即死亡。

有些房間由惡魔守衛,因此騎士在進入這些房間時會失去健康點數(若房間裡的值為負整數,則表示騎士將損失健康點數);其他房間要麼是空的(房間裡的值為 0),要麼包含增加騎士健康點數的魔法球(若房間裡的值為正整數,則表示騎士將增加健康點數)。

為了儘快到達公主,騎士決定每次只向右或向下移動一步。

編寫一個函式來計算確保騎士能夠拯救到公主所需的最低初始健康點數。

例如,考慮到如下佈局的地下城,如果騎士遵循最佳路徑 右 -> 右 -> 下 -> 下,則騎士的初始健康點數至少為 7

-2 (K) -3 3
-5 -10 1
10 30 -5 (P)

說明:

  • 騎士的健康點數沒有上限。
  • 任何房間都可能對騎士的健康點數造成威脅,也可能增加騎士的健康點數,包括騎士進入的左上角房間以及公主被監禁的右下角房間。

說了一大堆,把沒有用的話剔除,題目仍然是一道以圖網路為背景的題目。即有一個圖網路mxn其節點的權重有正有負。騎士K需要從(0, 0)節點走到(m-1, n-1)節點,只能選擇向下或者向右走。騎士K初始時有一定的“生命值”,且每經過一個節點其“生命值”加上該節點的權重,並且在過程中騎士K的“生命值”必須為正,然後要我們求滿足條件的最小“生命值”。

看上去比之前的題目確實複雜了不少,初步想法是建立一個二維陣列dp(m, n)用於儲存每個節點對應的最小生命值.我們還是看一個具體的過程來分析:

(i, j) <- (i+1,j)
   ^          
   |       
(i,j+1)        

還是三個相鄰節點:(i+1,j)(i, j+1)(i, j)。整個過程可以看做是從節點(m-1,n-1)反著走到(0, 0)節點。為什麼要反著想呢?因為題目要求是求出發時至少要多少生命值,即dp(0, 0) ,因此逆向走回去恰好可以求得(0, 0)點對應的生命值。那麼至少現在可以寫出如下遞推式:

d p ( i , j ) = m i n ( d p ( i , j + 1 ) , d p ( i + 1 , j ) ) v ( i , j )
但有一個問題,因為在路途過程中生命值始終不能低於0,最小都要為1,因此以上遞推式應該還要加上一個約束條件,即為:
d p ( i , j ) = m a x ( d p ( i , j ) , 1 )
同樣我們需要注意對邊緣的處理,因此最終答案如下:

class Solution {
public:
    int calculateMinimumHP(vector<vector<int>>& dungeon) {
        int h = dungeon.size();
        int w = dungeon[0].size();
        vector<vector<int>> OPT(h, vector<int>(w, 0));
        OPT[h-1][w-1] = dungeon[h-1][w-1] < 0 ? -dungeon[h-1][w-1]+1:1;
        for (int i=w-2; i>=0;--i) {
            OPT[h-1][i] = max(OPT[h-1][i+1]-dungeon[h-1][i], 1);
        }
        for (int i=h-2;i>=0;--i){
            OPT[i][w-1] = max(OPT[i+1][w-1]-dungeon[i][w-1], 1);
        }
        for (int i=h-2; i>=0;--i){
            for (int j=w-2; j>=0;--j){
                int right = max(OPT[i+1][j]-dungeon[i][j], 1);
                int down = max(OPT[i][j+1]-dungeon[i][j], 1);
                OPT[i][j] = min(right, down);
            }
        }
        return OPT[0][0];
    }
};

字串為背景的題目

以字串為背景的動態規劃題目就很多了,包括迴文串、字串啊、子序列等等。這裡簡單總結一下近期做過的相關題目:91.解碼方法、72.編輯距離、139.單詞拆分、140.單詞拆分II、514.自由之路、516.最長迴文子序列647.迴文子串5.最長迴文字串

先從迴文串入手,首先需要注意字串和子序列是有區別的,字串必須連續,而子序列可以不連續。迴文串是一個對稱的字串。知道一些基本概念後先來看第516題的題目描述:


給定一個字串s,找到其中最長的迴文子序列。可以假設s的最大長度為1000

示例 1:
輸入:

"bbbab"

輸出:

4

一個可能的最長迴文子序列為 “bbbb”。

示例 2:
輸入:

"cbbd"

輸出:

2

一個可能的最長迴文子序列為 “bb”。


碰到子序列即可以不連續的情況常常讓人發難,覺得無從下手。題目要返回最長的迴文子序列,經過前面幾題的套路,我們可以想到建立一個二維陣列dp其維度為MxM,其中M代表字串的長度。那麼dp(i, j)的含義為從字串i到j的範圍迴文子序列的最大長度。我們舉一個具體的例子:對於字串bbacddccadd

可以知道:

dp(9, 10)=2, dp(8, 10)=2, dp(7, 10)=2, dp(6, 10)=2, dp(5, 10)=3, dp(4, 10) =5

dp(8, 9) =1, dp(7, 9)=1, dp(6, 9)=2, dp(5, 9)=4

… … … …

可以發現dp(i, j)的大小與dp(i+1, j)、dp(i, j-1)以及dp(i+1, j-1)有關係。而回文字串的關鍵在於對稱性,因此我們可以得出如下遞推關係:

s[i] == s[j]時,有:

d p ( i , j ) = 2 + d p ( i + 1 , j 1 )
其他時候則是:
d p ( i , j ) = m a x ( d p ( i + 1 , j ) , d p ( i , j 1 ) )
因此可以寫出如下程式碼:

class Solution {
public:
    int longestPalindromeSubseq(string s) {
        if (s.empty())
            return 0;
        int len = s.length();
        vector<vector<int>> OPT(len, vector<int>(len, 0));
        for (int i=len-1; i>=0; --i){
            for (int j=i; j<len; ++j) {
                if (j == i)
                    OPT[i][j] = 1;
                else if (s[j] == s[i])
                    OPT[i][j] = OPT[i+1][j-1] + 2;
                else
                    OPT[i][j] = max(OPT[i+1][j], OPT[i][j-1]);                
            }
        }
        return OPT[0][len-1];
    }
};

接下來我們來看看647題迴文子串,其題目描述如下:


給定一個字串,你的任務是計算這個字串中有多少個迴文子串。具有不同開始位置或結束位置的子串,即使是由相同的字元組成,也會被計為是不同的子串。

示例 1:

輸入: "abc"
輸出: 3
解釋: 三個迴文子串: "a", "b", "c".

示例 2:

輸入: "aaa"
輸出: 6
說明: 6個迴文子串: "a", "a", "a", "aa", "aa", "aaa".

注意:

  1. 輸入的字串長度不會超過1000。

與上題不同之處在於不再是子序列而是字串,並且要求的不是最大長度而是字串的個數。那麼首先我們需要建立一個二維陣列dp,維度為MxM,M為字串長度。那麼dp(i,j)則代表字串從i到j的迴文字串個數。看上去好像蠻簡單的樣子,那麼我們繼續分析,用一個例項: