1. 程式人生 > >字串匹配(BF,BM,Sunday,KMP演算法解析)

字串匹配(BF,BM,Sunday,KMP演算法解析)

字串匹配一直是計算機領域熱門的研究問題之一,多種演算法層出不窮。字串匹配演算法有著很強的實用價值,應用於資訊搜尋,拼寫檢查,生物資訊學等多個領域。
今天介紹幾種比較有名的演算法:
1. BF
2. BM
3. Sunday
4. KMP

—,BF演算法
BF(Brute Force)演算法又稱為暴力匹配演算法,是普通模式匹配演算法。

其演算法思想很簡單,從主串S的第pos個字元開始,和模式串T的第一個字元進行比較,若相等,則主串和模式串都後移一個字元繼續比較;若不相同,則回溯到主串S的第pos+1個字元重新開始比較。
依次類推,直到模式串T中每個字元依次和主串S中的一個連續字串全部相等時,則匹配成功,返回模式串T的第一個字元在主串S中的位置;若主串遍歷完也沒有成功,則匹配失敗。

演算法步驟如下:

下標i    0  1  2  3  4  5  6  7  8  9  
主串S    a  b  a  b  c  a  b  c  a  c  
模式串T  a  b  c  a          

第一次比較,從左到右,S[0] = T[0],計數器++;比較S[1] = T[1],i++;當s[2] != T[2],主串回溯,從S[1]重新開始比較。

下標i    0  1  2  3  4  5  6  7  8  9  
主串S    a  b  a  b  b  a  b  c  a  c  
模式串T     a  b  c  a          

也就是說,主串i從0開始,每次比較失敗i++,重新比較,直到

下標i    0  1  2  3  4  5  6  7  8  9  
主串S    a  b  a  b  b  a  b  c  a  c  
模式串T                 a  b  c  a   

i = 5,匹配成功。返回 i=5。

可推得,BF演算法在最壞情況下需要比較,主串長度M 乘以 模式串長度N, 為-O(M * N)。

程式碼實現如下:

