最長遞增子序列詳解(longest increasing subsequence)

來源:互聯網
上載者:User

一個各公司都喜歡拿來做面試筆試題的經典動態規劃問題,互連網上也有很多文章對該問題進行討論,但是我覺得對該問題的最關鍵的地方,這些討論似乎都解釋的不很清楚,讓人心中不快,所以自己想徹底的搞一搞這個問題,希望能夠將這個問題的細節之處都能夠說清楚。

對於動態規劃問題,往往存在遞推解決方案,這個問題也不例外。要求長度為i的序列的Ai{a1,a2,……,ai}最長遞增子序列,需要先求出序列Ai-1{a1,a2,……,ai-1}中以各元素(a1,a2,……,ai-1)作為最大元素的最長遞增序列,然後把所有這些遞增序列與ai比較,如果某個長度為m序列的末尾元素aj(j<i)比ai要小,則將元素ai加入這個遞增子序列,得到一個新的長度為m+1的新序列,否則其長度不變,將處理後的所有i個序列的長度進行比較,其中最長的序列就是所求的最長遞增子序列。舉例說明,對於序列A{35,
36, 39, 3, 15, 27, 6, 42}當處理到第九個元素(27)時,以35, 36, 39, 3, 15, 27, 6為最末元素的最長遞增序列分別為
    35
    35,36
    35,36,39
    3
    3,15
    3,15,27
    3,6
當新加入第10個元素42時,這些序列變為
    35,42
    35,36,42
    35,36,39,42,
    3,42
    3,15,42
    3,15,27,42
    3,6,42

這其中最長的遞增序列為(35,36,39,42)和(3,15,27,42),所以序列A的最長遞增子序列的長度為4,同時在A中長度為4的遞增子序列不止一個。

該演算法的思想十分簡單,如果要得出Ai序列的最長遞增子序列,就需要計算出Ai-1的所有元素作為最大元素的最長遞增序列,依次遞推Ai-2,Ai-3,……,將此過程倒過來,即可得到遞推演算法,依次推出A1,A2,……,直到推出Ai為止,

代碼如下

