動態規划設計:最長遞增子序列


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

最長遞增子序列(Longest Increasing Subsequence,簡寫 LIS)是比較經典的一個問題,比較容易想到的是動態規划解法,時間復雜度 O(N^2),我們借這個問題來由淺入深講解如何寫動態規划。比較難想到的是利用二分查找,時間復雜度是 O(NlogN),我們通過一種簡單的紙牌游戲來輔助理解這種巧妙的解法。

先看一下題目,很容易理解:

title

注意「子序列」和「子串」這兩個名詞的區別,子串一定是連續的,而子序列不一定是連續的。下面先來一步一步設計動態規划算法解決這個問題。

一、動態規划解法

動態規划的核心設計思想是數學歸納法。

相信大家對數學歸納法都不陌生,高中就學過,而且思路很簡單。比如我們想證明一個數學結論,那么我們先假設這個結論在 \(k<n\) 時成立,然后想辦法證明 \(k=n\) 的時候此結論也成立。如果能夠證明出來,那么就說明這個結論對於 k 等於任何數都成立。

類似的,我們設計動態規划算法,不是需要一個 dp 數組嗎?我們可以假設 \(dp[0...i-1]\) 都已經被算出來了,然后問自己:怎么通過這些結果算出 dp[i]?

直接拿最長遞增子序列這個問題舉例你就明白了。不過,首先要定義清楚 dp 數組的含義,即 dp[i] 的值到底代表着什么?

我們的定義是這樣的:dp[i] 表示以 nums[i] 這個數結尾的最長遞增子序列的長度。

舉兩個例子:

1 2

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

gif1

根據這個定義,我們的最終結果(子序列的最大長度)應該是 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]\) 呢?

3

根據剛才我們對 dp 數組的定義,現在想求 dp[5] 的值,也就是想求以 nums[5] 為結尾的最長遞增子序列。

nums[5] = 3,既然是遞增子序列,我們只要找到前面那些結尾比 3 小的子序列,然后把 3 接到最后,就可以形成一個新的遞增子序列,而且這個新的子序列長度加一。

當然,可能形成很多種新的子序列,但是我們只要最長的,把最長子序列的長度作為 dp[5] 的值即可。

gif2
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(耐心排序)。

為了簡單期間,后文跳過所有數學證明,通過一個簡化的例子來理解一下思路。

首先,給你一排撲克牌,我們像遍歷數組那樣從左到右一張一張處理這些撲克牌,最終要把這些牌分成若干堆。

poker1

處理這些撲克牌要遵循以下規則:

只能把點數小的牌壓到點數比它大的牌上。如果當前牌點數較大沒有可以放置的堆,則新建一個堆,把這張牌放進去。如果當前牌有多個堆可供選擇,則選擇最左邊的堆放置。

比如說上述的撲克牌最終會被分成這樣 5 堆(我們認為 A 的值是最大的,而不是 1)。

poker2

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

poker3

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

LIS

我們只要把處理撲克牌的過程編程寫出來即可。每次處理一張撲克牌不是要找一個合適的牌堆頂來放嗎,牌堆頂的牌不是有序嗎,這就能用到二分查找了:用二分查找來搜索當前牌應放置的位置。

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,技術公眾號的清流,堅持原創,致力於把問題講清楚!

labuladong


免責聲明!

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



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