1. 程式人生 > >KMP算法詳解V1

KMP算法詳解V1

意義 最重要的 計算機 當前 [] 恰恰 ret 解析 信息

引言

KMP算法指的是字符串模式匹配算法,問題是:在主串T中找到第一次出現完整子串P時的起始位置。該算法是三位大牛:D.E.Knuth、J.H.Morris和V.R.Pratt同時發現的,以其名字首字母命名。在網上看了不少對KMP算法的解析,大多寫的不甚明了。直到我看到一篇博客的介紹,看完基本了解脈絡,本文主要是在其基礎上,在自己較難理解的地方進行補充修改而成。該博客地址為:https://www.cnblogs.com/yjiyjige/p/3263858.html,對作者的明晰的解析表示感謝。

1. 一般的解法

KMP算法要解決的問題就是在字符串(也叫主串)中的模式(pattern)定位問題。說簡單點就是我們平時常說的關鍵字搜索。模式串就是關鍵字(接下來稱它為P),如果它在一個主串(接下來稱為T)中出現,就返回它的具體位置,否則返回-1(常用手段)。

技術分享圖片

首先,對於這個問題有一個很直接的想法:從左到右一個個匹配,如果這個過程中有某個字符不匹配,就跳回去,將模式串向右移動一位。這有什麽難的?

我們可以這樣初始化:

技術分享圖片

之後我們只需要比較i指針指向的字符和j指針指向的字符是否一致。如果一致就都向後移動,如果不一致,如下圖:

技術分享圖片

A和E不相等,那就把i指針移回第1位(假設下標從0開始),j移動到模式串的第0位,然後又重新開始這個步驟:

技術分享圖片

基於這個想法我們可以得到以下的程序:

技術分享圖片
 1 /**
 2 
 3  * 暴力破解法
 4 
 5  * @param ts 主串
 6 
 7  * @param ps 模式串
 8 
 9  * @return 如果找到,返回在主串中第一個字符出現的下標,否則為-1
10 
11  */
12 
13 public static int bf(String ts, String ps) {
14 
15     char[] t = ts.toCharArray();
16 
17     char[] p = ps.toCharArray();
18 
19     int i = 0; // 主串的位置
20 
21     int j = 0; // 模式串的位置
22 
23     while (i < t.length && j < p.length) {
24 
25        if (t[i] == p[j]) { // 當兩個字符相同,就比較下一個
26 
27            i++;
28 
29            j++;
30 
31        } else {
32 
33            i = i - j + 1; // 一旦不匹配,i後退
34 
35            j = 0; // j歸0
36 
37        }
38 
39     }
40 
41     if (j == p.length) {
42 
43        return i - j;
44 
45     } else {
46 
47        return -1;
48 
49     }
50 
51 }
技術分享圖片

上面的程序是沒有問題的,但不夠好!(想起我高中時候數字老師的一句話:我不能說你錯,只能說你不對~~~)

註意:該算法程序很簡單,非常好理解,請認真看完,因為後面的算法是在該算法基礎上修訂的。

2.如果人眼來優化的話,怎樣處理

參考上面的算法,我們串中的位置指針i,j來說明,第一個位置下標以0開始,我們稱為第0位。下面看看,如果是人為來尋找的話,肯定不會再把i移動回第1位,因為主串匹配失敗的位置(i=3)前面除了第一個A之外再也沒有A,我們為什麽能知道主串前面只有一個A?因為我們已經知道前面三個字符都是匹配的!(這很重要)。移動過去肯定也是不匹配的!有一個想法,i可以不動,我們只需要移動j

即可,如下圖:

技術分享圖片

上面的這種情況還是比較理想的情況,我們最多也就多比較了再次。但假如是在主串“SSSSSSSSSSSSSA”中查找“SSSSB”,比較到最後一個才知道不匹配,然後i回溯,這個的效率是顯然是最低的。

大牛們是無法忍受“暴力破解”這種低效的手段的,於是他們三個研究出了KMP算法。其思想就如同我們上邊所看到的一樣:“利用已經部分匹配這個有效信息,保持i指針不回溯,通過修改j指針,讓模式串盡量地移動到有效的位置。”

