動態規劃之KMP字符匹配算法

讀完本文,你能夠去力扣拿下以下題目:c++

28.實現 strStr()git

-----------github

KMP 算法(Knuth-Morris-Pratt 算法)是一個著名的字符串匹配算法,效率很高,可是確實有點複雜。算法

不少讀者抱怨 KMP 算法沒法理解,這很正常,想到大學教材上關於 KMP 算法的講解,也不知道有多少將來的 Knuth、Morris、Pratt 被提早勸退了。有一些優秀的同窗經過手推 KMP 算法的過程來輔助理解該算法,這是一種辦法,不過本文要從邏輯層面幫助讀者理解算法的原理。十行代碼之間,KMP 灰飛煙滅。數組

先在開頭約定,本文用 pat 表示模式串,長度爲 Mtxt 表示文本串,長度爲 N。KMP 算法是在 txt 中查找子串 pat,若是存在,返回這個子串的起始索引,不然返回 -1框架

爲何我認爲 KMP 算法就是個動態規劃問題呢,等會再解釋。對於動態規劃,以前屢次強調了要明確 dp 數組的含義,並且同一個問題可能有不止一種定義 dp 數組含義的方法,不一樣的定義會有不一樣的解法。函數

讀者見過的 KMP 算法應該是,一波詭異的操做處理 pat 後造成一個一維的數組 next,而後根據這個數組通過又一波複雜操做去匹配 txt。時間複雜度 O(N),空間複雜度 O(M)。其實它這個 next 數組就至關於 dp 數組,其中元素的含義跟 pat 的前綴和後綴有關,斷定規則比較複雜,很差理解。本文則用一個二維的 dp 數組(但空間複雜度仍是 O(M)),從新定義其中元素的含義,使得代碼長度大大減小,可解釋性大大提升this

PS:本文的代碼參考《算法4》,原代碼使用的數組名稱是 dfa(肯定有限狀態機),由於咱們的公衆號以前有一系列動態規劃的文章,就不說這麼高大上的名詞了,我對書中代碼進行了一點修改,並沿用 dp 數組的名稱。spa

1、KMP 算法概述

首先仍是簡單介紹一下 KMP 算法和暴力匹配算法的不一樣在哪裏,難點在哪裏,和動態規劃有啥關係。設計

暴力的字符串匹配算法很容易寫,看一下它的運行邏輯:

// 暴力匹配(僞碼)
int search(String pat, String txt) {
int M = pat.length;
int N = txt.length;
for (int i = 0; i <= N - M; i++) {
int j;
for (j = 0; j < M; j++) {
if (pat[j] != txt[i+j])
break;
}
// pat 全都匹配了
if (j == M) return i;
}
// txt 中不存在 pat 子串
return -1;
}

對於暴力算法,若是出現不匹配字符,同時回退 txtpat 的指針,嵌套 for 循環,時間複雜度 O(MN),空間複雜度O(1)。最主要的問題是,若是字符串中重複的字符比較多,該算法就顯得很蠢。

好比 txt = "aaacaaab" pat = "aaab":

brutal

很明顯,pat 中根本沒有字符 c,根本不必回退指針 i,暴力解法明顯多作了不少沒必要要的操做。

KMP 算法的不一樣之處在於,它會花費空間來記錄一些信息,在上述狀況中就會顯得很聰明:

kmp1

再好比相似的 txt = "aaaaaaab" pat = "aaab",暴力解法還會和上面那個例子同樣蠢蠢地回退指針 i,而 KMP 算法又會耍聰明:

kmp2

由於 KMP 算法知道字符 b 以前的字符 a 都是匹配的,因此每次只須要比較字符 b 是否被匹配就好了。

KMP 算法永不回退 txt 的指針 i,不走回頭路(不會重複掃描 txt),而是藉助 dp 數組中儲存的信息把 pat 移到正確的位置繼續匹配,時間複雜度只需 O(N),用空間換時間,因此我認爲它是一種動態規劃算法。

KMP 算法的難點在於,如何計算 dp 數組中的信息?如何根據這些信息正確地移動 pat 的指針?這個就須要肯定有限狀態自動機來輔助了,別怕這種高大上的文學詞彙,其實和動態規劃的 dp 數組一模一樣,等你學會了也能夠拿這個詞去嚇唬別人。

還有一點須要明確的是:計算這個 dp 數組,只和 pat 串有關。意思是說,只要給我個 pat,我就能經過這個模式串計算出 dp 數組,而後你能夠給我不一樣的 txt,我都不怕,利用這個 dp 數組我都能在 O(N) 時間完成字符串匹配。

PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,所有發佈在 labuladong的算法小抄,持續更新。建議收藏,按照個人文章順序刷題,掌握各類算法套路後投再入題海就如魚得水了。

具體來講,好比上文舉的兩個例子:

txt1 = "aaacaaab"
pat = "aaab"
txt2 = "aaaaaaab"
pat = "aaab"

咱們的 txt 不一樣,可是 pat 是同樣的,因此 KMP 算法使用的 dp 數組是同一個。

只不過對於 txt1 的下面這個即將出現的未匹配狀況:

dp 數組指示 pat 這樣移動:

PS:這個j 不要理解爲索引,它的含義更準確地說應該是狀態(state),因此它會出現這個奇怪的位置,後文會詳述。

而對於 txt2 的下面這個即將出現的未匹配狀況:

dp 數組指示 pat 這樣移動:

明白了 dp 數組只和 pat 有關,那麼咱們這樣設計 KMP 算法就會比較漂亮:

public class KMP {
private int[][] dp;
private String pat;

public KMP(String pat) {
this.pat = pat;
// 經過 pat 構建 dp 數組
// 須要 O(M) 時間
}

public int search(String txt) {
// 藉助 dp 數組去匹配 txt
// 須要 O(N) 時間
}
}

這樣,當咱們須要用同一 pat 去匹配不一樣 txt 時,就不須要浪費時間構造 dp 數組了:

KMP kmp = new KMP("aaab");
int pos1 = kmp.search("aaacaaab"); //4
int pos2 = kmp.search("aaaaaaab"); //4

2、狀態機概述

爲何說 KMP 算法和狀態機有關呢?是這樣的,咱們能夠認爲 pat 的匹配就是狀態的轉移。好比當 pat = "ABABC":

如上圖,圓圈內的數字就是狀態,狀態 0 是起始狀態,狀態 5(pat.length)是終止狀態。開始匹配時 pat 處於起始狀態,一旦轉移到終止狀態,就說明在 txt 中找到了 pat。好比說當前處於狀態 2,就說明字符 "AB" 被匹配:

另外,處於不一樣狀態時,pat 狀態轉移的行爲也不一樣。好比說假設如今匹配到了狀態 4,若是遇到字符 A 就應該轉移到狀態 3,遇到字符 C 就應該轉移到狀態 5,若是遇到字符 B 就應該轉移到狀態 0:

具體什麼意思呢,咱們來一個個舉例看看。用變量 j 表示指向當前狀態的指針,當前 pat 匹配到了狀態 4:

若是遇到了字符 "A",根據箭頭指示,轉移到狀態 3 是最聰明的:

若是遇到了字符 "B",根據箭頭指示,只能轉移到狀態 0(一晚上回到解放前):

若是遇到了字符 "C",根據箭頭指示,應該轉移到終止狀態 5,這也就意味着匹配完成:

固然了,還可能遇到其餘字符,好比 Z,可是顯然應該轉移到起始狀態 0,由於 pat 中根本都沒有字符 Z:

這裏爲了清晰起見,咱們畫狀態圖時就把其餘字符轉移到狀態 0 的箭頭省略,只畫 pat 中出現的字符的狀態轉移:

KMP 算法最關鍵的步驟就是構造這個狀態轉移圖。要肯定狀態轉移的行爲,得明確兩個變量,一個是當前的匹配狀態,另外一個是遇到的字符;肯定了這兩個變量後,就能夠知道這個狀況下應該轉移到哪一個狀態。

下面看一下 KMP 算法根據這幅狀態轉移圖匹配字符串 txt 的過程:

請記住這個 GIF 的匹配過程,這就是 KMP 算法的核心邏輯

爲了描述狀態轉移圖,咱們定義一個二維 dp 數組,它的含義以下:

dpj = next
0 <= j < M,表明當前的狀態
0 <= c < 256,表明遇到的字符(ASCII 碼)
0 <= next <= M,表明下一個狀態

dp4 = 3 表示:
當前是狀態 4,若是遇到字符 A,
pat 應該轉移到狀態 3

dp1 = 2 表示:
當前是狀態 1,若是遇到字符 B,
pat 應該轉移到狀態 2

根據咱們這個 dp 數組的定義和剛纔狀態轉移的過程,咱們能夠先寫出 KMP 算法的 search 函數代碼:

public int search(String txt) {
int M = pat.length();
int N = txt.length();
// pat 的初始態爲 0
int j = 0;
for (int i = 0; i < N; i++) {
// 當前是狀態 j,遇到字符 txt[i],
// pat 應該轉移到哪一個狀態?
j = dpj;
// 若是達到終止態,返回匹配開頭的索引
if (j == M) return i - M + 1;
}
// 沒到達終止態,匹配失敗
return -1;
}

