(轉) KMP算法和狀態機的聯繫

學了很久的KMP算法,直到今天看到這一篇關於KMP與狀態機分析的文章,纔對所學的KMP有了更深一層的理解。這篇文章不管對於初學者還是已經已經學過KMP的人,都是很好的借鑑。特此轉來共享!

博客原址:http://blog.csdn.net/virtualxmars/article/details/8130868




應羣裏很多師弟的要求,現站出來解釋解釋大學時期學過的KMP算法本質是什麼。以及如何理解、應用它。


首先,KMP只是一個將字符串搜索變成狀態機的狀態命中檢測應用。然後,我要解釋什麼是狀態機了。用一個例子解釋再好不過:

假設有一臺糖果自動販賣機,擁有無限個糖果,可以進行的操作有:
1.開機
2.投幣
4.退幣
3.轉動搖桿
4.關機

那麼,看看下面的操作流程中,哪項是可以獲取糖果的?( )
a.開機、轉動搖桿
b.投幣、退幣、轉動搖桿
c.退幣、開機、退幣、退幣、退幣
d.開機、投幣、轉動搖桿

也許d是正確答案?正確!可是怎麼形式化地描述這個結果?爲什麼這個操作序列可以讓你有糖果吃,而a、b、c則不行?

下面讓我們轉換到狀態機模式來解釋它。


根據描述,我們可以爲糖果機劃分幾個狀態:
1.closed
2.empty
3.coins
4.canddy

對於每一個狀態,操作的結果是不一樣的。例如,當closed狀態時,關機操作沒有任何效果,即系統會依舊停留在closed狀態。而當系統處於coins狀態時,進行搖桿操作,則會轉換到canddy狀態!詳細的操作、狀態轉表如下(沒有下劃線CSDN無法對齊,將就着看)

_____ closed__empty__coins__canddy

開機__empty____/______/_______/
投幣____/____coins____/_______/
退幣____/______/_____empty____/
搖桿____/______/_____canddy___/
關機____/____closed__closed____/

那麼,這又和字符串搜索有什麼關係呢?讓我們試一下上面的操作看成是不同的字符:
開機->a
投幣->b
退幣->c
搖桿->d
關機->e
並且認爲字符集僅包含這些字符。

那麼,字符串搜索命中的過程,實際上就可以認爲是:一個糖果機操作流(即字符串)是否能最終獲取糖果(canddy狀態)的過程。

對於上面選擇題中的4個選項,可以分別“翻譯”爲字符串:
ad, bcd, caccc, abd

那麼,它們分別到達的最終狀態爲:
ad->empty
bcd->closed
caccc->empty
abd->canddy

所以,abd就是最終可以獲取糖果的“模式”——即我們搜索的子字符串。

反過來,如果我們有一個待搜索的字符串str,以及模式子串pattern,該怎麼理解呢?
例如《算法導論》上的例子:
str:"abcbababaabcbab"
pattern:"ababaca"

因爲pattern有7個字符組成,所以我們可以認爲命中模式應該有8個狀態,分別爲:
0.初始狀態(什麼都沒命中)
1.命中第一個位置的a
2.命中第二個位置的b
3.命中第三個位置的a
4.命中第四個位置的b
5.命中第五個位置的a
6.命中第六個位置的c
7.命中第七個位置的a

如果源字符串(操作流)能讓驅動狀態值達到狀態8,則表示找到了模式命中。
但請仔細考慮下面幾種源串的情況:
1)aa
遇到第一個a時,顯然應該進入狀態1,但遇到第二個a,顯然不符合預期,是否需要回到狀態0?答案是不需要的。雖然未能匹配第二個b,但因爲導致失配的字符是a,所以我們可以直接認爲即使失敗了,它也能作爲新一輪搜索時,模式串“ababaca”第一個字符命中的依據。所以狀態可以回到1,而不是0。
2)abaa
如剛纔的描述,子串aba已經達到了3號狀態,但最後的a與期望字符b不符,此時也不需要返回狀態0。因爲最後的那個a實際上可以認爲是新的搜索開始。所以,狀態轉換到1號狀態。
3)ababaa
同情況2),狀態轉換到1
4)ababab
這個比較有意思了,當命中了ababa時,當前狀態爲5,而隨後遇到字符b,而不是期待的c,應該轉換到哪個狀態?答案應該是狀態4。原因是,目前的ababab序列,雖然不能使狀態前進到6,但後四個字符組成的子序列abab能使狀態機驅動到狀態4,所以完全可以從後者繼續,而不需要從頭開始匹配。