所以,整個KMP的重點就在於當某一個字符與主串不匹配時,我們應該知道j指針要移動到哪

接下來我們自己來發現j的移動規律:

技術分享圖片

如圖:C和D不匹配了,我們要把j移動到哪?顯然是第1位。為什麽?因為前面有一個A相同啊:

技術分享圖片

如下圖也是一樣的情況:

技術分享圖片

可以把j指針移動到第2位,因為前面有兩個字母是一樣的:

技術分享圖片

至此我們可以大概看出一點端倪,當匹配失敗時,j要移動的下一個位置k。存在著這樣的性質:最前面的k個字符和j之前的最後k個字符是一樣的

如果用數學公式來表示是這樣的

P[0 ~ k-1] == P[j-k ~ j-1]

這個相當重要,如果覺得不好記的話,可以通過下圖來理解:

技術分享圖片

弄明白了這個就應該可能明白為什麽可以直接將j移動到k位置了。

因為:

當T[i] != P[j]時

有T[i-j ~ i-1] == P[0 ~ j-1]

由P[0 ~ k-1] == P[j-k ~ j-1]

必然:T[i-k ~ i-1] == P[0 ~ k-1]

原文說公式很無聊,但我覺得這樣簡單的公式就能清楚表達我們想說的含義,實在是幸甚。這個公式小學生都能看懂的,真的,我教三年級的娃就告訴她這個了。無非就是連續的序列的起始下標和連續序列長度三者之間的關系。設首下標為head,尾下標為tail,序列長度為len,則公式為:len=tail-head+1;head=tail-len+1;我們head為0,則更簡化了:len=tail+1;知道這個了,請一定耐著性子看懂,對我們的理解很有幫助。下面所有的公式都是這個相關的,請都要看懂。

這一段公式證明了我們為什麽可以直接將j移動到k而無須再比較前面的k個字符。

補充說明:

該規律是KMP算法的關鍵,KMP算法是利用待匹配的子串自身的這種性質,來提高匹配速度。該性質在許多其他中版本的解釋中還可以描述成:若子串的前綴集和後綴集中,重復的最長子串的長度為k,則下次匹配子串的j可以移動到第k位(下標為0為第0位)。我們將這個解釋定義成最大重復子串解釋。

這裏面的前綴集表示除去最後一個字符後的前面的所有子串集合,同理後綴集指的的是除去第一個字符後的後面的子串組成的集合。舉例說明如下:

在“aba”中,前綴集就是除掉最後一個字符‘a‘後的子串集合{a,ab},同理後綴集為除掉最前一個字符a後的子串集合{a,ba},那麽兩者最長的重復子串就是a,k=1;

在“ababa”中,前綴集是{a,ab,aba,abab},後綴集是{a,ba,aba,baba},二者最長重復子串是aba,k=3;

在“abcabcdabc”中,前綴集是{a,ab,abc,abca,abcab,abcabc,abcabcd,abcabcda,abcabcdab},後綴集是{c,bc,abc,dabc,cdabc,bcdabc,abcdabc,cabcdabc,bcabcdabc},二者最長重復的子串是“abc”,k=3;

下面我們用這個解釋,來再一次手動求解上面的過程:

首先如下圖所示:

技術分享圖片

如圖:C和D不匹配了,我們要把j移動到哪?j位前面的子串是ABA,該子串的前綴集是{A,AB},後綴集是{A,BA},最大的重復子串是A,只有1個字符,所以j移到k即第1位。

技術分享圖片

再分析下圖的情況:

技術分享圖片

在j位的時候,j前面的子串是ABCAB,前綴集是{A,AB,ABC,ABCA},後綴集是{B,AB,CAB,BCAB},最大重復子串是AB,個數是2個字符,因此j移到k即第2位。

技術分享圖片

上面說的,如果分解成計算機的步驟,則是如下的過程:

1)找出前綴pre,設為pre[0~m];

