最长递增子序列 (Longest Increasing Subsequence)

问题描述: 给定一个序列 An a1 ,a2 ,  ... , an ,找出最长的子序列使得对所有 j ,ai aj 

显然,暴力算法的时间复杂度是 O(2n ) ,因为搜索空间呈指数级增长。对于这种问题,如果要找复杂度为多项式时间的算法,自然而然地会想到动态规划。首先,要找出一种方法把该问题分解成只有多项式个子问题。考虑 a n 。如果最长递增子序列 包含 an ,则问题变成要在 An-1 中找最长递增子序列;否则,在 An-1 之中找最长子序列,且该子序列的最大值不能超 an ,然后再加上 an 。按照这个思路,我们可以用 OPT(i,x i ) 来表示Ai 之中最大值不超 的最长递增子序列的长度,则

      OPT(i,x) = max { OPT(i-1,x), a<= x ? OPT(i-1, ai )+1 : OPT(i-1, x) }

而最终的解则是 OPT(n, max{a1 , a2 , ..., an }) 。现在的问题是,到底有多少个子问题?这取决于 x 有多少个取值。观察上述递归式可知, x 只可能取 An 中的值,因此最多有 n 可能的值。所以,子问题的数量为 O(n2 )个。而每个子问题都可以通过 O(1) 的时间获解,从而总的时间复杂度是 O(n2 ) 。实现这个算法的时候,为了能够使用一个二维的数组 S[n][n] 来存储状态,我们可以用数组 B 来保存排过序之后的 序列 b1 , b2 , ..., bn ,从而对所有 i < j ,b< bj 。而 S[i][j] 表示 Ai 中最大值不超过 bj 的最长递增子序列的长度。

[c-sharp]  view plain copy
  1. copy A to B;  
  2. sort B by increasing order;  
  3. // initialize S[1][*]  
  4. for j from 1 to n  
  5.     if A[1] > B[j] then S[1][j] := 0  
  6.     else S[1][j] := 1  
  7. end  
  8. for i from 2 to n  
  9.     for j from 1 to n  
  10.         if A[i] > B[j] then S[i][j] := S[i-1][j]  
  11.         else   
  12.             find the index k of A[i] in B[j]; // k <= j  
  13.             S[i][j] := max( S[i-1][j], S[i-1][k]+1 )  
  14.         end  
  15.     end  
  16. end  
  17. return S[n][n]  

上述的实现中,第12行代码,我们可以使用一个哈希表来实现。我们可以看到,这个实现并不优美,既要一个额外的数组 B 和一次排序,还要动用一个哈希表。可以说,我们对这个算法并不满意。

那么,有没有更好一些的算法呢?注意到排序之后,我们得到新的序列 B,那么,显然,A 中最长的递增子序列也是序列 B 的子序列。因此,我们可以应用求两个序列的最长公共子序列的经典算法来求解,时间复杂也是 O(n2 )。这样,我们就省去了使用哈希表的“不雅之举”。然而,最长公共子序列是一个更一般的算法,它不要求在序列的元素之间有序关系 。那么,我们能不能利用本问题中元素之间的序关系来设计一个优美的算法呢?答案是肯定的。注意中在上面的算法中(姑且称为算法1吧),一共有 O(n2 ) 个子问题。事实上,我们从另外的角度看这个问题,从而获得只有 O(n) 中子问题的算法,只不过,计算每个子问题需要 O(n) 的时间。

首先,假设 An 中最长递增子序列 L 的最后一个元素是 at 。考虑 L 中在 at 之前的元素 ak ,则 ak <= at ,并且 L 由在 Ak 中包含 ak 的最长递增子序列和 a t 组成。因此,用 OPT(i) 来表示 Ai 中包含 ai 的最长递增子序列。因为 L 的最后一个元素必定在 An 中,因此,L 的长度为 OPT(1), OPT(2), ..., OPT(n) 中的最大值。这也证明了此算法的正确性。接下的问题就是如何计算OPT(i)。事实上,

      OPT(i) = max { OPT(j) |  j < i 且 aj < a}

