LeetCode演算法練習——動態規劃 DP
更多幹貨就在我的個人部落格ofollow,noindex">BlackBlog.tech 歡迎關注!
也可以關注我的csdn部落格:黑哥的部落格
謝謝大家!
前面幾周完成了DFS、BFS的章節,一共大約40題左右,今天開始練習動態規劃。動態規劃是一種將原問題拆分成子問題,對子問題進行求解,最終解決原文題的思想。動態規劃適用於子問題不是獨立的情況,也就是各子問題包含公共的子子問題。
DP的關鍵在於尋找狀態轉移方程,即如何從前一狀態轉移到後一狀態,也可以理解為子問題的遞推公式。
動態規劃設計的兩種方法:
自頂向下(又稱記憶化搜尋、備忘錄):基本上對應著遞迴函式實現,從大範圍開始計算,要注意不斷儲存中間結果,避免重複計算
自底向上(遞推):從小範圍遞推計算到大範圍
其實前幾節搜尋的問題很多也可以用DP的方式去求解。
給個目錄:
LeetCode63 不同路徑 II
LeetCode64 最小路徑和
LeetCode120 三角形最小路徑和
LeetCode91 解碼方法
LeetCode139 單詞拆分
LeetCode338 位元位計數
LeetCode357 計算各個位數不同的數字個數
LeetCode375 猜數字大小 II
LeetCode516 最長迴文子序列
LeetCode647 迴文子串
LeetCode63 不同路徑 II
題目
一個機器人位於一個 m x n 網格的左上角 (起始點在下圖中標記為“Start” )。
機器人每次只能向下或者向右移動一步。機器人試圖達到網格的右下角(在下圖中標記為“Finish”)。
現在考慮網格中有障礙物。那麼從左上角到右下角將會有多少條不同的路徑?
網格中的障礙物和空位置分別用 1 和 0 來表示。
說明:m 和 n 的值均不超過 100。
示例 1:
輸入: [ [0,0,0], [0,1,0], [0,0,0] ] 輸出: 2 解釋: 3x3 網格的正中間有一個障礙物。 從左上角到右下角一共有 2 條不同的路徑: 1. 向右 -> 向右 -> 向下 -> 向下 2. 向下 -> 向下 -> 向右 -> 向右
C++程式碼
class Solution { public: int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) { int m = obstacleGrid.size(); //地圖高度 int n = obstacleGrid[0].size(); ////地圖寬度 vector<vector<int>> dp(m+1,vector<int>(n+1,0)); //初始化一個dp陣列 bool row_flag = true; //用來判斷第一行是否有1 bool col_flag = true; //用來判斷第一列是否有1 for(int i=0;i<n;i++){ //將第一行第一個障礙之前的所有點 標記為1 表示有一種路徑可以到達 第一個障礙之後的所有點標記為0 表示有0種路徑可達 if(obstacleGrid[0][i]==1) row_flag = false; if(row_flag) dp[0][i]=1; else dp[0][i]=0; } for(int i=0;i<m;i++){ //將第一列第一個障礙之前的所有點 標記為1 表示有一種路徑可以到達 第一個障礙之後的所有點標記為0 表示有0種路徑可達 if(obstacleGrid[i][0]==1) col_flag = false; if(col_flag) dp[i][0]=1; else dp[i][0]=0; } for(int i=1;i<m;i++){ for(int j=1;j<n;j++){ //從[1][1]開始計算到達每一點的路徑數,如果該點不是障礙則計算,狀態轉移方程為dp[i][j] = max(dp[i][j],dp[i-1][j]+dp[i][j-1]) if(obstacleGrid[i][j]!=1) dp[i][j] = max(dp[i][j],dp[i-1][j]+dp[i][j-1]); } } return dp[m-1][n-1]; //返回右下角的值 } };
LeetCode64 最小路徑和
題目
給定一個包含非負整數的 m x n 網格,請找出一條從左上角到右下角的路徑,使得路徑上的數字總和為最小。
說明:每次只能向下或者向右移動一步。
示例:
輸入: [ [1,3,1], [1,5,1], [4,2,1] ] 輸出: 7 解釋: 因為路徑 1→3→1→1→1 的總和最小。
C++
class Solution { public: int minPathSum(vector<vector<int>>& grid) { //dp陣列表示從左上角到當前點的最小路徑和 int m = grid.size(); //矩陣的高度 int n = grid[0].size(); //矩陣的寬度 vector<vector<int>> dp(m+1,vector<int>(n+1,0)); //初始化一個dp陣列 dp[0][0] = grid[0][0]; //第一個點的最小路徑就是自己 for(int i=1;i<n;i++){ dp[0][i]=dp[0][i-1] + grid[0][i]; //初始化第一行 dp[0][i]=之前的路徑和+當前路徑 } for(int i=1;i<m;i++){ dp[i][0] = dp[i-1][0] + grid[i][0]; //初始化第一列 dp[i][0]=之前路徑和+當前路徑 } for(int i=1;i<m;i++){ for(int j=1;j<n;j++){ dp[i][j] = min(dp[i-1][j],dp[i][j-1])+grid[i][j]; //狀態轉移方程 每一點最小路徑等與左點 上點中的較小值+當前點的值 } } return dp[m-1][n-1]; //返回右下角點 } };
體會
簡單的DP題。dp[i][j]表示從左上角到[i]j]的最小路徑和,首先初始化第一行與第一列、第一行、第一列每一個最小路徑都等於前一個點的最小路徑加上當前路徑,從[1][1]點開始後,狀態轉移方程為dp[i][j] = min(dp[i-1][j],dp[i][j-1])+grid[i][j],每一點最小路徑等與 左點 上點 中的較小值+當前點的值。最後返回右下角的點。
LeetCode120 三角形最小路徑和
題目
給定一個三角形,找出自頂向下的最小路徑和。每一步只能移動到下一行中相鄰的結點上。
例如,給定三角形:
[ [2], [3,4], [6,5,7], [4,1,8,3] ]
自頂向下的最小路徑和為 11(即,2 + 3 + 5 + 1 = 11)。
說明:
如果你可以只使用 O(n) 的額外空間(n 為三角形的總行數)來解決這個問題,那麼你的演算法會很加分。
C++程式碼
int minimumTotal(vector<vector<int>> triangle) { int width = triangle[triangle.size()-1].size(); //三角形的最大寬度 int depth = triangle.size(); //三角形最大高度 vector<vector<int>> dp(triangle.size(),vector<int>(triangle[triangle.size()-1].size(),0));//初始化一個dp陣列 //dp[i][j]表示從底部開始向上計算 第i行j列的最小路徑和 for(int i=0;i<width;i++){ dp[depth-1][i] = triangle[depth-1][i]; //初始化最後一行的值 最後一行的最小路路徑就是三角形的路徑 } for(int i=depth-2;i>=0;i--){ //從倒數第二行向上開始計算 for(int j=0;j<triangle[i].size();j++){//遍歷一行中的所有資料 dp[i][j] = triangle[i][j] + min(dp[i+1][j],dp[i+1][j+1]);//狀態轉移方程 選出底下一行中較小的數字 將其與當前三角形內的值相加 } } return dp[0][0]; //最終的結果 }
體會
一道基礎的DP題,從底向上進行計算,dp[i][j]表示第i行j列的最小路徑和。從倒數第二行向上開始迴圈,狀態轉移方程為dp[i][j] = triangle[i][j] + min(dp[i+1][j],dp[i+1][j+1]);即選出底下一行中較小的數字,將其與當前三角形內的值相加。最終dp[0][0]為最終的結果。
LeetCode91 解碼方法
題目
一條包含字母 A-Z 的訊息通過以下方式進行了編碼:
'A' -> 1 'B' -> 2 ... 'Z' -> 26
給定一個只包含數字的非空字串,請計算解碼方法的總數。
示例 1:
輸入: "12" 輸出: 2 解釋: 它可以解碼為 "AB"(1 2)或者 "L"(12)。
示例 2:
輸入: "226" 輸出: 3 解釋: 它可以解碼為 "BZ" (2 26), "VF" (22 6), 或者 "BBF" (2 2 6) 。
C++程式碼
class Solution { public: int numDecodings(string s) { vector<int> dp(s.size()+1,0); dp[0]=1; //dp[i]表示前i位的解碼方式為dp[i]種 for(int i=1;i<dp.size();i++){ if(s[i-1]=='0') dp[i-1] = 0; //0 不能單獨解碼,dp[i-1]賦值為0 if(s[i-2]=='1' || (s[i-2]=='2'&&s[i-1]<'7')) dp[i] = dp[i-1]+dp[i-2]; //如果十位為1或者十位為2 個位<7 ,證明這個數字可以按照兩位數進行分解 else dp[i] = dp[i-1]; // 如果不滿足分解情況,直接dp[i] = dp[i-1] } return dp[s.size()]; //返回dp[size]為最終的結果 } };
體會
斐波納切問題,建立一個dp陣列,dp[i]表示前i為的解碼方式為dp[i]種。初值賦值為1,表示至少都有一種解碼方式,即逐個分割。之後對後面的數字進行迴圈,如果遇到0,0不能單獨解碼,將dp[i-1]設定為0;如果遇到1開頭,或者2開頭且後一位小於7的數字,證明其可以看成一個兩位數,則多一種解碼方式,dp[i] = dp[i-1]+1; 最終輸出dp[s.size()]表示最終的結果。
LeetCode139 單詞拆分
題目
給定一個非空字串 s 和一個包含非空單詞列表的字典 wordDict,判定 s 是否可以被空格拆分為一個或多個在字典中出現的單詞。
說明:
拆分時可以重複使用字典中的單詞。
你可以假設字典中沒有重複的單詞。
示例 1:
輸入: s = "leetcode", wordDict = ["leet", "code"] 輸出: true 解釋: 返回 true 因為 "leetcode" 可以被拆分成 "leet code"。
示例 2:
輸入: s = "applepenapple", wordDict = ["apple", "pen"] 輸出: true 解釋: 返回 true 因為 "applepenapple" 可以被拆分成 "apple pen apple"。 注意你可以重複使用字典中的單詞。
示例 3:
輸入: s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"] 輸出: false
C++程式碼
class Solution { public: bool wordBreak(string s, vector<string>& wordDict) { if(s.size()<0 || wordDict.empty()) return false; //如果字串長度小於0,或者字典為空,則返回空值。 unordered_set<string> dict; //將wordDict轉換成一個unordered_set,方便快速查詢 for(auto key:wordDict) dict.insert(key); //將wordDict轉化為dict vector<bool> dp(s.size()+1,false); //建立一個dp陣列 dp[i]表示前i個字元是否可以被拆分 dp[0] = true; //初始值為true for(int i=1;i<=s.size();i++){ //前i個數字 字串的終點 for(int j=0;j<=i;j++){ //字串的起點 string new_str = s.substr(j,i-j); //i-j表示字串的長度 j表示字串的起點 if(dp[j] && dict.count(new_str)) { //dp[j]表示前j個字元是否可以被劃分(相當於起點前的字串被劃分) new_str是新的字串 如果前面的字串可以被劃分且new_str可以被劃分,則字串前i個字元可以被劃分。 dp[i] = true; break; } } } return dp[s.size()]; } };
體會
動態規劃問題,判斷一個字串是否可以劃分,只需要將其不斷拆分為小的字串,判斷其是否可以劃分即可。dp[i]表示前i個字元是否可以劃分。i從1到size進行迴圈,j從0到i進行迴圈,i可以看成是子串的終點,j可以看成是子串的起點,如果dp[j]為true,表示前j個字元是可以劃分的,如果當前的new_str可以劃分,那麼證明前i個字元是可以劃分的,依次類推,迴圈整個字串。
LeetCode338 位元位計數
題目
給定一個非負整數 num。對於 0 ≤ i ≤ num 範圍中的每個數字 i ,計算其二進位制數中的 1 的數目並將它們作為陣列返回。
示例 1:
輸入: 2 輸出: [0,1,1]
示例 2:
輸入: 5 輸出: [0,1,1,2,1,2]
進階:
給出時間複雜度為O(n*sizeof(integer))的解答非常容易。但你可以線上性時間O(n)內用一趟掃描做到嗎?
要求演算法的空間複雜度為O(n)。
你能進一步完善解法嗎?要求在C++或任何其他語言中不使用任何內建函式(如 C++ 中的 __builtin_popcount)來執行此操作。
C++程式碼
class Solution { public: vector<int> countBits(int num) { vector<int> dp(num+1,0); for(int i=1;i<=num;i++) dp[i] = dp[i>>1]+(i&1);//1001 可以看成是 100中1的個數 + 最後一位是否為1 return dp; } };
體會
這個題的關鍵就在於狀態轉移方程。1001中1的個數可以看作是100中1的個數加上最後一位是否為1,所以狀態狀態轉移方程為dp[i] = dp[i>>1]+(i&1),若推導過程中用十進位制去思考,很難觀察到該狀態轉移方程。
LeetCode357 計算各個位數不同的數字個數
題目
給定一個非負整數 n,計算各位數字都不同的數字 x 的個數,其中 0 ≤ x < 10n 。
示例:
輸入: 2 輸出: 91 解釋: 答案應為除去 11,22,33,44,55,66,77,88,99 外,在 [0,100) 區間內的所有數字。
C++程式碼
int countNumbersWithUniqueDigits(int n) { if(n==0) return 0; //0 1 2 這三種情況直接輸出 if(n==1) return 10; if(n==2) return 91; if(n>2){ int sum = 91;//兩位數中的各個位不同的數字有91個 for(int j=3;j<=n;j++){ int bit = 9; //第二位有9種情況 int small_sum = 9; //第一位有9種情況 for(int i=0;i<j-1;i++){ small_sum = small_sum*bit; //各個位的情況相乘 bit--; //從第二位開始往後每位可選擇的數字少一個 } sum =sum+small_sum;//統計所有資料 } return sum; } }
體會
組合排列問題。當n=2時:十位有9種選擇,個位有9種選擇;當n=3時,百位有9種選擇,十位有9種選擇,個位有8種選擇;當n=4時,千位有9種選擇,百位有9種選擇,十位有8種選擇,個位有7種選擇。
所以:
n=2 9x9
n=3 9x9x8
n=4 9x9x8x7
以此類推,n在10以上後,必定有重複。
LeetCode375 猜數字大小 II
題目
我們正在玩一個猜數遊戲,遊戲規則如下:
我從 1 到 n 之間選擇一個數字,你來猜我選了哪個數字。
每次你猜錯了,我都會告訴你,我選的數字比你的大了或者小了。
然而,當你猜了數字 x 並且猜錯了的時候,你需要支付金額為 x 的現金。直到你猜到我選的數字,你才算贏得了這個遊戲。
示例:
n = 10, 我選擇了8. 第一輪: 你猜我選擇的數字是5,我會告訴你,我的數字更大一些,然後你需要支付5塊。 第二輪: 你猜是7,我告訴你,我的數字更大一些,你支付7塊。 第三輪: 你猜是9,我告訴你,我的數字更小一些,你支付9塊。 遊戲結束。8 就是我選的數字。 你最終要支付 5 + 7 + 9 = 21 塊錢。
給定 n ≥ 1,計算你至少需要擁有多少現金才能確保你能贏得這個遊戲。
C++程式碼
class Solution { public: int getMoneyAmount(int n) { vector<vector<int>> dp(n+1,vector<int>(n+1,0)); //極小極大問題 if(n<=2) return n-1; for(int i=2;i<=n;i++){ //i表示終點 for(int j=i-1;j>0;j--){ //j表示起點 int global = 999; //求最後的極小值 先初始化一個極大值 int local = 0; //需要找到每一個子序列的極大值 for(int k=j+1;k<i;k++){ local = k + max(dp[k+1][i],dp[j][k-1]); //狀態轉移方程 global = min(local,global); //最後取i j之間的最小值 } dp[j][i] = j+1==i?j:global; } } return dp[1][n];//整個序列 } };
體會
這個題目的技巧性還是比較高的,狀態轉移方法比較難以尋找。
那麼我們需要建立一個二維的dp陣列,其中dp[i][j]表示從數字i到j之間猜中任意一個數字最少需要花費的錢數,那麼我們需要遍歷每一段區間[j, i],維護一個全域性最小值global_min變數,然後遍歷該區間中的每一個數字,計算區域性最大值local_max = k + max(dp[j][k - 1], dp[k + 1][i]),這個正好是將該區間在每一個位置都分為兩段,然後取當前位置的花費加上左右兩段中較大的花費之和為區域性最大值,為啥要取兩者之間的較大值呢,因為我們要cover所有的情況,就得取最壞的情況。然後更新全域性最小值,最後在更新dp[j][i]的時候看j和i是否是相鄰的,相鄰的話賦為i,否則賦為global_min。這裡為啥又要取較小值呢,因為dp陣列是求的[j, i]範圍中的最低cost,比如只有兩個數字1和2,那麼肯定是猜1的cost低。
如果只有一個數字,那麼我們不用猜,cost為0。
如果有兩個數字,比如1和2,我們猜1,即使不對,我們cost也比猜2要低。
如果有三個數字1,2,3,那麼我們就先猜2,根據對方的反饋,就可以確定正確的數字,所以我們的cost最低為2。
如果有四個數字1,2,3,4,那麼情況就有點複雜了,那麼我們的策略是用k來遍歷所有的數字,然後再根據k分成的左右兩個區間,取其中的較大cost加上k。
當k為1時,左區間為空,所以cost為0,而右區間2,3,4,根據之前的分析應該取3,所以整個cost就是1+3=4。
當k為2時,左區間為1,cost為0,右區間為3,4,cost為3,整個cost就是2+3=5。
當k為3時,左區間為1,2,cost為1,右區間為4,cost為0,整個cost就是3+1=4。
當k為4時,左區間1,2,3,cost為2,右區間為空,cost為0,整個cost就是4+2=6。
引自:https://blog.csdn.net/xuxuxuqian1/article/details/81636415
LeetCode516 最長迴文子序列
題目
給定一個字串s,找到其中最長的迴文子序列。可以假設s的最大長度為1000。
示例 1:
輸入: "bbbab" 輸出: 4 一個可能的最長迴文子序列為 "bbbb"。
示例 2:
輸入: "cbbd" 輸出: 2 一個可能的最長迴文子序列為 "bb"。
C++程式碼
class Solution { public: int longestPalindromeSubseq(string s) { string t = s; reverse(t.begin(),t.end()); //新建一個字串 取反向 尋找公共子序列 就是最長迴文串 return lcs(t,s); } int lcs(string src,string dst){ int n = src.size(); vector<vector<int>> dp(n+1,vector<int>(n+1,0)); //新建一個dp陣列 //dp陣列初始化 第一行 第一列初始化為0 for(int i=0;i<=n;i++) { dp[0][i] = 0; dp[i][0] = 0; } for(int i=1;i<=n;i++){ //從第二個元素開始遍歷 for(int j=1;j<=n;j++){ if(src[i-1]==dst[j-1]) dp[i][j] = dp[i-1][j-1]+1; //狀態轉移方程 else { dp[i][j] = max(dp[i-1][j],dp[i][j-1]); } } } return dp[n][n]; } };
體會
判斷最長迴文子串,只需要將一個字串反轉後與原字串求LCS,就是最長迴文子串。
LCS的狀態轉移方程比較簡單。
0if i=0 or j=0
c[i,j] = c[i-1,j-1]+1if i,j>0 and xi= yj
max(c[i,j-1],c[i-1,j])if i,j>0 and xi != yj
記住就好
LeetCode647 迴文子串
題目
給定一個字串,你的任務是計算這個字串中有多少個迴文子串。
具有不同開始位置或結束位置的子串,即使是由相同的字元組成,也會被計為是不同的子串。
示例 1:
輸入: "abc" 輸出: 3 解釋: 三個迴文子串: "a", "b", "c".
示例 2:
輸入: "aaa" 輸出: 6 說明: 6個迴文子串: "a", "a", "a", "aa", "aa", "aaa".
注意:
輸入的字串長度不會超過1000。
C++程式碼
class Solution { public: int help(string str,int left,int right){ int count = 0; while(str[left]==str[right] && left>=0 && right<=str.size()-1){ //如果str[left]==str[right] 且沒有越界的情況下 向左右伸展 並記數 count++; //記數 left--; //向左伸展 right++; //向右伸展 } return count; } int countSubstrings(string s) { int sum = 0; //用來記數 for(int i=0;i<s.size();i++){ sum+= help(s,i,i); //長度為奇數的迴文數 sum+= help(s,i,i+1); //長度為偶數的迴文數 } return sum; } };
體會
簡單題,迴文數有兩種情況,一種是長度為奇數的迴文數,一種是長度為偶數的迴文數,找到中間值,向兩邊伸展同時進行判斷就可以。