引言
KMP算法指的是字符串模式匹配算法,問題是:在主串T中找到第一次出現完整子串P時的起始位置。該算法是三位大牛:D.E.Knuth、J.H.Morris和V.R.Pratt同時發現的,以其名字首字母命名。在網上看了很多對KMP算法的解析,大多寫的不甚明瞭。直到我看到一篇博客的介紹,看完基本瞭解脈絡,本文主要是在其基礎上,在本身較難理解的地方進行補充修改而成。該博客地址爲:https://www.cnblogs.com/yjiyjige/p/3263858.html,對做者的明晰的解析表示感謝。html
1. 通常的解法
KMP算法要解決的問題就是在字符串(也叫主串)中的模式(pattern)定位問題。說簡單點就是咱們平時常說的關鍵字搜索。模式串就是關鍵字(接下來稱它爲P),若是它在一個主串(接下來稱爲T)中出現,就返回它的具體位置,不然返回-1(經常使用手段)。前端
首先,對於這個問題有一個很直接的想法:從左到右一個個匹配,若是這個過程當中有某個字符不匹配,就跳回去,將模式串向右移動一位。這有什麼難的?程序員
咱們能夠這樣初始化:算法
以後咱們只須要比較i指針指向的字符和j指針指向的字符是否一致。若是一致就都向後移動,若是不一致,以下圖:數組
A和E不相等,那就把i指針移回第1位(假設下標從0開始),j移動到模式串的第0位,而後又從新開始這個步驟:數據結構
基於這個想法咱們能夠獲得如下的程序:post
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 }
上面的程序是沒有問題的,但不夠好!(想起我高中時候數字老師的一句話:我不能說你錯,只能說你不對~~~)優化
注意:該算法程序很簡單,很是好理解,請認真看完,由於後面的算法是在該算法基礎上修訂的。spa
2.若是人眼來優化的話,怎樣處理
參考上面的算法,咱們串中的位置指針i,j來講明,第一個位置下標以0開始,咱們稱爲第0位。下面看看,若是是人爲來尋找的話,確定不會再把i移動回第1位,由於主串匹配失敗的位置(i=3)前面除了第一個A以外再也沒有A了,咱們爲何能知道主串前面只有一個A?由於咱們已經知道前面三個字符都是匹配的!(這很重要)。移動過去確定也是不匹配的!有一個想法,i能夠不動,咱們只須要移動j便可,以下圖:3d
上面的這種狀況仍是比較理想的狀況,咱們最多也就多比較了再次。但假如是在主串「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指針的下一個位置。另外一個很是有用且恆等的定義,由於下標從0開始的,k值實際是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]裏求解最大重複子串。
咱們按照第2節介紹的查找最長重複子串的方法:從p[0~k-1]裏,第一步,以0位爲起始字符先挑選最大子串p[0~k-1],而後拿着這個子串,尾巴對齊,即看p[k-1]和p[j]對齊,與子串p[j-k+1~j]進行比較,見圖中綠色線段;若是線段上每一個值都相等了,則找到最大重複子串p[0~k-1];若是不等,則繼續縮小線段長度找下去。
下面重點來了,請注意:咱們看,在查找最大匹配的過程當中,將上面選擇的待比較的子串分紅兩部分:最後一個端點爲一部分,前面的一段爲一部分;好比上面的第一個選取的最大比較子串的例子:前綴的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](對應於代碼中的p[k]==p[j],注意在上個循環p[k]!=p[j]時,k已經被賦值next[k],而j仍是上次的那個j);若是這二者相等了,則重複子串的長度+1,next[j+1]=next[k]+1(k++即next[k]+1);若是不相等了,則說明倒數第二大的p[0~next[k]-1]都不行了,比這個重複子串小的最大的重複子串只能是k=next[next[k]]了,如此繼續查找下去。所以比較的都是按序遞減的最大重複子串,很是的有效,一點都沒有多比較。找不到的話,k會被賦值爲-1。
這個算法神奇難解之處就在k=next[k]這一處的理解上,網上解析的很是之多,有的就是例證,舉例子按代碼走流程,走出結果了,跟肉眼看的一致,就認爲解釋了爲何k=next[k];不多有看到解釋的很是清楚的,或者有,但我沒有仔細和耐心看下去。我通常掃一眼,就大概知道這個解析是否能說的通。仔細想了三天,搞的千轉百折,山重水複,一頭霧氣繚繞的。搞懂之後又以爲確實簡單,可是繞人,燒腦。
有了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不須要回溯了。
4.next數組求解算法優化
最後,來看一下上邊的算法存在的缺陷。來看第一個例子:
顯然,當咱們上邊的算法獲得的next數組應該是[ -1,0,0,1 ]
因此下一步咱們應該是把j移動到第1個元素咯:
不難發現,這一步是徹底沒有意義的。由於後面的B已經不匹配了,那前面的B也必定是不匹配的,一樣的狀況其實還發生在第2個元素A上。
顯然,發生問題的緣由在於P[j] == P[next[j]]。
補充說明:這部分做者說的也比較清楚了。實際上對下面的代碼if(p[++j]==p[++k]),咱們注意是先自加,再使用。因此咱們按照j不變的狀況下解釋下一步流程就是p[j+1]==p[next[j]+1];此時比較將無心義,由於p[next[k]+1]位就已經表示,就是k+1位和主串的i不相等,要移動的j下標爲next[K+1],由於p[k+1]又等於p[j+1],也就是說比較j+1位和主串的i位是否相等時,也將要j移到next[K+1]位去;
因此咱們也只須要添加一個判斷條件便可:
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題目。但願本身能挺下去。