最長遞增子序列


很多讀者反應,就算看了前文 動態規划詳解,了解了動態規划的套路,也不會寫狀態轉移方程,沒有思路,怎么辦?本文就借助「最長遞增子序列」來講一種設計動態規划的通用技巧:數學歸納思想。

最長遞增子序列(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 數組擴大成二維數組甚至三維數組。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM