圖解:什麼是最長遞增子序列?

Python實戰社羣

Java實戰社羣

長按識別下方二維碼,按需求添加

掃碼關注添加客服

進Python社羣▲

掃碼關注添加客服

進Java社羣

作者丨景禹

來源丨景禹(ID:LifeAtaraxia)

最長遞增子序列

題目描述

[LeetCode 300] 給定一個無序的整數數組,找到其中最長上升子序列的長度。

輸入輸出示例:

輸入: [10,9,2,5,3,7,101,18]
輸出: 4 
解釋: 最長的上升子序列是 [2,3,7,101] 或 [2,5,7,18] 或 [2,3,7,101],它的長度是 4。

輸入: [10,22,9,33,21,50,41,60,80]
輸出: 6
解釋: 最長的遞增子序列爲 [10,22,33,50,60,80]

輸入: [3,5,2,8]
輸出: 2
解釋: 最長的遞增子序列爲 [3,5,8]

說明:

可能會有多種最長上升子序列的組合,你只需要輸出對應的長度即可。你算法的時間複雜度應該爲 。

進階: 你能將算法的時間複雜度降低到 嗎?

最長遞增子序列(Longest Increasing Subsequence,LIS ),毫無疑問,可以使用動態規劃進行求解,

首先從輸入輸出示例中,你應該注意到:

其一,最長遞增子序列中元素並不要求連續,例如:[2,3,7,101] 中的元素在原序列中的元素 2 和 3 在原序列中並不緊挨;

其二,最長遞增子序列的組合可能有多個,並不唯一,例如:[1,1,3,2] 的最長遞增子序列爲 [1,2] 或 [1,3];

其三,最長遞增子序列中的「遞增」是「嚴格遞增」,例如:序列[1,1,3,2] 的最長遞增子序列中無重複的元素 1,因爲 [1,1,2] 不是嚴格遞增

其四,最長遞增子序列中元素的相對順序必須保持和原始序列中的元素相對順序一致,如下圖所示:

此處暫停,回憶回憶動態規劃的解題步驟,自己去力扣或者本地測一測,調一調!

方法一:遞歸

最優子結構:設 arr[0 ... n-1] 爲輸入數組,L(i) 表示以 arr[i] 爲結尾的最長遞增子序列的長度。

比如:輸入 arr[] = {3, 5, 2, 8}L(0) 表示以 arr[0] = 3 結尾的最長遞增子序列 3 的長度 1L(0) = 1L(1) 表示以 arr[1] = 5 結尾的最長遞增子序列 {3,5} 的長度 2L(1) = 2L(2) 表示以 arr[2] = 2 結尾的最長遞增子序列 {2} 的長度 1 ,即 L(2) = 1L(3) 表示以 arr[3] = 8 結尾的最長遞增子序列 {3,5,8} 的長度 3 ,即 L(3) = 3

L(i) 可以被遞歸地表示爲如下形式:

L(i) = 1 + max( L(j) ) ,其中 且 arr[j] < arr[i] ;比如  L(3) = 1 + L(1) = 3  .

或者 L(i) = 1 ,不存在 arr[j] < arr[i] 的情況;比如 L(2) = 1 ,因爲 arr[0] 和 arr[1] 的值均大於 arr[2] 的值。

而最終一個給定數組的 LIS 就是取值最大的 max(L(i)) ,其中 ; 比如,對於數組 arr[] = {3,5,2,8} 而言,其 LIS 的長度爲 L(3) = 3

由上面的分析可知,對於給定的數組 arr[], 以 arr[i] 結尾的最長遞增子序列的長度等於子問題 arr[j] 的解加 1,其中   且 arr[j] < arr[i] 。也就是說最長遞增子序列具有最優子結構性質,可以由子問題的解得到原問題的解。

一圖勝千文,我們可以看一下遞歸樹:

實現代碼

class LIS 
{ 
   static int maxLIS; // 存儲 max(f(i)) 0<=i<n
  
   /* 返回數組 arr[] 的 LIS 長度*/
   static int lisRecursion(int arr[], int n) 
   { 
       // 遞歸出口(base case) 數組中只有一個元素,則 LIS 的長度爲 1
       if (n == 1) 
           return 1; 
  
       // 'maxEndingWithI'表示以 arr[n-1] 結尾的 LIS 的長度
       int res, maxEndingWithI = 1; 
  
        /* 遞歸獲得以 arr[0], arr[1] ..., arr[n-2] 結尾的 LIS 的長度. 
        如果 arr[i-1] < arr[n-1], 則需要更新以 arr[n-1] 結尾的 LIS 的長度*/
        for (int i = 1; i < n; i++) 
        { 
            res = lisRecursion(arr, i); 
            if (arr[i-1] < arr[n-1] && res + 1 > maxEndingWithI){ 
                maxEndingWithI = res + 1; 
            }
        } 
  
        // 更新原數組最長遞增子序列的長度
        if (maxLIS < maxEndingWithI){
         maxLIS = maxEndingWithI;             
        } 
  
        // 返回以 arr[n-1] 結尾的最長公共子序列的長度
        return maxLIS; 
   } 
  
    // 包裝遞歸函數
    static int lis(int arr[], int n) 
    { 
        // 保存原數組的最長遞增子序列的長度 
         maxLIS = 1; 
        // 調用遞歸函數
        lisRecursion(arr, n);
        return maxLIS; 
    } 
 } 

複雜度分析

  • 時間複雜度:如上面繪製的遞歸樹一樣,遞歸存在大量的重複子問題,耗費了大量的時間,時間複雜度爲 量級。

  • 空間複雜度: ,除遞歸調用使用的內部堆棧空間外,沒有使用任何額外的外部空間。

方法二、動態規劃

如上圖所示,遞歸存在大量的子問題被重複計算,效率極低;可以通過備忘錄或者 DP Table 對其進行剪枝,避免子問題的重複計算,從而提高算法的執行效率!

我們還是以數組  arr[] = {3, 5, 2, 8} 爲例說明:

首先我們可以初始化 dp[] 中的元素均爲 1,因爲以數組 arr[] 中的任意一個元素結尾的最長遞增子序列的長度至少爲 1 (即自身,這就相當於假設數組僅包含一個元素,長度自然爲 1,也是遞歸的出口):

假設你沒有進行過遞歸的思考,現在直接進行 DP 解法的思考,也尚不知動態規劃轉移方程,那就耐心的一步一步分析!

當 i = 0 時,我們相當於僅考慮子數組 subarr[] = {3} 的情況,最長公共子序列的長度自然爲 1:

當 i = 1 時,以 arr[1] = 5 結尾的子數組 subarr[] = {3,5} 的最長遞增子序列爲 {3,5} ,長度爲 2,這似乎是顯而易見的,但是考慮用子問題 dp[0] = 1 的解來得到當前問題的解 dp[1] = 2 又該如何得到呢?這就要回到問題本身,arr[1] = 5 ,只有當子問題的值 arr[0] = 3 小於時,纔可以對其自身的長度加 1,而 arr[0] < arr[1]  ,所以 dp[1] = max(dp[1],dp[0]+1) = 2  :

當 i = 2 時,arr[2] = 2 < arr[1] = 5arr[2] < arr[0] ,所以不更新 dp[2] 的值:

當 i = 3 時,arr[0] = 3 < arr[3] = 8 ,則更新 dp[3] = max(dp[3],dp[0] + 1) = 2 ;

arr[1] = 5 < arr[3] = 8 ,則更新 dp[3] = max(dp[3],dp[1] + 1) = 3 ;

arr[2] = 2 < arr[3] = 8 ,則更新 dp[3] = max(dp[3],dp[2] + 1) = 3 ;

dp[i] 就表示以 arr[i] 結尾的最長遞增子序列的長度,那麼狀態轉移方程是什麼呢?其實就是你一步一步總結的規律:

實現代碼

class LIS 
{ 
    /* 動態規劃解法 */
    static int lis(int arr[],int n) 
    { 
        int dp[] = new int[n]; 
        int i,j,max = 0; 

        /* 初始化 dp[] 數組中的每一個元素爲 1 */
        for ( i = 0; i < n; i++ ){ 
            dp[i] = 1; 
        }

        /* 自底向上計算每一個問題的最優解*/
        for( i = 1; i < n; i++ ){ 
            for( j = 0; j < i; j++ ){  
                if ( arr[i] > arr[j] && dp[i] < dp[j] + 1){
                    dp[i] = dp[j] + 1;
                } 
            }
        }

        /* 遍歷 dp 數組,找出最大值並返回 */
        for( i = 0; i < n; i++ ){ 
            if ( max < dp[i] ){ 
                max = dp[i]; 
            }
        }
        return max; 
    } 
}

