算法設計 - LCS 最長公共子序列&&最長公共子串 &&LIS 最長遞增子序列

本章講解:
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)


一些定義:
字符串 XY 長度 分別mn

子串:字符串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>,則:

  1. xm = yn,則 zk = xm = ynZk-1Xm-1Yn-1 的最長公共子序列;

在這裏插入圖片描述

  • xm ≠ yn, 要麼ZXm-1Y 的最長公共子序列,要麼 ZXYn-1 的最長公共子序列。
    2.1 若 xm ≠ ynzk≠xm ,則 ZXm-1Y 的最長公共子序列;
    2.2 若 xm ≠ yn 且 zk ≠yn ,則 ZXYn-1 的最長公共子序列。
    綜合一下2 就是求二者的大者
  • 遞歸結構:

    在這裏插入圖片描述
    遞歸結構容易看到最長公共子序列問題具有子問題重疊性質。例如,在計算 XY 的最長公共子序列時,可能要計算出 XYn-1Xm-1Y 的最長公共子序列。而這兩個子問題都包含一個公共子問題,即計算 Xm-1Yn-1 的最長公共子序列。


    在這裏插入圖片描述

    遞歸結構容易看到最長公共子序列問題具有子問題重疊性質。例如,在計算 XY 的最長公共子序列時,可能要計算出 XYn-1Xm-1Y 的最長公共子序列。而這兩個子問題都包含一個公共子問題,即計算Xm-1Yn-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] 表示 以 XiYj 結尾的最長公共子串的長度,因爲要求子串連續,所以對於 XiYj 來講,它們要麼與之前的公共子串構成新的公共子串;要麼就是不構成公共子串。故狀態轉移方程

    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++ = 2MaxV[nMaxLength]=5LIS[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 = 3MaxV[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)就要考慮其他的了

    - 對,要想更好的方法就是要挖掘題目本身更加隱匿的性質了


    發表評論
    所有評論
    還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
    相關文章