還有一些類似的情況,參考更詳細的狀態轉換表,如下:
    0   1   2   3   4   5   6   7
-----------------------------------
a | 1   1   3   1   5   1   7   / 
b | 0   2   0   4   0   4   0   /
c | 0   0   0   0   0   6   0   /

有了這張狀態轉換表,我們就可以很輕鬆地用僞代碼將字符串搜索過程轉換爲如下的模式命中過程:
state <- 0;
for each ch in str
{
drive state by ch;
if (state==7)
        break;
}
hit <- (state==7);

那麼,針對上述模式的搜索程序即可硬編碼爲C++代碼:
  1. #include <iostream>  
  2. using namespace std;  
  3.   
  4. // 狀態轉換表 for "ababaca"    
  5. int statesTable[][7] = {  
  6. <span style="white-space:pre">  </span>{ 1, 1, 3, 1, 5, 1, 7 },  
  7.     { 0, 2, 0, 4, 0, 4, 0 },  
  8.     { 0, 0, 0, 0, 0, 6, 0 },  
  9. };  
  10.   
  11. int main() {  
  12.     const char* str = "abababacaba";  
  13.     const int len_pattern = strlen("ababaca");  
  14.   
  15.     int len = strlen(str);  
  16.     int i;  
  17.     int state = 0;  
  18.     for (i=0; i<len; ++i) {  
  19.         char ch = str[i];  
  20.         state = statesTable[ch-'a'][state];  
  21.   
  22.         if (state==7)  
  23.             break;  
  24.     }  
  25.   
  26.     if (state==7)  
  27.         cout<<"found at pos:"<<(i-len_pattern+1)<<endl;  
  28.     else  
  29.         cout<<"not found!"<<endl;  
  30.   
  31.     return 0;  
  32. }  

稍微休息一下,因爲,這~只是開始。


值得注意的是,KMP算法僅僅是將上面的狀態轉換表再換了一種方式來表達罷了。即:當發生不匹配時,應該從pattern的哪一個字符開始重新匹配?


對於模式"ababaca",讓我們逐個說明,如果:
1.第一個a都無法匹配,當然之後需要重頭開始了。
2.從第二個b無法匹配,必須從頭開始
3.從第三個a無法匹配,必須從頭開始
4.從第四個b無法匹配,這時就有點意思了,因爲當前命中了aba,而後面的a可以有效地做出提示:雖然不能繼續匹配,但最起碼已命中的串中,包含了一個a,那麼我們只需要從pattern的第二個b開始匹配,而不需要從頭開始。
5.從第五個a無法匹配,此時已經匹配了abab,所以下一個匹配可以從pattern的第三個a開始。
6.從第六個c無法匹配,此時已經匹配了ababa,所以下一個匹配可以從pattern的第四個b開始。
7.從第七個a無法匹配,此時已經匹配了ababac,我們可以選擇利用已命中的a,ab模式,調整pattern的位置,然後繼續匹配。如下:
  最後未匹配的a:
    ababacbabac
    ababaca

  =>ababacbabac
      ababaca
        ababaca

但這麼做是多餘的。因爲既然最後還命中了一個c,則可以肯定的是:無論這兩種調整中的哪一種,最終都必然會因爲已經遇到的c而不匹配。所以,需要從頭開始

可以從上面得出的規律既是:當發生失配時,對於pattern中已經命中的部分來說,找出該部分儘可能相同的前綴和後綴相同的部分的長度,那麼下一個該匹配的位置應該從這個相等部分的下一個字符開始匹配。

可形式化描述爲:
next[i] = 最大的k, 使得pattern[0...k-1]==pattern[i-k, i-1];

那麼,這和前面所說的狀態機之間怎麼聯繫起來?再看一次狀態轉換表,可以這麼理解:
    a   b   a   b   a   c   a          <=   pattern
    -1  0   0   1   2   3   0          <=   next值
    0   1   2   3   4   5   6   7      <=   狀態值
-----------------------------------
a | 1   1   3   1   5   1   7   / 
b | 0   2   0   4   0   4   0   /
c | 0   0   0   0   0   6   0   /


除了next[0] = -1外,next[i] 的值,應該是pattern的子pattern[0...i-1]所能包含的所有可能後綴
    pattern[1...i-1], pattern[2...i-1],pattern[3...i-1] ……
