一些常見的字串匹配演演算法

2023-04-25 12:01:39

作者:京東零售 李文濤

一、簡介

1.1 Background

字串匹配在文書處理的廣泛領域中是一個非常重要的主題。字串匹配包括在文字中找到一個,或者更一般地說,所有字串(通常來講稱其為模式)的出現。該模式表示為p=p[0..m-1];它的長度等於m。文字表示為t=t[0..n-1],它的長度等於n。兩個字串都建立在一個有限的字元集上。

一個比較常見的字串匹配方法工作原理如下。在一個大小通常等於m的視窗幫助下掃描文字。首先將視窗和文字的左端對齊,然後將視窗的字元與文字中的字元進行比較,這一特定的工作被稱為嘗試,在完全匹配或不匹配之後,將視窗移到右側。繼續重複同樣的過程,直到視窗的右端超過文字的右端,一般稱為滑動視窗機制。

1.2 Brute force

BF演演算法檢查文字中0到n-m之間的所有位置,是否有模式從那裡開始出現。然後,在每次嘗試之後,它將模式串向右移動一個位置。

BF演演算法不需要預處理階段,除了模式和文字之外,還需要一個恆定的額外空間。在搜尋階段,文字字元比較可以以任何順序進行。該搜尋階段的時間複雜度為O(mn)。

public static int strMatch(String s, String p){
    int i = 0, j = 0;
    while(i < s.length() && j < p.length()){
        if(s.charAt(i) == p.charAt(j)){
            i++;
            j++;
        }else{
            i = i - j + 1;
            j = 0;
        }
        if (j == p.length()){
            return i - j;
        }
    }
    return -1;
}



二、KMP

先回顧下brute force中匹配的情況。我們在文字串BBC#ABCDAB$ABCDABCDABDE中查詢模式串ABCDABD,文字串中第1個字元「B」與模式串中第1個字元「A」不匹配,所以我們將模式傳後移一位。

文字串中的第2個字元「B」和模式串中的第一個字元「A」不匹配,繼續後移。

基於這種方式不斷比較並且移動,我們發現文字串中的第5個字元「A」和模式串中的第1個字元「A」是匹配的,那麼繼續比較文字串和模式串的下一個字元。

不斷比較之後我們發現,文字串中的字元「$」和模式串中的最後一個字元「D」不匹配。

根據BF演演算法,我們應該繼續將模式串向後移動一位,然後從頭開始重新比較。

那我們不妨觀察下,上次匹配失敗的情況,當文字串中「$」與模式串中「D」不匹配時,我們其實已經完成了6次匹配,也就是說我們在文字串和模式串中已經找到了"ABCDAB"。同時我們可以發現模式串中字首「AB」是可以和文字串中已匹配成功部分的字尾「AB」相匹配,我們利用這個資訊,可以把模式串右移多位,而不僅僅是1位來去繼續匹配(換句話說,我們不需要回退文字串的搜尋位置),這加快了搜尋速率。

同樣的,當搜尋到下面情況時,文字串中的字元「C」和模式串中的字元「D」不匹配,利用已知的資訊,我們右移模式串,不回退搜尋位置,繼續去查詢匹配。

最終,查詢成功。

簡單來說,文字串和模式串匹配失敗時,kmp演演算法並沒有像bf演演算法描述中一樣,將模式串右移1位,從頭重新進行搜尋,而是利用已匹配資訊,不回退文字串的搜尋位置,繼續將模式串向後移動,減少比較次數,提高了效率。那麼當匹配失敗時,模式串究竟要向後移動多少位呢?

2.1 字首函數

字首是指從串首開始到某個位置結束的一個特殊子串。字串S以i結尾的字首表示為Prefix(S,i),也就是Prefix(S,i)=S[0..i]。

真字首指除了S本身的S的字首。

字尾是指從某個位置開始到整個串末尾結束的一個特殊子串。字串S的從i開頭的字尾表示為Suffix(S,i),也就是Suffix(S,i)=S[i..|S|-1]。

真字尾指除了S本身的S的字尾。

回到上文kmp演演算法匹配流程中,當文字串和模式串匹配失敗時,我們右移模式串的位數是多少呢?或者說,當文字串中字元與模式串中字元匹配失敗時,應該重新跟模式串中哪個字元再進行匹配呢?

上面的例子文字串中$與模式串中D匹配失敗,而由於已經匹配成功了「ABCDAB」這6個字元,我們發現可以將模式串右移4位元再進行比較,或者說此時,當匹配至模式串第7個字元失敗後,可以重新和模式串的第3個字元,也就是「C」進行比較,這是由於文字串中的「AB」恰好和模式串中的字首「AB」相匹配。而且我們發現匹配失敗前文字串中的「AB」和已匹配的模式串中的字尾「AB」也是相匹配的。所以實際上我們根據模式串自身的特點,就能知道匹配失敗時如何去匹配新的位置。

我們定義陣列prefix,其中prefix[i]表示以S.charAt(i)為結尾的即S[0..i]中最長的相同真前字尾的長度。以字串「aabaaab」為例:

i=0時,子串「a」無真前字尾,prefix[0]=0

i=1時,子串「aa」,其中[a]a和a[a]最長的相同真前字尾為a,prefix[1]=1

i=2時,子串「aab」無相同的真前字尾,prefix[2]=0

i=3時,子串「aaba」,其中[a]aba aab[a]最長的相同真前字尾為a,prefix[3]=1

i=4時,子串「aabaa」,其中 [aa]baa aab[aa] 最長的相同真前字尾為aa,prefix[4]=2

i=5時,子串「aabaaa」,其中[aa]baaa aaba[aa] 最長的相同真前字尾為aa,prefix[5]=2

i=6時,子串「aabaaab」,其中[aab]aaab aaba[aab]最長的相同真前字尾為aab,prefix[6]=3

上文匹配的prefix陣列如下:

如何求解prefix呢,很容易想到一種方法是,我們使用兩個for迴圈來遍歷給定字串的字首中的真字首和真字尾,內部去比較真字首和真字尾是否相同。即便我們從最長的真前字尾來嘗試匹配,這個方法的時間複雜度還是很高。

public static int[] getPrefix(String str){
        int[] res = new int[str.length()];
        for(int i = 1; i < res.length; ++i){
            for(int j = i; j > 0; --j){
                if (str.substring(0, j).equals(str.substring(i-j+1,i+1))){
                    res[i] = j;
                    break;
                }
            }
        }
        return res;
    }



2.2 第一個優化

我們觀察下由s[i]至s[i+1]求解最長的真前字尾匹配情況變化。

// compute "ABCDA" -> compute "ABCDAB"
// A        A       <-"ABCDA"時最長字首、字尾匹配
// AB       DA
// ABC      CDA
// ABCD     BCDA
// ->
// A        B
// AB       AB      <-"ABCDAB"時最長字首、字尾匹配
// ABC      DAB
// ABCD     CDAB
// ABCDA    BCDAB

// compute "ABCDA" -> compute "ABCDAP"
// A        A       <-"ABCDA"時最長字首、字尾匹配
// AB       DA
// ABC      CDA
// ABCD     BCDA
// ->
// A        P
// AB       AP
// ABC      DAP
// ABCD     CDAP
// ABCDA    BCDAP
// 無匹配

// A->AB
// 也就是說最好的情況下,以s[i]為結尾的最長的相同的真前字尾長度,一定是以s[i-1]為結尾的最大的相同的真前字尾相同的長度+1



根據上面的描述,在嘗試匹配真前字尾的時候,我們可以減少迴圈次數。

public static int[] getPrefix1(String str){
    int[] prefix = new int[str.length()];
    prefix[0] = 0;
    for (int i = 1; i < str.length(); ++i){
        for(int j = prefix[i-1] + 1; j > 0; --j){
            if (str.substring(0, j).equals(str.substring(i-j+1, i+1))){
                prefix[i] = j;
                break;
            }
        }
    }
    return prefix;
}



考慮一種情況,計算字串「baabaab」的prefix的時候,在計算i=5的時候,我們已經完成了「baa」的比較,當計算i=6的時候,我們比較字首「baab」和字尾「baab」,但是在上一次比較,我們知道字首「baa」和字尾「baa」已經匹配了。

為了減少這種重複的匹配,我們考慮一下利用雙指標來不斷的去比較所指的兩個字元

// if(s.charAt(i) == s.charAt(j))
//      prefix[i] = prefix[j-1] + 1;
//      or
//      prefix[i] = j + 1;
// }



具體實現如下:

