很多讀者反應,就算看了前文動態規划詳解,了解了動態規划的套路,也不會寫狀態轉移方程,沒有思路,怎么辦?本文就借助「最長遞增子序列」來講一種設計動態規划的通用技巧:數學歸納思想。
最長遞增子序列(Longest Increasing Subsequence,簡寫 LIS)是比較經典的一個問題,比較容易想到的是動態規划解法,時間復雜度 O(N^2),我們借這個問題來由淺入深講解如何寫動態規划。比較難想到的是利用二分查找,時間復雜度是 O(NlogN),我們通過一種簡單的紙牌游戲來輔助理解這種巧妙的解法。
先看一下題目,很容易理解:

注意「子序列」和「子串」這兩個名詞的區別,子串一定是連續的,而子序列不一定是連續的。下面先來一步一步設計動態規划算法解決這個問題。
一、動態規划解法
動態規划的核心設計思想是數學歸納法。
相信大家對數學歸納法都不陌生,高中就學過,而且思路很簡單。比如我們想證明一個數學結論,那么我們先假設這個結論在 \(k<n\) 時成立,然后想辦法證明 \(k=n\) 的時候此結論也成立。如果能夠證明出來,那么就說明這個結論對於 k 等於任何數都成立。
類似的,我們設計動態規划算法,不是需要一個 dp 數組嗎?我們可以假設 \(dp[0...i-1]\) 都已經被算出來了,然后問自己:怎么通過這些結果算出 dp[i]?
直接拿最長遞增子序列這個問題舉例你就明白了。不過,首先要定義清楚 dp 數組的含義,即 dp[i] 的值到底代表着什么?
我們的定義是這樣的:dp[i] 表示以 nums[i] 這個數結尾的最長遞增子序列的長度。
舉兩個例子:


算法演進的過程是這樣的,:

根據這個定義,我們的最終結果(子序列的最大長度)應該是 dp 數組中的最大值。
int res = 0;
for (int i = 0; i < dp.size(); i++) {
res = Math.max(res, dp[i]);
}
return res;
讀者也許會問,剛才這個過程中每個 dp[i] 的結果是我們肉眼看出來的,我們應該怎么設計算法邏輯來正確計算每個 dp[i] 呢?
這就是動態規划的重頭戲了,要思考如何進行狀態轉移,這里就可以使用數學歸納的思想:
我們已經知道了 \(dp[0...4]\) 的所有結果,我們如何通過這些已知結果推出 \(dp[5]\) 呢?

根據剛才我們對 dp 數組的定義,現在想求 dp[5] 的值,也就是想求以 nums[5] 為結尾的最長遞增子序列。
nums[5] = 3,既然是遞增子序列,我們只要找到前面那些結尾比 3 小的子序列,然后把 3 接到最后,就可以形成一個新的遞增子序列,而且這個新的子序列長度加一。
當然,可能形成很多種新的子序列,但是我們只要最長的,把最長子序列的長度作為 dp[5] 的值即可。

for (int j = 0; j < i; j++) {
if (nums[i] > nums[j])
dp[i] = Math.max(dp[i], dp[j] + 1);
}
這段代碼的邏輯就可以算出 dp[5]。到這里,這道算法題我們就基本做完了。讀者也許會問,我們剛才只是算了 dp[5] 呀,dp[4], dp[3] 這些怎么算呢?
類似數學歸納法,你已經可以算出 dp[5] 了,其他的就都可以算出來:
for (int i = 0; i < nums.length; i++) {
for (int j = 0; j < i; j++) {
if (nums[i] > nums[j])
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
還有一個細節問題,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[i] > nums[j])
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 數組的定義,運用數學歸納法的思想,假設 \(dp[0...i-1]\) 都已知,想辦法求出 \(dp[i]\),一旦這一步完成,整個題目基本就解決了。
但如果無法完成這一步,很可能就是 dp 數組的定義不夠恰當,需要重新定義 dp 數組的含義;或者可能是 dp 數組存儲的信息還不夠,不足以推出下一步的答案,需要把 dp 數組擴大成二維數組甚至三維數組。
最后想一想問題的 base case 是什么,以此來初始化 dp 數組,以保證算法正確運行。
二、二分查找解法
這個解法的時間復雜度會將為 O(NlogN),但是說實話,正常人基本想不到這種解法(也許玩過某些紙牌游戲的人可以想出來)。所以如果大家了解一下就好,正常情況下能夠給出動態規划解法就已經很不錯了。
根據題目的意思,我都很難想象這個問題竟然能和二分查找扯上關系。其實最長遞增子序列和一種叫做 patience game 的紙牌游戲有關,甚至有一種排序方法就叫做 patience sorting(耐心排序)。
為了簡單期間,后文跳過所有數學證明,通過一個簡化的例子來理解一下思路。
首先,給你一排撲克牌,我們像遍歷數組那樣從左到右一張一張處理這些撲克牌,最終要把這些牌分成若干堆。

處理這些撲克牌要遵循以下規則:
只能把點數小的牌壓到點數比它大的牌上。如果當前牌點數較大沒有可以放置的堆,則新建一個堆,把這張牌放進去。如果當前牌有多個堆可供選擇,則選擇最左邊的堆放置。
比如說上述的撲克牌最終會被分成這樣 5 堆(我們認為 A 的值是最大的,而不是 1)。

為什么遇到多個可選擇堆的時候要放到最左邊的堆上呢?因為這樣可以保證牌堆頂的牌有序(2, 4, 7, 8, Q),證明略。

按照上述規則執行,可以算出最長遞增子序列,牌的堆數就是最長遞增子序列的長度,證明略。

我們只要把處理撲克牌的過程編程寫出來即可。每次處理一張撲克牌不是要找一個合適的牌堆頂來放嗎,牌堆頂的牌不是有序嗎,這就能用到二分查找了:用二分查找來搜索當前牌應放置的位置。
PS:舊文二分查找算法詳解詳細介紹了二分查找的細節及變體,這里就完美應用上了。如果沒讀過強烈建議閱讀。
public int lengthOfLIS(int[] nums) {
int[] top = new int[nums.length];
// 牌堆數初始化為 0
int piles = 0;
for (int i = 0; i < nums.length; i++) {
// 要處理的撲克牌
int poker = nums[i];
/***** 搜索左側邊界的二分查找 *****/
int left = 0, right = piles;
while (left < right) {
int mid = (left + right) / 2;
if (top[mid] > poker) {
right = mid;
} else if (top[mid] < poker) {
left = mid + 1;
} else {
right = mid;
}
}
/*********************************/
// 沒找到合適的牌堆,新建一堆
if (left == piles) piles++;
// 把這張牌放到牌堆頂
top[left] = poker;
}
// 牌堆數就是 LIS 長度
return piles;
}
至此,二分查找的解法也講解完畢。
這個解法確實很難想到。首先涉及數學證明,誰能想到按照這些規則執行,就能得到最長遞增子序列呢?其次還有二分查找的運用,要是對二分查找的細節不清楚,給了思路也很難寫對。
所以,這個方法作為思維拓展好了。但動態規划的設計方法應該完全理解:假設之前的答案已知,利用數學歸納的思想正確進行狀態的推演轉移,最終得到答案。
我最近精心制作了一份電子書《labuladong的算法小抄》,分為【動態規划】【數據結構】【算法思維】【高頻面試】四個章節,共 60 多篇原創文章,絕對精品!限時開放下載,在我的公眾號 labuladong 后台回復關鍵詞【pdf】即可免費下載!
歡迎關注我的公眾號 labuladong,技術公眾號的清流,堅持原創,致力於把問題講清楚!