【資料結構與演算法】字串匹配(字尾陣列)

gonghr 發表於 2021-08-10
演算法 資料結構

概念

簡介

在電腦科學裡, 字尾陣列(英語:suffix array)是一個通過對字串的所有字尾經過排序後得到的陣列。此資料結構被運用於全文索引、資料壓縮演算法、以及生物資訊學。

字尾字串

  • 字尾字串:從後往前依次遞增擷取的字串。長度為 n 的字串有 n 個字尾

image

字尾陣列和rank陣列

image

  • 字尾陣列:排名和原下標的對映。把字串的n個字尾子串按照字典序從小到大排列,形成的陣列,在陣列中記錄字尾的起始下標。是排名到下標的對映。

    sa[m]=n 表示排名是m的字尾字串在原字串的起點是n

    如:sa[0]=5 表示排名是0位的字尾字串在原字串的起點是5

  • rank陣列:給定字尾的下標,返回其字典序。是下標到排名的對映。

    rk[n]=m 表示字尾在原字串起點是n的字串的排名是m

    如:rk[5]=0 表示字尾在原字串起點是5的字串排名為0位

  • 顯然,字尾陣列和rank陣列是互補的 sa[rk[i]] = rk[sa[i]] = i

思路分析

字尾陣列主要用於字串匹配的查詢。

有一個顯而易見的基本概念:字串的子串一定是某個字尾的字首

如果給定一個字串,想看它是不是母串的子串,那麼我們可以先構造母串的字尾陣列,然後使用二分查詢,根據字典序查詢出與該字串最匹配的字尾,然後遍歷字尾,如果該字串是該字尾的字首,那麼就說明該字串是母串的子串;否則不是。

如何求字尾陣列以及進行字串匹配

樸素法

獲取所有字尾放入陣列,然後使用Arrays.sort() 按照字典序排序。

注意:不僅要獲取字尾,字尾在母串的起點下標也需要獲取,也就是說字尾和其在母串的起點下標是一體,不可分割的,為了解決這個問題,需要把字尾字串和其起點下標封裝成物件,並且實現Comparable介面。


    //求字尾陣列
    public static Suff[] getSa(String src) {
        int strLength = src.length();
        /*sa是排名到下標的對映,即sa[i]=k說明排名為i的字尾是從k開始的*/
        Suff[] suffixArray = new Suff[strLength];
        for (int i = 0; i < strLength; i++) {
            String suffI = src.substring(i);//擷取字尾
            suffixArray[i] = new Suff(i, suffI);
        }
        Arrays.sort(suffixArray);//依據Suff的比較規則進行排序
        return suffixArray;
    }

//封裝字尾和起點下標
class Suff implements Comparable<Suff> {

    String str;  //字尾內容
    int index;//字尾的起始下標

    public Suff(int index, String str) {
        this.index = index;
        this.str = str;
    }

    @Override
    public int compareTo(Suff o2) {
        return this.str.compareTo(o2.str);
    }

    @Override
    public String toString() {
        return "Suff{" +
                "str='" + str + '\'' +
                ", index=" + index +
                '}';
    }
}
  • 時間複雜度:快排O(nlogn),字串一一匹配還需乘O(n),所以總的時間複雜度是O(n^2·logn)

倍增法

倍增演算法的主要思路是:用倍增的方法對每個字元開始的長度為2^k的子字串進行排序,求出排名,即rank值。k 從О開始,每次加1,當2^k大於n以後,每個字元開始的長度為2^k的子字串便相當於所有的字尾。並且這些子字串都一定已經比較出大小,即rank值中沒有相同的值,那麼此時的rank值就是最後的結果。每一次排序都利用上次長度為2^(k-1)的字串的rank值,那麼長度為2^k的字串就可以用兩個長度為2^(k-1)的字串的排名作為關鍵字表示,然後進行快速排序,便得出了長度為2^k的字串的rank值。

這裡和羅穗騫論文裡的排序思路略有不同,採用快速排序簡化程式碼幫助理解。

image

用rank陣列記錄sa陣列中每個index的排名。

修改一下Suff類:

class Suff implements Comparable<Suff> {
    public char c;//字尾內容
    private String src;
    public int index;//字尾的起始下標

    public Suff(char c, int index, String src) {
        this.c = c;
        this.index = index;
        this.src = src;
    }

    @Override
    public int compareTo(Suff o2) {
        return this.c - o2.c;
    }

    @Override
    public String toString() {
        return "Suff{" +
                "char='" + src.substring(index) + '\'' +
                ", index=" + index +
                '}';
    }
}