public static int[] getPrefix2(String str){
    int[] prefix = new int[str.length()];
    int j = 0;
    int i = 1;
    while(i < str.length()){
        if (str.charAt(j) == str.charAt(i)){
            j++;
            prefix[i] = j;
            i++;
        }else{
            // 匹配失敗時,
            while(j > 0 && !str.substring(0, j).equals(str.substring(i-j+1, i+1))){
                j--;
            }
            prefix[i] = j;
            i++;
        }
    }
    return prefix;
}



2.3 第二個優化

上面的優化是針對匹配成功時候的情況,那麼匹配失敗時,難道真的需要重新去列舉其他的真前字尾,來去不斷的嘗試匹配嗎?我們觀察下,匹配失敗時,能否利用前面已經計算完的結果呢?

當s[j]!=s[i]的時候,我們是知道s[0..j-1]和s[i-j..i-1]是相同的,到這裡再回想一下prefix陣列的定義,prefix[j-1]表示的是以s.charAt(j-1)字元為結尾的即s[0..j-1]中最長的相同真前字尾的長度,如果prefix[j-1]=x(x!=0),我們很容易得到s[0..x-1]和s[j-x..j-1]是相同的。

再將s[i-j..i-1]展開來看一下,因為我們知道s[0..j-1]和s[i-j..i-1]是相同的,所以s[i-j..i-1]也同樣存在相同的真前字尾,即真字首s[i-j-x..i-j]以及真字尾s[i-x..i-1],而且由於s[0..x-1]和s[j-x..j-1]是相同的,s[j-x..j-1]和s[i-x..i-1]是相同的(整體相同,對應的部分也是相同的),可以容易得到s[0..x-1]和s[i-x..i-1]是相同的。

再回到原始的字串上來觀察,s[0..x-1]正是字串s的真字首,而s[i-x..i-1]是以i-1為結尾的真字尾,由於這兩部分相同,我們更新j=x=prefix[j-1],準確找到已經匹配的部分,繼續完成後續的匹配即可。

程式碼實現如下:

public static int[] getPrefix4(String str){
    int[] prefix = new int[str.length()];
    int j = 0;
    int i = 1;
    while(i < str.length()){
        if (str.charAt(j) == str.charAt(i)){
            // 更新j,同時j++也正是已匹配的最大長度
            j++;
            prefix[i] = j;
            i++;
        }else if(j == 0){
            // 當str.charAt(j) != str.charAt(i) && j == 0時,後移i即可
            i++;
        }else{
            // 找到已匹配的部分,繼續匹配即可
            j = prefix[j-1];
        }
    }
    return prefix;
}



2.4 求解next

很多kmp演演算法的講解都提到了next陣列,那麼實際上next陣列求解和上面的prefix求解本質是一樣的,next[i]實際上就是以i-1為結尾的最長的相同真前字尾的長度。

定義next[j]為當s[i] != p[j]時,需要跳轉匹配的模式串的索引,特別的當next[0] = -1

public static int[] getNext(String str){
    int[] next = new int[str.length()+1];
    int i = 1;
    int j = 0;
    // next[0] = -1 指代匹配失敗,更新文字串索引+1
    next[0] = -1;
    while(i < str.length()){
        if (j == -1 || str.charAt(i) == str.charAt(j)){
            i++;
            j++;
            next[i] = j;
        }else{
            j = next[j];
        }
    }
    return next;
}



2.5 完整程式碼

public static int search(String s, String p){
    int[] next = getNext(p);
    int i = 0, j = 0;
    while(i < s.length() && j < p.length()){
        if (j == -1 || s.charAt(i) == p.charAt(j)){
            i++;
            j++;
        }else{
           j = next[j];
        }
        if (j == p.length()){
            return i - j;
        }
    }
    return -1;
}



2.6 優化next

以上面的next陣列為例,當i=5,匹配失敗時,應該跳轉i=1進行比較,但是我們知道s[5]=s[1]="B",這樣匹配下去也是必定會失敗的,基於這一點,還可以簡單優化下next陣列的求解過程。

public static int[] getNext1(String str){
    int[] next = new int[str.length()+1];
    int i = 1;
    int j = 0;
    next[0] = -1;
    while(i < str.length()){
        if (j == -1 || str.charAt(i) == str.charAt(j)){
            i++;
            j++;
            if (i < str.length() && str.charAt(i) != str.charAt(j)){
                next[i] = j;
            }else{
                // 如果相同,根據next[j]跳轉即可
                next[i] = next[j];
            }
        }else{
            j = next[j];
        }
    }
    return next;
}



三、其他演演算法

這一部分,介紹幾種其他字串搜尋的演演算法

