字符串匹配——KMP與有限狀態自動機


前言

本文記錄了一下自己對KMP和有限狀態自動機算法的理解,方便復習

KMP與有限狀態自動機算法其實我認為可以看做是同一個算法,他們具有相同的本質,即利用最長公共前后綴

但他們對這個思想的實現不一樣,本文先介紹KMP,進而在理解KMP的基礎上再介紹有限狀態自動機算法。

第一部分 KMP

算法介紹

暴力算法

首先看我們在字符串匹配時最原始的暴力算法,在text中找到pattern

int find(string pattern, string text) {
  for (int i = 0->text.length() - 1) {
    for (int j = 0->pattern.length() - 1) {
      if (text[i + j] != pattern[j]) {
        break;
      }
      if (j == pattern.length() - 1) {
        return i;
      }
    }
  }
}

算法的時間復雜度是O(mn),m是pattern的長度,n是text的長度(下文沿用m和n這兩個記號)

簡單解釋就是對於text中每一個字符,我們都檢查以他為開頭的子串是否匹配pattern

在KMP中,我們借用公共前后綴的概念,可以避免許多重復的檢查

最長公共前后綴

什么是公共前后綴?

假設在一個字符串 s = "abaaba" 中,子串 "aba" 既是 "abaaba" 的前綴也是他的后綴,"aba" 就是 s 的公共前后綴,同理 "a" 也是 s 的公共前后綴,我們將一個字符串中長度最長的公共前后綴稱為該字符串的最長公共前后綴,以下我們用 “前綴” 來代指作為前綴時的最長公共前后綴,在上面的例子中,即為 "abaaba"的前三個字符,“后綴”同理,代指作為后綴時的最長公共前后綴,即 "abaaba" 的后三個字符

(本文使用的前綴、后綴均不包含字符串自身也不包含空串,即 proper prefix/postfix)

我們將最長公共前后綴的概念應用於字符串匹配,下面是匹配算法的流程

算法過程

記 i 為text的下標,j 為pattern的下標,均初始化為0

假設我們有一個next數組,next[x]記載的是子串pattern[0-x]的最長公共前后綴長度,怎么求next[x]我們會在后面講解

算法步驟:

  1. 若text[i] == pattern[j],則i++, j++,代表的是 text 中的字符和 pattern 中的字符成功匹配,兩個下標均往后走一位,匹配下一個字符
  2. 若text[i] != pattern[j],則j = next[j-1],代表的是,不匹配時,使 j 退到 pattern[0-j-1] 的“前綴”的后一位

重復以上步驟直到 j = m-1(成功匹配)或 i = n-1 (匹配失敗)

讓我們來舉個例子

假設 text 為"ababaca", pattern 為"abababa",我們要在text中找到pattern

image

通過算法步驟1,我們很容易就跑到了 i = 5,此時我們已完成了前5個字符"ababa"的匹配

現在我們去匹配第6個字符 text[ i=5 ] 與 pattern[ j=5 ],但我們發現 'c' 與 'b' 不匹配。此時我們做的不是將 i 回退到text[1],j 回退到0,完全重新匹配一次。在KMP中,我們的 i 永遠只會往后走,我們需要回退pattern的下標 j,使 j = next[j-1] = 3。在pattern[0-j-1] : "ababa" 的最長公共前后綴是"aba",我們將j退到pattern[0-j-1]的"前綴”的后一位,下標就是next[j-1],text不用退,i不變,繼續用text[i]與pattern[j]匹配

image

因為text[ i=5 ]與pattern[ j=3]還是不匹配,pattern[0-j-1]: "aba"的最長公共前后綴是"a",使 j = next[j-1],即把j退到"a"的后一位,j = 1

image

j=1, i=5時還是不匹配,但pattern[0-j-1]不存在最長公共前后綴,我們使 j=0

image

若 j=0時仍不匹配,則使 i++,重復算法步驟,直到 j = m- 1 或 i = n - 1
image

這樣我們就完成了KMP的字符串匹配,代碼如下

  for (int i = 0 -> n-1) {
    while (j > 0 && text[i] != pattern[j]) {
      j = next[j - 1];
    }
    if (text[i] == pattern[j]) {
      j++;
    }
    if (j == m) {
      return i - m + 1;
    }
  }

但此時我們還不知道,為什么要在字符不匹配時將 j 回退到pattern[0-j-1]前綴的后一位,和如何找到最長公共前后綴

理解字符串匹配

我們先講為什么要在字符不匹配時,將 j 回退到 pattern[0-j-1] 前綴的后一位

我們使用上面的例子,text為"ababaca", pattern為"abababa"

當匹配到第6個字符時,i=5,j=5,text[i] != pattern[j]

image

此時滿足如下幾個條件

  1. pattern[0-4] = text[0-4]
  2. A是pattern[0-4]的“前綴”,A = B

