前言

由於需要做一個快速匹配敏感關鍵詞的服務,為了提供一個高效,準確,低能耗的關鍵詞匹配服務,我進行了漫長的探索。這裡把過程記錄成系列部落格,供大家參考。

在一開始,接收到快速敏感詞匹配時,我就想到了 KMP 翻譯過來叫“看毛片“的演算法,因為大學的時候就學過它。聽說到它的效率非常高。把原本字串匹配效率 O(n*m) 縮短到了O(n+m),把✖️變成了➕,真是了不得。

每次我回顧 KMP 演算法時,都會發現自己是個小白,或者每次回顧時,都發現上次因為回顧而寫的總結居然是錯的!所以為了學習快速字串匹配,並再次溫故 KMP ,所以我決定使用 KMP 演算法試一試。如果以後在面試的時候,可以將KMP 完整的寫出來,那豈不是很牛逼?

孔子說過的“溫故而知新” 真的是很有道理的,經過這次回顧,我覺得是時候為此寫一篇全新的部落格了,因為這次的理解肯定是正確的!

KMP 快是因為啥呢?是因為利用了字串公共前後綴的特性,加快了匹配速度,但是轉念一想,敏感關鍵詞公共前後綴相等的情況可是很少的呀。那還有必要用KMP 嗎?

當然有必要了,所謂技多不壓身,瞭解掌握一種演算法準沒壞處,而且還可以比較 KMP 和 C# 中String.Contains()的效率,開拓自己的眼界。

KMP

以前在學習 KMP 的時候,我也看了網上很多部落格,關於這個演算法講解的部落格是非常多的,而且講解的都很細緻。奈何我每看過一次,就會忘記一次。所以這次溫故,我是完全在紙上畫畫,自己理解的。畢竟自己的思路不容易忘,而別人的思路總是很容易忘的。並且,理解一個演算法,得找到適合自己的角度。

因此我理解 KMP 演算法的角度,就是 字串字首和字尾,在我的腦子裡,用字首和字尾去理解 KMP 是很容易的。

公共前後綴長度

字首和字尾,很容易理解,就是一個字串的前半部分和後半部分。比如字串 a b c x y a b c 的字首有

a
a b
a b c 等等,字尾有

c
b c
a b c等等。

那麼公共前後綴的意思就是,字首和字尾相等。在上面這個例子中,公共前後綴 就是 a b c,長度為3。請注意,公共前後綴 和 迴文串是不一樣的哦。

a b c x y c b a 的公共前後綴,只是a ,而不是a b c

原始的字串匹配

瞭解完 公共前後綴後。暫且放在一旁,去了解一下,原始的字串匹配。

首先我們把 待匹配的字串叫做 文字字串,匹配的字串叫做匹配字串,比如我們要在 a b c x y a b c x y a 中匹配 a b c x y a b c y 是否存在。

於是 文字字串 S 就是 :a b c x y a b c x y a

匹配字串 P 就是: a b c x y a b c y

從肉眼看出來,匹配一定是失敗的,因為 匹配字串 最後一個字母 y 不匹配。

那麼原始的字串匹配過程就是 暴力的一位一位去比。首先,從第一位開始比較:

   0   1   2   3   4   5   6   7   8   9  10
   ↓
S  a   b   c   x   y   a   b   c   x   y   a
P  a   b   c   x   y   a   b   c   y
   ↑

第一位,相同比較第二位,一直比到第 8 位

   0   1   2   3   4   5   6   7   8   9  10
                                   ↓
S  a   b   c   x   y   a   b   c   x   y   a
P  a   b   c   x   y   a   b   c   y
                                   ↑

發現不相同,匹配失敗,於是把 匹配字串 向右移動一位。

   0   1   2   3   4   5   6   7   8   9  10
       ↓
S  a   b   c   x   y   a   b   c   x   y   a
P      a   b   c   x   y   a   b   c   y
       ↑

繼續重複上面的過程,直到 文字字串全部遍歷完。 這種方法的效率最差的時候是 O( n*m ) ,就是那種每次都是最後一個字元匹配不了的情況。

快速移動

