1. 程式人生 > >最長遞增子序列LIS的O(nlogn)的求法

最長遞增子序列LIS的O(nlogn)的求法

最長遞增子序列(Longest Increasing Subsequence)是指n個數的序列的最長單調遞增子序列。比如,A = [1,3,6,7,9,4,10,5,6]的LIS是1 3 6 7 9 10。我們現在希望程式設計求出一個給定的陣列,我們能得到LIS的長度。
關於LIS的求法使用DP演算法的文章也很多,時間複雜度是O(n2),這裡,我們介紹一個只需要不到15行的Python程式碼或者Java程式碼來實現一個複雜度O(nlogn)的演算法。

設tails是一個數組,用於儲存在tails[i]中,所有長度為i+1的遞增子序列的最小的尾元素。
例如,我們有一個nums = [4, 5, 6, 3],那麼所有的遞增子序列是:

# 長度為1
[4], [5], [6], [3]          =>  tails[0] = 3
# 長度為2
[4, 5], [5, 6], [4, 6]      =>  tails[1] = 5
# 長度為3
[4, 5, 6]                   =>  tails[2] = 6

tails的第i個位置記錄nums中長度為i+1的所有遞增子序列中,結尾最小的數字。
我們很容易證明,tails是一個遞增的陣列。首先,tails[0]一定是所有元素中最小的那個數字min1,因為長度為1的子序列中,結尾最小的數字就是序列中最小的那個。同樣,長度為2的子序列中,結尾最小的的那個子序列的結尾元素一定大於min1,因為首先所有長度為2的遞增子序列,第二個元素一定比第一個元素大,如果長度為2的子序列中某個子序列的結尾元素小於min1,那麼在第一次操作中,這個元素就會更新為min1。對於長度為3的子序列,假設之前tails已經儲存了前兩個結尾最小數[a, b],若長度為三的子序列結尾數字c3小於b,即[c1, c2, c3]是一個遞增子序列,且c3 < b,則必然有c2 < b,這樣和之前的結論b是長度為2的遞增子序列結尾最小元素矛盾。所以,通過這樣的一步步的反證法,很容易證明tails一定是一個遞增的陣列。那麼很容易通過二分查詢, 找到在tails陣列中需要被更新的那個數。
每次我們遍歷陣列nums,只需要做以下兩步中的一步:

  1. 如果 x 比所有的tails都大,說明x可以放在最長子序列的末尾形成一個新的自許下,那麼就把他append一下,並且最長子序列長度增加1
  2. 如果tails[i-1] < x <= tails[i],說明x需要替換一下前面那個大於x的數字,以便保證tails是一個遞增的序列,那麼就更新tails[i]
    這樣維護一個tails變數,最後的答案就是這個長度。

Python程式碼如下:

def lengthOfLIS(self, nums):
    tails = [0] * len(nums)
    size = 0
    for x in nums:
        i, j = 0
, size while i != j: m = (i + j) // 2 if tails[m] < x: i = m + 1 else: j = m tails[i] = x size = max(i + 1, size) return size

舉一個具體的例子來說,比如我們的目標陣列是[3, 4, 7, 2, 5]。我們從前往後開始遍歷陣列。tails = [3, 0, 0, 0, 0]
1. x = 3,此時i = 0,直接令tails[0] = 3,tails = [3, 0, 0, 0, 0]。說明到目前為止長度為1的遞增子序列末尾最小為3。
2. x = 4,此時i != j,但是x大於tails的末尾,直接另tail[1] = 4, tails = [3, 4, 0, 0, 0]。說明到目前為止長度為1的遞增子序列末尾最小為3,長度為2的遞增子序列末尾最小為4。
3. x = 7,大於tails的末尾,直接令tails[2] = 7,tails = [3, 4, 7, 0, 0]。說明到目前為止長度為1的遞增子序列末尾最小為3,長度為2的遞增子序列末尾最小為4,長度為3的遞增子序列末尾最小為7.
4. x = 2,此時x小於tails的末尾,需要用二分查詢到比x大的最小的那個數更新之,查詢到tails中比2大的最小數是3,更新tail[0] = 2,此時tails = [2, 4, 7, 0, 0]。說明到目前為止長度為1的遞增子序列末尾最小為2,長度為2的遞增子序列末尾最小為4,長度為3的遞增子序列末尾最小為7。這一步理解很關鍵,[2, 4, 7, 0, 0]的存在並不是說目前為止的遞增子序列是2 4 7,而是長度分別為1,2, 3的遞增子序列目前所能得到的最小結尾元素是2,4,7。我們這樣做的目的就是,通過維護tails中的元素,保證每次對於長度為i+1的一個子序列對應的tails[i]元素最小,這樣新元素的出現並替換前面的一個值,這就是在告訴我們,“雖然在我之前,你們形成了一個長度為m的遞增序列,但是呢,你們長度為m這個序列的末尾最大的一個數比我還大,不如把我和末尾最大的那個元素換一下,這樣你看咱們還是一個遞增序列,長度也不變,但是我和你們更親近”。別的元素一聽是這麼個道理啊,於是就踢出最後一個元素,換上了這個新的更小的元素。
1

在元素2還沒進入的時候,形成的狀態是這樣的,我們從正面看就是我們得到那個tails陣列,其實每個陣列對應一個相應的遞增序列,也就是從左側或者右側看得到的實際的遞增序列。下面元素2進入:

2

因為2比3小,所以能夠形成的長度為1的最小的遞增子序列是2。其餘不變。

3

  1. x = 5, 通過比較,5比7小,比4大。

4

發生替換:

5

通過這個圖我們也能很直觀的看出來,此時的tails陣列變成了[2, 4, 5, 0, 0],而相應的長度為1,2,3的最小遞增陣列分別為[2], [3, 4], [3, 4, 5]。這樣,如果再進入一個6,就直接放在5的後面,遞增陣列長度+1;反之,如果進來的是個1,就替換掉2。通過維護這樣一個tails陣列,我們就能夠很方便的求出遞增子序列的最大長度了。遞增子序列的最大長度也就是當前tails陣列中所能到達的最右側的位置。
而這種方法通過二分查詢,時間效率只有O(nlogn),空間效率最壞情況也是O(n), 只需要維護一個長度為n的tails陣列即可。
如果需要求的是非嚴格單調遞增陣列,只需要把if tails[m] < x:改為if tails[m] <= x:即可。

JAVA

public int lengthOfLIS(int[] nums) {
    int[] tails = new int[nums.length];
    int size = 0;
    for (int x : nums) {
        int i = 0, j = size;
        while (i != j) {
            int m = (i + j) / 2;
            if (tails[m] < x)
                i = m + 1;
            else
                j = m;
        }
        tails[i] = x;
        if (i == size) ++size;
    }
    return size;
}
// Runtime: 2 ms