【數據結構】41_KMP字串查找算法

問題

如何在目標字符串 S 中,查找是否存在子串 P?

樸素解法

int sub_index(const char *s, const char* p)
{
    int ret = -1;
    int sl = strlen(s);
    int pl = strlen(p);
    int len = sl - pl;
    
    for (int i = 0; (ret < 0) && (i <= len); ++i)
    {
        bool equal = true;
        
        for (int j = 0; equal && (j<pl); ++j)
            equal = equal && (s[i + j] == p[j]);
            
        ret = (equal ? i : -1);
    }
    
    return ret;
}

樸素算法的一個線索優化

image.png

由於,pa != pb 且 pb == sb;
因此,Pa != sb;
結論,字串 p 右移 1 位比較,沒有意義且低效ios


優化示例

image.png

image.png

偉大的發現: KMP算法

KMP算法是一種改進的字符串匹配算法,由D.E.Knuth,J.H.Morris和V.R.Pratt提出的,所以人們稱它爲克努特莫里斯普拉特操做(簡稱KMP算法)。KMP算法的核心是利用匹配失敗後的信息,儘可能減小模式串與主串的匹配次數以達到快速匹配的目的算法

  • 匹配失敗時的右移位數與子串自己相關,與目標串無關
  • 移動位數 = 已匹配的字符數 - 對應的部分匹配值
  • 任意字串都存在一個惟一的部分匹配表

部分匹配表示例

image.png

用法:

image.png

==> 第 7 位匹配失敗
==> 前 6 位匹配成功
==> 查表PMT[6]
==> 右移位數 6 - PMT[6] = 6 - 2 = 4編程

問題:部分匹配表怎麼獲得?

關鍵概念

  • 前綴優化

    • 除了最後一個字符之外,一個字符串的所有頭部組合
  • 後綴spa

    • 除了第一個字符之外,一個字符串的所有尾部組合
  • 部分匹配值code

    • 前綴和後綴最長共有元素的長度

示例 :ABCDABD

字符 前綴 後綴 交集 匹配值
1 A 0
2 AB A B 0
3 ABC A,AB BC,C 0
4 ABCD A,AB,ABC BCD,CD,D 0
5 ABCDA A,AB,ABC,ABCD BCDA,CDA,DA,A A 1
6 ABCDAB A,AB,ABC,ABCD,ABCDA BCDAB,CDAB,DAB,AB,B AB 2
7 ABCDABD A,AB,ABC,ABCD,ABCDA,ABCDAB BCDABD,CDABD,DABD,ABD,BD,D 0

"ABCDABD" 部分匹配值爲 2blog

問題: 怎樣編程產生部分匹配表?

實現關鍵 (前輩經驗)

  • (1) PMT[1] = 0 (下標爲 0 的元素匹配值爲 0)
  • (2) 從 2 個字符開始遞推 (從下標爲 1 的字符開始遞推)
  • (3) 假設 PMT[n] = PMT[n - 1] + 1 (最長共有元素的長度)
  • (4) 當假設不成立, PMT[n] 在 PMT[n-1] 的基礎上減小

示例 ababax

  • LL : longest length (前綴、後綴交集元素的最長長度)
  • (a) 當前要求的 LL 值經過歷史 LL 值推導 (+1)
  • (b) 當可選的 LL 值爲0,直接比較首首尾元素
  • (c) 將已匹配最長交集元素做爲種子,分別向後擴展一個字符,比較擴展字符

33.png

分解:

(0) "a" LL = 0
第1個元素的LL值爲0,即 PMT[1] = 0。字符串

(1) "ab" LL = 0
從第1個元素開始推導,假設 PMT[2] = PMT[1] + 1;
判斷假設是否成立:,當可選的LL值爲0,直接比較首尾元素, a != b
假設不成立,因此 PMT[2] = 0;get

(2) "aba" LL = 1
假設 PMT[3] = PMT[2] + 1;
判斷假設是否成立:,當可選的LL值爲0,直接比較首尾元素, a == a
假設成立,因此 PMT[3] = 0 + 1 = 1string

(3) "abab" LL = 2
假設 PMT[4] = PMT[3] + 1;
判斷假設是否成立: 將已匹配最長交集元素做爲種子,分別向後擴展一個字符,比較擴展字符,a-> ab | a->ab
b == b 假設成立,因此 PMT[4] = 1 + 1 = 2

(4) "ababa" LL = 3
假設 PMT[5] = PMT[4] + 1;
判斷假設是否成立: 將已匹配最長交集元素做爲種子,分別向後擴展一個字符,比較擴展字符,ab-> aba | ab->aba
a == a 假設成立,因此 PMT[5] = 2 + 1 = 3

