摘要:本文介紹了動態規划法的基本概念,通過詳細解析動態規划法的特征,給出判斷問題是否使用動態規划法結題的思路。
本文分享自華為雲社區《五大基礎算法--動態規划法》,作者: 大金(內蒙的)。
一、基本概念
動態規划法,和分治法極其相似。區別就是,在求解子問題時,會保存該子問題的解,后面的子問題求解時,可以直接拿來計算。
專業的說法是:
對於一個規模為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); } }