字符串匹配算法
- 簡介
- 暴力匹配
- kmp算法
- BM算法
- Sunday算法
首先是一系列概念定義:
- 文本Text: 是一個長度為n的數組T[1..n] (⚠️這里第一位置索引是數字1)
- 模式Pattern: 是一個長度為m的數組P[1..m], 並且m<=n.
- T和P的元素都屬於有限的字母表Σ 表
- 概念:有效位移Valid Shift(用字母s代表)。即P在T中出現,並且位置移動s次。如果0<= s <= n-m ,並且T[s+1..s+m] = P[1..m],則s是有效位移。
上圖的有效位移是3。
解決字符串的算法非常多:
朴素算法(Naive Algorithm)、Rabin-Karp 算法、有限自動機算法(Finite Automation)、 Knuth-Morris-Pratt 算法(即 KMP Algorithm)、Boyer-Moore 算法、Simon 算法、Colussi 算法、Galil-Giancarlo 算法、Apostolico-Crochemore 算法、Horspool 算法和 Sunday 算法等。
字符串匹配算法通常分為2個步驟:預處理和匹配。算法的總運行時間是兩者之和。
下文舉例:
- 朴素的字符串匹配算法(Naive String Matching Algorithm)
- Knuth-Morris-Pratt 字符串匹配算法(即 KMP 算法)
- Boyer-Moore 字符串匹配算法
朴素的字符串匹配算法(Naive String Matching Algorithm)
就是窮舉法,枚舉法,也叫暴力匹配。是最低效最原始的算法。特點:
- 無預處理階段。(因為是暴力匹配)
- 對Pattern,可以從T的首或尾開始逐個匹配字母,比較順序沒有限制。
- 最壞時間復雜度O((n-m+1)*m).
方法是使用循環來檢查是否在范圍n-m+1中存在滿足條件P[1..m] = T[s+1..s+m]的有效位移s。
偽代碼:
Native_string_matcher(T, P) n <- length[T] m <- length[P] for s <- 0 to n - m do if P[1..m] = T[s+1..s+m] then print "Pattern occurs with shift"
Knuth-Morris-Pratt 字符串匹配算法(即 KMP 算法)
⚠️學習kmp算法的時候,很費了一番功夫,參考了多篇文章,主要是從知乎上獲得靈感,一名網友建議先熟悉代碼然后再理解原理。我是先看原理,但始終不能完全理解。后來改變方法,通過直接看代碼,使用數據,最后理解了這個算法。
自己根據理論推導,但有些亂。 j == -1的判斷是怎么來的? j = next_s[j] ,next_s是怎么來的?kmp是怎么利用的,用已有的代碼倒推。 晚上,根據已有代碼來理解,成功了。
這是對Pattern進行預處理的算法。
我的理解基本理解:
找到T中對P的第一次匹配,當P[1..(i-1)]等於T[1..(i-1)],但P[i]不匹配T[i]的情況,不使用使用窮舉法,而是使用更優化的算法kmp,減少了不必要的字符比較。(⚠️這里指針i, 代表字符串中的第幾個字符,不是數組的索引)
什么是“不必要的字符比較”?
因為P[1..(i-1)]成功在Text匹配,即和T[1..(i-1)]兩個字符串相同。那么:
- 首先,確定T[1..i]的后綴集合和P[1..i]前綴集合。
- 然后,查看是否有兩個集合的相交集合。
- 如果有,確認集合中那個最長的字符串max_string。
- ⚠️,max_string就是 P[1..(max_string.length)]。因此下輪再比較時,就無需比較這些字符了。
簡單來說就是找到P[1..(i-1)]的前綴等於T[1..(i-1)]的后綴的字符串,這個字符串的字符,無需再比較。
這種算法比窮舉法好太多了。
預先處理模式Pattern字符串
因此當已知字符串p時,先對它進行預處理。
理論:對p[1], p[1..2],p[1..i]..p[1..n]逐一處理(1<= i <=n),找到每個p[1..i]的前后綴相交的最長字符串,得到這個字符串的長度x。然后存入kmp數組。(但這樣花費太多時間)
實際:求解Pattern的kmp或next_s的方法,使用的是遞歸的方法
由此得到一套字符串P的最大共有字符串的長度集合,即kmp數組。
例如:
下圖給出了關於模式 P = “ababababca”的kmp(即前綴和后綴集合,共有的字符串集合中,最長的字符串的的長度的值)的表格,稱為部分(即部分字符串)匹配表(Partial Match Table)。
計算過程
kmp[0] = 0,匹配a 僅一個字符,前綴和后綴為空集,共有元素最大長度為 0;
kmp[1] = 0,匹配ab 的前綴 a,后綴 b,不匹配,共有元素最大長度為 0;
kmp[2] = 1,aba,前綴 a ab,后綴 ba a,共有元素最大長度為 1;
kmp[3] = 2,abab,前綴 a ab aba,后綴 bab ab b,共有元素最大長度為 2;
kmp[4] = 3,ababa,前綴 a ab aba abab,后綴 baba aba ba a,共有元素最大長度為 3;
kmp[5] = 4,ababab,前綴 a ab aba abab ababa,后綴 babab abab bab ab b,共有元素最大長度為 4;
kmp[6] = 5,abababa,前綴 a ab aba abab ababa ababab,后綴 bababa ababa baba aba ba a,共有元素最大長度為 5;
kmp[7] = 6,abababab,前綴 .. ababab ..,后綴 .. ababab ..,共有元素最大長度為 6;
kmp[8] = 0,ababababc,前綴和后綴不匹配,共有元素最大長度為 0;
kmp[9] = 1,ababababca,前綴 .. a ..,后綴 .. a ..,共有元素最大長度為 1;
之后就可以利用這個表了。
具體使用這個表來匹配查找字符串的做法:
如果在j處發生不匹配,那么主字符串i指針之前部分與P[1..j-1]相同/匹配。通過求得的上面的表格可以找到j前面一位的kmp.
這共同的字符串就無需參加后續比較。j從新定位到這個字符串的后一位,和i比較。
具體流程是i不動,j指針指向P[1..j -1]字符串的共有字符串的后面一位,j = 4。然后下輪判斷p[4] 是否等於 p[6].
例子:
圖(a)在i位發生不匹配 (此時j = i = 6, j - 1 = 5),所以P[0..5] = "ababab"是和主string匹配的。找到了“ababab”的共有字符串是“abab”,就是圖灰色部分。這部分字符串無需在之后比較了。
利用部分匹配表,可知kmp[5] == 4 ,進行下一輪匹配時,直接移動j指針到P[4],即j = kmp[j-1]從這里開始匹配,達到優化字符串的匹配/查找的目的。
另外,比較到發生不匹配時,需要在匹配表找kmp[j-1], 所以為了編程方便,將kmp數組向后移動一個位置,產生一個next數組, 使用這個next數組即可。⚠️這本身只是為了讓代碼看起來更優雅。無其他意義。反而對初學者來說,不好理解。
Ruby代碼:
def Kmp_matcher(text, pattern) # 傳入的text = "abababca",這里kmp已經給出。 kmp = [0,0,1,2,3,4,0, 1] i, j = 0, 0 while i < text.length && j < pattern.length if text[i] == pattern[j] i += 1 j += 1 else if j == 0 #第一個字符不匹配只能繼續i++了, 就是窮舉法了。 i += 1 else #使用kmp法, 即匹配失敗,模式p相對於文本向👉移動。 j = kmp[j - 1] end end end if j == pattern.length # 返回pattern出現在text中的的位置。 return i - j else # pattern不匹配text return -1 end end
上面代碼可知當pattern只有一個字母時,就是窮舉法,所以i += 1。
優化代碼使用next數組:
next[j]是什么?
next_s的意義:代表當前字符j之前的字符串中,有多大長度的相同前綴后綴(可稱為最長前綴/后綴)。
例如:next[j] = k ,代表j之前的字符串中,最大前綴/后綴的長度為k。
本例子next_s = [-1, 0,0,1,2,3,4,0], 其實就是在kmp數組頭部插入了一個元素-1, 或者說整體向后移動一個位置。
因為代碼j = kmp[j - 1],所以使用j = next_s[j],但需要對第一個字符就不匹配的情況改代碼:
特殊情況:
pattern只有一個字母"x"時,不匹配,我們設置next[0]等於-1。
當p = 'x', p[0]不等於text[0], 只能是窮舉法了,每輪i+1,j不變。
因此要修改一下條件判斷 : if j == -1 || text[j] == pattern[j]
- 因為j等於-1,所以判斷true, 於是i和j都加+1。 那么下一輪i =1, j= 0, 繼續比較。
- 當j = 0的情況時,如果不匹配,那么j = next_s[j] ,即j等於-1。 然后下一輪 , 又是true。
⚠️本質就是一個變通的方法,以適應next_s數組。見改后的代碼:
def next_s_matcher(text, pattern) # 傳入的text = "abababca" next_s = [-1, 0,0,1,2,3,4,0] i, j = 0, 0 while i < text.length && j < pattern.length if j == -1 || text[i] == pattern[j] i += 1 j += 1 else j = next_s[j] end end if j == pattern.length # 返回pattern出現在text中的的位置。 return i - j else # pattern不匹配text return -1 end end
⚠️有知乎網友說:先學AC自動機,就好理解kmp了。另外kmp也不是時間復雜度最好的算法。還可以優化。
那么最重要的問題來了,如何計算kmp或者next 數組?(可以使用代碼遞歸的方法)
前面已經講解了next[j]的意義👆。這里再強調一下:
next[j]的意義是什么?
j前面的匹配字符串是p[0..j-1], 它的前綴后綴集合的交集中,最長的字符串(最長前綴/最長后綴)的字符數就是next[j]。
(為了方便表示,在前綴集合中的這個字符串稱為最大前綴,在后綴集合中的這個字符串稱為最大后綴,它們是一樣的。)
因此next[j]表示的就是
- 這個最大前綴/后綴的長度,
- 也表示了最大前綴的后面一位字符的索引。
先放上代碼:ruby代碼。
def getNext(pattern) pLen = pattern.length next_s = [] next_s[0] = -1 i = 0 # pattern的下標 j = -1 while i < pLen - 1 #只處理前PLen -1個字符的情況。所以是PLen - 1 if j == -1 || pattern[j] == pattern[i] i += 1 j += 1 next_s[i] = j else j = next_s[j] end end return next_s end
(代碼好像很類似kmp的代碼呵,但是看不懂。看了很多文章才慢慢理解。)
這個代碼利用了遞歸的方法來得到next_s數組。
通過上面文章,我們知道最大前綴中的字符,無需再度匹配。因此下一輪匹配,文本字符串i不變,模式的指針j->最大前綴字符的后一位。
next的求解其實是對模式字符串自身的匹配比較,因此和kmp方法代碼類似。
理解next的核心是理解if..else語句
假設開始新一輪循環,j等於6,i等於4,我們要判斷p[6]是否等於p[4]
此時p[0..6]這個字符串是"ababab?"。“?”號代表要判斷的p[6]。p[6]前面字符串的“ababab”是已經匹配成功的字符串,它的最大前綴/后綴是“abab”, max_len = 4。
現在"ababab"尾巴增加一個字符“?”,我們求這個“ababab?”的最大前綴/后綴的長度是多少?無需把前綴,后綴都列出來,然后找到它們的交集的笨方法。而是使用遞歸的方法:
想得到"ababab?"的最大前綴,我們要知道要"?"是什么字符,這里有2種情況:
第一種:(見圖2)
如果“?”和"ababab"的最大前綴字符串的后一位字符相同,即“?” == "a", 那么 p[0..6]即"abababa"的最大前綴/后綴就是:“abab”+"a"。p[0..6]的max_len = 4 + 1 等於5。
可以看圖2來增進理解,已知一個字符串pattern的最大前綴/后綴“abab”,如果pattern尾巴上增加一個字符"?",即pattern的長度加1, 那么pattern的最大前綴/后綴可能也會加1。這個可能實現的前提條件就是,恰好新增的字符等於最大前綴后面的第一個字符(p[i]==p[j])。即產生“新的最大前綴” == “新的最大后綴”。
這樣就理解了if語句的前半部分:
if j == -1 || pattern[j] == pattern[i] i += 1 j += 1 next_s[i] = j else ...
# 如果pattern[j] 等於pattern[i],那么產生的新的最大前綴/后綴,其實就是之前的最大前綴+它后的一個字符。因此新的最大前綴/后綴長度+1.
# 即next_s[i] = j
另一種情況:(見圖1)
本例子,不是"a", 那么這個字符串p[0..6]的最大共同字符串的長度不會增長,那么我們就要考慮新的最大共同字符串的長度和之前一樣還是更小,甚至沒有。
我們當然不會使用先把前綴和后綴的集合列出來,然后找共同的最長字符串,這是個笨方法。
在這里,我們用到了遞歸的方法。每輪比較,確定上一輪的最大前綴/后綴的最大前綴/后綴,是否是p[0..6]的最大前綴/后綴。
因此要向字符串頭部移動指針j。讓指針j指向最大前綴的最大前綴。
else j = next_s[j]
如下圖2:兩條紅線的后一位比較不相等,那么就讓紅線1的最大前綴:綠線1的后一位和紅線2的最大后綴黃線2的后一位p[i]比較。
還是不太理解,為何遞歸前綴索引j = next_s[j], 就能找到長度更短的相同前綴后綴?
假設新增的元素是p[i], 我們求的next[i]就是目的, 即找到p[0..i]的最大前綴/后綴(共同字符串)!
傳統辦法是找到前綴集合,后綴集合,然后找到其中的的共同字符串,可能有a, b, c 多個共同字符串,它們有2個特點:
- 長度比較:a < b < c。
- c的前綴后綴中共同字符串中最長的就是b。同理,b的最大共同字符串是a。
根據這個2特點我們可以使用遞歸的方法了:
我們在上一輪比較,已經知道p[0..i-1]的最大前綴, 這里設p[0..j-1] == p[i-j, i-1]。即共同字符串集合中的c。
前綴c+p[k]后綴c+"p[i]比較,發現二者不相同。
下一步就是前綴字符串b+它的后一位,和后綴字符串b+p[i]進行比較,二者不相等。
再下一步是前綴字符串c+它的后一位,和后綴字符串c+p[i]進行比較,二者還不相等。
最后一步,是p[0] 和p[j]比較,即字符串的第一個字符,和新增的尾巴字符比較。
通過一步步的遞歸,推導出p[0..i]有沒有最大共同字符串。
由這個過程:我們發現了::
- 找p[0..i]的最大共同字符串的問題,其實就是,比較p[0..i-1]的共同字符串集合[a,b,c]的每個字符串的后一位,是否等於新加入的元素p[i]!
- 因為要找到最大的共同字符串,同時共同集合字符串的長度a<b<c,所以先比較c的后一位,判斷不同后繼續比較,最后比較首字符和新增尾字符。
因此,我們使用遞歸的方法,先比較p[i] 是否等於p[j] , 如果不相等則指針j指向當前前綴的前綴的后一位。並遞歸下去,直到得到一個結果。
圖1:
圖2:
假設i和j的位置如上圖,則next[i] = j ([0..j]的后一位字符的下標或者說是[0..j-1]的字符的數量)
區段 [0, i - 1] 的最長相同前后綴分別是 [0, j - 1] 和[i - j, i - 1],即這兩區段內容相同。
按照算法流程,if (P[i] == P[j])
,則i++; j++; next[i] = j;
;若不等,則j = next[j]
,
- 即2個藍圈的字符相等,判斷true。
- 不等false。那么需要重新確定最長共有字符串的長度。j = next[j]。next[j]代表上圖左下紅線的字符串"abab"的最長前綴/后綴的長度。即2條綠線代表“abab”的前綴后后綴。j = 2。
- 然后再循環判斷:if (p[i] == p[j])即p[2] ==p[6], 則next[6] = 2; 若不等, 則j = next[j]
- 其含義就是向字符串的前面找p[0..6]的“最大共同字符串”,每下一輪都是判斷本輪最大前綴的最大前綴的字符串。
j 開始被賦值為-1, 是為了讓next[0] = -1,但會導致:
- 程序剛開始時,使用p[i] ==p[j]判斷,無疑p[j]會邊界溢出。
- else語句中的j = next[j], j指針不斷向字符串頭部移動,當j被賦值-1時,溢出.
所以判斷語句if上加上 j == -1。
對next數組的小優化
行文到此,已經理解了kmp算法的原理,和流程。
這里有一個小的優化,節省一步遞歸。情況是這樣的:
如果遇到:text = 'abacababc', pattern = 'abab', 已經求得next_s = [-1,0,0,1],模擬流程:
當j = 3, i =3時,比較發現p[3] 不等於t[3],於是j = next[j] ,即移動j到1,然后下一輪比較p[1]和t[3]是否相等。
結果發現仍然不相等。當然不相等啦!
觀察一下,注意到,模式"abab",p[1]等於p[3],因此上面的一步遞歸后的再比較p[1]和t[3]完全沒有比較。
既然已經知道如果p[1] == p[3], 那么下一步遞歸,和t[3]的比較同樣不相同,就無需這一步的比較了,直接跳過去。
即在比較p[3]和t[3]后,直接進行2次遞歸。按照這個思路next_s[i]就儲存2次遞歸后的值了:
if p[i] != p[j] next_s[i] = j else # 如果相等,則2次遞歸 next_s[i] = next[j] end
完全的代碼見git:https://github.com/chentianwei411/-/blob/master/kmp.rb
kmp的時間復雜度分析
text長度為n,模式為m, 時間復雜度就是O(n+m)。
Kmp算法流程:
假設文本string匹配到i位置,模式串pattern匹配到j位置:
- 如果j == -1, 或者s[i] == p[j],則令i, j都➕1。繼續匹配下一個字符。
- 如果上面的判斷都false, 則i指針不變,j指針移動到next[j]。即匹配失敗的話,pattern相對於string向右移動了j-next[j]位置。
因為匹配成功,則i+1,j+1,匹配失敗則i不動,整個算法最壞的情況是i移動到終點,仍然模式不匹配文本。所以花費時間是線性的O(n)
再加上預處理模式串的時間O(m), 最壞時間復雜度是O(n+m)
BM算法
1977年,德克薩斯大學的2名教授發明了一種新的字符串匹配算法:BM算法。(和kmp一樣,也是以名字命名)。
最壞時間復雜度是O(n),。 比kmp算法更高效。
參考阮一峰http://www.ruanyifeng.com/blog/2013/05/boyer-moore_string_search_algorithm.html
特點是模式串從左向右比較。(比BM算法還早的Horspool算法也是這樣。)
兩個規則:
- 壞字符規則:當文本串中的某個字符跟模式串的某個字符不匹配時,我們稱文本串中的這個失配字符為壞字符,此時模式串需要向右移動,移動的位數 = 壞字符在模式串中的位置 - 壞字符在模式串中最右出現的位置。此外,如果"壞字符"不包含在模式串之中,則最右出現位置為-1。
- 好后綴規則:當字符失配時,后移位數 = 好后綴在模式串中的位置 - 好后綴在模式串上一次出現的位置,且如果好后綴在模式串中沒有再次出現,則為-1。
Sunday算法
http://www.voidcn.com/article/p-gcxakovf-sg.html
比kmp算法快的算法非常多,而且比kmp更好理解。kmp的確是很折磨新手。
Sunday算法由Daniel M.Sunday在1990年提出,實際上比BM算法還快。
Sunday算法是從前往后匹配,在匹配失敗時關注的是主串中參加匹配的最末位字符的下一位字符。
- 如果該字符沒有在模式串中出現則直接跳過,即移動位數 = 模式串長度 + 1;
- 否則,其移動位數 = 模式串長度 - 該字符最右出現的位置(以0開始) = 模式串中該字符最右出現的位置到尾部的距離 + 1。
參考:
參考了不少文章,有的太復雜有的太簡單。還是推薦知乎上的這個https://www.zhihu.com/question/21923021
和這篇https://subetter.com/algorithm/kmp-algorithm.html
擴展章節摘錄:https://blog.csdn.net/v_july_v/article/details/7041827