2)找出後綴post,設為post[0~n];

3)從前綴pre裏,先以最大長度的s[0~m]為子串,即設k初始值為m,跟post[n-m+1~n]進行比較:

  如果相同,則pre[0~m]則為最大重復子串,長度為m,則k=m;

如果不相同,則k=k-1;縮小前綴的子串一個字符,在跟後綴的子串按照尾巴對齊,進行比較,是否相同。

如此下去,直到找到重復子串,或者k沒找到。

改天,這裏我寫個代碼說明,怎麽找重復子串。

根據上面的求解過程,我們知道子串的j位前面,有j個字符,前後綴必然少掉首尾一個字符,因此重復子串的最大值為j-1,因此知道下一次的j指針最多移到第j-1位。

我為什麽要補充上面這段說明,是因為該說明能便於我們理解下面的求解next數組的過程,上面實際也是指出了人工求解next[j]的過程。不知道next[j]為何物沒關系,看到下面的定義以後,請到時再繞回來回味就行了。

3.求next數組

好,接下來就是重點了,怎麽求這個(這些)k呢?因為在P的每一個位置都可能發生不匹配,也就是說我們要計算每一個位置j對應的k,所以用一個數組next來保存,next[j] = k,表示當T[i] != P[j]時,j指針的下一個位置。請時刻牢記next數組的定義。

很多教材或博文在這個地方都是講得比較含糊或是根本就一筆帶過,甚至就是貼一段代碼上來,為什麽是這樣求?怎麽可以這樣求?根本就沒有說清楚。而這裏恰恰是整個算法最關鍵的地方。

技術分享圖片
 1 public static int[] getNext(String ps) {
 2 
 3     char[] p = ps.toCharArray();
 4 
 5     int[] next = new int[p.length];
 6 
 7     next[0] = -1;
 8 
 9     int j = 0;
10 
11     int k = -1;
12 
13     while (j < p.length - 1) {
14 
15        if (k == -1 || p[j] == p[k]) {
16 
17            next[++j] = ++k;
18 
19        } else {
20 
21            k = next[k];
22 
23        }
24 
25     }
26 
27     return next;
28 
29 }
技術分享圖片

這個版本的求next數組的算法應該是流傳最廣泛的,代碼是很簡潔。可是真的很讓人摸不到頭腦,它這樣計算的依據到底是什麽?

好,先把這個放一邊,我們自己來推導思路,現在要始終記住一點,next[j]的值(也就是k)表示,當P[j] != T[i]時,j指針的下一步移動位置

先來看第一個:當j為0時,如果這時候不匹配,怎麽辦?

技術分享圖片

像上圖這種情況,j已經在最左邊了,不可能再移動了,這時候要應該是i指針後移。所以在代碼中才會有next[0] = -1;這個初始化。

如果是當j為1的時候呢?

技術分享圖片

顯然,j指針一定是後移到0位置的。因為它前面也就只有這一個位置了~~~

下面這個是最重要的,請看如下圖:

技術分享圖片 技術分享圖片

請仔細對比這兩個圖。

我們發現一個規律:

當P[k] == P[j]時,

有next[j+1] == next[j] + 1

其實這個是可以證明的:

因為在P[j]之前已經有P[0 ~ k-1] == p[j-k ~ j-1]。(next[j] == k)

這時候現有P[k] == P[j],我們是不是可以得到P[0 ~ k-1] + P[k] == p[j-k ~ j-1] + P[j]。

即:P[0 ~ k] == P[j-k ~ j],即next[j+1] == k + 1 == next[j] + 1。

原文說公式不好懂,看圖容易。我覺得,公式實際挺簡單的,結合圖再把公式耐著性子看懂。實際上,該公式無非是用字母下標代表序列的起始段,描述了前綴和後綴重復相等的一段長度的序列罷了。

那如果P[k] != P[j]呢?比如下圖所示:

技術分享圖片

像這種情況,如果你從代碼上看應該是這一句:k = next[k];為什麽是這樣子?你看下面應該就明白了。