3.1 BM

1977 年,德克薩斯大學的 Robert S.Boyer 教授和 J StrotherMoore 教授發明了一種新的字串匹配演演算法:Boyer-Moore演演算法,簡稱BM 演演算法。BM演演算法的基本思想是通過字尾匹配獲得比字首匹配更多的資訊來實現更快的字元跳轉。

通常我們都是從左至右去匹配文字串和模式串的,下面我們從右至左嘗試匹配並觀察下。文字串中的字元「S」,在模式串中未出現,那麼我們是不是可以跳過多餘的匹配,不用去考慮模式串從文字串中第1個、第2個、第m個字元進行匹配了。可以直接將模式串向後滑動m個字元進行匹配。

繼續觀察下面匹配失敗的情況,我們可以發現,模式串後三個字元「E」、「L」、「P」一定無法和文字串中的字元「M」進行匹配。換句話說,直到移動到模式串中最右邊的「M」(如果存在的話)之前,都是無法匹配成功的。基於這個觀察,我們可以直接向後移動模式串,使最右邊出現的「M」和文字串中的「M」對齊,再去繼續匹配。

總結:1.當出現失配字元時(文字串的字元),如果模式串不存在該字元,則將模式串右移至失配字元的右邊。

2.如果模式串中存在該字元,將模式串中該字元在最右邊的位置,和文字串的失配字元對齊。

我們再觀察下面的情況,我們發現文字串中字元「A」和模式串中的字元「B」匹配失敗,此時已匹配的字尾「AB」我們可以在模式串中找到同樣的子串「AB」,我們完全可以向後移動模式串,將兩個串中的「AB」來對齊,再繼續匹配。

再觀察下面這種情況,已經匹配的字尾「CBAB」我們無法在模式串中找到同樣的部分,難道就沒有辦法加快匹配了嗎?我們以匹配的字串「CBAB」中的幾個真字尾「BAB」、「AB」、「B」,其中「AB」作為字首出現在了模式串中,那我們可以後移模式串,將文字串中的字尾「AB」和模式串中的字首「AB」對齊,從而繼續進行匹配。

為什麼已匹配的字元的真字尾必須要和模式串中的字首匹配才可以移動呢?我們可以看下面這個例子。已匹配的「CBAB」中的真字尾「AB」,在模式串中是存在的(非字首),那我們向後移動模式串把這兩部分對齊繼續匹配如何呢?這樣做看似合理,但實際上卻是一個無效的匹配位置。很明顯,因為文字串中「AB」前的字元和模式串中「AB」前的字元一定是不匹配的,否則我們是可以找到一個比「AB」更長的匹配,且這個匹配的一定是模式串中的字首,這就符合我們上面說的情況了。所以當沒有能夠匹配上合理字尾這種情況出現時,正確的移動是將模式串向後移動m位。

總結:1.當模式串中有子串和已匹配字尾完全相同,則將最靠右的那個子串移動到字尾的位置繼續進行匹配。

2.如果不存在和已匹配字尾完全匹配的子串,則在已匹配字尾中找到最長的真字尾,且是模式串的字首(t[m-s…m]=P[0…s])

3.如果完全不存在和好字尾匹配的子串,則右移整個模式串。

BM演演算法在實際匹配時,考慮上面兩種策略,當匹配失敗發生時,會選擇能夠移動的最大的距離,來去移動模式串,從而加速匹配。實際情況,失配字元移動策略已經能很好的加速匹配過程,因為模式串本身字元數量是要少於文字串的,Quick Search algorithm(Sunday)正是利用這一策略的演演算法(有些許不同),或者說是一種簡化版的BM演演算法。

3.2 Sunday

Sunday 演演算法是 Daniel M.Sunday 於 1990 年提出的字串模式匹配。其效率在匹配隨機的字串時比其他匹配演演算法還要更快。Sunday 演演算法的實現可比 KMP,BM 的實現容易的多。

Sunday演演算法思想跟BM演演算法很相似,在匹配失敗時關注的是文字串中參加匹配的最末位字元的下一位字元。如果該字元沒有在模式串中出現則直接跳過,即移動步長= 模式串長度+1;否則,同BM演演算法一樣其移動步長=模式串中最右端的該字元到末尾的距離+1。

文字串T中字元「c」和模式串中的字元「d」不匹配。我們觀察文字串參與匹配的末位的下一個字元「e」,可以知道「e」沒有出現在模式串中。於是移動模式串長度+1。

