很多讀者反應,就算看了前文 動態規划詳解,了解了動態規划的套路,也不會寫狀態轉移方程,沒有思路,怎么辦?本文就借助「最長遞增子序列」來講一種設計動態規划的通用技巧:數學歸納思想。
最長遞增子序列(Longest Increasing Subsequence,簡寫 LIS)是比較經典的一個問題,比較容易想到的是動態規划解法,時間復雜度 O(N^2),我們借這個問題來由淺入深講解如何寫動態規划。
比較難想到的是利用二分查找,時間復雜度是 O(NlogN),我們通過一種簡單的紙牌游戲來輔助理解這種巧妙的解法。
先看一下題目,很容易理解:
注意「子序列」和「子串」這兩個名詞的區別,子串一定是連續的,而子序列不一定是連續的。下面先來一步一步設計動態規划算法解決這個問題。
一、動態規划解法
動態規划的核心設計思想是數學歸納法。
相信大家對數學歸納法都不陌生,高中就學過,而且思路很簡單。比如我們想證明一個數學結論,那么我們先假設這個結論在 k<n 時成立,然后想辦法證明 k=n 的時候此結論也成立。如果能夠證明出來,那么就說明這個結論對於 k 等於任何數都成立。
類似的,我們設計動態規划算法,不是需要一個 dp 數組嗎?我們可以假設 d**p[0...i−1] 都已經被算出來了,然后問自己:怎么通過這些結果算出dp[i] ?
直接拿最長遞增子序列這個問題舉例你就明白了。不過,首先要定義清楚 dp 數組的含義,即 dp[i] 的值到底代表着什么?
我們的定義是這樣的:****dp[i] 表示以 nums[i] 這個數結尾的最長遞增子序列的長度。
舉個例子:
算法演進的過程是這樣的:
根據這個定義,我們的最終結果(子序列的最大長度)應該是 dp 數組中的最大值。
int res = 0;
for (int i = 0; i < dp.length; i++) {
res = Math.max(res, dp[i]);
}
return res;
讀者也許會問,剛才這個過程中每個 dp[i] 的結果是我們肉眼看出來的,我們應該怎么設計算法邏輯來正確計算每個 dp[i] 呢?
這就是動態規划的重頭戲了,要思考如何進行狀態轉移,這里就可以使用數學歸納的思想:
我們已經知道了 d**p[0...4] 的所有結果,我們如何通過這些已知結果推出 d**p[5]呢?
根據剛才我們對 dp 數組的定義,現在想求 dp[5] 的值,也就是想求以 nums[5] 為結尾的最長遞增子序列。
nums[5] = 3,既然是遞增子序列,我們只要找到前面那些結尾比 3 小的子序列,然后把 3 接到最后,就可以形成一個新的遞增子序列,而且這個新的子序列長度加一。
當然,可能形成很多種新的子序列,但是我們只要最長的,把最長子序列的長度作為 dp[5] 的值即可。
for (int j = 0; j < i; j++) {
if (nums[j] < nums[i])
dp[i] = Math.max(dp[i], dp[j] + 1);
}
這段代碼的邏輯就可以算出 dp[5]。到這里,這道算法題我們就基本做完了。讀者也許會問,我們剛才只是算了 dp[5] 呀,dp[4], dp[3] 這些怎么算呢?
類似數學歸納法,你已經可以通過 dp[0...4] 算出 dp[5] 了,那么任意 dp[i] 你肯定都可以算出來:
for (int i = 0; i < nums.length; i++) {
for (int j = 0; j < i; j++) {
if (nums[j] < nums[i])
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
還有一個細節問題,就是 base case。dp 數組應該全部初始化為 1,因為子序列最少也要包含自己,所以長度最小為 1。下面我們看一下完整代碼:
public int lengthOfLIS(int[] nums) {
int[] dp = new int[nums.length];
// dp數組全部初始化為1
Arrays.fill(dp, 1);
for (int i = 0; i < nums.length; i++) {
for (int j = 0; j < i; j++) {
if (nums[j] < nums[i])
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
int res = 0;
for (int i = 0; i < dp.length; i++)
res = Math.max(res, dp[i]);
return res;
}
至此,這道題就解決了,時間復雜度 O(N^2)。總結一下動態規划的設計流程:
首先明確 dp 數組所存數據的含義。這步很重要,如果不得當或者不夠清晰,會阻礙之后的步驟。
然后根據 dp 數組的定義,運用數學歸納法的思想,假設 d**p[0...i−1] 都已知,想辦法求出 d**p[i],一旦這一步完成,整個題目基本就解決了。
但如果無法完成這一步,很可能就是 dp 數組的定義不夠恰當,需要重新定義 dp 數組的含義;或者可能是 dp 數組存儲的信息還不夠,不足以推出下一步的答案,需要把 dp 數組擴大成二維數組甚至三維數組。