這些子串中,能將狀態驅動得到的最大值,即爲next[i]的值。

例如,next[4],對應的是:
    ababaca  的子pattern[0...3],即
    abab     所包含的所有可能後綴
           b     <= pattern[3]
         ab     <= pattern[2...3] 
       bab     <= pattern[1...3]
分別用這3個後綴字符串,從狀態0開始,能到達的最大狀態值分別爲:
    b   -> 0
    ab  -> 2
    bab -> 2
所以,next[4] = 2

C++測試代碼如下:
  1. #include <iostream>  
  2. using namespace std;  
  3. // 狀態轉換表 for "ababaca"    
  4. int statesTable[][7] = {  
  5.     { 1, 1, 3, 1, 5, 1, 7 },  
  6.     { 0, 2, 0, 4, 0, 4, 0 },  
  7.     { 0, 0, 0, 0, 0, 6, 0 },  
  8. };  
  9.   
  10. int driveState(const char* str, int n) {  
  11.     int state = 0;  
  12.     int i=0;  
  13.     while (n>0) {  
  14.         state = statesTable[str[i]-'a'][state];  
  15.         ++i;  
  16.         --n;  
  17.     }  
  18.   
  19.     return state;  
  20. }  
  21.   
  22. int main() {  
  23.     const char* pattern = "ababaca";  
  24.     int next[7] = {};  
  25.     next[0] = -1;  
  26.     int len = strlen(pattern);  
  27.     for (int i=1; i<len; ++i) {  
  28.         int state = 0;  
  29.         const char* beg = pattern + 1;  
  30.         const char* end = pattern + i;  
  31.         while(beg<end) {  
  32.             if (driveState(beg, end-beg)>state)  
  33.                 state = driveState(beg,end-beg);  
  34.   
  35.             ++beg;  
  36.         }  
  37.         next[i] = state;  
  38.     }  
  39.   
  40.     for (int i=0; i<len; ++i)   
  41.         cout<<next[i]<<' ';  
  42.   
  43.     return 0;  
  44. }  


KMP算法包括有窮狀態自動機的搜索算法,所能帶來的主要利益有兩點:

1.不需要對源數據的回退訪問。

    曾經,我參與的一個項目需要對海量數據進行搜索。處理過程中,需要將源數據進行分塊(例如10K),在其中進行搜索後,再搜索下一塊。但這樣的處理方式會導致遺漏,如圖

     |________第一塊_________|________第二塊__________|

     |_________abc_________abc_________________abc__|

如果在這兩塊數據中搜索"abc",應該結果有3個命中,但實際上,由於第二個"abc"處於兩塊數據交匯處,所以導致搜索遺失。補救辦法是對前一塊的結尾取一小段數據,再加上後一塊前面的一小段數據,進行二次搜索。如

     |__________________[___abc___]___________________|


但,這種打補丁式的處理方式又會導致另一種bug:重複命中!這取決於被搜索的模式串長度,以及這個被重複搜索區域的大小。做個一般性假設:補救辦法中,爲了準確,而將重疊區取的很大,那很可能包含了前面的abc。

     |________[_abc_________abc_________]_____________|

這時,爲了避免重複的命中,我們還需要維護一個命中表,用來進行“去重”的操作。這顯然是在補丁上打補丁的做法。

最終,這個問題的解決,需要用到KMP或狀態機搜索算法。前面的算法分析中,一個非常有意義的細節是:我們一直沒有對源數據進行回退訪問!對狀態機來說,每一個源字符都會驅動狀態的變化,如果產生失配,則下一個狀態也應該由源的下一個字符來決定,而不需要用之前的源數據。對於KMP來說,情況類似。

這種性質對於海量數據的搜索,以及一些不方便回退數據的搜索(如磁帶機)都是非常有幫助的。

2.算法效率提升

    這個好處是受限的,因爲無論是計算狀態轉換表,還是next數組,都需要一定的時間,但如果源數據量非常大,那麼這種前期處理時間將被攤分,直到可以認爲其對複雜度的影響爲零。


另外簡單提到一點,這種算法還是可擴展的,對於多個模式串的搜索,如“abcd”,“abde”。狀態轉換表中,只需要添加兩個狀態即可支持,並且源數據只需要遍歷一次。而樸素的搜索方法,需要針對每一個模式串都遍歷一次源數據。所以,狀態機搜索一般也應用於多模式搜索的情境中

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章