也就是说,通过遍历一次 已经计算好的 OPT(1), OPT(2), ..., OPT(i-1) 就可以计算出 OPT(i),其时间复杂度为 O(i)。总的时间复杂度为 O(1) + O(2) + ... + O(n-1) = O(n2 )。

[cpp]  view plain copy
  1. // Let S[i] be OPT(i)  
  2. S[1] := 1  
  3. L := S[1]  
  4. for i from 2 to n  
  5.     S[i] := 1 // at least contain A[i]  
  6.     for j from 1 to i-1  
  7.         if A[j] < A[i] then S[i] := max( S[i], S[j]+1 )  
  8.     end  
  9.     L := max( L, S[i] )  
  10. end  
  11. return L  

相比之一,这个算法的实现就很干净和优美,且不容易出错。

O(n2 ) 的时间复杂度似乎已经是很不错了。那么,有没有更快的算法?事实上,存在 O(n logk ) 的算法,其中 k为最长递增子序列的长度。为了达到这个时间复杂度,就需要费点脑筋了。考虑 A= a1 , a2 , ..., ai 。记Tail(X) 为递增序列 X 的最后一个元素(尾元素),令 Ri,j 表示 Ai 中所有长度为 j 的递增子序列的集合。在所有属于 R i,j 的序列的尾元素中,必有一个最小值 ,记为用 mi,j 。则

观察一 :   对任何 i,mi,1 <= mi,2 <= ... <= mi,j 。 

因此,如果我们想找以 ai+1 结尾的最长递增子序列,则只要找到 k ,使得 mi,k < ai+1 <= mi,k+1 ,并且该最长递增子序列的长度为 k+1 。对于这个搜索过程,利用上述观察一,可以使用二分法搜索 (binary search)。同时,我们注意到

观察二 :   mi+1,k+1 = ai+1 , 且对于所有 t 不等于 k+1 , mi+1,t = mi,t 

同时,注意到计算 mi+1,* 只需要用到 mi,* ,因此,我们可以用 K[j] 来表示 mi,j 在 Ai 中的下标。当计算在 Ai+1上进行时,我们只需在 K 中找到一个元素 k ,使得 mi,k < ai+1 <= mi,k+1 ,然后更新 K[k+1] ,这时,K[j] 就可以表示 mi+1,j 在 Ai+1 中的下标了。

[cpp]  view plain copy
  1. L := 0  
  2. for i from 1 to n  
  3.     find j in K such that A[K[j]] < A[i] <= A[K[j+1]] by binary search; if no such j, then set j := 0  
  4.     P[i] := K[j]  
  5.     if j == L or A[i] < A[K[j+1]] then  
  6.         K[j+1] := i  
  7.         L := max( L, j+1 )  
  8.     end  
  9. end  
  10. return L  

其中,P[k] 记录了以 ak 结尾的最长递增子序列的前一个元素在 An 中的下标。通过数组 P,我们就可以求出最长递增子序列,为

        ..., A[P[P[L]]], A[P[L]], A[L]。从上述伪代码可以看出,总的时间复杂度为 O(n logL )。

 

注:这是一道经典的题目。本人解这道题时,想到了第一种算法,在脑子里写出了大概的递归方程。然而,当真正在纸上写下来并建立一个二维的状态表时,才意识到问题没那简单。一开始,我的递归方程是 OPT(i,x) = max { OPT(i-1,x),  OPT(i-1, min(x, ai ))+1  。这是不对的,但似乎不太容易发现,直到建立状态时,我才发现。而且,这个算法对于有重复元素的序列也存在问题,需要进一步修正。而第二个算法则是比较普遍的动态规划解,该算法优美,且对任何序列都不会有问题。最后一个算法则是参考了Algorithmist 上的一篇文章 。同时,该文章也给出 C/C++ 实现。以前解类似的动态规划问题时,总只是在脑子里把递归方程过一遍就觉得OK了,看来,以后要尽量把方程精确写出来并画画状态表。同时,也可以进一步思考更优美的动太规划算法,例如第二个算法。前两个算法都是典型的动态规划思路,但显然,第二个算法的状态要少,也更好。自勉之。

你可能感兴趣的:(最长递增子序列 (Longest Increasing Subsequence))