到這裏,應該仍是很好理解的吧,dp 數組就是咱們剛纔畫的那幅狀態轉移圖,若是不清楚的話回去看下 GIF 的算法演進過程。下面講解:如何經過 pat 構建這個 dp 數組?

PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,所有發佈在 labuladong的算法小抄,持續更新。建議收藏,按照個人文章順序刷題,掌握各類算法套路後投再入題海就如魚得水了。

3、構建狀態轉移圖

回想剛纔說的:要肯定狀態轉移的行爲,必須明確兩個變量,一個是當前的匹配狀態,另外一個是遇到的字符,並且咱們已經根據這個邏輯肯定了 dp 數組的含義,那麼構造 dp 數組的框架就是這樣:

for 0 <= j < M: # 狀態
for 0 <= c < 256: # 字符
dpj = next

這個 next 狀態應該怎麼求呢?顯然,若是遇到的字符 cpat[j] 匹配的話,狀態就應該向前推動一個,也就是說 next = j + 1,咱們不妨稱這種狀況爲狀態推動

若是字符 cpat[j] 不匹配的話,狀態就要回退(或者原地不動),咱們不妨稱這種狀況爲狀態重啓

那麼,如何得知在哪一個狀態重啓呢?解答這個問題以前,咱們再定義一個名字:影子狀態(我編的名字),用變量 X 表示。所謂影子狀態,就是和當前狀態具備相同的前綴。好比下面這種狀況:

當前狀態 j = 4,其影子狀態爲 X = 2,它們都有相同的前綴 "AB"。由於狀態 X 和狀態 j 存在相同的前綴,因此當狀態 j 準備進行狀態重啓的時候(遇到的字符 cpat[j] 不匹配),能夠經過 X 的狀態轉移圖來得到最近的重啓位置

好比說剛纔的狀況,若是狀態 j 遇到一個字符 "A",應該轉移到哪裏呢?首先只有遇到 "C" 才能推動狀態,遇到 "A" 顯然只能進行狀態重啓。狀態 j 會把這個字符委託給狀態 X 處理,也就是 dp[j]['A'] = dp[X]['A']

爲何這樣能夠呢?由於:既然 j 這邊已經肯定字符 "A" 沒法推動狀態,只能回退,並且 KMP 就是要儘量少的回退,以避免多餘的計算。那麼 j 就能夠去問問和本身具備相同前綴的 X,若是 X 碰見 "A" 能夠進行「狀態推動」,那就轉移過去,由於這樣回退最少。

固然,若是遇到的字符是 "B",狀態 X 也不能進行「狀態推動」,只能回退,j 只要跟着 X 指引的方向回退就好了:

你也許會問,這個 X 怎麼知道遇到字符 "B" 要回退到狀態 0 呢?由於 X 永遠跟在 j 的身後,狀態 X 如何轉移,在以前就已經算出來了。動態規劃算法不就是利用過去的結果解決如今的問題嗎?

這樣,咱們就細化一下剛纔的框架代碼:

int X # 影子狀態
for 0 <= j < M:
for 0 <= c < 256:
if c == pat[j]:
# 狀態推動
dpj = j + 1
else:
# 狀態重啓
# 委託 X 計算重啓位置
dpj = dpX

4、代碼實現

若是以前的內容你都能理解,恭喜你,如今就剩下一個問題:影子狀態 X 是如何獲得的呢?下面先直接看完整代碼吧。

public class KMP {

private int[][] dp;
private String pat;

public KMP(String pat) {
    this.pat = pat;
    int M = pat.length();
    // dp[狀態][字符] = 下個狀態
    dp = new int[M][256];
    // base case
    dp[0][pat.charAt(0)] = 1;
    // 影子狀態 X 初始爲 0
    int X = 0;
    // 當前狀態 j 從 1 開始
    for (int j = 1; j < M; j++) {
        for (int c = 0; c < 256; c++) {
            if (pat.charAt(j) == c) 
                dp[j][c] = j + 1;
            else 
                dp[j][c] = dp[X][c];
        }
        // 更新影子狀態
        X = dp[X][pat.charAt(j)];
    }
}

public int search(String txt) {...}

}

先解釋一下這一行代碼:

// base case
dp0 = 1;

這行代碼是 base case,只有遇到 pat[0] 這個字符才能使狀態從 0 轉移到 1,遇到其它字符的話仍是停留在狀態 0(Java 默認初始化數組全爲 0)。

影子狀態 X 是先初始化爲 0,而後隨着 j 的前進而不斷更新的。下面看看到底應該如何更新影子狀態 X