unsigned int LISS(const int array[], size_t length, int result[]){    unsigned int i, j, k, max;    //變長數組參數,C99新特性,用於記錄當前各元素作為最大元素的最長遞增序列長度    unsigned int liss[length];    //前驅元素數組,記錄當前以該元素作為最大元素的遞增序列中該元素的前驅節點,用於列印序列用    unsigned int pre[length];    for(i = 0; i < length; ++i)    {        liss[i] = 1;        pre[i] = i;    }    for(i = 1, max = 1, k = 0; i < length; ++i)    {        //找到以array[i]為最末元素的最長遞增子序列        for(j = 0; j < i; ++j)        {            //如果要求非遞減子序列只需將array[j] < array[i]改成<=,            //如果要求遞減子序列只需改為>            if(array[j] < array[i] && liss[j] + 1> liss[i])            {                liss[i] = liss[j] + 1;                pre[i] = j;                //得到當前最長遞增子序列的長度,以及該子序列的最末元素的位置                if(max < liss[i])                {                    max = liss[i];                    k = i;                }            }        }    }    //輸出序列    i = max - 1;    while(pre[k] != k)    {        result[i--] = array[k];        k = pre[k];    }    result[i] = array[k];    return max;}

該Function Compute出長度為length的array的最長遞增子序列的長度,作為傳回值返回,實際序列儲存在result數組中,該函數中使用到了C99變長數組參數特性(這個特性比較贊),不支援C99的同學們可以用malloc來申請函數裡面的兩個陣列變數。函數的時間複雜度為O(nn),下面我們來介紹可以將時間複雜度降為O(nlogn)改進演算法。

在基本演算法中,我們發現,當需要計算前i個元素的最長遞增子序列時,前i-1個元素作為最大元素的各遞增序列,無論是長度,還是最大元素值,都毫無規律可循,所以開始計算前i個元素的時候只能遍曆前i-1個元素,來找到滿足條件的j值,使得aj < ai,且在所有滿足條件的j中,以aj作為最大元素的遞增子序列最長。有沒有更高效的方法,找到這樣的元素aj呢,實際是有的,但是需要用到一個新概念。在之前我舉的序列例子中,我們會發現,當計算到第10個元素時,前9個元素所形成最長子序列分別為

    35
    35,36
    35,36,39
    3
    3,15
    3,15,27

    3,6

這其中長度為3的子序列有兩個,長度為2的子序列有3個,長度為1的子序列2個,所以一個序列,長度為n的遞增子序列可能不止一個,但是所有長度為n的子序列中,有一個子序列是比較特殊的,那就是最大元素最小的遞增子序列(挺拗口的概念),在上述例子中,序列(3),(3,6),(3,5,27)就滿足這樣的性質,他們分別是長度為1,2,3的遞增子序列中最大元素最小的(截止至處理第10個元素之前),隨著元素的不斷加入,滿足條件的子序列會不斷變化。如果將這些子序列按照長度由短到長排列,將他們的最大元素放在一起,形成新序列B{b1,b2,……bj},則序列B滿足b1
< b2 < …… <bj。這個關係比較容易說明,假設bxy表示序列A中長度為x的遞增序列中的第y個元素,顯然,如果在序列B中存在元素bmm > bnn,且m < n則說明子序列Bn的最大元素小於Bm的最大元素,因為序列是嚴格遞增的,所以在遞增序列Bn中存在元素bnm < bnn,且從bn0到bnm形成了一個新的長度為m的遞增序列,因為bmm > bnn,所以bmm > bnm,這就說明在序列B中還存在一個長度為m,最大元素為bnm < bmm的遞增子序列,這與序列的定義,bmm是所有長度為m的遞增序列中第m個元素最小的序列不符,所以序列B中的各元素嚴格遞增。發現了如此的一個嚴格遞增的序列,這讓我們柳暗花明,可以利用此序列的嚴格遞增性,利用二分尋找,找到最大元素剛好小於aj的元素bk,將aj加入這個序列尾部,形成長度為k+1但是最大元素又小於bk+1的新序列,取代之前的bk+1,如果aj比Bn中的所有元素都要大,說明發現了以aj為最大元素,長度為n+1的遞增序列,將aj做Bn+1的第n+1個元素。從b1依次遞推,就可以在O(nlogn)的時間內找出序列A的最長遞增子序列。

理論說明比較枯燥,來看一個例子,以序列{6,7,8,9,10,1,2,3,4,5,6}來說明改進演算法的步驟:

程式開始時,最長遞增序列長度為1(每個元素都是一個長度為1的遞增序列),當處理第2個元素時發現7比最長遞增序列6的最大元素還要大,所以將6,7結合產生長度為2的遞增序列,說明已經發現了長度為2的遞增序列,依次處理,到第5個元素(10),這一過程中B數組的變化過程是

    6
    6,7
    6,7,8
    6,7,8,9
    6,7,8,9,10

開始處理第6個元素是1,尋找比1大的最小元素,發現是長度為1的子序列的最大元素6,說明1是最大元素更小的長度為1的遞增序列,用1替換6,形成新數組1,7,8,9,10。然後尋找比第7個元素(2)大的最小元素,發現7,說明存在長度為2的序列,其末元素2,比7更小,用2替換7,依次執行,直到所有元素處理完畢,產生新的數組1,2,3,4,5,最後將6加入B數組,形成長度為6的最長遞增子序列.

這一過程中,B數組的變化過程是

    1,7,8,9,10
    1,2,8,9,10
    1,2,3,9,10
    1,2,3,4,10
    1,2,3,4,5
    1,2,3,4,5,6

當處理第10個元素(5)時,傳統演算法需要查看9個元素(6,7,8,9,10,1,2,3,4),而改進演算法只需要用二分尋找數組B中的兩個元素(3, 4),可見改進演算法還是很陰霸的。

下面是該演算法的實現:

unsigned int LISSEx(const int array[], size_t length, int result[]){    unsigned int i, j, k, l, max;    //棧數組參數,C99新特性,這裡的liss數組與上一個函數意義不同,liss[i]記錄長度為i + 1    //遞增子序列中最大值最小的子序列的最後一個元素(最大元素)在array中的位置    unsigned int liss[length];    //前驅元素數組,用於列印序列    unsigned int pre[length];    liss[0] = 0;    for(i = 0; i < length; ++i)    {        pre[i] = i;    }    for(i = 1, max = 1; i < length; ++i)    {        //找到這樣的j使得在滿足array[liss[j]] > array[i]條件的所有j中,j最小        j = 0, k = max - 1;        while(k - j > 1)        {            l = (j + k) / 2;            if(array[liss[l]] < array[i])            {                j = l;            }            else            {                k = l;            }        }        if(array[liss[j]] < array[i])        {            j = k;        }        //array[liss[0]]的值也比array[i]大的情況        if(j == 0)        {            //此處必須加等號,防止array中存在多個相等的最小值時,將最小值填充到liss[1]位置            if(array[liss[0]] >= array[i])            {                liss[0] = i;                continue;            }        }                //array[liss[max -1]]的值比array[i]小的情況                if(j == max - 1)        {            if(array[liss[j]] < array[i])            {                pre[i] = liss[j];                liss[max++] = i;                continue;            }        }        pre[i] = liss[j - 1];        liss[j] = i;    }    //輸出遞增子序列    i = max - 1;    k = liss[max - 1];    while(pre[k] != k)    {        result[i--] = array[k];        k = pre[k];    }    result[i] = array[k];    return max;}

這個演算法的思想可以算得上巧妙,在時間複雜度上提升明顯,但是同時在實現時也比通俗演算法多了好些坑,這裡說明一下:

  • 演算法中為了獲得實際的序列,數組B中儲存的不是長度為j的遞增序列的最大元素的最小值,而是該值在輸入數組A中的位置,如果只想求出最長遞增子序列的長度,則B數組可以直接儲存滿足條件元素的值
  • 二分尋找的結果,我們的目的是找到這樣的一個j,使滿足A[B[j]] > A[i]的所有j中,j取得最小值,但是在二分尋找的時候可能會發生兩種特殊情況,B數組的所有元素都不小於A[i],B數組的所有元素都比A[i]小,對於這兩中情況需要專門處理
  • 對於B中所有元素都不小於A[i]的情況,要將A[i]更新到B[0]的位置
  • 對於B中所有元素都小於A[i]的情況,要將更新到B[max]的位置,同時將max值增加1,說明找到了比當前最長的遞增序列更長的結果
  • 對於其他情況,在更新新節點的前驅節點時,要注意,當前元素的前驅節點是B[j-1],而不是pre[B[j]],這點要格外留意,後者看似有道理,但實際上在之前的更新中可能已經被變更過。

效能比較:長度為5000的隨機數組,在我的機器上,改進演算法的速度提升將近200倍,可見演算法改進在程式效能表現中的重要性。不過傳統演算法也並非毫無價值,

首先,傳統演算法可以用來驗證改進演算法的正確性。二分搜尋中的不確定性還是相當讓人頭痛的。其次,如果要求最長非遞減子序列,最長遞減子序列等等,傳統演算法改起來非常的直觀(已經注釋說明),而改進演算法,最起碼我沒有一眼看出來如何一下就能改好。

目前我搜到的網上的有關此改進演算法,在二分搜尋滿足條件的節點時,聊聊幾筆,就完成了功能,但是我按照那種寫法無一例外都遇到了某種類型的序列無法處理的情況,不知是否是我在理解演算法方面出現偏差。

後繼,研究完這個問題之後產生了兩個遺留問題,暫時沒有答案,和大家分享一下

  • 對於一個序列A,最長遞增子序列可能不止一個,傳統演算法找到的是所有遞增子序列中,最大值下標最小(最早出現)的遞增子序列,而改進演算法找到的是最大值最小的遞增子序列,那麼改進演算法所找到的遞增子序列,是不是所有最長遞增子序列中各元素合最小的一個呢,我感覺很可能是,但是還沒想出怎麼證明。
  • 對於元素互不相同的隨機數序列A,他的最長遞增子序列的數學期望是多少呢?

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.