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

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

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

代碼如下
  1. unsigned int LISS(const int array[], size_t length, int result[])  
  2. {  
  3.     unsigned int i, j, k, max;  
  4.   
  5.     //變長數組參數,C99新特性,用於記錄當前各元素作爲最大元素的最長遞增序列長度  
  6.     unsigned int liss[length];  
  7.   
  8.     //前驅元素數組,記錄當前以該元素作爲最大元素的遞增序列中該元素的前驅節點,用於打印序列用  
  9.     unsigned int pre[length];  
  10.   
  11.     for(i = 0; i < length; ++i)  
  12.     {  
  13.         liss[i] = 1;  
  14.         pre[i] = i;  
  15.     }  
  16.   
  17.     for(i = 1, max = 1, k = 0; i < length; ++i)  
  18.     {  
  19.         //找到以array[i]爲最末元素的最長遞增子序列  
  20.         for(j = 0; j < i; ++j)  
  21.         {  
  22.             //如果要求非遞減子序列只需將array[j] < array[i]改成<=,  
  23.             //如果要求遞減子序列只需改爲>  
  24.             if(array[j] < array[i] && liss[j] + 1> liss[i])  
  25.             {  
  26.                 liss[i] = liss[j] + 1;  
  27.                 pre[i] = j;  
  28.   
  29.                 //得到當前最長遞增子序列的長度,以及該子序列的最末元素的位置  
  30.                 if(max < liss[i])  
  31.                 {  
  32.                     max = liss[i];  
  33.                     k = i;  
  34.                 }  
  35.             }  
  36.         }  
  37.     }  
  38.   
  39.     //輸出序列  
  40.     i = max - 1;  
  41.   
  42.     while(pre[k] != k)  
  43.     {  
  44.         result[i--] = array[k];  
  45.         k = pre[k];  
  46.     }  
  47.   
  48.     result[i] = array[k];  
  49.   
  50.     return max;  
  51. }  
該函數計算出長度爲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),可見改進算法還是很陰霸的。

下面是該算法的實現:
  1. unsigned int LISSEx(const int array[], size_t length, int result[])  
  2. {  
  3.     unsigned int i, j, k, l, max;  
  4.   
  5.     //棧數組參數,C99新特性,這裏的liss數組與上一個函數意義不同,liss[i]記錄長度爲i + 1  
  6.     //遞增子序列中最大值最小的子序列的最後一個元素(最大元素)在array中的位置  
  7.     unsigned int liss[length];  
  8.   
  9.     //前驅元素數組,用於打印序列  
  10.     unsigned int pre[length];  
  11.   
  12.     liss[0] = 0;  
  13.   
  14.     for(i = 0; i < length; ++i)  
  15.     {  
  16.         pre[i] = i;  
  17.     }  
  18.   
  19.     for(i = 1, max = 1; i < length; ++i)  
  20.     {  
  21.         //找到這樣的j使得在滿足array[liss[j]] > array[i]條件的所有j中,j最小  
  22.         j = 0, k = max - 1;  
  23.   
  24.         while(k - j > 1)  
  25.         {  
  26.             l = (j + k) / 2;  
  27.   
  28.             if(array[liss[l]] < array[i])  
  29.             {  
  30.                 j = l;  
  31.             }  
  32.             else  
  33.             {  
  34.                 k = l;  
  35.             }  
  36.         }  
  37.   
  38.         if(array[liss[j]] < array[i])  
  39.         {  
  40.             j = k;  
  41.         }  
  42.   
  43.         //array[liss[0]]的值也比array[i]大的情況  
  44.         if(j == 0)  
  45.         {  
  46.             //此處必須加等號,防止array中存在多個相等的最小值時,將最小值填充到liss[1]位置  
  47.             if(array[liss[0]] >= array[i])  
  48.             {  
  49.                 liss[0] = i;  
  50.                 continue;  
  51.             }  
  52.         }  
  53.   
  54.                 //array[liss[max -1]]的值比array[i]小的情況  
  55.                 if(j == max - 1)  
  56.         {  
  57.             if(array[liss[j]] < array[i])  
  58.             {  
  59.                 pre[i] = liss[j];  
  60.                 liss[max++] = i;  
  61.                 continue;  
  62.             }  
  63.         }  
  64.   
  65.         pre[i] = liss[j - 1];  
  66.         liss[j] = i;  
  67.     }  
  68.   
  69.     //輸出遞增子序列  
  70.     i = max - 1;  
  71.     k = liss[max - 1];  
  72.   
  73.     while(pre[k] != k)  
  74.     {  
  75.         result[i--] = array[k];  
  76.         k = pre[k];  
  77.     }  
  78.   
  79.     result[i] = array[k];  
  80.   
  81.     return max;  
  82. }  
這個算法的思想可以算得上巧妙,在時間複雜度上提升明顯,但是同時在實現時也比通俗算法多了好些坑,這裏說明一下:
  • 算法中爲了獲得實際的序列,數組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,他的最長遞增子序列的數學期望是多少呢?
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章