改進的求字尾陣列方法:

     public static Suff[] getSa(String src) {
        int n = src.length();
        Suff[] sa = new Suff[n];
        for (int i = 0; i < n; i++) {
            sa[i] = new Suff(src.charAt(i), i, src);//存單個字元,接下來排序
        }
        Arrays.sort(sa);    //單個字元使用快排

        /*rk是下標到排名的對映*/
        int[] rk = new int[n];  //rank陣列
        rk[sa[0].index] = 1;    //排名從1開始
        for (int i = 1; i < n; i++) {
            rk[sa[i].index] = rk[sa[i - 1].index];    //下標所指字元相同則排名相同
            if (sa[i].c != sa[i - 1].c) rk[sa[i].index]++;    //字元不同,則排名加一
        }
        //倍增法
        for (int k = 2; rk[sa[n - 1].index] < n; k *= 2) {  //外層O(logn)
            final int kk = k;
            Arrays.sort(sa, (o1, o2) -> {
                //不是基於字串比較,而是利用之前的rank
                int i = o1.index;
                int j = o2.index;
                if (rk[i] == rk[j]) {//如果第一關鍵字相同
                    if (i + kk / 2 >= n || j + kk / 2 >= n)
                        return -(i - j);  //如果某個字尾不具有第二關鍵字,那肯定較小,索引靠後的更小
                    return rk[i + kk / 2] - rk[j + kk / 2];
                } else {
                    return rk[i] - rk[j];
                }
            });
            /*---排序 end---*/
            // 更新rank
            rk[sa[0].index] = 1;
            for (int i = 1; i < n; i++) {
                int i1 = sa[i].index;
                int i2 = sa[i - 1].index;
                rk[i1] = rk[i2];
                try {       //兩個字串不相同,排名加一
                    if (!src.substring(i1, i1 + kk).equals(src.substring(i2, i2 + kk)))
                        rk[i1]++;
                } catch (Exception e) {  //i1+kk越界了,則說明i1字串比i2字串短,且原先排名在i2之後,所以排名加一
                    rk[i1]++;
                }
            }
        }
        return sa;
    }
  • 時間複雜度:快排複雜度O(nlogn),外層迴圈logn層,所以總的時間複雜度是O(n(logn)^2)

更好的優化

內部字串比較的時候使用基數排序O(n)可以時總的時間複雜度降低到O(nlogn)
還有時間複雜度為O(n)級別的DC3SA-IS方法,可自行查閱資料,已放在文末。

二分法匹配字串

    private static void match(String s, String p) {  //s是母串,p是模式串
        Suff[] sa = getSa(s);  //獲取字尾陣列
        int l = 0;
        int r = s.length() - 1;
        //二分查詢,nlog(m)
        while (r >= l) {
            int mid = l + ((r - l) >> 1);
            //居中的字尾
            Suff midSuff = sa[mid];
            String suffStr = s.substring(midSuff.index);  //獲取字尾
            int compareRes;
            //將字尾和模式串比較,O(n)
            if (suffStr.length() >= p.length())  //字尾字串長度大於等於模式串,擷取字尾字串的字首與模式串比較
                compareRes = suffStr.substring(0, p.length()).compareTo(p);
            else                                 //字尾字串長度小於模式串,直接進行比較
                compareRes = suffStr.compareTo(p);
            //相等了,輸出字尾的起始位置
            if (compareRes == 0) {
                System.out.println(midSuff.index);
                break;
            } else if (compareRes < 0) {       //字尾小於模式串,左指標右移
                l = mid + 1;
            } else {                           //字尾大於模式串,右指標左移
                r = mid - 1;
            }
        }
    }

高度陣列

概念

  • 高度陣列:(height)是字尾陣列中每兩個相鄰字串元素的最長公共字首的長度的集合

  • LCP:(longestCommonSubString)最長公共字首

  • height[i] = LCP(sa[i],sa[i-1])

思路和實現

image

如果已經知道字尾陣列中i與i+1的lcp為h,那麼i代表的字串與i+1代表的字串去掉首字母后的lcp為h-1.

根據這個我們可以發現,如果知道i與字尾陣列中在它後一個的lcp為k,那麼它去掉首字母后的字串與其在字尾陣列中的後一個的lcp大於等於k-1.
height[rk(i+1)] >= height[rk(i)]-1

例如對於字串abcefabc,我們知道abcefabc與abc的lcp為3.
那麼bcefabc與bc的lcp大於等於3-1.
利用這一點就可以O(n)求出高度陣列。

public static int[] getHeight(String src, Suff[] sa) {
    int strLength = src.length();
    int[] rk = new int[strLength];
    //將rank表示為不重複的排名即0~n-1
    for (int i = 0; i < strLength; i++) {
      rk[sa[i].index] = i;
    }
    int[] height = new int[strLength];

    int k = 0;
    for (int i = 0; i < strLength; i++) {
      int rk_i = rk[i];  //i字尾的排名
      if (rk_i == 0) {
        height[0] = 0;
        continue;
      }
      int rk_i_1 = rk_i - 1;
      int j = sa[rk_i_1].index;//j是i串字典序靠前的串的下標
      if (k > 0) k--;

      for (; j + k < strLength && i + k < strLength; k++) {
        if (src.charAt(j + k) != src.charAt(i + k))
          break;
      }
      height[rk_i] = k;
    }
    return height;
  }

參考資料

oi-wiki

國家集訓隊2009論文集字尾陣列——處理字元

相關文章