字符串匹配算法


字符串匹配算法

  • 簡介
  • 暴力匹配
  • 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)

就是窮舉法,枚舉法,也叫暴力匹配。是最低效最原始的算法。特點:

  1. 無預處理階段。(因為是暴力匹配)
  2. 對Pattern,可以從T的首或尾開始逐個匹配字母,比較順序沒有限制。
  3. 最壞時間復雜度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)]兩個字符串相同。那么:

  1. 首先,確定T[1..i]的后綴集合和P[1..i]前綴集合。
  2. 然后,查看是否有兩個集合的相交集合。
  3. 如果有,確認集合中那個最長的字符串max_string。
  4. ⚠️,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 ..,共有元素最大長度為 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]表示的就是

  1. 這個最大前綴/后綴的長度,
  2. 也表示了最大前綴的后面一位字符的索引。

 

先放上代碼: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個特點:

  1. 長度比較:a < b < c。
  2. 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]有沒有最大共同字符串。

由這個過程:我們發現了::

  1. 找p[0..i]的最大共同字符串的問題,其實就是,比較p[0..i-1]的共同字符串集合[a,b,c]的每個字符串的后一位,是否等於新加入的元素p[i]!
  2. 因為要找到最大的共同字符串,同時共同集合字符串的長度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,但會導致:

  1. 程序剛開始時,使用p[i] ==p[j]判斷,無疑p[j]會邊界溢出。
  2. 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

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM