求解最長遞增子序列(LIS) | 動態規劃(DP)+ 二分法

一、題目描述

    給定數組arr,返回arr的最長遞增子序列。算法

二、舉例

    arr={2,1,5,3,6,4,8,9,7},返回的最長遞增子序列爲{1,3,4,8,9}。數組

三、解答

    本期主要從動態規劃和二分法兩個方向來求解最長遞增子序列問題。ide

3.1 動態規劃求解最長遞增子序列

    先介紹時間複雜度爲O(N^2^)的方法,具體過程以下:學習

  1. 生成數組dp,dp[i]表示在以arr[i]這個數結尾的狀況下,arr[0…i]中的最大遞增子序列長度。
  2. 對第一個數arr[0]來講,令dp[0]=1,接下來從左到右(i=1,2,…,N-1)依次算出以每一個位置的數結尾的狀況下,最長遞增子序列長度。
  3. 假設計算到位置i,求以arr[i]結尾狀況下的最長遞增子序列長度,即dp[i]。若是最長遞增子序列以arr[i]結尾,那麼在arr[0…i-1]中全部比arr[i]小的數均可以做爲倒數第二個數。在這麼多倒數第二個數的選擇中,以哪一個數結尾的最大遞增子序列更大,就選那個數做爲倒數第二個數,因此dp[i]=max{dp[j]+1(0≤j<i,arr[j]<arr[i])}。若是arr[0…i-1]中全部的數都不比arr[i]小,令dp[i]=1便可,說明以arr[i]結尾狀況下的最長遞增子序列只包含arr[i]。

    按照步驟1~3能夠計算出dp數組,具體過程請參看以下代碼中的方法,參考代碼以下:優化

#include<stdio.h>

#define MAXN 1000
int arr[MAXN + 10];
int dp[MAXN + 10];

int main() {
    int N, i, j;
    scanf("%d", &N);
    for (i = 0; i < N; ++i) {
        scanf("%d", &arr[i]);
    }
    dp[0] = 1;
    for (i = 1; i < N; ++i) {
        /*
         * 每次求以第i個數爲終點的最長上升子序列的長度
         */
        int tmp = 0;/* 記錄知足條件的、第i個數左邊的上升子序列的最大長度 */
        for (j = 0; j < i; ++j) {
            /* 查看以第j個數爲終點的最長上升子序列 */
            if (arr[i] > arr[j]) {
                if (tmp < dp[j])
                    tmp = dp[j];
            }
        }
        dp[i] = tmp + 1;
    }
    int ans = -1;
    for (i = 0; i < N; ++i) {
        if (ans < dp[i])
            ans = dp[i];
    }
    printf("%d\n", ans);
    return 0;
}

    程序執行完後,數組arr[]和狀態數組dp[]以下:
在這裏插入圖片描述設計

    最長上升子序列有6個:(1,5,6,8,9)、(2,5,6,8,9)、(2,3,6,8,9)、(2,3,4,8,9)、(1,3,4,8,9)和(1,3,6,8,9),長度都是5。3d

        

    問題:若是還要輸出最長的子序列呢?例如,除了輸出5以外,還要輸出(1,3,4,8,9)這個序列。code

    接下來解釋如何根據求出的dp數組獲得最長遞增子序列。以題目的例子來講明,arr={2,1,5,3,6,4,8,9,7},求出的數組dp={1,1,2,2,3,3,4,5,4}。具體求解步驟以下:blog

  1. 遍歷dp數組,找到最大值以及位置。在本例中最大值爲5,位置爲7,說明最終的最長遞增子序列的長度爲5,而且應該以arr[7]這個數(arr[7]=9)結尾。
  2. 從arr數組的位置7開始從右向左遍歷。若是對某一個位置i,既有arr[i]<arr[7],又有dp[i]=dp[7]-1,說明arr[i]能夠做爲最長遞增子序列的倒數第二個數。在本例中,arr[6]<arr[7],而且dp[6]=dp[7]-1,因此8應該做爲最長遞增子序列的倒數第二個數。
  3. 從arr數組的位置6開始繼續向左遍歷,按照一樣的過程找到倒數第三個數。在本例中,位置5知足arr[5]<arr[6],而且dp[5]=dp[6]-1,同時位置4也知足。選arr[5]或者arr[4]做爲倒數第三個數均可以。
  4. 重複這樣的過程,直到全部的數都找出來。

    dp數組包含每一步決策的信息,其實根據dp數組找出最長遞增子序列的過程就是從某一個位置開始逆序還原出決策路徑的過程。具體過程請參看以下代碼:遊戲

#include<stdio.h>
#include<stdlib.h>    /* 動態內存分配 */

#define MAXN 1000
int arr[MAXN + 10];
int dp[MAXN + 10];

int main() {
    int N, i, j;
    scanf("%d", &N);
    for (i = 0; i < N; ++i) {
        scanf("%d", &arr[i]);
    }
    dp[0] = 1;
    for (i = 1; i < N; ++i) {
        /*
         * 每次求以第i個數爲終點的最長上升子序列的長度
         */
        int tmp = 0;/* 記錄知足條件的、第i個數左邊的上升子序列的最大長度 */
        for (j = 0; j < i; ++j) {
            /* 查看以第j個數爲終點的最長上升子序列 */
            if (arr[i] > arr[j]) {
                if (tmp < dp[j])
                    tmp = dp[j];
            }
        }
        dp[i] = tmp + 1;
    }
    int ans = -1;
    for (i = 0; i < N; ++i) {
        if (ans < dp[i])
            ans = dp[i];
    }
    printf("%d\n", ans);    /* 輸出最長遞增子序列的長度 */

    /*
     * 下面根據dp數組還原出最長遞增子序列。
     * len中記錄了最長遞增子序列的長度,固然有len=ans。
     * index記錄最長遞增子序列中最後一個數在arr數組中的位置。
     */
    int len = 0;
    int index = 0;
    for (i = 0; i < N; ++i) {
        if (dp[i] > len) {
            len = dp[i];
            index = i;
        }
    }

    /*
     * lis數組用來存放最長遞增子序列。
     */
    int* lis = (int*)malloc(sizeof(int) * len);
    lis[--len] = arr[index];        /* 最長遞增子序列中最後一個數爲arr[index] */
    for (i = index; i >= 0; i--) {  /* 從index位置開始從右往左掃描數組arr */
        if (arr[i] < arr[index] && dp[i] == dp[index] - 1) {
            lis[--len] = arr[i];
            index = i;
        }
    }
    /* 打印最長遞增子序列 */
    for (i = 0; i < ans; ++i) {
        printf("%d", lis[i]);
        if (i < ans - 1)printf(" ");
    }
    printf("\n");

    free(lis);

    return 0;
}

輸入:

9
2 1 5 3 6 4 8 9 7

輸出:

5
1 3 4 8 9

運行結果:
在這裏插入圖片描述
    計算dp數組過程的時間複雜度爲O(N^2^),根據dp數組獲得最長遞增子序列過程的時間複雜度爲O(N),因此整個過程的時間複雜度爲O(N^2^)。

    問題:若是把序列的長度增長到N=10^4^,10^5^,10^6^ 呢?如何將計算dp數組的時間複雜度降到O(Nlog N)?

3.2 二分法求解最長遞增子序列

    時間複雜度O(Nlog N)生成dp數組的過程是利用二分查找來進行的優化。先生成一個長度爲N的數組ends,初始時ends[0]=arr[0],其餘位置上的值爲0。生成整型變量right, 初始時right=0。在從左到右遍歷arr數組的過程當中,求解dp[i]的過程須要使用ends數組和 right變量,因此這裏解釋一下其含義。遍歷的過程當中,ends[0..right]爲有效區, ends[right+1..N-1]爲無效區。對有效區上的位置b若是有ends[b]=c,則表示遍歷到目前爲止,在全部長度爲b+1的遞增序列中,最小的結尾數是c。無效區的位置則沒有意義。

    好比,arr=[2,1,5,3,6,4,8,9,7],初始時 dp[0]=1,ends[0]=2, right=0。ends[0..0]爲有效區, ends[0]=2的含義是,在遍歷過arr[0]以後,全部長度爲1的遞增序列中(此時只有[2]),最小的結尾數是2。以後的遍歷繼續用這個例子來講明求解過程。

  1. 遍歷到arr[1]==1。ends有效區=ends[0..0]=[2],在有效區中找到最左邊的大於或等於arr[1]的數。發現是ends[0],表示以arr[1]結尾的最長遞增序列只有arr[1],因此令dp[1]=1。而後令ends[0]=1,由於遍歷到目前爲止,在全部長度爲1的遞增序列中,最小的結尾數是1,而再也不是2。
  2. 遍歷到arr[2]==5。ends有效區=ends[0..0]=[1],在有效區中找到最左邊大於或等於arr[2]的數。發現沒有這樣的數,表示以arr[2]結尾的最長遞增序列長度=ends有效區長度+1, 因此令dp[2]=2。ends整個有效區都沒有比arr[2]更大的數,說明發現了比ends有效區長度更長的遞增序列,因而把有效區擴大,ends有效區=ends[0..1]=[1,5]。
  3. 遍歷到arr[3]==3。ends有效區=ends[0..1]=[1,5],在有效區中用二分法找到最左邊大於或等於arr[3]的數。發現是ends[1],表示以arr[3]結尾的最長遞增序列長度爲2,因此令dp[3]=2。而後令ends[1]=3,由於遍歷到目前爲止,在全部長度爲2的遞增序列中,最小的結尾數是3,而再也不是5。
  4. 遍歷到arr[4]==6。ends有效區=ends[0..1]=[1,3],在有效區中用二分法找到最左邊,大於或等於arr[4]的數。發現沒有這樣的數,表示以arr[4]結尾的最長遞增序列長度=ends 有效區長度+1,因此令dp[4]=3。ends整個有效區都沒有比arr[4]更大的數,說明發現了比 ends有效區長度更長的遞增序列,因而把有效區擴大,ends有效區=ends[0..2]=[1,3,6]。
  5. 遍歷到arr[5]==4。ends有效區=ends[0..2]=[1,3,6],在有效區中用二分法找到最左邊大於或等於arr[5]的數。發現是ends[2],表示以arr[5]結尾的最長遞增序列長度爲3,因此令dp[5]=3。而後令ends[2]=4,表示在全部長度爲3的遞增序列中,最小的結尾數變爲4。
  6. 遍歷到arr[6]==8。ends有效區=ends[0..2]=[1,3,4],在有效區中用二分法找到最左邊大於或等於arr[6]的數。發現沒有這樣的數,表示以arr[6]結尾的最長遞增序列長度=ends有效區長度+1,因此令dp[6]=4。ends整個有效區都沒有比arr[6]更大的數,說明發現了比 ends有效區長度更長的遞增序列,因而把有效區擴大,ends有效區=ends[0..3]=[1,3,4,8]。
  7. 遍歷到arr[7]==9。ends有效區=ends[0..3]=[1,3,4,8],在有效區中用二分法找到最左邊大於或等於arr[7]的數。發現沒有這樣的數,表示以arr[7]結尾的最長遞增序列長度=ends 有效區長度+1,因此令dp[7]=5。ends整個有效區都沒有比arr[7]更大的數,因而把有效區 擴大,ends 有效區=ends[0..5]=[1,3,4,8,9]。
  8. 遍歷到arr[8]==7。ends有效區=ends[0..5]=[1,3,4,8,9],在有效區中用二分法找到最左邊大於或等於arr[8]的數。發現是ends[3],表示以arr[8]結尾的最長遞增序列長度爲4, 因此令dp[8]=4。而後令ends[3]=7,表示在全部長度爲4的遞增序列中,最小的結尾數變爲7。

    具體過程請參看以下代碼:

#include<stdio.h>
#include<stdlib.h>    /* 動態內存分配 */

#define MAXN 100000
int arr[MAXN + 10];
int dp[MAXN + 10];
int ends[MAXN + 10];

int max(int x, int y) {
    return x > y ? x : y;
}

int main() {
    int N, i;
    scanf("%d", &N);
    for (i = 0; i < N; ++i) {
        scanf("%d", &arr[i]);
    }
    dp[0] = 1;
    ends[0] = arr[0];
    int right = 0;
    int ll = 0;
    int rr = 0;
    int mm = 0;
    for (i = 1; i < N; ++i) {
        ll = 0;
        rr = right;
        while (ll <= rr) {
            mm = (ll + rr) / 2;
            if (arr[i] > ends[mm]) {
                ll = mm + 1;
            } else {
                rr = mm - 1;
            }
        }
        right = max(right, ll);
        ends[ll] = arr[i];
        dp[i] = ll + 1;
    }

    int ans = -1;
    for (i = 0; i < N; ++i) {
        if (ans < dp[i])
            ans = dp[i];
    }
    printf("%d\n", ans); /* 輸出最長遞增子序列的長度 */

    /*
     * 下面根據dp數組還原出最長遞增子序列。
     * len中記錄了最長遞增子序列的長度,固然有len=ans。
     * index記錄最長遞增子序列中最後一個數在arr數組中的位置。
     */
    int len = 0;
    int index = 0;
    for (i = 0; i < N; ++i) {
        if (dp[i] > len) {
            len = dp[i];
            index = i;
        }
    }

    /*
     * lis數組用來存放最長遞增子序列。
     */
    int* lis = (int*) malloc(sizeof(int) * len);
    lis[--len] = arr[index]; /* 最長遞增子序列中最後一個數爲arr[index] */
    for (i = index; i >= 0; i--) { /* 從index位置開始從右往左掃描數組arr */
        if (arr[i] < arr[index] && dp[i] == dp[index] - 1) {
            lis[--len] = arr[i];
            index = i;
        }
    }
    /* 打印最長遞增子序列 */
    for (i = 0; i < ans; ++i) {
        printf("%d", lis[i]);
        if (i < ans - 1)
            printf(" ");
    }
    printf("\n");

    free(lis);

    return 0;
}

運行結果:
在這裏插入圖片描述

四、文章推薦

推薦一:《用x種方式求第n項斐波那契數,99%的人只會第一種》,文章內容:斐波那契數列及其求法,動態規劃,數組的巧妙使用--滾動數組。

推薦二:《深刻淺出理解動態規劃(二) | 最優子結構》,文章內容:經典例題---數字三角形求解。

推薦三:《深刻淺出理解動態規劃(一) | 交疊子問題》,文章內容:記憶化搜索算法、打表法求解第n個斐波那契數。

做者: C you again,從事軟件開發 努力在IT搬磚路上的技術小白
公衆號:C you again】,分享計算機類畢業設計源碼、IT技術文章、遊戲源碼、網頁模板、程序人生等等。公衆號回覆 【粉絲】進博主技術羣,與大佬交流,領取乾貨學習資料
關於轉載:歡迎轉載博主文章,轉載時代表出處
求贊環節:創做不易,記得 點贊+評論+轉發 謝謝你一路支持

相關文章
相關標籤/搜索