技術分享圖片

現在你應該知道為什麽要k = next[k]了吧!像上邊的例子,我們已經不可能找到[ A,B,A,B ]這個最長的後綴串了,但我們還是可能找到[ A,B ]、[ B ]這樣的前綴串的。所以這個過程像不像在定位[ A,B,A,C ]這個串,當C和主串不一樣了(也就是k位置不一樣了),那當然是把指針移動到next[k]啦。

補充說明:看了上面這段的描述,你是否真的理解了P[k]!=P[j]時,是要使用k=next[k]的語句呢?我反正是沒弄懂,我總覺得這段else的代碼有點反人類,無法理解。實際上,我們的目的是用數學歸納法,來求解next數組的每個值。當前已經求到next[j],接著就應該求解next[j+1],此時就分兩種情況,一種重復的字符串個數會增加,即所謂的p[k]=p[j],此時p[j+1]=k+1;即p[++j]=++k;另一種就是不能增加,也就是說P[k]!=P[j],即最大重復子串的長度不能增加了;按照next[j]的定義,就是當子串的第j位和主串的第i位不一致時,下一次,和主串i位進行比較的子串的j指針的位置。這個定義還是不太直觀,主要是指腦子裏不知道是怎樣實際操作的,那你回頭看看,我上面寫的另一個最大重復子串的解釋,next[j]的值k就是j位之前的子串中,前綴集和後綴集中的最大重復子串的個數。以這個解釋我們來嘗試在next[j]=k,p[k]!=p[j]時,手動求解next[j+1]的值。

請看下面的圖:

技術分享圖片

當p[j]!=p[k]時我們要找的就是j+1位前面的子串,即p[0~j]的最大重復子串長度。就是說找到一個最長的子串,假設最長重復子串長度為k1,即p[0~k1-1],使得p[0~k1-1]===p[j+1-k1~j],此時k1即為所求的位置即next[j+1]=k1;求解的過程中,我們已經知道p[k]!=p[j]了,因此k1最大等於k,即最大可能的重復子串只可能是p[0~k-1]裏的子串。此時我們人工求解的話,我們按照最大重復子串的求解過程,實際是個試探的過程,做如下工作:從p[0~k-1]裏,以0位為起始字符先挑選一個最大子串p[0~k-1],然後拿著這個子串,尾巴對齊,即看p[k-1]和p[j]對齊,與子串p[j-k+1~j]進行比較;如果相等了,則找到最大重復子串p[0~k-1];如果不等,則繼續縮小k值找下去;該過程跟我2節描述的過程一致。

下面重點來了,請註意:

查找的過程中,可以理解成將上面選擇的待比較的子串分成兩部分:最後一個端點為一部分,前面的一段為一部分;比如上面的第一個選取的最大比較子串的例子:前綴的p[0~k-1]分成兩段為p[0~k-2]和p[k-1],和後綴的p[j-k+1~j-1]和p[j]分別比較,即p[0~k-2]和p[j-k+1~j-1]比較,p[k-1]和p[j]比較;通過這個例子我們知道,只要前面一段盡可能的長,那麽加上最後一個端點這個重復子串也必將是最長的。我們繼續分析,因為next[j]已經求出,即p[0~k-1]===p[j-k~j-1],我們可以把上面的第一段的比較進一步轉換成,比較p[0~k-2]和p[1~k-1]子串了;看到沒有,這個就是求k位前的子串p[0~k-1]的最大重復子串,很顯然不就是求next[k]嘛?!很明顯p[0~next[k]-1]就是我們要找的第一個最大的子串,而不必要從第一個最可能大的子串p[0~k-2]而嘗試起。因為根據next[j]的定義我們知道,next[k]就是要求的子串為p[0~k-1]的最大重復子串的大小,我們是充分利用了前面k<j時,next[k]已經求出來的條件,減少了子串比較的次數;這解釋了為什麽把k=next[k]。此時,p[0~next[k]-1]和p[j-next[k]~j-1]子串已經恒等了,我們只要比較另外的一部分即兩個端點,p[next[k]]和p[j+1](對應於代碼中的p[k]==p[j]);如果這兩者相等了,則重復子串的長度+1;即next[j+1]=next[k]+1;如果不相等了,則會走如上面同樣的分析流程繼續查找。