由 1 可以推導出

  1. B = D

進而可以推導出 A = D

A = D 代表着,pattern[0-2]與text[2-4]已經自動成功匹配,我們接下來就應該用pattern[3]與text[5]比較,即將j回退到3,i不變。可以看出通過這種方式,我們省去了重復匹配pattern[0-2]的過程,這就是我們為什么要找“最長公共前后綴”,也是KMP的威力所在。

讓我們繼續往下看上面的例子,可以理解得更透徹一點,現在比較pattern[3]與text[5],並不匹配

image

此時滿足以下幾個條件

  1. pattern[0-2] = pattern[2-4]
  2. pattern[2-4] = text[2-4]
  3. A是pattern[0-2]的“前綴”,A = B
  4. E = F

和我們之前的推導一樣,我們根據1和3可以推導出

  1. pattern[0-2] = text[2-4]

再根據1,得到

  1. B = E

加上條件3得到

  1. A = E

根據5和8,得到結論

A = F

A = F代表着,pattern[0]與text[4]成功匹配,我們接下來就應該用pattern[1]與text[5]比較,而不需要重復比較pattern[0]和 text[4]

我們應該已經明白了,利用起最長公共前后綴,可以大大地減少比較次數

如何尋找最長公共前后綴

接下來我們會講解如何找到最長公共前后綴

我們需要找到的是pattern[0/-1],pattern[0/-2] , ... ,pattern[0-m-1]的最長公共前后綴長度,與 text 無關

我們用數組next來記錄最長公共前后綴長度, next[ x ]記載的是子串pattern[0-x]的最長公共前后綴長度

用 i 和 j 來作為 pattern的下標,我們要找的始終是next[ j ],i 記錄的是pattern[0-j] “前綴” 的最后一位(也有可能不是,請繼續往下看),先初始化 i = 0,j = 1,next[0]可以直接寫入0,因為一個字符不存在最長公共前后綴(我們前面提到了前后綴不包括自己)

算法的步驟如下:

若 pattern[ i ] == pattern[ j ],next[ j ] = i+1,i++,j++

當匹配成功時,i 記錄的是 “前綴” 最后一位,i+1就是“前綴”的長度,寫入next[j]

可以看這個下面例子

image

pattern[0] == pattern[1],i記錄的是pattern[0-1]的前綴 "a" 的最后一位,使next[j] = i + 1,到下一個狀態

image

因為pattern[0] = pattern[1],我們知道了A = B,我們在 A = B 的基礎上想進一步知道

A+pattern[1] == B+pattern[2] 是否為真,如果為真的話我們可以得到一個長度為2的最長公共前后綴,事實上確實是為真的,我們就使next[j] = i+1,i++,j++。可以看出我們在利用已知的最長公共前后綴來幫助推導新的最長公共前后綴

我們省略中間簡單的步驟,直接到 i = 3 , j = 5 的情況

image

此時已知A = B,但pattern[3] != pattern[4],我們此時就需要講若 pattern[i] != pattern[j] 怎么辦

若 pattern[i] != pattern[j],使 i = next[i-1],再比較pattern[i]與pattern[j],若仍不匹配,繼續使 i = next[i-1],直到 i = 0 為止,若 i = 0 時匹配成功,按照我們上面講的pattern[i] = pattern[j] 的情況處理,若不匹配,i不動,j++。重復以上步驟,直到next數組填滿

讓我們沿用上面的例子來講解一下這個算法

雖然 A = B 這個結論無法使用,但我們找到A的最長公共前后綴,回退到前綴后一位

image

A = B 蘊含着隱含條件,C = D,我們在算法理解第一部分已經有過類似的推導,但在這里還是重復一遍

已知條件

  1. A = B

根據1可知

  1. A[1-2] = B[1-2], 即 pattern[1-2] = pattern[2-3]

A中最長公共前后綴是"aa",可知

  1. pattern[0-1] = pattern[1-2]

根據2和3,得到

  1. pattern[0-1] = pattern[2-3],即C = D

雖然 A = B 利用不成功,但我們還可以利用起 C = D 來找最長公共前后綴,所以將i退到 C 后一位,但因為仍不匹配,需要繼續回退,退到C的最長公共前后綴的后一位。重復以上的步驟,直到 i = 0 ,這下我們知道了沒有任何已知的公共前后綴可以給我們利用,只能從頭開始匹配,若 i = 0時頭尾也不匹配,說明pattern[0-j]不存在公共前后綴,next[ j ]記為0,使 j++,找此時pattern[0-j]的最長公共前后綴

上面的例子在回退時並沒有利用起我們已經算出來的最長公共前后綴,下面這個例子是可以利用的情況

image

這種情況下,A+pattern[i] 與 B+pattern[j]不匹配,但我們可以利用A的最長公共前后綴

