# Longest Increasing Subsequence 最長遞增子序列 在此文章中,了方便說明,會將 Longest Increasing Subsequence 簡稱為 LIS ## 說明 最長遞增子序列就是在一個數列中,找出最長的子序列,且子序列的數字依序遞增,且不一定要在原先的數列中連續。 ## 舉例 假設現在有一數列: **1 , 2 , 5 , 3 , 4 , 10 , 9** 那這個數列的 LIS 就會是: **1 , 2 , 3 , 4 , 10** **1 , 2 , 3 , 4 , 9** 沒錯,LIS 可以有很多個 因此大部分的問題都主要是求 LIS 的長度 ## 解法 目前 LIS 長度有兩種方法可以求得,首先來講比較好理解的,動態規劃解法 ### 動態規劃 動態規劃最重要的部分就是定義和寫出轉移式,所以先來定義轉移式 首先先定義一個一維陣列 $dp[i]$ 代表以第 $i$ 個位置結尾的 LIS 長度,$num[i]$ 表示原數列的第 $i$ 個位置 而 $dp[i]$ 會從前面的 $dp[j]$ $(j<i)$ 中取最大的 LIS 長度來轉移,而如果此時 $num[j]<num[i]$,代表可以將 $num[i]$ 這個位置加入 LIS 當中(LIS+1),否則 $dp[i]$ 就保持原本的 LIS 長度,因此可以推出轉移式: **$dp[i]=max(dp[i],dp[j]+1)$** 別忘了要把 $dp$ 每一個值都初始化為 $1$,因為自己也算是 LIS 的一個數,否則轉移式無法正確運行 ### 範例題目 給一個長度為 $n$ $(n<=10^3)$ 的數列 $num$,求 LIS 的長度? 程式碼: ```cpp= #include<bits/stdc++.h> using namespace std; const int N=1000; int num[N],dp[N]; int main(){ int n,ans=1; cin>>n; for(int i=0;i<n;i++) cin>>num[i]; fill(dp,dp+n,1);//初始化為1 for(int i=1;i<n;i++){ for(int j=0;j<i;j++){ if(num[j]<=num[i]){ dp[i]=max(dp[i],dp[j]+1);//轉移式 ans=max(ans,dp[i]);//用一個變數存取最大的LIS } } } cout<<ans<<'\n'; } ``` ### 探討 使用動態規劃的解法會需要開到兩層迴圈,這樣複雜度是 $O(n^2)$,難道這就是極限了嗎? 不,還有一種更快的方法可以把複雜度壓縮至 $O(nlogn)$,就讓我們繼續看下去。 ### 使用二分搜的解法 這個解法的思路在於,要維護一個 LIS 的數組 $LIS$,每當遇到一個數字 $i$,就去與 $LIS$ 的最後數字相比,如果比較大就將 $i$ 插入到 $LIS$ 最後;如果 $i$ 沒有比最後的數還大,那就使用二分搜找尋 $LIS$ 中大於 $i$ 的最小數字,並將那個數替代為 $i$。 這邊拿維基百科的 GIF 來把剛剛的解法演示一下: ![](https://hackmd.io/_uploads/rJVse4Yyp.gif) 這樣一來,只會將每個數遍歷一次,並且每次最差只會二分搜一次,因此時間複雜度僅為 $O(nlogn)$ ### 實作程式碼 程式碼: ```cpp= int main(){//LIS ios::sync_with_stdio(0),cin.tie(0); int n; cin>>n; vector<int> num(n),LIS; for(auto &i:num) cin>>i; for(const auto &i:num){ if(LIS.empty()||LIS.back()<=i) LIS.push_back(i);//將i插入最後 else *lower_bound(LIS.begin(),LIS.end(),i)=i;//用二分搜尋找並替代 } cout<<LIS.size(); } ``` **注意!這邊維護的數組 $LIS$ 裡面的數字並不是實際的 LIS,因此無法直接使用 $LIS$ 來當作其中一組 LIS 來使用!** ### 相關的練習題 其實 LIS 的解法都不複雜,因此每個題目都是難在你看不出那是 LIS 的題型 [CSES Towers](https://cses.fi/problemset/task/1073) ||LIS裸題,不知道為什麼是LIS的可以參考這個 [LIS GitHub](https://github.com/labuladong/fucking-algorithm/blob/master/%E5%8A%A8%E6%80%81%E8%A7%84%E5%88%92%E7%B3%BB%E5%88%97/%E5%8A%A8%E6%80%81%E8%A7%84%E5%88%92%E8%AE%BE%E8%AE%A1%EF%BC%9A%E6%9C%80%E9%95%BF%E9%80%92%E5%A2%9E%E5%AD%90%E5%BA%8F%E5%88%97.md)|| [ZJ d052. 11456 - Trainsorting](https://zerojudge.tw/ShowProblem?problemid=d052) ||LIS 變形題|| [ZJ f608. 4. 飛黃騰達](https://zerojudge.tw/ShowProblem?problemid=f608) ||LIS 變形題,因為題目不是嚴格遞增,相等也可以,所以要換成使用 upper_bound()|| ### 延伸 如果題目要求的不只有 LIS 的長度,還要求一組的 LIS 怎麼辦,剛剛的兩個解法都只能用來求 LIS 的長度,無法用來求實際的一組 LIS,比如像這題: [ZJ d242. 00481 - What Goes Up](https://zerojudge.tw/ShowProblem?problemid=d242) 其實不難,可以使用一個陣列 $dp$ 用來儲存每次數字最後到達 $LIS$ 的位置,並在變歷完整個數列後,倒著將 $dp$ 逆推回去,即可得出一組最後出現的 LIS 程式碼: ```cpp= //d242. 00481 - What Goes Up #include<bits/stdc++.h> using namespace std; #define all(x) x.begin(),x.end() const int N=5e5+5; int dp[N]={}; int main(){ ios::sync_with_stdio(0),cin.tie(0); int n; vector<int> num,lis,ans; while(cin>>n){ num.push_back(n); } for(int i=0;i<num.size();i++){ if(lis.empty()||lis.back()<num[i]){ lis.push_back(num[i]); dp[i]=lis.size(); } else{ auto iter=lower_bound(all(lis),num[i]); dp[i]=iter-lis.begin()+1; *iter=num[i]; } } cout<<lis.size()<<"\n-\n"; int length=lis.size(); for(int i=num.size()-1;i>=0;i--){ if(dp[i]==length){ ans.push_back(num[i]); length--; } } for(int i=ans.size()-1;i>=0;i--) cout<<ans[i]<<'\n'; } ``` ## 應用 在 Bazaar(一個版本控制系統)上,使用著 Patience Diff 的比較演算法,在其中用來求得 LCS(最長公共子序列)的 Patience sorting 就是 LIS Patience sorting 的過程: 建立一個堆陣列,依序取出數字,並加入比該數字小的堆中,如果沒有符合的堆,則新增一個堆並加入該數。等數字全部取完後,將各個堆串接就可以得出排序完的數列。 ### 舉例(使用 wiki 的例子) 有一個欲排序的數列 3, 5, 7, 1, 4 1. 取出數字 3, 由於目前沒有任何堆所以產生一號堆並把 3 放入 堆 一 內容: 3 2. 取出數字 5, 5 比一號堆的第一個數字 3 大, 故產生二號堆並把 5 放入 堆 一 內容: 3 堆 二 內容: 5 3. 取出數字 7, 7 比一號堆和二號堆的第一個數字大, 故產生三號堆並把 7 放入 堆 一 內容: 3 堆 二 內容: 5 堆 三 內容: 7 4. 取出數字 1, 所有堆的第一個數字都比 1 大, 故放入一號堆 堆 一 內容: 3, 1 堆 二 內容: 5 堆 三 內容: 7 5. 取出數字 4, 只有一號堆的第一個數字比 4 小, 所以將 4 放入二號堆 堆 一 內容: 3, 1 堆 二 內容: 5, 4 堆 三 內容: 7 這個過程與前面提的題目[CSES Towers](https://cses.fi/problemset/task/1073) 解題過程幾乎一模一樣,LIS 就是堆的數量,像剛剛的例子: 3, 5, 7, 1, 4 這個數列的 LIS 就是 3,因為他最後使用了三個堆 程式碼範例: ```python= from bisect import bisect def LCS(a, b): #這個演算法會避免受到重覆文本的干擾,所以會去除重複的文本 #找字串 a 的單詞在文本中出現的位置 index = {} for i in range(len(a)): line = a[i] if line in index: index[line] = None else: index[line]= i #找到字串 a 和 b 都有出現的單詞 btoa = [None] * len(b) index2 = {} for pos, line in enumerate(b): next = index.get(line) if next is not None: if line in index2: btoa[index2[line]] = None del index[line] else: index2[line] = pos btoa[pos] = next #耐心排序的部分(LIS) backpointers = [None] * len(b) stacks = [] lasts = [] k = 0 for bpos, apos in enumerate(btoa): if apos is None: continue if stacks and stacks[-1] < apos: k = len(stacks) elif stacks and stacks[k] < apos and (k == len(stacks) - 1 or stacks[k+1] > apos): k += 1 else: k = bisect(stacks, apos) if k > 0: backpointers[bpos] = lasts[k-1] if k < len(stacks): stacks[k] = apos lasts[k] = bpos else: stacks.append(apos) lasts.append(bpos) if len(lasts) == 0: return [] result = [] k = lasts[-1] while k is not None: result.append((btoa[k], k)) k = backpointers[k] result.reverse() return result text1 = "Hello Hello , world ! This is an example text for testing." text2 = "Hello ! This is a world example text for testing." result = LCS(text1.split(), text2.split()) print(result) for i in result: print(text1.split()[i[0]],end=" ") ```