(5) "ababax" LL = 0
假設 PMT[6] = PMT[5] + 1;
判斷假設是否成立: 已匹配最長交集元素做爲種子,分別向後擴展一個字符,比較擴展字符,aba-> abab | aba->abax
b != x 假設不成立;
==> 將已匹配最長交集元素最爲總支再次嘗試匹配
"aba|b" "aba|x" ==> "aba" ==> LL1 = PMT(3) = 1; ==> 交集元素 "a"
將已匹配最長交集元素做爲種子,分別向後擴展一個字符,比較擴展字符:a-> ab | a->ax;
b != x 匹配失敗
==> 將已匹配最長交集元素最爲總支再次嘗試匹配
"a|b" "a|x" ==> "a" ==> LL2 = PMT(1) = 0; 【嘗試匹配結束條件】
當可選的LL值爲0,直接比較首尾元素, a != x, 因此PMT[6] = 0

編程實驗:部分匹配表的遞推與實現

文件:main.cpp

#include <iostream>
#include <cstring>

using namespace std;

int *make_pmt(const char *s)
{
    size_t len = strlen(s);
    int *ret = static_cast<int*>(malloc(sizeof(int) * len));

    if (ret != nullptr)
    {
        int ll = 0;

        // 第0個元素的ll值爲0
        ret[0] = 0;

        for (size_t i=1; i<len; ++i)
        {
            // 假設不成功時,再次嘗試擴展
            while ((ll > 0) && (s[i] != s[ll]))
            {
                ll = ret[ll - 1];
            }

            // 對首尾字符或者擴展後的字符進行判斷
            if (s[i] == s[ll])
            {
                // 假設成功時,ll自加
                ++ll;
            }

            ret[i] = ll;
        }
    }

    return ret;
}

int main()
{
    int * pmt = make_pmt("ababax");

    for (size_t i=0; i<strlen("ababax"); ++i)
    {
        cout << "i" << ":" << pmt[i] << endl;
    }

    free(pmt);
    
    return 0;
}

輸出:

i:0
i:0
i:1
i:2
i:3
i:0

部分匹配表的使用 (KMP算法)

==> 下標 j 處匹配失敗
==> 前 j 位匹配成功
==> 查表 PMT[j-1]
==> 右移位數 j - PMT[j-1]

image.png

由於, S[i] != p[j]
因此,查表, LL = PMT[j - 1]
因而,右移,i 的值不變, j 的值改變, j = j - (j - LL) = LL = PMT[j-1]

編程實驗: KMP 字串查找算法的實現

文件:main.cpp

#include <iostream>
#include <cstring>

using namespace std;

int *make_pmt(const char *p)  // O(m)
{
    size_t len = strlen(p);
    int *ret = static_cast<int*>(malloc(sizeof(int) * len));

    if (ret != nullptr)
    {
        int ll = 0;

        // 第0個元素的ll值爲0
        ret[0] = 0;

        for (size_t i=1; i<len; ++i)
        {
            // 假設不成功時,再次嘗試擴展
            while ((ll > 0) && (p[i] != p[ll]))
            {
                ll = ret[ll - 1];
            }

            // 對首尾字符或者擴展後的字符進行判斷
            if (p[i] == p[ll])
            {
                // 假設成功時,ll自加
                ++ll;
            }

            ret[i] = ll;
        }
    }

    return ret;
}

int kmp(const char *s, const char *p)  // O(m + n)
{
   int ret = -1;
   int sl = strlen(s);
   int pl = strlen(p);
   int *pmt = make_pmt(p);  // O(m)

   if ((pmt != nullptr) && (0 < pl) &&(pl <= sl))
   {
       for (int i=0, j = 0; i < sl; ++i)  // O(n)
       {
           while ((j > 0) && (s[i] != p[j]))
           {
               j = pmt[j - 1];  // j = j - (j - LL) = LL = PMT[j-1];
           }

           if (s[i] == p[j])
           {
               ++j;
           }

           if (j == pl)
           {
               ret = i + 1 - pl;
               break;
           }
       }
   }

   free(pmt);

   return ret;
}

int main()
{
    cout << kmp("abcde", "cde") << endl;
    cout << kmp("abcde", "") << endl;
    cout << kmp("abcde", "a") << endl;
    cout << kmp("abcde", "bc") << endl;

    return 0;
}

輸出:

2
-1
0
1

小結

  • 部分匹配表是提升字串查找效率的關鍵
  • 部分匹配值定義爲前綴和後綴最長共有元素的長度
  • 能夠用遞推的方法產生部分匹配表
  • KMP 利用部分匹配值與字串移動位數的關係提升查找效率

以上內容整理於狄泰軟件學院系列課程,請你們保護原創!

相關文章
相關標籤/搜索