image

我們可以推導出 C = D,而且此時 C +pattern[i] = D + pattern[j] 為真,可以得到 pattern[0-j]的最長公共前后綴是 C+1,利用起了已經算出的最長公共前后綴

到這里我們就知道如何計算next數組(最長公共前后綴)了,而且我們可以發現,字符串匹配部分和構造next的算法是非常相似的,這是因為不僅字符串匹配是在利用next數組,連next數組的構造也在使用自己

到這里,KMP的詳解就結束了

這個算法可以總結為兩步

  1. 找next數組
  2. 利用next數組去匹配字符串

下面是代碼實現

C++代碼實現

#include <string>
using std::string;
void find_next(string pattern, int* next);

int KMP(string pattern, string text) {
  int m = pattern.length();
  int n = text.length();
  int* next = new int[m];
  for (int i = 0; i < m; i++) {
    next[i] = 0;
  }
  find_next(pattern, next);
  int j = 0;
  for (int i = 0; i < n; i++) {
    while (j > 0 && text[i] != pattern[j]) {
      j = next[j - 1];
    }
    if (text[i] == pattern[j]) {
      j++;
    }
    if (j == m) {
      return i - m + 1;
    }
  }
  return -1;
}

void find_next(string pattern, int* next) {
  int m = pattern.length();
  int head = 0;
  next[0] = 0;
  for (int tail = 1; tail < m; tail++) {
    while (head > 0 && pattern[head] != pattern[tail]) {
      head = next[head - 1];
    }
    if (pattern[head] == pattern[tail]) {
      head++;
    }
    next[tail] += head;
  }
}

復雜度分析

時間復雜度

待寫

第二部分 有限狀態自動機算法

什么是有限狀態自動機

有限狀態自動機的英文叫做finite-state automaton,也叫finite-state machine,這里引用了我老師程京德教授上課使用的課件中,對finite-state automaton的介紹,解釋的非常簡潔明了

image

再簡單解釋以下

Q就是這個有限狀態自動機中存在的狀態的集合

Σ是能輸入這個有限狀態自動機的字符的集合

δ是從 Q與Σ的笛卡爾積 到 Q 上的函數,叫做狀態轉移方程,意思是在狀態A接受一個輸入字符x,會轉移到δ(A,x)狀態

q0是開始的狀態,F是一系列接受狀態的集合,當轉移到F其中的狀態,有限狀態自動機就會結束工作

image

image

如果將A輸入M,最終能根據 δ 轉移到accepted states,我們將A稱為是M的語言,也叫"M recognizes A"

而且M如果recognize A,要滿足 δ(qi,wi+1) = qi+1,這個條件我們待會會用到

算法介紹

這個算法,要在text中找到pattern,我們要根據pattern來構建一個有限狀態自動機 M,並將 text 輸入 這個有限狀態自動機,判斷 M 是否 recognize text

所以這個算法的重點就落在如何根據 pattern 構造一個有限狀態自動機

如何構造有限狀態自動機

一個有限狀態自動機包含 5 個元素,只要確定他們便構造完成,下文是對各個元素的逐一確定

下文將我們構造的有限狀態自動機簡稱為M

Q

當 pattern 長度為m,Q 的 cardinality 應該為 m+1,即有 m+1 個元素,分別為q0,q1,... ,qm,q0就是start state

Σ

根據 pattern 而定,本文暫定為ASCII中0-255所代表的字符

F

accepted states只包含一個元素,就是qm

δ

這個是構造有限狀態自動機的重點部分,下文會闡述如何確定δ

假設 pattern 為 "aabaaabb",這個例子中 alphabet只有"a"和"b",便於講解

下方這個表是δ的初始狀態,我們要根據pattern填滿從state 0到state (m-1)的內容

表中每個格子要寫入的是,在當前state下,接受這個input,會轉移到哪個state,比如 δ(1,b) 代表的是在q1時接受input "b",將會轉移到的state

image

填表的第一條規則是:當處於qx,input是pattern[x],qx轉移到qx+1,這與上文提到的有限狀態自動機 M accept w 第二個條件一致

根據這條規則,這個表有一部分我們可以很容易地就根據pattern每個位置的字符來寫入

image

把δ用於字符串匹配,當我們的輸入到達了state qx,代表已經完成了前x個字符的匹配,當到達q8,代表匹配成功

假設我們此時的 text 剛好和 pattern 完全相同,我們理應能夠直接順序地從q0走到q8

將這個與pattern完全相同的 text 輸入M。當狀態為q0時,遇到 text 第一個字符 "a" 就應該進入 q1 。當狀態為 q1 時,遇到 pattern 第二個字符 "a" 就應該進入 q2 。省略中間一大段過程,直接到當狀態為q7時,遇到"b"就應該進入q8,也就是the accepted state

