KMP算法詳解

相信很多人(包括自己)初識KMP算法的時候始終是丈二和尚摸不着頭腦,要麼完全不知所云,要麼看不懂書上的解釋,要麼自己覺得好像心裏瞭解KMP算法的意思,卻說不出個究竟,所謂知其然不知其所以然是也。
     經過七八個小時地仔細研究,終於感覺自己能說出其所以然了,又覺得數據結構書上寫得過於簡潔,不易於初學者接受,於是決定把自己的理解拿出來與大家分享,希望能拋磚引玉,這便是Bill寫這篇文章想要得到的最好結果了
-----------------------------------謹以此文,獻給剛接觸KMP算法的朋友,定有不足之處,望大家指正----------------------------------------
 
 
 
【KMP算法簡介】
 
         KMP算法是一種改進後的字符串匹配算法,由D.E.Knuth與V.R.Pratt和J.H.Morris同時發現,因此人們稱它爲克努特——莫里斯——普拉特操作(簡稱KMP算法)。通過一個輔助函數實現跳過掃描不必要的目標串字符,以達到優化效果。
 
 
 
【傳統字符串匹配算法的缺憾】
 
         Bill認爲,對於一種優化的算法,既要知道優化的細節,也更應該瞭解它的前身(至於KMP是否基於傳統算法,我不清楚,這裏只作語境上的前身),瞭解是什麼原因導致了人們要去優化它,因此加入了這一段:
請看以下傳統字符串匹配的代碼:
C++ code

 
void NativeStrMatching( ElemType Target[], ElemType Pattern[] )            
{            
        register int TarLen = 0;        // Length of Target            
        register int PatLen = 0;        // Length of Pattern            
         
        // Compute the length of Pattern            
        while( '\0' != Pattern[PatLen] )            
                PatLen++;            
         
        while( '\0' != Target[TarLen] )            
        {            
                int TmpTarLen = TarLen;            
                for(int i=0; i<PatLen; i++)            
                {            
                        if( Target[TmpTarLen++] != Pattern[i] )            
                                break;            
                        if( i == PatLen-1 )            
                                cout<<"Native String Matching,pattern occurs with shift "<<TarLen<<endl;            
                }            
                TarLen++;            
        }            
}    

 
【代碼思想】
     傳統匹配思想是,從目標串Target的第一個字符開始掃描,逐一與模式串的對應字符進行匹配,若該組字符匹配,則檢測下一組字符,如遇失配,則退回到Target的第二個字符,重複上述步驟,直到整個Pattern在Target中找到匹配,或者已經掃描完整個目標串也沒能夠完成匹配爲止。
     這樣的算法理解起來很簡單,實現起來也容易,但是其中包含了過多不必要的操作,也就是在目標串中,有些字符是可以直接跳過,不必檢測的。
不妨假設我們的目標串
Target =  "a b c d e a b c d e a b c d f"
需要匹配的模式串
Pattern = "c d f";
那麼當匹配到如下情況時
 
       

 
 
 
由於 'e' != 'f' ,因此失配,那麼下次匹配起始位置就是目標串的'd'字符
     

 
 
 
 
 
我們發現這裏照樣失配,直到運行到下述情況
 

 
 
 
 
 
 
也就是說,中間的四個字符 d e a b 完全沒有必要檢測,直接跳轉到下一個'c'開始的地方進行檢測  
    
     由此可見傳統算法雖然簡單易行,但其中包含了過多的不必要操作,並不能很好地達到實際工作中需要的效率,因此個人認爲此方法適合爲初識字符串匹配做一個鋪墊作用,有拋磚引玉之意。
     說其拋磚引玉並不爲過,對KMP算法的理解便可以基於傳統模式串匹配算法進行思考。
 
 
 
【KMP算法的引入】
 
     既然知道了傳統算法的不足之處,就要對症下藥,優化這個冗餘的檢測算法。
     KMP算法就能很好地解決這個冗餘問題。
     其主要思想爲:
          在失配後,並不簡單地從目標串下一個字符開始新一輪的檢測,而是依據在檢測之前得到的有用信息(稍後詳述),直接跳過不必要的檢測,從而達到一個較高的檢測效率。
     如我們的
 
 
        當第一次失配後,並不從紅色標記字符'd'開始檢測,而是通過一些有用信息,直接跳過後幾個肯定不可能匹配的冗餘字符,而直接讓模式串Pattern從目標串的紅色標記字符'c'開始新一輪的檢測,從而達到了減少循環次數的效果
 
 
 
