如果你想了解KMP算法,請靜下心讀完這篇文章,一定不會辜負你的時間
暴力匹配(BF)
字符串匹配是我們在編程中常見的問題,其中從一個字符串(主串)中檢測出另一個字符串(模式串)是一個非常經典的問題,當提及到這個問題時我們首先想到的算法可能就是暴力匹配,下面的動圖就展示了暴力匹配的流程。
上圖中箭頭指向的字符都爲藍色時代表二者匹配,都爲黑色時代表二者不匹配,紅色則代表在主串中找到模式串。
這種算法大致思路就是每當模式串和主串中有字符不匹配,模式串與主串對應的位置整體向後移動一位,再次從模式串第一位開始比較,重複上述做法直至在主串中匹配到模式串或者匹配到主串最後一位結束。
如果主串與模式串都比較短時,用暴力匹配還是不錯的選擇,編碼也相對容易;但是如果主串與模式串過長時,我們只是簡單想想就知道這個過程是非常耗時的,那麼會不會有對應的優化算法呢?
下面就介紹本文的主角——KMP算法,不扯沒用的概念,直接講算法的應用過程及利用Python實現該算法的代碼,最後會通過二者時間複雜度的分析,總結出爲何KMP算法會優於暴力匹配算法。
KMP算法
構建前綴表
我們首先要確定一下引例的主串和模式串:
- 主 串 S = "abacaababc"
- 模式串P="ababc"
在模式串與主串匹配時,我們暫時只看第4步,明顯主串S中的c和模式串P中的b是不匹配的:
如果用暴力匹配算法,那麼就是後移模式串P,在從P的第一個字符開始比較。但是現在通過匹配我們可以知道的是當第4位不匹配時,前三個字符爲"aba"是確定的,這個已知信息是十分有用的。
而KMP算法的核心就是利用匹配失敗後獲取的信息,儘量減少模式串與主串的匹配次數以達到快速匹配的目的,比如對於這個不匹配現象我們是不是可以直接這樣移動模式串呢?
那麼信息從何而來呢?在KMP算法中,對於一個模式串都可以先計算出其內部的匹配信息,這樣在匹配失敗時可以最有效的移動模式串,從而減少匹配次數。在此之前,需要先理解一下前綴和後綴。
- 前綴:abcde的前綴可以是a、ab、abc、abcd
- 後綴:abcde的後綴可以是e、de、cde、bcde
這裏需要引出一個新的概念——前綴表,可以用profix表示、且下標從0開始,profix[i]存儲的信息就是前i+1個字符的最長公共前後綴,並且這個最長公共前後綴長度一定是小於字符串長度的。
可以看到"ababc"不是前後綴,但也被列到了表中。如果你曾經瞭解過KMP算法,那你可能聽過next數組,當前綴錶轉化爲next數組時,最後一位的值會被覆蓋掉,對過程是沒有什麼影響的。由於本文僅是靠着前綴表profix完成KMP算法,所以不再過多講述next數組,不同的方法只是表示形式不一樣,但歸根結底原理還是相同的。
上面的前綴表是我們通過肉眼比對得出的,程序畢竟不是人嘛,所以需要通過一種程序能夠識別的方法構建前綴表,依據下圖進行講述流程。
通過這個動圖可以將構建前綴表規劃成下面五步:
- 首先創建兩個指針,指針j指向模式串第一位(下標爲0)、指針i指向模式串第二位(下標爲1)。
- 由於模式串最開始是單一字符,沒有前綴和後綴,所以對應前綴表第一位總爲0。
- 當j=0時,比較j和i指向的字符,如果字符不匹配,i對應的前綴表位置填入0,且將i向後移動移位,j原地不變。
- 當j和i指向的字符匹配時,i對應的前綴表位置填入(j+1),且將j和i都後移一位。
- 如果j和i指向的字符不匹配,並且此時$j\neq 0$,j需要回溯到profix[j-1]的位置,再次與i指向的字符比較,重複此步驟直至j和i指向的字符匹配或者j=0。
當結合動圖讀完這五個步驟時,我猜你會不理解第五步,如果你都理解了,我也只能感嘆一句NiuBi,利用下面這個例子更能凸顯出步驟五的回溯機制。
依據上面步驟我寫出了前綴表的前五位,而此時j和i指向的字符不匹配且j不等於0,這裏j的下標是3,所以需要在前綴表中找到下標爲j-1的值,即profix[2],然後將j回溯到對應的位置。
這樣回溯是因爲可以在模式串頭部找到和j和i之間的字符串相匹配的前綴,也就是這個例子中的a,如果此時j和i指向的字符相匹配,那麼最長公共前後綴的長度就是已匹配的前綴的長度(a)再加1。由此可見如果j和i之間字符串很長時,這個操作可以節省很多時間。
而此時j和i指向的字符仍然不匹配,那麼需要繼續回溯j,方法和上述一致,回溯的位置就是profix[0]。
此時j和i指向的字符還是不匹配,但這裏需要做的就不是回溯了,因爲j=0已經滿足回溯結束條件,只需將i對應前綴表的位置(profix[5])中填入0即可,用肉眼匹配也會發現此時的確沒有公共前後綴。
在理解上述步驟之後,可以將其當成僞代碼,依據僞代碼很容易編寫出構建前綴表函數。
def PrefixTable(Pattern):
i = 1;j = 0
prefix = [0]*len(Pattern)
while(i<len(Pattern)):
if Pattern[j]==Pattern[i]:
prefix[i] = j+1
j+=1;i+=1
else:
if j == 0:
prefix[i] = 0
i+=1
else:
j = prefix[j-1]
return prefix
可以輸入一個模式串,測試一下該代碼是否能夠得出對應前綴表。
優化前綴表
經過上文解釋你可能會發現一個基本事實,即前綴表最後一位沒有任何作用,這麼說的理由是什麼呢?因爲當j和i指向的字符不匹配時,這裏的解決辦法是回溯j,而回溯依據一直都是prefix[j-1],j是永遠不可能超越i的,所以前綴表最後一位永遠也不會用到。
那麼最後一位就可以去掉,將所有元素整體後移一位,並向前綴表第一位填入-1,如下圖:
填入-1這個操作的原理等下結合圖片一起講述會更易懂,目前我們只需知道這個操作並且瞭解其對應代碼即可。
def MoveTable(prefix):
for i in range(len(prefix)-1,0,-1):
prefix[i] = prefix[i-1]
prefix[0] = -1
return prefix
KMP匹配機制
主串和模式串還是利用上文所舉例子,這裏省略了一些簡單的匹配過程,直接看關鍵點。
可以看到主串和模式串的第4位是不匹配的,現在需要做的是將Pattern[prefix[4]]對應主串中需要匹配的元素,也就是模式串下標爲1的元素後移至與主串第4位對應的位置,看圖可懂。
對應位置仍然不匹配,需要繼續後移模式串,該位置對應前綴表的值爲0,所以將Pattern[prefix[0]]對應主串中需要匹配的元素,即模式串下標爲0的元素與主串該位置對應。
此時兩串對應位置還是不匹配,但是a已經是模式串的第一位元素了,如果按照上面方法需要繼續後移模式串,讓主串那個位置與模式串下標爲-1的元素匹配,可是前綴表中並不存在下標爲-1的元素。
所以比較時如果模式串和主串對應位置不匹配,且模式串的元素對應前綴表的值爲-1,那麼直接將模式串整體後移一位,並且將指向主串的指針後移一位即可,這也是爲什麼在前綴表第一位插入-1的原因。
下面動圖是利用KMP算法在主串中查找模式串的全過程。
KMP算法的代碼如下:
def KMP(TheString,Pattern):
m = len(TheString);n = len(Pattern)
prefix = PrefixTable(Pattern)
prefix = MoveTable(prefix)
i = 0;j = 0#i爲主串指針,j爲模式串指針
while(i<m):
if j==n-1 and TheString[i]==Pattern[j]:
print("已在主串下標%d處找到模式串" % (i-j))
j = prefix[j]
if TheString[i]==Pattern[j]:
i+=1;j+=1
else:
j = prefix[j]
if j==-1:
i+=1;j+=1
這裏只講一下第一個if語句,當j指向了模式串最後一位,並且此時如果主串和模式串對應位置匹配,則代表在主串中找到了模式串,並打印出第一個字符出現的位置。而j = prefix[j]這個語句的作用是在找到模式串後繼續匹配剩餘的主串,因爲可能會有主串中含有若干個模式串的現象出現。
最後整個程序運行截圖如下:
BF與KMP比較
爲什麼KMP會優於BF,這裏通過對比二者的時間複雜度給出原因,假設有這麼兩個比較極端的主串和模式串:
- 主 串 S = "aaaaaaab"
- 模式串P="aaab"
首先看一下BF算法解決該匹配問題的流程:
然後再看一下KMP算法解決該匹配問題的流程:
假設主串長度爲m,模式串長度爲n。對於BF算法,每當遇到不匹配字符時,都要從模式串開頭再次匹配,所以對應時間複雜度$O(m*n)$;對於KMP算法,每當遇到不匹配字符時,根據獲得的信息它不會重複匹配的已知前綴,所以對應時間複雜度爲$O(m+n)$。當字符串較長時,就時間複雜度而言KMP算法是完全優於BF算法的。
總結
個人認爲KMP算法難度不低,講這個算法的博客與視頻很多,但都各有差異,雖然原理都是大致相同的,但不要同時看前綴表和next數組,由於這兩個很像所以會容易混淆,可以先弄透前綴表然後再看next數組相關知識點,這樣對於KMP的理解纔算透徹。
關注公衆號【奶糖貓】獲取更多精彩好文呀