這樣看來,如果只根據第一條規則填表,也是可以完成字符串匹配的。但如果 text 和 pattern 不完全相同呢?我們先把剩余的格子都填入 0

image

這代表的是,每次匹配失敗,就回到q0,從頭開始匹配,這和我們的暴力算法是一致的。但如果有好的規則來規定δ,不用在每次匹配失敗時都要回到q0,可以省去許多重復的匹配。

於是,科學家設計了這個算法,和KMP一樣,利用了最長公共前后綴,來省略重復的匹配

具體機制如下

先規定,在q0,除了字符匹配成功,其他輸入都應該使狀態不變,即 δ(0, x) = 0(x != pattern[0])

image

現在我們有一個變量 j 和一個變量next,j 初始化為0,next的值跟着 j 的變化而變化

next 的值應該等於將pattern[1-j]輸入M后,最后到達的狀態

根據這個規則,我們先算出 j=0 時的next,因為pattern[1-j]不存在,輸入M后,仍在q0,所以next=0

image

我們確定δ剩余格子的規則如下,

當處於qj,若pattern[j] != input,δ( j,input ) = δ( next(j),input )

根據這條規則,得到δ(1,b) = δ(next=0,b) = 0

當 j=1,pattern[1-1]為a,輸入M后,到達了q1,所以此時next=1,δ(2,a) = δ(1,a) = 2

當 j=2,pattern[1-2]為ab,輸入M后,還是在q0,此時next=0,δ(3,b) = δ(0,b) = 0

對於next,有一個更快的計算方法,每次更新 j,next = δ(next, pattern[j]),代表的是在原先next所在的狀態添加上新的字符

通過這些規則,我們就可以填滿δ和得到各個階段的next了,結果如下

image

image

借用我老師唐博教授的圖,用下面這個示意圖可以更容易理解δ

image

但δ中規定的狀態回退的原理是什么呢?我們現在來看看

把pattern[1-j]輸入M中,最后走到的狀態用next記錄,實際上,next就是pattern[0-j]最長公共前后綴的長度,因為我們已經根據pattern[0-j]構造完成了M從q0到qj的部分 ,把pattern[1-j]輸入M,可以找到pattern[1-j]的末尾,有多長的子串和pattern[0-j]開頭的子串完全相同,也就是去找pattern[0-j]的最長公共前后綴。

舉個例子

當 j=6,我們已經構造好了M從 q0 到 q6 的部分,pattern[1-6]最后3個字符是"aab",當把pattern[1-6]輸入M,最終會到達q3,因為 "aab" 與pattern[0-6]前3個字符相同,3也就代表着pattern[0-6]的最長公共前后綴的長度

實際上,δ也是在用自己構造自己,這點KMP中的next數組一致

當我們知道了這個原理,也就明白了為什么,當處於qj,若pattern[j] != text[j],δ( j,input ) = δ( next,input )

這本質上是在用輸入的字符和pattern[0-j]的“前綴”的后一位匹配,省略了一部分“自動完成”的比較,這里的字符串匹配原理和KMP中的幾乎完全一樣,若還不完全理解的可以回到第一部分KMP的“理解字符串匹配”部分復習一下,但不同的是

到這里,用於字符串匹配的有限狀態自動機算法就講完了。

C++代碼實現

#include <string>
#define total_chars 256
#define trans_func(i, j) (trans_func[(i) * (m) + (j)])

using std::string;

void transition(int* trans_func, string pattern, int m);

int FSA(string pattern, string text) {
  int m = pattern.length();
  int n = text.length();
  int* trans_func = new int[total_chars * m];
  transition(trans_func, pattern, m);
  int q = 0;
  for (int i = 0; i < n; i++) {
    q = trans_func(text[i], q);
    if (q == m) {
      return i - m + 1;
    }
  }
  return -1;
}

void transition(int* trans_func, string pattern) {
  int m = pattern.length();
  for (int i = 0; i < total_chars; i++) {
    if (i == pattern[0]) {
      trans_func(i, 0) = 1;
    } else {
      trans_func(i, 0) = 0;
    }
  }
  int X = 0;
  for (int j = 1; j < m; j++) {
    for (int i = 0; i < total_chars; i++) {
      if (pattern[j] == i) {
        trans_func(i, j) = j + 1;
      } else {
        trans_func(i, j) = trans_func(i, X);
      }
    }
    X = trans_func(pattern[j], X);
  }
}

復雜度分析

時間復雜度

待寫

第三部分 總結

當我們把兩種算法都理解透徹了,發現他們兩個只是一種思想的兩種實現方式

KMP中的next數組和有限狀態自動機算法中的next其實是一樣的,都是記錄了最長公共前后綴的長度

字符串匹配部分都是使用了最長公共前后綴的概念,省去了重復的匹配部分,十分精妙


免責聲明!

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



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