繼續匹配,我們發現文字串T中字元「a」和模式串中的字元「d」不匹配。我們觀察文字串參與匹配的末位的下一個字元「a」,可以知道「a」出現在模式串中(最右的位置)。於是移動模式串該字元到末尾的距離+1。

3.3 Rabin-Karp

Rabin-Karp 演演算法,由 Richard M. Karp 和 Michael O. Rabin 在 1987 年發表,它也是用來解決多模式串匹配問題的。該演演算法實現方式與上述的字元匹配不同,首先是計算兩個字串的雜湊值,然後通過比較這兩個雜湊值的大小來判斷是否出現匹配。

為了幫助更好的解決字串匹配問題,雜湊函數應該具有以下屬性:

1.高效的、可計算的

2.更好的識別字串

3.在計算hash(y[j+1 ..j+m])應該可以容易的從hash(y[j..j+m-1])和y[j+m]中得到結果,即hash(y[j+1 ..j+m])=rehash(y[j],y[j+m],hash(y[j..j+m-1])

我們定義hash函數如下:

hash(w[0 ..m-1])=(w[0]*2m-1+w[1]*2m-2+···+w[m-1]*2^0) mod q

由於計算的hash值可能會很大,所以需要取模操作,q最好選取一個比較大的數,且是一個質數,w[i]表示y[i]對應的ASCII碼。

hash(w[1..m])=rehash(w[0],w[m],hash(w[0..m-1]))

rehash(a,b,h)= ((h-a*2^m-1)*2+b) mod q

匹配過程中,不斷滑動視窗來計算文字串的hash值和模式串的是否相同,當出現相同時,還需要再檢查一遍字串是否真正相同,因為會出現雜湊碰撞的情況。

3.4 Shift-and/or

Shift-and演演算法的總體思路是把模式串預處理成一種特殊編碼形式,然後根據這種編碼形式去逐位匹配文字串。

首先對模式串進行預處理,利用二進位制數位進行編碼。如果模式串為「abac」,a出現在第0位和第2位,那麼則可以儲存a的資訊為5(二進位制為0101),同樣的,我們把模式串出現的所有字元均用這種方式編碼,並儲存起來。

對於每一位文字串字元,我們定義一個對應的狀態碼數位P,當P[i]=1時,則表示以這一位文字串為末尾時,能和模式串的第0位到第i位的字元能完全匹配。我們看一下具體的匹配過程。

文字串「aeabcaabace」和模式串「abac」,初始化P=0,遍歷文字串中的每一個字元,同時根據儲存的字元編碼資訊,來更新匹配結果,也就是狀態碼P。

在第一次計算完成後,狀態碼P=0001,根據我們上面的定義,P[0]=1即表示以這一位文字串為末尾,模式串中的第0位到第0位的字元是匹配的。

進行完一次匹配後,P左移一位,將第0位置1,同時和對應字元的編碼進行&操作(即嘗試匹配該字元),更新狀態碼P。

可以看到當狀態碼P=0101時,P[2]=1表示當前字元匹配了模式串p[0..2]=「aba」,P[0]=1表示當前字元匹配了模式串p[0..0]=「a」,也就是說,狀態碼P是能夠儲存多種部分匹配的結果。

繼續匹配

當P=1000時,也就是說P[3]=1即匹配模式串p[0...3]=「abac」,正好找到了一個對應的匹配,而我們也可以根據此條件來判斷是否已經找到了匹配。

Shift-and使用的二進位制資訊來編碼模式串,使用位運算&來達到並行匹配字串,利用狀態碼P來儲存當前位的匹配結果。可以觀察出演演算法的時間複雜度很低,如果模式串的長度不超過機器字長,其效率是非常高的。

Shift-or在這裡就不多做介紹了,其原理和Shift-and類似,只不過Shift-or使用0來標識存在,同時使用|來代替&進行狀態碼的計算。

相關參考:

1.http://igm.univ-mlv.fr/~lecroq/string/node8.html

2.http://igm.univ-mlv.fr/~lecroq/string/node14.html

3.https://shanire.gitee.io/oiwiki/string/kmp/

4.https://shanire.gitee.io/oiwiki/string/bm/

5.http://igm.univ-mlv.fr/~lecroq/string/node6.html

6.https://baike.baidu.com/item/sunday 演演算法/1816405

7.http://igm.univ-mlv.fr/~lecroq/string/node5.html