複雜度分析

  • 時間複雜度:兩層嵌套的 for 循環,外層爲 n-1 次,內層最大爲 n-2 次,時間複雜度爲 量級

  • 空間複雜度:dp[] 轉態數組的大小與原數組 arr[] 相同,空間複雜度爲  .

如果你回答到這裏,面試官已經很滿意了,但是你一定要再進一步,考慮一下如何將算法的時間複雜度降低到 呢?

方法三、動態規劃 + 二分查找

從現在開始無需再考慮遞歸和 DP 解法,我們首先考慮一個簡單的輸入,然後動態添加元素,將其擴展到較複雜的輸入。儘管這種方法可能很複雜,但是隻要理解了其邏輯,編碼就會很簡單。

考慮初始的輸入數組爲 arr[] = {2,5,3} ,然後在隨後的解釋中不斷擴展這個數組。

對於數組 arr[] = {2,5,3} 而言,LIS 爲 {2,3} 或者 {2,5} 。同樣,這裏的遞增還是嚴格遞增的。

然後我們在原數組中添加兩個元素, 7 和 11,即 arr[] = {2,5,3,7,11} 。此時數組的遞增序列將進一步變長,即  {2,3,7,11}{2,5,7,11}

緊接着,我們再向數組中添加一個元素 8 ,即 arr[] = {2,5,3,7,11,8} 。可以看到 8 比任意一個活動序列(active sequence,就是一個名字而已,可以動態生長的序列,  {2,3,7,11}{2,5,7,11} 就是活動序列)的最小元素都大。那麼我們該如何用 8 來擴展現有遞增序列呢?第一, 8 是否屬於 LIS 中的元素?如果是,LIS 是什麼樣的呢?如果我們添加 8 ,其應該添加到 7 的後面(替換 11),即   {2,3,7,8}{2,5,7,8}

由於我們現在模擬的是動態添加元素,動態更新數組的最長遞增子序列,所以我們並不能確定添加 8 是否可以擴展 LIS 的長度。例如,假設 9 在輸入數組中,即 arr[] = {2,5,3,7,11,8,7,9,...} ,我們就可以用 8 來替換 11, 因爲潛在的元素 9 可以擴展新的序列 {2,3,7,8}{2,5,7,8}  。

結論一: 假設最長遞增序列最末尾的元素爲 E ,我們可以在現存序列上添加(替換)當前元素 A[i] 的條件是:存在一個元素 A[j] ( ),使得 (添加) ,或者 (替換)。比如上面的例子中,最長遞增序列   {2,3,7,11}{2,5,7,11}  最末尾的元素 E = 11 ,當前決定是否添加(替換)的元素爲 A[i] = 8A[j] = 9 ,由於 ,所以將 11 替換爲 8。(其實這就是各位大佬所說的貪心,如果已經得到的上升子序列結尾的數越小,遍歷的時候後面接上一個數,就會有更大的可能性構成一個更長的上升子序列, 但是這樣的貪心策略是有侷限性的,接着向下看!)

在初始數組 arr[] = {2,5,3} 中,我們同樣會碰到是否想遞增序列 {2,5} 中添加元素 3 的問題,前面直接給出初始數組的兩個最長遞增子序列是爲了解釋的方便,但看到這裏,我們其實可以用 3 替換序列 {2,5} 中的元素 5(因爲 5 > 3 < 7),得到當前最長子序列 {2,3}

此時,你可能還是有些許疑惑,不妨耐心看完下文!

問自己一個問題:什麼情況下在一個現有序列中添加或替換一個元素是合理的?

我們一起考慮另外一個簡單的例子,初始的輸入數組依然爲 {2,5,3} ,但是 3 的下一個元素爲 1,顯然並不能擴展現有序列 {2,3}{2,5} ,但是新的最下元素 1 有可能作爲 LIS 的第一個元素,比如當數組爲 arr[] = {2,5,3,1,2,3,4,5} 時,1 作爲當前新的最長遞增 {1,2,3,4,5} 的第一個元素。

結論二: 當我們在數組中遇到一個新的最小元素時,其可能是一個潛在的新序列的第一個元素。這就是僅考慮貪心策略會從一開始陷入局部最優的可能)。

基於上面兩個結論,我們需要維護遞增活動序列列表。

一般而言,我們有一個變長的活動序列列表集合。我們向列表中的所有活動序列添加一個元素 A[i] ,按照活動遞增序列長度的降序掃描活動序列,並且檢查所有活動序列最末尾的元素,找到最末尾元素小於 A[i] 的活動序列。

然後就是匹配下面三種情況:

