1. 程式人生 > >最長遞增子序列詳解(longest increasing subsequence)(by joylnwang)

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

看了幾個講最長遞增子序列的部落格,都有點迷,不過看了這個之後瞬間就懂了,轉來分享

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

對於動態規劃問題,往往存在遞推解決方法,這個問題也不例外。要求長度為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;
}

該函式計算出長度為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,他的最長遞增子序列的數學期望是多少呢?