剛入學時學的算法,已經忘的差不多了,回顧一下。
對於普通的最長不下降子序列,每個數都要從頭開始遍歷,復雜度 $O(n^2)$,只能處理 $10^4$ 以內的數據。
剛剛學弟問我,就寫了一下普通版的,順便貼一下,這是 $openjudge$ 上的最長上升序列。
廢話不多說,$nlogn$ 的算法如何實現?
利用序列的單調性。
對於任意一個單調序列,如 $1\ 2\ 3\ 4\ 5$(是單增的),若這時向序列尾部增添一個數 $x$,我們只會在意 $x$ 和 $5$ 的大小,若 $x>5$,增添成功,反之則失敗。由於普通代碼是從頭開始比較,而 $x$ 和 $1,2,3,4$ 的大小比較是沒有用處的,這種操作只會造成時間的浪費,所以效率極低。對於單調序列,只需要記錄每個序列的最后一個數,每增添一個數 $x$,直接比較 $x$ 和末尾數的大小。只有最后一個數才是有用的,它表示該序列的最大限度值。
實現方法就是新開一個數組 $d$,用它來記錄每個序列的末尾元素,以求最長不下降為例,$d[k]$ 表示長度為k的不下降子序列的最小末尾元素。
我們用 $len$ 表示當前湊出的最長序列長度,也就是當前 $d$ 中的最后那個位置。
這樣就很 $easy$ 了,每讀入一個數 $x$,如果 $x$ 大於等於 $d[len]$,直接讓 $d[len+1]=x$,然后 $len++$,相當於把 $x$ 接到了最長的序列后面;
如果 $x$ 小於 $d[len]$,說明 $x$ 不能接到最長的序列后面,那就找 $d[1...len-1]$ 中末尾數小於等於 $x$ 的的序列,然后把 $x$ 接到它后面。舉個例子,若當前 $x==7,len==8$:
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
2 |
3 |
4 |
7 |
7 |
10 |
12 |
29 |
$d[1]\cdots d[5]$ 均小於等於 $x$,若在 $d[1]$ 后接 $x$,則 $d[2]$ 應換成 $x$,但 $d[2]==3$,比 $x$ 小,能接更多的數,用 $7$ 去換 $3$ 顯然是不划算的,所以 $x$ 不能接 $d[1]$ 后。同理,$d[2]\cdots d[4]$ 均不能接 $x$。由於 $d[5]\le x$ 且 $x<d[6]$,$7$ 能比 $10$ 接更多的數,所以選擇在 $d[5]$ 后接 $x$,用 $x$ 替換 $10$。
根據這個操作過程,易知數組 $d$ 一定是單調的序列,所以查找的時候可以用二分!二分效率是 $logn$ 的,所以整個算法的效率就是 $nlogn$ 的啦~
對於最長不下降,可以用 $stl$ 中的 $upperbound()$ 函數,比如上述操作可以寫為:
1 for (int i=2;i<=n;i++) 2 { 3 if (a[i]>=d[len]) d[++len]=a[i]; //如果可以接在len后面就接上 4 else //否則就找一個最該替換的替換掉 5 { 6 int j=upper_bound(d+1,d+len+1,a[i])-d;//找到第一個大於它的d的下標 7 d[j]=a[i]; 8 } 9 }
但是,對於其他的單調序列,比如最長不上升等等,需要根據情況來手寫二分。
注意 $upperbound$ 是找單增序列中第一個大於 $x$ 的,$lowerbound$ 是找單增序列中第一個大於等於 $x$ 的,只要不是這兩個,都需要手寫二分。
代碼:

1 //最長不下降子序列nlogn Song 2 3 #include<cstdio> 4 #include<algorithm> 5 using namespace std; 6 7 int a[40005]; 8 int d[40005]; 9 10 int main() 11 { 12 int n; 13 scanf("%d",&n); 14 for (int i=1;i<=n;i++) scanf("%d",&a[i]); 15 if (n==0) //0個元素特判一下 16 { 17 printf("0\n"); 18 return 0; 19 } 20 d[1]=a[1]; //初始化 21 int len=1; 22 for (int i=2;i<=n;i++) 23 { 24 if (a[i]>=d[len]) d[++len]=a[i]; //如果可以接在len后面就接上 25 else //否則就找一個最該替換的替換掉 26 { 27 int j=upper_bound(d+1,d+len+1,a[i])-d; //找到第一個大於它的d的下標 28 d[j]=a[i]; 29 } 30 } 31 printf("%d\n",len); 32 return 0; 33 }
關於最長序列的序列輸出,網上有講解說 $nlogn$ 算法的缺點是不能輸出序列,當時剛上高中的我很不服氣,於是花了一天自己搞出來了 $nlogn$ 的序列輸出代碼。
當時寫的:
“想了好久,認為 $nlogn$ 做法也是可以輸出序列的,這時候需要增加一個 $c$ 數組 用來記錄每個元素在最長序列中的位置,即 c[i] 表示 a[i] 被放到了序列的第幾個位置。
輸出時,從 數組 a 的尾部開始,逆序依次找出 c 為 len, len-1, len-2 … 3, 2, 1 的元素,並且找到一個就接着尋找 c[i]-1,直到找到 c[i] 為 1 的數。
舉個例子:
a: | 13 | 7 | 9 | 16 | 38 | 24 | 37 | 18 | 44 | 19 | 21 | 22 | 63 | 15 |
c: | 1 | 1 | 2 | 3 | 4 | 4 | 5 | 4 | 6 | 5 | 6 | 7 | 8 | 3 |
len = 8;
我們從 15 開始倒着找 c 為 8 的元素,找到 63,接着找 c 為 7 的,找到 22,再找 c 為 6 的,找到 21,再找 c 為 5 …… 以此類推。
從而,我們得出的序列為 63,22,21,19,18,16,9,7
逆序輸出來,就是 7,9,16,18,19,21,22,63
為什么這個方法是對的呢?倒序查找保證了兩個條件:
- 如果 c 中有多個相同的數,后面的一定是最新更新的;
- 在保證一條件的前提下,倒序找,后面的數一定可以接到前面數的后面。”
代碼:

1 //From - Milky Way 2 3 #include <cstdio> 4 #include <algorithm> 5 #include <stack> 6 using namespace std; 7 8 int d[100], c[100], a[100], len = 1; 9 10 int main() { 11 int n; scanf("%d", &n); 12 for (int i = 1; i <= n; ++ i) { 13 scanf("%d", &a[i]); 14 } 15 16 d[1] = a[1], c[1] = 1; 17 for (int i = 2; i <= n; ++ i) { 18 if (d[len] <= a[i]) { 19 d[++ len] = a[i], c[i] = len; 20 } else { 21 int j = upper_bound(d + 1, d + len + 1, a[i]) - d; 22 d[j] = a[i], c[i] = j; 23 } 24 } 25 26 stack<int> sta; 27 for (int i = n, j = len; i >= 1; -- i) { 28 if (c[i] == j) { 29 sta.push(a[i]); --j; 30 } 31 if (j == 0) break; 32 } 33 34 printf("%d\n", len); 35 while (!sta.empty()) { 36 printf("%d ", sta.top()); 37 sta.pop(); 38 } 39 40 return 0; 41 }