有沒有更快的方法呢? 肯定是有的。但是不著急,我們還是按照上面的步驟,繼續走下去。

當 匹配字串 一直向右移動,移動到第 5 位的時候,終於發現首字母是匹配的情況了。,如下

   0   1   2   3   4   5   6   7   8   9  10
                       ↓
S  a   b   c   x   y   a   b   c   x   y   a
P                      a   b   c   x   y   a   b   c   y
                       ↑

其實我們發現,從 文字字串 第一位之後的 b c x y 其實都沒必要匹配的,因為它們和 匹配字串首字母都不一樣,如果可以直接跳過就好了。

那麼有什麼依據可以直接跳過嗎?當然有,之前的 公共前後綴 就發揮作用了。

a b c x y a b c y 中的子串 a b c x y a b c 的公共前後綴是 a b c

當一開始,我們發現第 8 位不匹配時,

    0   1   2   3   4   5   6   7   8   9  10
                                    ↓
 S  a   b   c   x   y   a   b   c   x   y  a
 P  a   b   c   x   y   a   b   c   y
                                    ↑

我們可以直接將 匹配字串向右移到第五位,然後再從第 8 位繼續進行判斷

   0   1    2   3   4   5   6   7   8   9  10
                        |           ↓
S  a   b   c    x   y   a   b   c   x   y   a
P                       a   b   c   x   y   a   b   c   y
                        |           ↑

為什麼呢?

因為a b c = a b c啊,在0 - 7 位的字串中,它有公共前後綴a b c,所以我們可以把匹配字串直接移到 公共字尾的起始位置,也就是 第 5位。

因為前面都不用去看,是一定不匹配的!,只有在第五位開始匹配,才有可能成功。

移動的結果,起始就是將一個字串的字首部分,移到和字尾部分對齊。這是成功匹配的前提。你可以想象成 :匹配字串的子串一直在找自己的字尾,然後靠上去,去匹配。

如下

          字尾              
a b c x y a b c
          a b c  x y a b c
          字首

那麼這樣移動之後,咱們就可以接著 第 8 位 繼續往下匹配,而不用從頭再來了。所以這種方法下,文字字串只遍歷一次,它不會倒退的。

這就是我所理解的 KMP 演算法的核心思想。** KMP 就是利用字串的字首和字尾做文章**

具體過程

KMP 演算法的物理核心思想理解了,接下來就是程式碼實現了。如果儲存 匹配字串的公共前後綴資訊,以及它的子串的公共前後綴資訊呢?一旦匹配不成功,我怎麼確定匹配字串的子串移動多少位,恰好靠上字尾呢?

第一個問題,用一個數組就可以維護,這是大家都耳熟能詳的Next陣列

Next 陣列,Next[i] 表示的是 從 0 開始到 i 結束的子串 的最長公共前後綴的長度 ,咱們舉個栗子就很好理解了。比如下面的字串 s :

a   b   a   b   c   a   b

Next [ 0 ] => a ,只有一個字元,字首和字尾的概念這裡就不存在了,所以Next [ 0 ] = 0

Next [ 1 ] => a b,字首 a 不等於字尾 b ,所以也是 0,Next[ 1 ] = 0

Next [ 2 ] => a b a,字首 a 等於字尾b,但是字首a b不等於字尾b a ,所以 Next[ 2 ] = 1

Next [ 3 ] => a b a b,字首 a b等於字尾a b,所以Next[ 3 ] = 3

經過上面的栗子,大概就可以知道 Next 陣列是幹嘛的了吧。回到之前的匹配字串 P:

P  a b c x y a b c y

它的 Next 陣列是啥呢?看著字串演算法一下就可以得出了

Next[9]= { 0 , 0 , 0 , 0 , 0 , 1 , 2 , 3, 0 }

當我們匹配到第8位,也就是最後一個字元的時候,發現不匹配了

    0   1   2   3   4   5   6   7   8   9  10
                                    ↓
S   a   b   c   x   y   a   b   c   x   y   a
P   a   b   c   x   y   a   b   c   y
                                    ↑