說的我自己都入迷了,我分析了兩天才搞懂。這個算法就想俠客行一樣,令人參透不了。我們現在的解釋,大部分都是事後諸葛亮,是先看到代碼了,然後在想著怎樣去解釋。正確的人類想法都是,自己分析了算法,然後跟句算法寫代碼。(慚愧,我還沒有去學習原版算法的解釋,也許那個是先數學推到,然後寫出的算法)。所以,後面我們還是想根據這個最大重復子串的求解過程,來自己寫代碼,然後在優化到上面的next[j]的代碼上,估計好懂些。

有了next數組之後就一切好辦了,我們可以動手寫KMP算法了:

技術分享圖片
 1 public static int KMP(String ts, String ps) {
 2 
 3     char[] t = ts.toCharArray();
 4 
 5     char[] p = ps.toCharArray();
 6 
 7     int i = 0; // 主串的位置
 8 
 9     int j = 0; // 模式串的位置
10 
11     int[] next = getNext(ps);
12 
13     while (i < t.length && j < p.length) {
14 
15        if (j == -1 || t[i] == p[j]) { // 當j為-1時,要移動的是i,當然j也要歸0
16 
17            i++;
18 
19            j++;
20 
21        } else {
22 
23            // i不需要回溯了
24 
25            // i = i - j + 1;
26 
27            j = next[j]; // j回到指定位置
28 
29        }
30 
31     }
32 
33     if (j == p.length) {
34 
35        return i - j;
36 
37     } else {
38 
39        return -1;
40 
41     }
42 
43 }
技術分享圖片

和暴力破解相比,就改動了4個地方。其中最主要的一點就是,i不需要回溯了。

最後,來看一下上邊的算法存在的缺陷。來看第一個例子:

技術分享圖片

顯然,當我們上邊的算法得到的next數組應該是[ -1,0,0,1 ]

所以下一步我們應該是把j移動到第1個元素咯:

技術分享圖片

不難發現,這一步是完全沒有意義的。因為後面的B已經不匹配了,那前面的B也一定是不匹配的,同樣的情況其實還發生在第2個元素A上。

顯然,發生問題的原因在於P[j] == P[next[j]]

所以我們也只需要添加一個判斷條件即可:

技術分享圖片
public static int[] getNext(String ps) {

    char[] p = ps.toCharArray();

    int[] next = new int[p.length];

    next[0] = -1;

    int j = 0;

    int k = -1;

    while (j < p.length - 1) {

       if (k == -1 || p[j] == p[k]) {

           if (p[++j] == p[++k]) { // 當兩個字符相等時要跳過

              next[j] = next[k];

           } else {

              next[j] = k;

           }

       } else {

           k = next[k];

       }

    }

    return next;

} 
優化的這部分,我還沒有功夫去看,等後面有功夫再更新。 我十多年前剛工作的時候,實在沒想到十多年後的不惑之年,居然重新開始做程序員,才感嘆自己已經不寫程序好久了,對自己是否還能寫程序,還能有那個熱情,產生了隱隱作痛的懷疑。為了新生活,為了在異國他鄉重新找工作,我給自己定了目標,學ASP.NET、前端開發和算法。猶記讀書時,老師的數據結構課程中對KMP算法,就略過不講。我自己看的,我記得當時應該是看懂的。可現在我居然又想了兩天,其中好幾次,在懂了,又不懂了的過程中恍惚徘徊,我都懷疑是不是真的年紀大了,腦子不好使了。心情異常沮喪也無可奈何,自己的選擇,必須堅定的走下去。本篇是我第一篇程序員博客,將紀錄我的新的人生歷程。我目前的主要關註點在ASP.NET MVC和REACT等前端開發,並且開始刷leetcode題目。希望自己能挺下去。

KMP算法詳解V1