【KMP算法思想詳述與實現】
 
        前面提到,KMP算法通過一個“有用信息”可以知道目標串中下一個字符是否有必要被檢測,這個“有用信息”就是用所謂的“前綴函數(一般數據結構書中的next函數)”來存儲的。
        這個函數能夠反映出現失配情況時,系統應該跳過多少無用字符(也即模式串應該向右滑動多長距離)而進行下一次檢測,在上例中,這個距離爲4.
        總的來講,KMP算法有2個難點:
              一是這個前綴函數的求法。
              二是在得到前綴函數之後,怎麼運用這個函數所反映的有效信息避免不必要的檢測。
下面分爲兩個板塊分別詳述:
 
 
【前綴函數的引入及實現】
 
【前綴函數的引入】
        對於前綴函數,先要理解前綴是什麼:
        簡單地說,如字符串A = "abcde"        B = "ab"
        那麼就稱字符串B爲A的前綴,記爲B ⊏ A(注意那不是"包含於",Bill把它讀作B前綴於A),說句題外話——"⊏"這個符號很形象嘛,封了口的這面相當於頭,在頭前面的就是前綴了。
        同理可知 C = "e","de" 等都是 A 的後綴,以爲C ⊐ A(Bill把它讀作C後綴於A)
      
理解了什麼是前、後綴,就來看看什麼是前綴函數:
        在這裏不打算引用過多的理論來說明,直接引入實例會比較容易理解,看如下示例:
 
      (下述字符若帶下標,則對應於圖中畫圈字符)
      這裏模式串 P = “ababaca”,在匹配了 q=5 個字符後失配,因此,下一步就是要考慮將P向右移多少位進行新的一輪匹配檢測。傳統模式中,直接將P右移1位,也就是將P的首字符'a'去和目標串的'b'字符進行檢測,這明顯是多餘的。通過我們肉眼的觀察,可以很簡單的知道應該將模式串P右移到下圖'a3'處再開始新一輪的檢測,直接跳過肯定不匹配的字符'b',那麼我們“肉眼”觀察的這一結果怎麼把它用語言表示出來呢?
 

     我們的觀察過程是這樣的:
          P的前綴"ab"中'a' != 'b',又因該前綴已經匹配了T中對應的"ab",因此,該前綴的字符'a1'肯定不會和T中對應的字串"ab"中的'b'匹配,也就是將P向右滑動一個位移是無意義的。
          接下來考察P的前綴"aba",發現該前綴自身的前綴'a1'與自身後綴'a2'相等,"a1 b a2" 已經匹配了T中的"a b a3",因此有 'a2' == 'a3', 故得到 'a1' == 'a3'......
          利用此思想,可推知在已經匹配 q=5 個字符的情況下,將P向右移 當且僅當 2個位移時,才能滿足既沒有冗餘(如把'a'去和'b'比較),又不會丟失(如把'a1' 直接與 'a4' 開始比較,則丟失了與'a3'的比較)。
          而前綴函數就是這樣一種函數,它決定了q與位移的一一對應關係,通過它就可以間接地求得位移s。
   
     通過對各種模式串進行上述分析(大家可以自己多寫幾個模式串出來自己分析理解),發現給定一個匹配字符數 q ,則唯一對應一個有效位移,如上述q=5,則對應位移爲2.
     這就形成了一一對應關係,而這種唯一的關係就是由前綴函數決定的。
     這到底是怎樣的一種關係呢?
     通過對諸多模式串實例的研究,我們會找到一個規律(規律的證明及引理詳見《算法導論(第二版)》)。
     上例中,P 已經匹配的字符串爲"ababa",那麼這個字符串中,滿足既是自身真後綴(即不等於自身的後綴),又是自身最長前綴的字符串爲"aba",我們設這個特殊字串的長度爲L,顯然,L = 3. 故我們要求的 s = q - L = 5 - 3 = 2 ,滿足前述分析。
   
     根據這個規律,即可得到我們要求的有效位移s,等於已經匹配的字符數 q 減去長度 L。
     即 s = q - L
     因爲這個長度 L 與 q 一一對應,決定於q,因此用一函數來表達這一關係非常恰當,這就是所謂的前綴函數了。
     因爲已經分析得到該關係爲一一對應關係,因此用數組來表示該函數是比較恰當的,以數組的下標表示已經匹配的字符數 q,以下標對應的數據存儲 L。
 
【前綴函數的實現】
   
下面就來分析怎麼用代碼來表達這種關係。
這裏採用《算法導論(第二版)》中的思想求解。
不妨以 PrefixFunc[] 表示這個前綴函數,那麼我們將得到以下求前綴函數的函數:
由於 0 個匹配字符數在計算中沒有意義,因此PrefixFunc下標從1開始,也就是從已經有一個字符(即首字符)匹配的情況開始
C++ code
 