情況一:如果 A[i] 比活動列表中所有活動序列最末尾的元素都小,我們將創建一個長度爲 1 的新的活動序列,並刪除其他等長的活動序列;

情況二:如果 A[i] 比活動列表中所有活動序列最末尾的元素都大,我們將複製最長的活動序列,並將 A[i] 添加進去;

情況三:如果 A[i] 介於中間,我們將找到一個最末尾元素比 A[i] 小的活動序列,複製並添加 A[i] 。並且將所有與添加新元素 A[i] 的活動序列等長的序列刪除。

注意:在我們構造活動列表的時候,無論何時都遵循 “較短的活動序列最末尾的元素一定小於較長的活動序列最末尾的元素“。

我們用一個 wiki 上的例子 A[] = {0, 8, 4, 12, 2, 10, 6, 14, 1, 9, 5, 13, 3, 11, 7, 15} 一起來 “驗算” 一遍即可。

A[0] = 0 ,情況一,當前活動列表爲空,創建一個活動列表:

由於數組 A[] 太長,之後的圖中就不畫數組了,我們只關心當前要添加的元素即可。

A[1] = 8 ,情況二,複製並擴展:

A[2] = 4 ,情況三,複製、擴展 和 丟棄:

A[3] = 12 ,情況二,複製並擴展:

A[4] = 2 ,情況三,複製、擴展 和 丟棄:

之後的情況就直接給大家用一個長圖表示了奧!

理解上面最長遞增子序列的構造過程對於設計一個算法直觀重要!很明顯,最後得到活動列表滿足 ”較短的活動序列最末尾的元素一定小於較長的活動序列最末尾的元素“ 的條件。你可以嘗試對文章最開始的三個輸入示例,按照上面的處理步驟自己操作一番,一定會對於你理解接下來的文章大有裨益,而且你會掌握的更牢固。

Tell me and I will forget. Show me and I will remember. Involve me and I will understand.(大學時候英語老師老說的一句話!)

PS:不妨拿出一副撲克牌,然後多洗幾次撲克牌,隨機抽出 10 張左右,按照上面的過程找出你抽出的撲克牌的最長遞增子序列,這樣一定會讓你對 LIS 的理解和記憶幫助大大滴有!

在上面的整個過程中,你是否注意到,我們 所有的操作都是圍繞活動序列最末尾的元素進行比較和操作的,我們可以把所有活動序列最末尾的元素存儲在一個數組中,丟棄活動序列的操作就可以用替換數組中的元素模擬,擴展活動序列就相當於向數組中添加元素

我們將使用一個輔助數組來存儲最末尾的元素,這個數組的最大長度和輸入數組一樣長,當然也可能比輸入數組的長度短。在最壞的情況下,數組被分割成 N 個大小爲 1 的列表(注意,這並不會導致最壞的時間複雜度)。

丟棄一個元素,我們將在輔助數組中找到 A[i] 的位置(再次觀察上面的活動列表的構造過程),並用A[i] 替換相應位置的值。通過向輔助數組中添加元素來擴展最長遞增子序列,同時需要一個變量作爲個計數器來保存輔助數組的長度。

請不要介意,我可能還要重複一遍上面的操作,並且結合輔助數組和輔助數組的長度計數器再講一個新的例子:A[] = [10,9,2,5,3,7,101,18]

如下圖所示,先來簡單的認識一下,我們涉及的存儲變量,數組 A[] ,輔助數組 tailTable[] ,輔助數組的長度 len ,還有我們之前模擬的活動列表,當然接下來就是用輔助數組取代活動列表,爲了讓大家有一個清晰的認識和對應關係,我將兩者放到了同一張圖中:

第一步:用數組 A[0] 初始化輔助數組 tailTable ,即 tailTable[0] = A[0]len = 1  ,活動列表相應的添加 A[0] :

第二步:添加 A[1] = 9 ,情況一,添加元素 A[1] 小於當前活動列表中所有活動序列最末尾的元素,則創建一個長度爲 1 的新的活動序列,並刪除其他等長的活動序列。對應到輔助數組,該如何做到這一件事情呢?輔助數組 tailTable[] 本身存儲的就是活動序列最末尾的元素,要比較大小,直接用 A[1] 和輔助數組 tailTable[] 中的元素比較不就可以了?發現當前輔助數組中就一個元素,且大於添加元素 A[1] ,直接替換不就可以了?

第三步,添加元素 A[2] = 2 ,這就和第二步一樣嗎?

