本章講解:
1. LCS(最長公共子序列)O(n^2)
的時間複雜度,O(n^2)
的空間複雜度;
2. 與之類似但不同的最長公共子串方法。
最長公共子串用動態規劃可實現O(n^2)
的時間複雜度,O(n^2)
的空間複雜度;還可以進一步優化,用後綴數組的方法優化成線性時間O(nlogn)
;空間也可以用其他方法優化成線性。
3.LIS(最長遞增序列)DP方法可實現O(n^2)
的時間複雜度,進一步優化最佳可達到O(nlogn)
一些定義:
字符串 X
, Y
長度 分別m
,n
子串:字符串S的子串r[i,...,j],i<=j
,表示r
串從i到j這一段,也就是順次排列r[i],r[i+1],...,r[j]
形成的字符串
前綴:Xi =﹤x1,⋯,xi﹥
即 X
序列的前 i
個字符 (1≤i≤m)
;Yj=﹤y1,⋯,yj﹥
即 Y
序列的前 j
個字符 (1≤j≤n)
;
假定 Z=﹤z1,⋯,zk﹥∈LCS(X , Y)
LCS
問題描述
定義:
一個數列 S,如果分別是兩個或多個已知數列的子序列,且是所有符合此條件序列中最長的,則 S 稱爲已知序列的最長公共子序列。
例如:輸入兩個字符串 BDCABA 和 ABCBDAB,字符串 BCBA 和 BDAB 都是是它們的最長公共子序列,則輸出它們的長度 4,並打印任意一個子序列. (Note: 不要求連續)
判斷字符串相似度的方法之一 - LCS 最長公共子序列越長,越相似。
複雜度
對於一般性的 LCS 問題(即任意數量的序列)是屬於 NP-hard。但當序列的數量確定時,問題可以使用動態規劃(Dynamic Programming)在多項式時間解決。可達時間複雜度:O(m*n)
July 10分鐘講LCS視頻,
暴力方法
動態規劃方法
最優子結構性質:
設序列 X=<x1, x2, …, xm>
和 Y=<y1, y2, …, yn>
的一個最長公共子序列 Z=<z1, z2, …, zk>
,則:
- 若
xm = yn
,則zk = xm = yn
則Zk-1
是Xm-1
和Yn-1
的最長公共子序列;
xm ≠ yn
, 要麼Z
是 Xm-1
和 Y
的最長公共子序列,要麼 Z
是X
和 Yn-1
的最長公共子序列。2.1 若
xm ≠ yn
且 zk≠xm
,則 Z
是 Xm-1
和 Y
的最長公共子序列;2.2 若
xm ≠ yn 且 zk ≠yn
,則 Z
是X
和 Yn-1
的最長公共子序列。綜合一下2 就是求二者的大者
遞歸結構:
遞歸結構容易看到最長公共子序列問題具有子問題重疊性質。例如,在計算 X
和 Y
的最長公共子序列時,可能要計算出 X
和 Yn-1
及 Xm-1
和 Y
的最長公共子序列。而這兩個子問題都包含一個公共子問題,即計算 Xm-1
和 Yn-1
的最長公共子序列。
遞歸結構容易看到最長公共子序列問題具有子問題重疊性質。例如,在計算 X
和 Y
的最長公共子序列時,可能要計算出 X
和 Yn-1
及 Xm-1
和 Y
的最長公共子序列。而這兩個子問題都包含一個公共子問題,即計算Xm-1
和 Yn-1
的最長公共子序列。
計算最優值:
子問題空間中,總共只有O(m*n)
個不同的子問題,因此,用動態規劃算法自底向上地計算最優值能提高算法的效率。
長度表C 和 方向變量B:
java實現:
/* 動態規劃
* 求最長公共子序列
* @ author by gsm
* @ 2015.4.1
*/
import java.util.Random;
public class LCS {
public static int[][] lengthofLCS(char[] X, char[] Y){
/* 構造二維數組c[][]記錄X[i]和Y[j]的LCS長度 (i,j)是前綴
* c[i][j]=0; 當 i = j = 0;
* c[i][j]=c[i-1][j-1]+1; 當 i = j > 0; Xi == Y[i]
* c[i][j]=max(c[i-1][j],c[i][j+1]); 當 i = j > 0; Xi != Y[i]
* 需要計算 m*n 個子問題的長度 即 任意c[i][j]的長度
* -- 填表過程
*/
int[][]c = new int[X.length+1][Y.length+1];
// 動態規劃計算所有子問題
for(int i=1;i<=X.length;i++){
for (int j=1;j<=Y.length;j++){
if(X[i-1]==Y[j-1]){
c[i][j] = c[i-1][j-1]+1;
}
else if(c[i-1][j] >= c[i][j-1]){
c[i][j] = c[i-1][j];
}
else{
c[i][j] = c[i][j-1];
}
}
}
// 打印C數組
for(int i=0;i<=X.length;i++){
for (int j=0;j<=Y.length;j++){
System.out.print(c[i][j]+" ");
}
System.out.println();
}
return c;
}
// 輸出LCS序列
public static void print(int[][] arr, char[] X, char[] Y, int i, int j) {
if(i == 0 || j == 0)
return;
if(X[i-1] == Y[j-1]) {
System.out.print("element " + X[i-1] + " ");
// 尋找的
print(arr, X, Y, i-1, j-1);
}else if(arr[i-1][j] >= arr[i][j-1]) {
print(arr, X, Y, i-1, j);
}else{
print(arr, X, Y, i, j-1);
}
}
public static void main(String[] args) {
// TODO Auto-generated method stub
char[] x ={'A','B','C','B','D','A','B'};
char[] y ={'B','D','C','A','B','A'};
int[][] c = lengthofLCS(x,y);
print(c, x, y, x.length, y.length);
}
}
最長公共子串
一個問題
定義 2 個字符串 query 和 text, 如果 query 裏最大連續字符子串在 text 中存在,則返回子串長度. 例如: query="acbac",text="acaccbabb", 則最大連續子串爲 "cba", 則返回長度 3.
方法
時間複雜度:O(m*n)
的DP
這個 LCS 跟前面說的最長公共子序列的 LCS 不一樣,不過也算是 LCS 的一個變體,在 LCS 中,子序列是不必要求連續的,而子串則是 “連續” 的
我們還是像之前一樣 “從後向前” 考慮是否能分解這個問題,類似最長公共子序列的分析,這裏,我們使用c[i,j]
表示 以 Xi
和 Yj
結尾的最長公共子串的長度,因爲要求子串連續,所以對於 Xi
與 Yj
來講,它們要麼與之前的公共子串構成新的公共子串;要麼就是不構成公共子串。故狀態轉移方程
X[i-1] == Y[j-1],c[i,j] = c[i-1,j-1] + 1;
X[i-1] != Y[j-1],c[i,j] = 0;
對於初始化,i == 0 或者 j == 0,c[i,j] = 0
代碼:
public class LCString {
public static int lengthofLCString(String X, String Y){
/* 構造二維數組c[][]記錄X[i]和Y[j]的LCS長度 (i,j)是前綴
* c[i][j]=0; 當 i = j = 0;
* c[i][j]=c[i-1][j-1]+1; 當 i = j > 0; Xi == Y[i]
* c[i][j]=0; 當 i = j > 0; Xi != Y[i]
* 需要計算 m*n 個子問題的長度 即 任意c[i][j]的長度
* -- 填表過程
*/
int[][]c = new int[X.length()+1][Y.length()+1];
int maxlen = 0;
int maxindex = 0;
for(int i =1;i<=X.length();i++){
for(int j=1;j<=Y.length();j++){
if(X.charAt(i-1) == Y.charAt(j-1)){
c[i][j] = c[i-1][j-1]+1;
if(c[i][j] > maxlen)
{
maxlen = c[i][j];
maxindex = i + 1 - maxlen;
}
}
}
}
return maxlen;
}
public static void main(String[] args) {
String X = "acbac";
String Y = "acaccbabb";
System.out.println(lengthofLCString(X,Y));
}
}
時間複雜度O(nlogn)
的後綴數組的方法
有關後綴數組以及求最長重複子串
前面提過後綴數組的基本定義,與子串有關,可以嘗試這方面思路。由於後綴數組最典型的是尋找一個字符串的重複子串,所以,對於兩個字符串,我們可以將其連接到一起,如果某一個子串 s 是它們的公共子串,則 s 一定會在連接後字符串後綴數組中出現兩次,這樣就將最長公共子串轉成最長重複子串的問題了,這裏的後綴數組我們使用基本的實現方式。
值得一提的是,在找到兩個重複子串時,不一定就是 X 與 Y 的公共子串,也可能是 X 或 Y 的自身重複子串,故在連接時候我們在 X 後面插入一個特殊字符‘#’,即連接後爲 X#Y。這樣一來,只有找到的兩個重複子串恰好有一個在 #的前面,這兩個重複子串纔是 X 與 Y 的公共子串
各方案複雜度對比
設字符串 X 的長度爲 m,Y 的長度爲 n,最長公共子串長度爲 l。
對於基本算法(brute force),X 的子串(m 個)和 Y 的子串(n 個)一一對比,最壞情況下,複雜度爲 O(m*n*l),空間複雜度爲 O(1)。
對於 DP 算法,由於自底向上構建最優子問題的解,時間複雜度爲 O(m*n);空間複雜度爲 O(m*n),當然這裏是可以使用滾動數組來優化空間的,滾動數組在動態規劃基礎回顧中多次提到。
對於後綴數組方法,連接到一起並初始化後綴數組的時間複雜度爲 O(m+n),對後綴數組的字符串排序,由於後綴數組有 m+n 個後綴子串,子串間比較,故複雜度爲 O((m+n)*l*lg(m+n)),求得最長子串遍歷後綴數組,複雜度爲 O(m+n),所以總的時間複雜度爲 O((m+n)*l*lg(m+n)),空間複雜度爲 O(m+n)。
總的來說使用後綴數組對數據做一些 “預處理”,在效率上還是能提升不少的。
LIS 最長遞增子序列
問題描述:找出一個n個數的序列的最長單調遞增子序列: 比如A = {5,6,7,1,2,8}
的LIS是5,6,7,8
1. O(n^2)
的複雜度:
1.1 最優子結構:LIS[i]
是以arr[i]
爲末尾的LIS序列的長度。則:LIS[i] = {1+Max(LIS(j))}
; j<i, arr[j]<arr[i]
;LIS[i] = 1, j<i
, 但是不存在arr[j]<arr[i]
;
所以問題轉化爲計算Max(LIS(j))
0<i<n
1.2 重疊的子問題:
以arr[i] (1<= i <= n)
每個元素結尾的LIS序列的值是 重疊的子問題。
所以填表時候就是建立一個數組DP[i]
, 記錄以arr[i]
爲序列末尾的LIS長度。
1.3 DP[i]怎麼計算?
遍歷所有j<i
的元素,檢查是否DP[j]+1>DP[i] && arr[j]<arry[i]
若是,則可以更新DP[i]
int maxLength = 1, bestEnd = 0;
DP[0] = 1;
prev[0] = -1;
for (int i = 1; i < N; i++)
{
DP[i] = 1;
prev[i] = -1;
for (int j = i - 1; j >= 0; j--)
if (DP[j] + 1 > DP[i] && array[j] < array[i])
{
DP[i] = DP[j] + 1;
prev[i] = j;
}
if (DP[i] > maxLength)
{
bestEnd = i;
maxLength = DP[i];
}
2. O(nlog)
的複雜度
基本思想:
首先通過一個數組MaxV[nMaxLength]
來緩存遞增子序列LIS的末尾元素
最小值;通過nMaxLength
記錄到當前遍歷爲止的最長子序列的長度;
然後我們從第2元素開始,遍歷給定的數組arr
,
1. arr[i] > MaxV[nMaxLength]
, 將arr[i]
插入到MaxV[++nMaxLength]
的末尾 -- 意味着我們找到了一個新的最大LIS
2. arr[i] <= MaxV[nMaxLength]
, 找到MaxV[]
中剛剛大於arr[i]
的元素,arr[j]
.arr[i]替換arr[j]
因爲MaxV是一個有序數組,查找過程可以使用log(N)
的折半查找。
這樣運行時間: n
個整數和每個都需要折半查找 -- n*logn = O(nlogn)
-
if >
說明j
能夠放在最長子序列的末尾形成一個新的最長子序列. -
if<
說明j
需要替換
前面一個剛剛大
與array[j]
的元素
最後,輸出LIS時候,我們會用一個LIS[]
數組,這邊LIS[i]
記錄的是以元素arr[i]
爲結尾的最長序列的長度
初始化準備工作:
MaxV[1]
首先會被設置成序列第一個元素 即 MaxV[1] = arr[0]
,在遍歷數組的過程中會不斷的更新。nMaxLength = 1
舉個栗子:arr = {2 1 5 3 6 4 8 9 7}
首先
i=1
, 遍歷到1, 1 通過跟MaxV[nMaxLength]比較:1<MaxV[nMaxLength]
,
發現1更有潛力
(更小的有潛力,更小的替換之)
1 更有潛力, 那麼1
就替換MaxV[nMaxLength]
即MaxV[nMaxLength] =1
;
這個時候MaxV={1}, nMaxlength = 1,LIS[1] = 1
;然後
i =2
, 遍歷到5, 5通過跟MaxV[nMaxLength]
比較,5>MaxV[nMaxLength]
,
發現5更大
; 鏈接到目前得到的LIS尾部;
這個時候MaxV={1,5}
,nMaxlength++ = 2
,MaxV[nMaxLength]=5
,LIS[i] = 1+1 = 2
;然後
i =3
,遍歷到3, 3 通過跟MaxV[nMaxLength]比較,3<MaxV[nMaxLength]
,
發現3更有潛力
,然後從nMaxLength
往前比較,找到第一個剛剛比3大元素替換之。(稍後解釋什麼叫剛剛大)
這個時候MaxV={1,3}, nMaxlength = 2
; 3只是替換,LIS[i]不變 = LIS[3]= 2
;然後
i =4
,遍歷到6, 6 通過跟MaxV[nMaxLength]
比較,6>MaxV[nMaxLength]
,
發現6更大; 6就應該鏈接到目前得到的LIS尾部;
這個時候,MaxV={1,3,6} ,nMaxlength = 3
,MaxV[nMaxLength+1]=6 , LIS[4] = 3
然後
i =5
,遍歷到4, 4 通過跟MaxV[nMaxLength] = 6
比較,4<MaxV[nMaxLength]
,
發現4更有潛力
,然後從nMaxLength
往前比較,找到剛剛比4大元素 也就是 6替換之。
這個時候MaxV={1,3,4}, nMaxlength = 3
,4只是替換,LIS[i]不變 = LIS[5]= 3
;然後
i=6
, 遍歷到8, 8通過跟MaxV[nMaxLength]
比較,8>MaxV[nMaxLength]
,
發現8更大
; 8就應該鏈接到目前得到的LIS尾部;
這個時候MaxV={1,3,4,8}, nMaxlength = 4, Maxv[nMaxlength]=8 LIS[6]=4
,然後
i=7
, 遍歷到9, 9通過跟MaxV[nMaxLength]
比較,9>MaxV[nMaxLength]
,
發現9更大
; 9就應該鏈接到目前得到的LIS尾部;
這個時候MaxV={1,3,4,8,9}, nMaxlength = 5, Maxv[nmaxlength]=9, LIS[7] = 5;
然後
i=8
, 遍歷到7, 7 通過跟MaxV[nMaxLength] = 9
比較,7<MaxV[nMaxLength]
,
發現7更有潛力
,然後從nMaxLength
往前比較,找到第一個比7大元素 也就是 8替換之。
這個時候MaxV={1,3,4,7,9}
,nMaxLength = 5, Maxv[nMaxlength]=9
LIS[8] = LIS[替換掉的index] = 4
;
-- | 2 | 1 | 5 | 3 | 6 | 4 | 8 | 9 | 7 |
---|---|---|---|---|---|---|---|---|---|
i | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
LIS | 1 | 1 | 2 | 2 | 3 | 3 | 4 | 5 | 4 |
MaxV | 2 | 1 | 1,5 | 1,3 | 1,3,6 | 1,3,4 | 1,3,4,8 | 1,3,4,8,9 | 1,3,4,7 |
java實現:
import java.util.*;
public class LIS {
public static int lengthofLCS(int[] arr){
// 輔助變量
int[] MaxV = new int [arr.length+1]; // 記錄遞增子序列 LIS 的末尾元素最小值
int nMaxLength = 1; // 當前LIS的長度
int [] LIS = new int[arr.length+1]; //LIS[i]記錄的是以第i個元素爲結尾的最長序列的長度
// 初始化
MaxV[0] = -100;
MaxV[nMaxLength] = arr[0];
LIS[0] = 0;LIS[1] = 1;
for(int i=1;i<arr.length;i++){
if(arr[i] >MaxV[nMaxLength]){
MaxV[++nMaxLength] = arr[i];
LIS[i] = LIS[i-1]+1;
}
else{
// 新元素 更小,更有“潛力”,替換大的元素
int index = binarySearch(MaxV,arr[i],0,nMaxLength);
//*
LIS[i] =index;
MaxV[index] = arr[i];
}
}
Arrays.sort(LIS);
return LIS[LIS.length-1];
}
// 在MaxV數組中查找一個元素剛剛大於arr[i]
// 返回這個元素的index
public static int binarySearch(int []arr, int n, int start, int end){
while(start<end){
int mid = (start + end)/2;
if(arr[mid]< n){
start = mid+1;
}
else if(arr[mid]> n) {
end = mid -1;
}
else
return mid;
}
return end;
}
public static void main(String[] args) {
int[] arr = {2,1,5,3,6,4,8,9,7};
System.out.println(lengthofLCS(arr));
}
}
- : MaxV裏面的數組下標代表了長度爲index的最長子序列末尾元素,反過來就是末尾元素在MaxV裏對應的下標就是他子序列的長度
可以轉化爲LCS的問題
- 給一個字符串,求這個字符串最少增加幾個字符能變成迴文
- 要在一條河的南北兩邊的各個城市之間造若干座橋.橋兩邊的城市分別是 a(1)...a(n) 和 b(1)...b(n). 且南邊 a(1)...a(n) 是亂序的,北邊同理,但是要求 a(i) 只可以和 b(i) 之間造橋, 同時兩座橋之間不能交叉. 希望可以得到一個儘量多座橋的方案.
以我和藍盆友的討論做結:
- 通常DP是一個不算最好,但是比最直接的算法好很多的方法。 DP一般是O(n^2);但是如果想進一步優化 O(nlogn)就要考慮其他的了
- 對,要想更好的方法就是要挖掘題目本身更加隱匿的性質了