int X = 0;
for (int j = 1; j < M; j++) {

...
// 更新影子狀態
// 當前是狀態 X,遇到字符 pat[j],
// pat 應該轉移到哪一個狀態?
X = dp[X][pat.charAt(j)];

}

更新 X 其實和 search 函數中更新狀態 j 的過程是很是類似的:

int j = 0;
for (int i = 0; i < N; i++) {

// 當前是狀態 j,遇到字符 txt[i],
// pat 應該轉移到哪一個狀態?
j = dp[j][txt.charAt(i)];
...

}

其中的原理很是微妙,注意代碼中 for 循環的變量初始值,能夠這樣理解:後者是在 txt 中匹配 pat,前者是在 pat 中匹配 pat[1..end],狀態 X 老是落後狀態 j 一個狀態,與 j 具備最長的相同前綴。因此我把 X 比喻爲影子狀態,彷佛也有一點貼切。

另外,構建 dp 數組是根據 base case dp[0][..] 向後推演。這就是我認爲 KMP 算法就是一種動態規劃算法的緣由。

下面來看一下狀態轉移圖的完整構造過程,你就能理解狀態 X 做用之精妙了:

至此,KMP 算法的核心終於寫完啦啦啦啦!看下 KMP 算法的完整代碼吧:

public class KMP {

private int[][] dp;
private String pat;

public KMP(String pat) {
    this.pat = pat;
    int M = pat.length();
    // dp[狀態][字符] = 下個狀態
    dp = new int[M][256];
    // base case
    dp[0][pat.charAt(0)] = 1;
    // 影子狀態 X 初始爲 0
    int X = 0;
    // 構建狀態轉移圖(稍改的更緊湊了)
    for (int j = 1; j < M; j++) {
        for (int c = 0; c < 256; c++)
            dp[j][c] = dp[X][c];
        dp[j][pat.charAt(j)] = j + 1;
        // 更新影子狀態
        X = dp[X][pat.charAt(j)];
    }
}

public int search(String txt) {
    int M = pat.length();
    int N = txt.length();
    // pat 的初始態爲 0
    int j = 0;
    for (int i = 0; i < N; i++) {
        // 計算 pat 的下一個狀態
        j = dp[j][txt.charAt(i)];
        // 到達終止態,返回結果
        if (j == M) return i - M + 1;
    }
    // 沒到達終止態,匹配失敗
    return -1;
}

}

通過以前的詳細舉例講解,你應該能夠理解這段代碼的含義了,固然你也能夠把 KMP 算法寫成一個函數。核心代碼也就是兩個函數中 for 循環的部分,數一下有超過十行嗎?

5、最後總結

傳統的 KMP 算法是使用一個一維數組 next 記錄前綴信息,而本文是使用一個二維數組 dp 以狀態轉移的角度解決字符匹配問題,可是空間複雜度仍然是 O(256M) = O(M)。

pat 匹配 txt 的過程當中,只要明確了「當前處在哪一個狀態」和「遇到的字符是什麼」這兩個問題,就能夠肯定應該轉移到哪一個狀態(推動或回退)。

對於一個模式串 pat,其總共就有 M 個狀態,對於 ASCII 字符,總共不會超過 256 種。因此咱們就構造一個數組 dp[M][256] 來包含全部狀況,而且明確 dp 數組的含義:

dp[j][c] = next 表示,當前是狀態 j,遇到了字符 c,應該轉移到狀態 next

明確了其含義,就能夠很容易寫出 search 函數的代碼。

對於如何構建這個 dp 數組,須要一個輔助狀態 X,它永遠比當前狀態 j 落後一個狀態,擁有和 j 最長的相同前綴,咱們給它起了個名字叫「影子狀態」。

在構建當前狀態 j 的轉移方向時,只有字符 pat[j] 才能使狀態推動(dp[j][pat[j]] = j+1);而對於其餘字符只能進行狀態回退,應該去請教影子狀態 X 應該回退到哪裏(dp[j][other] = dp[X][other],其中 other 是除了 pat[j] 以外全部字符)。

對於影子狀態 X,咱們把它初始化爲 0,而且隨着 j 的前進進行更新,更新的方式和 search 過程更新 j 的過程很是類似(X = dp[X][pat[j]])。

KMP 算法也就是動態規劃那點事,咱們的公衆號文章目錄有一系列專門講動態規劃的,並且都是按照一套框架來的,無非就是描述問題邏輯,明確 dp 數組含義,定義 base case 這點破事。但願這篇文章能讓你們對動態規劃有更深的理解。

_____________

個人 在線電子書 有 100 篇原創文章,手把手帶刷 200 道力扣題目,建議收藏!對應的 GitHub 算法倉庫 已經得到了 70k star,歡迎標星!

相關文章
相關標籤/搜索