第四步:添加元素  A[3] = 5  ,情況二,添加元素 A[3] 大於所有活動序列最末尾的元素,相應的直接和輔助數組最末尾的元素比較大小即可判斷是否是情況二,如果是直接在輔助數組中添加元素 A[3]:

第五步:添加元素 A[4] = 3 ,情況三, 2 < A[4] < 5,複製 2 → 添加 3 → 丟棄等長的;對應到輔助數組,首先如何確定 A[4] 應該添加在誰的後面呢,也就是 A[4] 的位置?我們使用二分查找法,確定之後替換比其大的元素 5 即可,如下所示:

第六步:添加元素 A[5] = 7 , 情況二,直接在輔助數組中添加元素 A[5] = 7 ,活動列表的相應變化如圖所示,注意活動列表中的元素和輔助數組中的元素:

第七步:添加元素 A[6] = 101 ,情況二,直接在輔助數組中添加 A[6] = 101 :

第八步:添加元素 A[7] = 18 ,情況三,用二分查找法在輔助數組 tailTable 中找位置,然後替換相應的元素:

這就是 算法的由來,我想你也理解了,不過這裏給大家再次提個醒,輔助數組中的元素是活動列表中所有活動序列最末尾的元素,而不是我們要求的最長遞增子序列

舉個簡單的例子,上面的第八步是添加元素 A[7] = 18 ,現在我們將其改成 A[7] = 4 進行添加效果會如何呢?依舊是情況三,但是此時的 3 < 4 < 7 ,我們將會把輔助數組中的 7 替換爲 4,活動列表則是複製 [2,3]  → 添加 4  →  刪除等長的 [2,3,7] :

但是可以確定的是,最長遞增子序列的長度是和輔助數組的長度相同的,所有最後返回 len 即可。

實現代碼

不妨先自己寫寫二分查找,然後根據上面的講解自己調一調代碼,其實理解了思想,實現代碼相當簡單

class LIS { 
    // 二分查找
    static int CeilIndex(int tail[], int left, int right, int key) 
    { 
        while (right - left > 1) { 
            int mid = left + (right - l) / 2; 
            if (tail[mid] >= key){
                right = mid;                 
            } 
            else{
             left = mid;     
            }
        } 
        return right; 
    } 
  
    static int lengthOfLIS(int A[], int n) 
    { 
        // 創建一個大小爲 n 的輔助數組
        int[] tailTable = new int[n]; 
        int len; // 存儲輔助數組的元素個數
  
        //邊界情況,將數組中的第一個元素直接添加進去
        tailTable[0] = A[0]; 
        len = 1; //長度加 1
        
        for (int i = 1; i < n; i++) { 
            // 新的最小值,情況一
            if (A[i] < tailTable[0])
            { 
                tailTable[0] = A[i];
            }
            else if (A[i] > tailTable[len - 1]){ 
                // A[i] 爲最大值,情況二
                tailTable[len++] = A[i]; 
            }
            else{ // 情況三,找到 A[i] 在 tailTable 的位置並替換
                tailTable[CeilIndex(tailTable, -1, len - 1, A[i])] = A[i];
            }
        } 
        return len; 
    } 
    public static void main(String[] args) 
    { 
        int A[] = {10,9,2,5,3,7,101,18}; 
        System.out.println("Length of Longest Increasing Subsequence is: " + lengthOfLIS(A, A.length)); 
    } 
} 

複雜度分析

  • 時間複雜度:外層循環的大小爲 ,內層的 else 語句中的二分查找在最壞的情況下時間複雜度爲 ,【你可以考慮一下最壞情況,或者一種輸入樣例,歡迎評論區留言,說出你心中的例子,可能有驚喜奧!】所有總的時間複雜度爲 量級。

  • 空間複雜度: 。

感謝各位小夥伴能讀到這裏,最後再給大家佈置個小作業,你能否實現一個輸出給定數組最長遞增子序列的代碼呢?

比如輸入:A[] = [ 2, 5, 3, 7, 11, 8, 10, 13, 6] ,輸出爲:[2,3,7,8,10,13]

程序員專欄 掃碼關注填加客服 長按識別下方二維碼進羣

近期精彩內容推薦:  

 員工因上廁所時間超長被開除了

 程序員連續15天加班到凌晨2點在餐廳淚崩!

 還在try...catch?如果是那你就out了!

 Python很慢?Python之父一句話亮了


在看點這裏好文分享給更多人↓↓

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章