// Compute Prefix function            
void CptPfFunc( ElemType Pattern[], int PrefixFunc[] )                
{      
        register int iLen = 0;    // Length of Pattern[]            
        while( '\0' != Pattern[iLen] )            
                iLen++;            
                    
        int LOLP = 0;     // Lenth of longest prefix            
        PrefixFunc[1] = 0;            
         
        for( int NOCM=2; NOCM<iLen+1; NOCM++ )     // NOCM represent the number of characters matched            
        {            
                while( LOLP>0 && (Pattern[LOLP] != Pattern[NOCM-1]) )            
                        LOLP = PrefixFunc[LOLP];            
                if( Pattern[LOLP] == Pattern[NOCM-1] )            
                        LOLP++;            
                PrefixFunc[NOCM] = LOLP;            
        }            
}            
    

 

    
 對此函數的詳解,不妨以一實例帶入(建議大家自己手算一下,算完應該就有感覺了),易於理解:
                            
 不妨設模式串Pattern = "a  b  c  c  a  b  c  c  a  b  c  a"
      Pattern 數組編號: 0  1  2  3  4  5  6  7  8  9 10 11
NOCM 表示 已經匹配的字符數
LOLP 表示 既是自身真後綴又是自身最長前綴的字符串長度
以下是計算流程:
PrefixFunc[1] = 0; //只匹配一個字符就失配時,顯然該值爲零
LOLP = 0;   NOCM = 2;   LOLP = 0;    PrefixFunc[2] = 0;
LOLP = 0;   NOCM = 3;   LOLP = 0;    PrefixFunc[3] = 0;
LOLP = 0;   NOCM = 4;   LOLP = 0;    PrefixFunc[4] = 0;
LOLP = 0;   NOCM = 5;   LOLP = 1;    PrefixFunc[5] = 1;
LOLP = 1;   NOCM = 6;   LOLP = 2;    PrefixFunc[6] = 2;
LOLP = 2;   NOCM = 7;   LOLP = 3;    PrefixFunc[7] = 3;
LOLP = 3;   NOCM = 8;   LOLP = 4;    PrefixFunc[8] = 4;
LOLP = 4;   NOCM = 9;   LOLP = 5;    PrefixFunc[9] = 5;
LOLP = 5;   NOCM = 10; LOLP = 6;    PrefixFunc[10] = 6;
LOLP = 6;   NOCM = 11; LOLP = 7;    PrefixFunc[11] = 7;
LOLP = 7;   NOCM = 12;

---------此時滿足條件while( LOLP>0 && (Pattern[LOLP] != Pattern[NOCM-1]) )-------------

while語句中的執行
{
           LOLP = 7;   NOCM = 12;  LOLP = PrefixFunc[7] = 3;
           LOLP = 3;   NOCM = 12;  LOLP = PrefixFunc[3] = 0;
}

LOLP = 0;   NOCM = 12; LOLP = 1;    PrefixFunc[12] = 1;
最後我們的前綴函數 PrefixFunc[] = { 0,0,0,0,1,2,3,4,5,6,7,1 }
其間最精妙的要屬失配時的操作
while( LOLP>0 && (Pattern[LOLP] != Pattern[NOCM-1]) )
              LOLP = PrefixFunc[LOLP];
其中 LOLP = PrefixFunc[LOLP];  遞歸調用PrefixFunc函數,直到整個P字串都再無最長前綴或者找到一個之前的滿足條件的最長前綴。
 
 
 
 
 【應用前綴函數優化傳統匹配算法——KMP算法實現】

由以上分析,不難推導KMP算法的實現
C++ code
void KMPstrMatching( ElemType Target[], ElemType Pattern[] )            
{            
        int PrefixFunc[MAX_SIZE];            
        register int TarLen = 0;            
        register int PatLen = 0;            
         
        // Compute the length of array Target and Pattern            
        while( '\0' != Target[TarLen] )            
                TarLen++;            
         
        while( '\0' != Pattern[PatLen] )            
                PatLen++;            
                    
        // Compute the prefix function of Pattern            
        CptPfFunc( Pattern, PrefixFunc );            
         
        int NOCM = 0;     // Number of characters matched            
         
        for( int i=0; i<TarLen; i++ )            
        {            
                while( NOCM>0 && Pattern[NOCM] != Target[i] )            
                        NOCM = PrefixFunc[NOCM];            
                if( Pattern[NOCM] == Target[i] )            
                        NOCM++;            
                if( NOCM == PatLen )            
                {            
                        cout<<"KMP String Matching,pattern occurs with shift "<<i - PatLen + 1<<endl;            
                        NOCM = PrefixFunc[NOCM];            
                }            
        }            
}        
 
/*
** 由於時間關係,沒能將上述KMP算法的實現細節一一講清,以後有時間補上
*/
【參考文獻】
《Introduction to Algorithms》Second Edition
by Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest and Clifford .
本文轉自原文鏈接
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章