字符串匹配(查找)算法是一類重要的字符串算法(String Algorithm)。有兩個字符串, 長度為m的haystack(查找串)和長度為n的needle(模式串), 它們構(gòu)造自同一個有限的字母表(Alphabet)。如果在haystack中存在一個與needle相等的子串,返回子串的起始下標,否則返回-1。C/C++、PHP中的strstr函數(shù)實現(xiàn)的就是這一功能。LeetCode上也有類似的題目,比如#28、#187.
關(guān)于KMP,網(wǎng)上有很多很好的資料了,但我看了好久才看基本明白這到底是個什么玩意。于是決定自己總結(jié)一下,在這里做個筆記。
Knuth-Morris-Pratt算法,簡稱KMP,是一個經(jīng)典的字符串查找算法。Knuth就是傳說中的Donald Knuth,著有[The Art of Computer Programming](The Art of Computer Programming)。
我們知道,在進行字符串查找時,應當盡量避免不必要的匹配,以此來提高查找效率。因此Boyer-Moore、Sunday、KMP等算法,都試著在發(fā)生失配時,利用模式串或查找串的某種信息來跳過一些無意義的匹配嘗試。KMP關(guān)注的是模式串的部分匹配信息,這種信息是獨立于查找串之外的。
先來看個栗子,圖中上面的串是haystack,下面的則是needle。
在上圖中的查找過程中,當haystack[5]=c,needle[5]=d時發(fā)生失配了。接下來當然要把needle往右移動啦,最Naive辦法是只移動一位,而這必然是低效的,圖樣圖森破。
我們先來觀察一下這個needle串:
在發(fā)生失配的字母d的左邊,右一個子串
needle[3...4]=ab
,在needle的最開始,也有一個子串needle[0...1]=ab
。我們直到d才發(fā)現(xiàn)失配,說明d之前的串都是能夠匹配的,所以haystack串中也有兩個ab
。當我們右移needle時,還是要求在至少haystack的后續(xù)串中能找到一個ab#ab#
,而如果這倆#恰好分別是c和d,就查找成功了。我想說的就是,這里d失配了,但是d之前的部分匹配串應該被利用起來。如果我們右移needle,使得左邊那個ab
跟haystack[3...4]=ab
對應,那我們就成功越過了很多個位置,而且不會有遺漏某個成功匹配的危險。于是我們到了這個狀態(tài):
接著發(fā)現(xiàn)在haystack[7]=d的時候右失配了:
接著觀察needle,發(fā)現(xiàn)在needle[4]之前有一個
needle[3]=a
,在needle的開始處needle[0]
也是a
。于是我們右移needle,把needle[0]=a和haystack[6]=a對其:
繼續(xù)這個過程,直到haystack結(jié)束,或者找到一個完整的needle。
在上述過程中,對haystack的搜索是持續(xù)向右的,下標沒有任何回退。并且對于needle,我們利用部分匹配的串,將其游標往回倒了倒。注意,只是把下標往回倒,重新對齊。(注意這些左移右移什么鬼的,是相對的)
KMP算法正是利用這種部分匹配串的信息,來跳過不必要的匹配嘗試。KMP算法定義了一個數(shù)組,通常稱為next數(shù)組。next[i]是一個非負整數(shù),表示的意思是,如果在needle的位置i上發(fā)生失配,應該使needle的下標回到哪個位置。例如對于上面的needle串,當needle[5]=d發(fā)生失配,我們右移needle使得needle[0..1]和haystack[3...4]對齊。于是在下一輪查找中,我們可以直接從needle[2]=c開始匹配,也就是說,needle的下標由5回退到了2。所以對于此needle,其next[5]=2。
對于KMP算法的理解,難點就在于next數(shù)組的計算。這里嘗試用自認為比較好理解的表述方式來寫一寫。
如前所述,next[i]表示當在needle[i]的位置發(fā)生失配時,下標i應該倒回的位置,同時也表示,在i的前一個位置,有多長的一個子串是與needle開頭部分一樣長的。next數(shù)組的長度正是needle的長度,并且有,next[0]=0,next[1]=0 (如果有next[1]的話)。next數(shù)組的計算是從左往右的,假設(shè)我們現(xiàn)在已經(jīng)完成了next[i]的計算,得到了next=[k],形成了下圖這種狀況。
圖中,整個長條表示的是needle串。已經(jīng)計算完了next[i]的意思就是說,我們已經(jīng)知道了如果在i位置失配,應該把下標i重置為幾;也就是說,已經(jīng)知道了在i之前一小串,有一串跟needle的開頭部分是一樣的,就是圖中的兩塊綠色,它們的長度正是k=next[i]。這兩塊綠色,前一塊是needle[0...k-1]這個子串,后一塊是needle[i-k...i-1]的子串。注意,我們在計算next[i]的時候,利用的是needle[i-1]這個字符,而并未訪問needle[i]。
接下來將要計算的是next[i+1],我們需要考慮的正是字符needle[i]。我們試著擴展上一次得到的部分匹配串,考慮needle[i]和needle[k],如下圖所示,考慮兩個藍色塊:
如果needle[i]==needle[k],則上一次得到的部分匹配串可以擴展一個位置,得到這樣的情形:
于是我們知道,next[i+1]=k+1,這是比較好理解的。那么如果needle[i]不等于needle[k],咋整?
Calm the hell down, and carry on...
既然next是從左往右計算的,那么needle從0到k這些位置,也是經(jīng)過同樣的計算方式得到的,我們來仔細看下這一段:
next[k]的值是已經(jīng)知道的,它表示如果在位置k失配,應該回到哪,同時也表示,在k之前有一個長度為next[k]的子串與needle開頭部分的一個長度為next[k]的子串是一樣長的。現(xiàn)在needle[i]!=needle[k],我們只能尋求更短的部分匹配串了。現(xiàn)在可以確定的是,圖中四個綠色的塊是相等的串。那如果不能從上面的長綠色塊擴展,能不能退而求其次,將本圖中的短綠色塊擴展呢?所以現(xiàn)在令k=next[k],然后查看新的needle[k]這個字符,如果它等于needle[i],說明這個擴展是可行的,因此next[i+1]=k+1。如果仍然沒法匹配,則重復上述過程,繼續(xù)令k=next[k]...直到找到這樣的匹配,或者直到k為0依然沒法找到這樣的匹配。
接下來用Python實現(xiàn)一下這個求next數(shù)組的過程:
def getNxt(s):
nxt=[0]*(len(s))
for i in range(1, len(s)-1):
k=nxt[i]
while k and s[k]!=s[i]:
k=nxt[k]
nxt[i+1]=k+1 if s[k]==s[i] else 0
return nxt
注意如果我們沒法擴展部分匹配串,則置next[i+1]為0。也就是說,搜索過程中如果在needle[i+1]失配,則從needle開端位置重新開始匹配。
一旦得到了next數(shù)組,字符串的搜索過程就變得很簡單了。這個過程的思想是類似于next的求解過程的,本文前面也描述過。直接上代碼吧。
def strStr(self, haystack, needle):
if not needle:
return 0
nxt=getNxt(needle)
j=0
for i in range(len(haystack)):
while j and needle[j]!=haystack[i]:
j=nxt[j]
if needle[j]==haystack[i]:
j+=1
if j==len(needle):
return i-j+1
return -1
這里的j是needle的下標,當?shù)玫揭粋€匹配時,它加一,如果能加到len(needle),則查找成功了,返回當時的haystack起始下標。如果當前的hays[i]不能與needle[j]匹配,也就是在needle[j]發(fā)生了失配,則把j置為next[j],直到找到匹配,或者回到needle開始位置。
簡單分析一下算法的復雜度。KMP是一個線性時間復雜度的算法。對于查找函數(shù)strStr,主體部分有兩層循環(huán),咋會是線性的呢?首先,haystack的下標i是在每次for循環(huán)之后都增加1的;其次,while循環(huán)對needle做回退,如果站在haystack的視角來看,頂多能回退到已經(jīng)匹配了的子串那么長,也就是說對haystack的訪問頂多就一來一回兩次,所以均攤下來是2*len(haystack)的。
也可以把strStr的主體部分改寫一下,就更清楚了:
上圖中,while循環(huán)的跳出條件是m+j>=len(haystack),這里的m是haystack的當前子串的起點,也就說,每次考察的是needle[j]是否匹配haystack[m+j]。如果前一個分支被執(zhí)行,則i會加1,以至于m+i會加1;如果后一個分支被執(zhí)行,則要么m加1,要么m+i-next[i],而i>next[i],因此m總會增加。因此不管哪個分支被執(zhí)行,m+i總會變大,因此時間復雜度是O(n)。
本文遵守知識共享協(xié)議:署名-非商業(yè)性使用-相同方式共享 (BY-NC-SA)及簡書協(xié)議轉(zhuǎn)載請注明:作者曾會玩
Reference
[1] Knuth, Donald; Morris, James H.; Pratt, Vaughan (1977). "Fast pattern matching in strings". SIAM Journal on Computing 6 (2): 323–350.
[2] Knuth–Morris–Pratt algorithm, Wikipedia
[3] 插圖的一部分靈感激發(fā)自某次無意中翻到的博客,找不到鏈接了,請認領(lǐng)。