於是我們可以直接將 匹配字串 向右移動 5位,

   0   1    2   3   4   5   6   7   8   9  10
                        |           ↓
S  a   b   c    x   y   a   b   c   x   y   a
P                       a   b   c   x   y   a   b   c   y
                        |           ↑
                        0   1   2   3   4   5   6   7   8

這個過程其實就是,當 S [ 8 ] != P [ 8 ] 時 ,S [ 8 ] 直接繼續和 P [ 3 ] 進行比較,依據就是 Next [ 7 ] 的值是 3

因為子串 P[ 0-7 ] 的最大公共前後綴長度是 3,所以S[ 8 ] 只要和 公共字首的下一個字元P[ Next[ 7 ] ] (Next[ i ] 同樣也是公共字首的下一個字元的下標,這很好理解)進行比較,也就是 P[ 3 ],這麼做的的原因是 P[ 0 ],P[ 1 ],P[ 2 ] 和 S[ 5 ] ,S[ 6 ],S[ 7 ] 是公共前後綴,它們都是一樣的!

以上,就是經典 KMP 演算法的全部過程。

程式碼實現

先是要求 Next[] 陣列,怎麼求呢?很簡單,咱們利用動態規劃的思想。Next[ i ]的值要麼是在已有最長公共前後綴的字串基礎上 +1 ,要麼子串一個符合的都沒有,自己另起爐灶。

Next[ i ] 的值有兩種情況:

  • Next [ i - 1 ]不為 0,說明子串 中有公共前後綴,那我就去字串中公共字首的下一個字串 P[ Next [ i-1 ] ],如果P[ i ] == P [ Next [ i - 1] ],那麼公共前後綴長度就+1 也就是 Next [ i ] = Next[ i -1 ]+1。那如果不相等呢?那就去找 P [ Next [ i-1 ] ] 的 Next 值,重複上面的過程,有點遞迴的意思。其實這個過程就是在找字串裡的公共字首,看看有沒有符合條件的(即P [ i ] == P[Next [ k] ]),沒有的話,就在字首裡再去找字首,直到找到為止,或者發現已經沒用公共字首了,那就跳出來。
  • 發現子串沒有符合條件,讓自己+1的,於是只能從自己開始,看看P[ i ] == P[ 0 ] 如果相等,那就是1 ,如果不相等,那就只能是0 了。

程式碼實現如下,理解了其實還是很簡單的,隨時都能手寫出來,也不會忘記。

    void getNext(string str)
    {
        next[0]=0;
        for(int i=1;i<str.length();i++)
        {
            int k=next[i-1];
            while(k!=0&&str[i]!=str[k])
            {
                k=next[k-1];
            }
            
            
            if(str[i]==str[k])
                next[i]=k+1;
            else
                next[i]=0;
            
        }
    }

這就是我對Next 陣列的理解,我覺得這樣理解,我能記得住。

還有一種很精簡版的Next 陣列實現,我不打算貼出來,亂我心志,我就用我能理解,能看懂的程式碼。

Next 陣列求出來,就是字串匹配了。也很簡單哦。

int KMP(string content,string str)
    {
        getNext(str);
        
        int i=0,j=0;
        while(i<content.length()&&j<str.length())
        {
            if (j==0 ||content[i]==str[j])
            {
                if(content[i]==str[j])
                    j++;
                i++;
            }
            else
            {
                j=next[j-1];
            }
        }
        
        if(j>=str.length())
        {
            return i-str.length();
        }
        else
            return -1;
    }

j = next [j-1] 就是我上面所有的,移動的過程,其他的也很好理解的。

然後可以用KMP 去通過LeetCode 的一道題目,以檢測自己寫的程式碼是否正確:https://leetcode.com/problems/implement-strstr/

總結

KMP 演算法就介紹到這裡了,關於KMP 還有很多升級的版本。

字串快速匹配,第一彈,看毛片。回顧一下,感覺以後應該都不會忘記了吧。

開頭說的 把 KMP 和C#的 String.Contains 進行PK ,要留到下一篇博文裡。下一篇博文將對 字串的匹配的效能來個大排序,並且見識一下微軟的黑科技。