int BF(const char *str1, const char *str2)
{
    int str1_len = strlen(str1);
    int
str2_len = strlen(str2); int i = 0; int j = 0; if(str1 == NULL || str2 == NULL){ return -1; } while(i < str1_len && j < str2_len){ if(str1[i] == str2[j]){ i++; j++; //相等則繼續逐個比較 } else{ i = i -j + 1; j = 0; //不相等則主串回溯,重新比較 } } if (j == str2_len){ return i - j; }else{ return -1; } }

BF演算法簡單易懂,但是這個演算法效率很差,原因在於每次失敗後回溯,浪費了之前的比較,導致了很多次的無用比較,時間損耗較大。

二,BM演算法
BM(Boyer Moore)演算法是1977年,Robert S.Boyer和J Strother Moore提出了一種在O(n)時間複雜度的匹配演算法。時間複雜度要低於BF。

BM演算法之所以能夠在模式匹配中有更高的的效率,主要是因為BM演算法構造了兩個跳轉表,分別叫做 壞字尾表 ,和 好字尾表 。這兩個表涉及了BM演算法中的兩個規則:

壞字元規則:當文字串中的某個字元跟模式串的某個字元不匹配時,我們稱文字串中的這個失配字元為壞字元,此時模式串需要向右移動,移動的位數 = 壞字元在模式串中的位置 - 壞字元在模式串中最右出現的位置。此外,如果”壞字元”不包含在模式串之中,則最右出現位置為-1。

好字尾規則:當字元失配時,後移位數 = 好字尾在模式串中的位置 - 好字尾在模式串上一次出現的位置,且如果好字尾在模式串中沒有再次出現,則為-1。

演算法步驟如下:
1. 首先,”文字串”與”模式串”頭部對齊,從尾部開始比較。”S”與”E”不匹配。這時,”S”就被稱為”壞字元”(bad character),即不匹配的字元,它對應著模式串的第6位。且”S”不包含在模式串”EXAMPLE”之中(相當於最右出現位置是-1),這意味著可以把模式串後移6-(-1)=7位,從而直接移到”S”的後一位。
這裡寫圖片描述
2. 依然從尾部開始比較,發現”P”與”E”不匹配,所以”P”是”壞字元”。但是,”P”包含在模式串”EXAMPLE”之中。因為“P”這個“壞字元”對應著模式串的第6位(從0開始編號),且在模式串中的最右出現位置為4,所以,將模式串後移6-4=2位,兩個”P”對齊。
這裡寫圖片描述
3. 依次比較,得到 “MPLE”匹配,稱為”好字尾”(good suffix),即所有尾部匹配的字串。注意,”MPLE”、”PLE”、”LE”、”E”都是好字尾。
這裡寫圖片描述
4. 發現“I”與“A”不匹配:“I”是壞字元。如果是根據壞字元規則,此時模式串應該後移2-(-1)=3位。問題是,有沒有更優的移法?
這裡寫圖片描述
5. 更優的移法是利用好字尾規則:當字元失配時,後移位數 = 好字尾在模式串中的位置 - 好字尾在模式串中上一次出現的位置,且如果好字尾在模式串中沒有再次出現,則為-1。
所有的“好字尾”(MPLE、PLE、LE、E)之中,只有“E”在“EXAMPLE”的頭部出現,所以後移6-0=6位。
可以看出,“壞字元規則”只能移3位,“好字尾規則”可以移6位。每次後移這兩個規則之中的較大值。這兩個規則的移動位數,只與模式串有關,與原文字串無關。
這裡寫圖片描述
6. 繼續從尾部開始比較,“P”與“E”不匹配,因此“P”是“壞字元”,根據“壞字元規則”,後移 6 - 4 = 2位。因為是最後一位就失配,尚未獲得好字尾。
這裡寫圖片描述
匹配完成,可見BM演算法根據好壞字元規則,使得失配時,文字串能夠後移更多位,使得效率高於BF按部就班的匹配。

BM演算法的時間複雜度為-O(N)。

程式碼實現主要在於構建兩表。
壞字元表:

void BuildBadC(const char* pattern, size_t pattern_length, unsigned int* badc, size_t alphabet_size)  
{  
    unsigned int i;  

    for(i = 0; i < alphabet_size; ++i)  
    {  
        badc[i] = pattern_length;  
    }   
    for(i = 0; i < pattern_length; ++i)  
    {  
        badc[pattern[i] - 'A'] = pattern_length - 1 - i;  
    }  
}  

好字尾表:

void BuildGoodS(const char* pattern, size_t pattern_length, unsigned int* goods)  
{  
    unsigned int i, j, c;  

    for(i = 0; i < pattern_length - 1; ++i)  
    {  
        goods[i] = pattern_length;  
    }  
    //初始化pattern最末元素的好字尾值  
    goods[pattern_length - 1] = 1;    
    //此迴圈找出pattern中各元素的pre值,這裡goods陣列先當作pre陣列使用  
    for(i = pattern_length -1, c = 0; i != 0; --i)  
    {  
        for(j = 0; j < i; ++j)  
        {  
            if(memcmp(pattern + i, pattern + j, (pattern_length - i) * sizeof(char)) == 0)  
            {  
                if(j == 0)  
                {  
                    c = pattern_length - i;  
                }  
                else  
                {  
                    if(pattern[i - 1] != pattern[j - 1])  
                    {  
                        goods[i - 1] = j - 1;  
                    }  
                }  
            }  
        }  
    }    
    //根據pattern中個元素的pre值,計算goods值  
    for(i = 0; i < pattern_length - 1; ++i)  
    {  
        if(goods[i] != pattern_length)  
        {  
            goods[i] = pattern_length - 1 - goods[i];  
        }  
        else  
        {  
            goods[i] = pattern_length - 1 - i + goods[i];  

            if(c != 0 && pattern_length - 1 - i >= c)  
            {  
                goods[i] -= c;  
            }  
        }  
    }  
}  

構建完表BM演算法就很簡單了。

unsigned int BM(const char* text, size_t text_length, const char* pattern, size_t pattern_length, unsigned int* matches)  
{  
    unsigned int i, j, m;  
    unsigned int badc[ALPHABET_SIZE];  
    unsigned int goods[pattern_length];   
    i = j = pattern_length - 1;  
    m = 0;  

    //構建好字尾和壞字元表  
    BuildBadC(pattern, pattern_length, badc, ALPHABET_SIZE);  
    BuildGoodS(pattern, pattern_length, goods);  

    while(j < text_length)  
    {  
        //發現目標傳與模式傳從後向前第1個不匹配的位置  
        while((i != 0) && (pattern[i] == text[j]))  
        {  
            --i;  
            --j;  
        }  
        //找到一個匹配的情況  
        if(i == 0 && pattern[i] == text[j])  
        {  
            matches[m++] = j;  
            j += goods[0];  
        }  
        else  
        {  
            //壞字元表用字典構建比較合適  
            j += goods[i] > badc[text[j]-'A'] ? goods[i] : badc[text[j]-'A'];  
        }  
        i = pattern_length - 1;  
    }  

    return m;  
}  

BM演算法的思想在字串匹配中很重要,其中好字尾與KMP演算法的思想不謀而合,而壞字元跳躍,跟Sunday演算法很相似。總的來說,BM是很高效的匹配演算法。

三,Sunday演算法
Sunday演算法是Daniel M.Sunday於1990年提出的字串模式匹配。據網上說,Sunda演算法的效率高於BM和KMP(不太明白)。在我看來,Sunday演算法是BM演算法的優化,邏輯簡單易懂,程式碼也實現更容易(無需構造兩表)。

Sunday演算法的思想是:
1. 從文字串S的第pos個字元開始,和模式串T的第一個字元進行比較,若相等,則主串和模式串都後移一個字元繼續比較;
2. 若不相同,則將文字串參與匹配的最末位字元的後一個字元與模式串逆著匹配。
3. 若匹配完模式串沒有該字元,則模式串直接跳過,即移動位數 = 匹配串長度 + 1。
4. 若模式串匹配到了該字元,則模式串中相同字元移動到文字串該字元下,與該字元對齊。其移動位數 = 模式串中最右端的該字元到末尾的距離+1。

演算法步驟如下:

下標i    0  1  2  3  4  5  6  7  8  9  10  11  12
主串S    c  b  a  b  d  c  b  a  c  b   b   a   d
模式串T  c  b  b  a    

從前往後比較,在i = 2處失配,關注i= 4( d )是否包含在模式串T中。遍歷比較完發現不包含,將模式T移動到i = 5繼續比較。(失配後,主串這輪參與比較的後一位(d)肯定要參與下一輪的比較,T中都沒有d,肯定匹配不成功啊!還比什麼,直接後移至(d)的下一位。)

下標i    0  1  2  3  4  5  6  7  8  9  10  11  12
主串S    c  b  a  b  d  c  b  a  c  b   b   a   d
模式串T                 c  b  b  a  

在i = 7處失配,關注i = 9(b),在模式串中找b,發現i = 6, i = 7處都有b,那應該和誰對齊?? 應該和後面的(i = 7)處的對齊,這就是為什麼要倒著在模式串中找b了,找到了直接break,並將此處與i = 9對齊。(為什麼和後面的b對齊,大家思考)

下標i    0  1  2  3  4  5  6  7  8  9  10  11  12
主串S    c  b  a  b  d  c  b  a  c  b   b   a   d
模式串T                       c  b  b   a  

在i=7處失配,關注i=11(a),匹配到模式串中最後一位為a,break;將模式串中的a與i = 17 處的 a 對齊。繼續比較。

下標i    0  1  2  3  4  5  6  7  8  9  10  11  12
主串S    c  b  a  b  d  c  b  a  c  b   b   a   d
模式串T                          c  b   b   a  

匹配完成!

可見Sunday演算法的核心思想就是失配時,模式串能儘可能多的向後移動,使得匹配次數減少,效率提高。

Sunday和BM不同點在於:
1. BM從後往前匹配,Sunday從前往後。
2. BM演算法失配關注的是“最後一位”,Sunday關注的是“最後一位的下一位”。
3. BM有壞字串好字串之分,Sunday沒有。(但思想比較相似,BM中的好字元 和 壞字元包含在模式串內,可以類比於Sunday演算法找到了該字元;好字元和壞字串沒出現,則類比於沒在模式串中找到該字元)。

程式碼實現:

int Sunday(const char *str1, const char *str2)
{
    int str1_len = strlen(str1);
    int str2_len = strlen(str2);
    int i = 0;
    int j = 0;
    enum{FALSE,TRUE};
    int Y = FALSE;   

    if(str1 == NULL || str2 == NULL){
    return -1;
    }

    while(i < str1_len && j < str2_len){
        if(str1[i] == str2[j]){
        i++;
        j++; 
        }else{
        //關注最後一位的後一位這個字元
        int num = i - j + str2_len ;
        for(j = str2_len-1; j >= 0; j--){
        //該字元與模式串比較,找到相同的則與該字元對齊。
        if(str1[num] == str2[j]){
            i = num - j;
                    Y = TRUE;
            break;
            }
        }
        if(Y == FALSE){
        //沒找到模式串就直接跳到該字元的下一位。
        i = num + 1; 
        }
        Y = FALSE;
        j = 0;
        }
    }       
    if(i == str1_len){
    return -1;
    }else
        return i - str2_len;;
}

Sunday演算法的應用價值很強,(實際效率高於KMP和BM演算法),程式碼實現也很簡單,希望大家能夠掌握。

四,KMP演算法
Knuth-Morris-Pratt 字串查詢演算法,簡稱為 “KMP演算法”由Donald Knuth、Vaughan Pratt、James H. Morris三人於1977年聯合發表,故取這3人的姓氏命名此演算法。
KMP演算法在網上說的非常麻煩,我覺得就是之前介紹的類似於BM演算法的好字元字尾匹配規則,和next[]陣列的推導兩點而已。

演算法的思想是:假設現在文字串S匹配到 i 位置,模式串P匹配到 j 位置如果j = -1(標記),或者當前字元匹配成功(即S[i] == P[j]),都令i++,j++,繼續匹配下一個字元;
如果j != -1,且當前字元匹配失敗(即S[i] != P[j]),則令 i 不變,j = next[j]。意味失配時,模式串P相對於文字串S向右移動了j - next [j] 位。換言之,當匹配失敗時,模式串向右移動的位數為:失配字元所在位置 - 失配字元對應的next 值。即移動的實際位數為:j - next[j],且此值大於等於1。

Next陣列的值含義是:代表失配前的字串中,有多大長度的相同的字首字尾。比如Next[j] = k;表示 j 之前的字串中有最大長度為k 的相同字首字尾。
此也意味著在某個字元失配時,該字元對應的next 值會告訴你下一步匹配中,模式串應該跳到j-Next[j]這個位置上。所以重點在於求Next[]。

如下:
ABCDAB ABCDABC ABCDABCDABDABD 文字串
ABCDABD 模式串

最大字首字尾相同數:

  A               左                         右                     
  AB              A                          B                      0
  ABC            A,AB                       C,BC                    0
  ABCD          A,AB,ABC                   D,CD,BCD                 0
  ABCDA        A,AB,ABC,ABCD              A,DA,CDA,BCDA             1
  ABCDAB     A,AB,ABC,ABCD,ABCDA         B,AB,DAB,CDAB,BCDAB        2
  ABCDABD   A,AB,ABC,ABCD,ABCDA,ABCDAB  D,BD,ABD,DABD,CDABD,BCDABD  0

  最大字首字尾公共元素長度對照表
  A   B   C   D   A   B   D 
  0   0   0   0   1   2   0    

Next 陣列考慮的是除當前字元外的最長相同字首字尾,所以通過上步驟求得各個字首字尾的公共元素的最大長度後,只要稍作變形即可:將第求得的值整體右移一位,然後初值賦為-1,如下表格所示:

 A   B   C   D   A   B   D 
-1   0   0   0   0   1   2

在匹配失配時,只需要用失配位置 j 減去 Next[j],就可以得到模式串移動到什麼位置了。

Next[]的求取程式碼實現如下:

int *get_next(const char* str2)
{
    int str2_len = strlen(str2);
    int *next = (int *)malloc(sizeof(int)*str2_len);
    next[0] = -1;
    int left = -1;
    int right = 0;

    while(right < str2_len - 1){
    if(left == -1 || str2[left] == str2[right]){
        left++;
        right++;
        next[right] = left;
    }else
        left = next[left];
    }
    return next;
}

這段程式碼是為了求取Next[]對應的值,並沒有什麼實際意思,能得出正確的Next[]就行(求Next[]的值,網上好像就這一種程式碼實現辦法)。

       0   1   2   3   4   5   6 
 next -1   0   0   0   0   1   2   

我們來根據程式碼驗證下是否準確
str_len - 1~6
left = -1
right = 0
——————————————

       0   1   2   3   4   5   6 
 next -1   0

  left = 0
  right = 1
  next[1] = 0  
--------------------------------------------
  ABCDABD
  right = 1
  left = next[0] = -1
--------------------------------------------
  left = 0
  right = 2
  next[2] = 0
      0   1   2   3   4   5   6 
next -1   0   0

可以看出是正確的(後來的就不用推演了),程式碼設計的很巧妙,能恰好算出Next[]所對應的值。(這段程式碼不需理解,記住就行,就是為了求Next[]而專門設計的演算法)。

求出Next[]的對應值,KMP演算法程式碼就很容易了。

int KMP(const char* str1, const char* str2)
{
    int str1_len = strlen(str1);
    int str2_len = strlen(str2);
    int *next = get_next(str2); 
    int i = 0;
    int j = 0;

    if(str1 == NULL || str2 == NULL){
    return -1;
    }
    while(i < str1_len && j < str2_len){
        if(j == -1 || str1[i] == str2[j]){
            i++;
            j++;
        }else{
            //關鍵一步,失配時根據Next[]跳轉
            j = next[j];
        }
    }
    free(next);  
    if(j == str2_len){
        return (i-str2_len);    
    }
    return -1;
}

KMP的時間複雜度:
我們發現如果某個字元匹配成功,模式串首字元的位置保持不動,僅僅是i++、j++;如果匹配失配,i 不變(即 i 不回溯),模式串會跳過匹配過的next [j]個字元。整個演算法最壞的情況是,當模式串首字元位於i - j的位置時才匹配成功,演算法結束。
所以,如果文字串的長度為n,模式串的長度為m,那麼匹配過程的時間複雜度為-O(n),算上計算next的-O(m)時間,KMP的整體時間複雜度為-O(m + n)。

四種經典的字串匹配演算法介紹完畢,大家在紙上多畫多算,能更好的理解演算法思想。