摘要:本文介紹了動態規劃法的基本概念,通過詳細解析動態規劃法的特徵,給出判斷問題是否使用動態規劃法結題的思路。
本文分享自華為雲社群《五大基礎演算法--動態規劃法》,作者: 大金(內蒙的)。
一、基本概念
動態規劃法,和分治法極其相似。區別就是,在求解子問題時,會儲存該子問題的解,後面的子問題求解時,可以直接拿來計算。
專業的說法是:
對於一個規模為n的問題,將其分解為k個規模較小的子問題(階段),按順序求解子問題,前一子問題的解,為後一子問題的求解提供了有用的資訊。在求解任一子問題時,通過決策求得區域性最優解,依次解決各子問題。最後可以通過簡單的判斷,得到原問題的解。
說法有些晦澀難懂,我給大家解釋下:
階段:求解第n個子問題稱為第n個階段。動態規劃是按照順序去求解子問題的,這裡子問題的求解順序很重要。
狀態:在求解第n個階段時,已求解n-1個階段的解,稱為狀態。
決策:在求解第n個階段時,根據狀態和計算規則,可以得到第n個階段時解。
二、基本特徵
動態規劃法所能解決的問題一般具有以下幾個特徵:
1) 大問題可分解性
該問題可以分解為若干個規模較小的問題,即該問題具有最優子結構性質。
2) 子問題易解決性
該問題的規模縮小到一定的程度就可以容易地解決
3) 解可合併性
利用該問題分解出的子問題的解可以合併為該問題的解;
4) 子問題重疊性
當計算出某個子問題的解時,後續多個問題都需要計算該子問題的解,所以在計算某個子問題的解,將其儲存,就節省了分治法重複計算的時間。
三、一些誤解
1.狀態轉移方程
很多部落格都說什麼狀態轉移方程,感覺說的很高大上,一般解題上來就是狀態轉移方程是xxxx,程式碼是xxxx,翻譯下是什麼意思呢?
在求解第n個子問題的時候,通過已求解n-1個階段的解和計算規則,可以得到第n個階段時解。
即是最新的狀態=目前的狀態+決策。
2.初始化
很多題目解題的時候都說初始化,這並不是動態規劃法的步驟。應該正確的去理解這些操作。動態規劃在劃分子問題求解順序時,基本是先求解易求解最小的子問題,在由這些已經求解的階段+計算規則,就能直接求得第n階段的解。所以初始化的含義是,求得初始階段的解。
3.邊界條件
一般題目會說邊界是啥,可以理解為怎麼判斷所有的子問題已經求解結束了。正常人也不會寫while(true)吧,你總得讓程式結束,判斷你已經解決好這個問題了。
四、動態規劃法的基本步驟
step1 分解:
將一個問題分解為多個子問題,需要注意子問題解決的順序,應該先求解易求解的子問題,且後續的階段可以通過前面的階段+決策得到。
step2 狀態轉移:
通過得到的規律,寫出狀態轉移方程。
第n階段=當前狀態+決策(前n個階段解和計算規則)
step3 寫程式碼:
將最先算的階段計算出來,中間階段通過狀態轉移方程計算狀態,直到所有階段計算結束。
step4 得到解:
所有階段計算結束,可以通過簡單的統計,例如Max,Min等遍歷階段的值,得到最後的解。
五、經典問題
好記性不如爛筆頭,有一些適用動態規劃法的問題,可以幫我們不斷強化的解題思想。在解決問題時,希望大家可以注意判斷題目的解決思路,看是否符合動態規劃法的四個特徵,這樣不斷強化,才能將演算法掌握。
最長迴文子串
下面附上我的題解:
//動態規劃法兩個基本要素:最優子結構性質和子問題重疊性質。 //很多答案寫了初始化和邊界條件,個人認為你要分清楚他的目的是什麼。 //很多初始化和邊界條件,是因為狀態轉移方程,是需要初始化的子問題的解,從而避免重複計算,說白了還是子問題重疊和最優子結構問題。 //我們應該注重某一個問題的重疊子問題分解和狀態最優的決策分析。 //解題思路: //計算某個字串時, 如果它首尾字元相等,則它是不是迴文,取決於去掉頭尾之後的字串是否為迴文串。 // 如果它首尾字元不相等,則它一定不是迴文 //leetcode submit region begin(Prohibit modification and deletion) class Solution { public String longestPalindrome(String s) { int len = s.length(); // 特判 if (len < 2){ return s; } int maxLen = 1; int begin = 0; // 1. 狀態定義 // dp[i][j] 表示s[i...j] 是否是迴文串,現在表示全部為0,不是迴文串 boolean[][] dp = new boolean[len][len]; char[] chars = s.toCharArray(); // 2. 子問題計算順序:先計算短字串,在計算長字串,同時根據已求得的短字串或者計算規則,可以得到長字串的解。 // 注意:s表示計算的元素順序。 // 0 1 2 3 4 // 0 xx s1 s2 s4 s7 // 1 xx s3 s5 s8 // 2 xx s6 s9 // 3 xx s10 // 4 xx // 為什麼這麼寫呢,因為你要保證保證計算某個元素時,通過狀態轉移方程能得到左上角元素的dp[][]。 // 填表規則:先一列一列的填寫,再一行一行的填,保證計算某個元素時,它左上方的單元格已經被計算出了結果 // 填表規則:當然你也可以由左往右一行一行寫,這樣也能保證計算某個元素時,它左上方的單元格已經被計算出了結果 for (int j = 1;j < len;j++){ for (int i = 0; i < j; i++) { // 頭尾字元不相等,不是迴文串 if (chars[i] != chars[j]){ dp[i][j] = false; }else { // 相等的情況下 // 因為考慮頭尾去掉以後沒有字元剩餘,或者剩下一個字元的時候,肯定是迴文串 if (j - i -1 <= 1){ dp[i][j] = true; }else { // 頭尾相等,中間有大於1個元素,這個時候,我們無法直接判斷他是不是迴文,但是我們可以通過狀態轉移方程去判斷 // 其實這個就是在計算s8這個元素時,我們無法判斷dp[1][4]在1和4位元素相等時候,整個字串是否是迴文。 // 所以要通過s4去判斷,s4是迴文,s8就是。s4不是,那s8就不是。 dp[i][j] = dp[i + 1][j - 1]; } } // 只要dp[i][j] == true 成立,表示s[i...j] 是否是迴文串 // 此時更新記錄迴文長度和起始位置 if (dp[i][j] && (j - i + 1 > maxLen)){ maxLen = j - i + 1; begin = i; } } } // 3. 初始化 // 很多答案寫了這個,這一步,我們細想,其實完全沒有必要。 // 因為主對角線,值是可以直接判斷出來的。 // 而且在求解過程中,我們的狀態轉移方程不會用到這個值。因為只有主對角線會用到這幾個值。 // 而且單個元素的子問題解,我們並不需要。 // 所以,即使我這步初始化放到計算之後,甚至是直接去掉,也完全不影響結果。大家可以自己試一下 // for (int i = 0; i < len; i++) { // dp[i][i] = true; // } // 4. 返回值 return s.substring(begin,begin + maxLen); } }