动态规划之KMP字符匹配算法
動態(tài)規(guī)劃之KMP字符匹配算法
文章目錄
- 動態(tài)規(guī)劃之KMP字符匹配算法
- 一、問題引入
- 二、 KMP 算法概述
- 三、 狀態(tài)機概述
- 四、 構(gòu)建狀態(tài)轉(zhuǎn)移圖
- 五、 代碼實現(xiàn)
- 六、 最后總結(jié)
本文的KMP算法是通過狀態(tài)機的改進版本,普通的KMP實現(xiàn)方法請點擊:👇
https://blog.csdn.net/wolfGuiDao/article/details/108299448
一、問題引入
KMP 算法(Knuth-Morris-Pratt 算法) 是?個著名的字符串匹配算法, 效率很?, 但是確實有點復(fù)雜。
先在開頭約定, 本?? pat 表?模式串, ?度為 M , txt 表??本串,?度為 N 。 KMP 算法是在 txt 中查找?串 pat , 如果存在, 返回這個?串的起始索引, 否則返回 -1。
讀者?過的 KMP 算法應(yīng)該是, ?波詭異的操作處理 pat 后形成?個?維的數(shù)組 next , 然后根據(jù)這個數(shù)組經(jīng)過??波復(fù)雜操作去匹配 txt 。 時間復(fù)雜度 O(N), 空間復(fù)雜度 O(M)。
其實它這個 next 數(shù)組就相當(dāng)于 dp 數(shù)組, 其中元素的含義跟 pat 的前綴和后綴有關(guān), 判定規(guī)則?較復(fù)雜, 不好理解。 本?則??個?維的 dp 數(shù)組(但空間復(fù)雜度還是 O(M)) , 重新定義其中元素的含義, 使得代碼?度??減少, 可解釋性??提?。
二、 KMP 算法概述
?先還是簡單介紹?下 KMP 算法和暴?匹配算法的不同在哪?, 難點在哪?, 和動態(tài)規(guī)劃有啥關(guān)系。
暴?的字符串匹配算法很容易寫, 看?下它的運?邏輯:
// 暴?匹配(偽碼) int search(String pat, String txt) {int M = pat.length;int N = txt.length;for (int i = 0; i <= N - M; i++) {for (int j = 0; j < M; j++) {if (pat[j] != txt[i + j])break;}// pat 全都匹配了if (j == M) return i;} // txt 中不存在 pat ?串return -1; }對于暴?算法, 如果出現(xiàn)不匹配字符, 同時回退 txt 和 pat 的指針, 嵌套 for 循環(huán), 時間復(fù)雜度O(MN)O(MN)O(MN), 空間復(fù)雜度O(1)O(1)O(1)。 最主要的問題是,如果字符串中重復(fù)的字符?較多, 該算法就顯得很蠢。
?如 txt = “aaacaaab” pat = “aaab”:
很明顯, pat 中根本沒有字符 c, 根本沒必要回退指針 i , 暴?解法明顯多做了很多不必要的操作。
KMP 算法的不同之處在于, 它會花費空間來記錄?些信息, 在上述情況中就會顯得很聰明:
再?如類似的 txt = “aaaaaaab” pat = “aaab”, 暴?解法還會和上?那個例??樣蠢蠢地回退指針 i , ? KMP 算法?會耍聰明:
因為 KMP 算法知道字符 b 之前的字符 a 都是匹配的, 所以每次只需要?較字符 b 是否被匹配就?了。
KMP 算法永不回退 txt 的指針 i , 不?回頭路(不會重復(fù)掃描txt ) , ?是借助 dp 數(shù)組中儲存的信息把 pat 移到正確的位置繼續(xù)匹配, 時間復(fù)雜度只需 O(N), ?空間換時間, 所以我認(rèn)為它是?種動態(tài)規(guī)劃算法。
KMP 算法的難點在于, 如何計算 dp 數(shù)組中的信息? 如何根據(jù)這些信息正確地移動 pat 的指針? 這個就需要確定有限狀態(tài)?動機來輔助了, 別怕這種??上的?學(xué)詞匯, 其實和動態(tài)規(guī)劃的 dp 數(shù)組如出?轍, 等你學(xué)會了也可以拿這個詞去嚇唬別?。
還有?點需要明確的是: 計算這個 dp 數(shù)組, 只和 pat 串有關(guān)。 意思是說, 只要給我個 pat , 我就能通過這個模式串計算出 dp 數(shù)組, 然后你可以給我不同的 txt , 我都不怕, 利?這個 dp 數(shù)組我都能在 O(N) 時間完成字符串匹配
具體來說, ?如上?舉的兩個例?:
txt1 = "aaacaaab" pat = "aaab" txt2 = "aaaaaaab" pat = "aaab"我們的 txt 不同, 但是 pat 是?樣的, 所以 KMP 算法使?的 dp 數(shù)組是同?個。
只不過對于 txt1 的下?這個即將出現(xiàn)的未匹配情況:
dp 數(shù)組指? pat 這樣移動:
這個 j 不要理解為索引, 它的含義更準(zhǔn)確地說應(yīng)該是狀態(tài)(state) ,所以它會出現(xiàn)這個奇怪的位置
?對于 txt2 的下?這個即將出現(xiàn)的未匹配情況:
dp 數(shù)組指? pat 這樣移動:
明?了 dp 數(shù)組只和 pat 有關(guān), 那么我們這樣設(shè)計 KMP 算法就會?較漂亮:
public class KMP {private int[][] dp;private String pat;public KMP(String pat) {this.pat = pat;// 通過 pat 構(gòu)建 dp 數(shù)組// 需要 O(M) 時間} public int search(String txt) {// 借助 dp 數(shù)組去匹配 txt// 需要 O(N) 時間} }這樣, 當(dāng)我們需要?同? pat 去匹配不同 txt 時, 就不需要浪費時間構(gòu)造 dp 數(shù)組了:
KMP kmp = new KMP("aaab"); int pos1 = kmp.search("aaacaaab"); //4 int pos2 = kmp.search("aaaaaaab"); //4
- 代碼描述:
三、 狀態(tài)機概述
為什么說 KMP 算法和狀態(tài)機有關(guān)呢? 是這樣的, 我們可以認(rèn)為 pat 的匹配就是狀態(tài)的轉(zhuǎn)移。 ?如當(dāng) pat = “ABABC”:
如上圖, 圓圈內(nèi)的數(shù)字就是狀態(tài), 狀態(tài) 0 是起始狀態(tài), 狀態(tài) 5( pat.length ) 是終?狀態(tài)。
開始匹配時 pat 處于起始狀態(tài), ?旦轉(zhuǎn)移到終?狀態(tài), 就說明在 txt 中找到了 pat 。 ?如說當(dāng)前處于狀態(tài) 2, 就說明字符 “AB” 被匹配:
另外, 處于不同狀態(tài)時, pat 狀態(tài)轉(zhuǎn)移的?為也不同。 ?如說假設(shè)現(xiàn)在匹配到了狀態(tài) 4, 如果遇到字符 A 就應(yīng)該轉(zhuǎn)移到狀態(tài) 3, 遇到字符 C 就應(yīng)該轉(zhuǎn)移到狀態(tài) 5, 如果遇到字符 B 就應(yīng)該轉(zhuǎn)移到狀態(tài) 0:
具體什么意思呢, 我們來?個個舉例看看。 ?變量 j 表?指向當(dāng)前狀態(tài)的指針, 當(dāng)前 pat 匹配到了狀態(tài) 4:
如果遇到了字符 “A”, 根據(jù)箭頭指?, 轉(zhuǎn)移到狀態(tài) 3 是最聰明的:
如果遇到了字符 “B”, 根據(jù)箭頭指?, 只能轉(zhuǎn)移到狀態(tài) 0(?夜回到解放前) :
如果遇到了字符 “C”, 根據(jù)箭頭指?, 應(yīng)該轉(zhuǎn)移到終?狀態(tài) 5, 這也就意味著匹配完成:
當(dāng)然了, 還可能遇到其他字符, ?如 Z, 但是顯然應(yīng)該轉(zhuǎn)移到起始狀態(tài) 0,因為 pat 中根本都沒有字符 Z:
這?為了清晰起?, 我們畫狀態(tài)圖時就把其他字符轉(zhuǎn)移到狀態(tài) 0 的箭頭省略, 只畫 pat 中出現(xiàn)的字符的狀態(tài)轉(zhuǎn)移:
好了,上面我們已經(jīng)逼逼賴賴了那么多,重點來了,KMP 算法最關(guān)鍵的步驟就是構(gòu)造這個狀態(tài)轉(zhuǎn)移圖 。
要確定狀態(tài)轉(zhuǎn)移的?為, 得明確兩個變量, ?個是當(dāng)前的匹配狀態(tài), 另?個是遇到的字符;確定了這兩個變量后, 就可以知道這個情況下應(yīng)該轉(zhuǎn)移到哪個狀態(tài)。
為了描述狀態(tài)轉(zhuǎn)移圖, 我們定義?個?維 dp 數(shù)組, 它的含義如下:
dp[j][c] = next 0 <= j < M, 代表當(dāng)前的狀態(tài) 0 <= c < 256, 代表遇到的字符(ASCII 碼) 0 <= next <= M, 代表下?個狀態(tài) dp[4]['A'] = 3 表?: 當(dāng)前是狀態(tài) 4, 如果遇到字符 A, pat 應(yīng)該轉(zhuǎn)移到狀態(tài) 3dp[1]['B'] = 2 表?: 當(dāng)前是狀態(tài) 1, 如果遇到字符 B, pat 應(yīng)該轉(zhuǎn)移到狀態(tài) 2根據(jù)我們這個 dp 數(shù)組的定義和剛才狀態(tài)轉(zhuǎn)移的過程, 我們可以先寫出 KMP算法的 search 函數(shù)代碼:
public int search(String txt) {int M = pat.length();int N = txt.length();// pat 的初始態(tài)為 0int j = 0;for (int i = 0; i < N; i++) {// 當(dāng)前是狀態(tài) j, 遇到字符 txt[i],// pat 應(yīng)該轉(zhuǎn)移到哪個狀態(tài)?j = dp[j][txt.charAt(i)];// 如果達到終?態(tài), 返回匹配開頭的索引if (j == M) return i - M + 1;} // 沒到達終?態(tài), 匹配失敗return -1; }到這?, 應(yīng)該還是很好理解的吧, dp 數(shù)組就是我們剛才畫的那幅狀態(tài)轉(zhuǎn)移圖,
四、 構(gòu)建狀態(tài)轉(zhuǎn)移圖
重重重點又來了!如何構(gòu)建狀態(tài)轉(zhuǎn)移圖???
要確定狀態(tài)轉(zhuǎn)移的?為, 必須明確兩個變量, ?個是當(dāng)前的
匹配狀態(tài), 另?個是遇到的字符, ?且我們已經(jīng)根據(jù)這個邏輯確定了 dp數(shù)組的含義, 那么構(gòu)造 dp 數(shù)組的框架就是這樣:
這個 next 狀態(tài)應(yīng)該怎么求呢? 顯然, 如果遇到的字符 c 和 pat[j] 匹配的話, 狀態(tài)就應(yīng)該向前推進?個, 也就是說 next = j + 1 , 我們不妨稱這種情況為狀態(tài)推進:
如果字符 c 和 pat[j] 不匹配的話, 狀態(tài)就要回退(或者原地不動) , 我們不妨稱這種情況為狀態(tài)重啟:
那么, 如何得知在哪個狀態(tài)重啟呢? 解答這個問題之前, 我們再定義?個名字: 影?狀態(tài) , ?變量 X 表?。 所謂影?狀態(tài), 就是和當(dāng)前狀態(tài)具有相同的前綴。 ?如下?這種情況:
當(dāng)前狀態(tài) j = 4 , 其影?狀態(tài)為 X = 2 , 它們都有相同的前綴 “AB”。 因為狀態(tài) X 和狀態(tài) j 存在相同的前綴, 所以當(dāng)狀態(tài) j 準(zhǔn)備進?狀態(tài)重啟的時候(遇到的字符 c 和 pat[j] 不匹配) , 可以通過 X 的狀態(tài)轉(zhuǎn)移圖來獲得最近的重啟位置。
?如說剛才的情況, 如果狀態(tài) j 遇到?個字符 "A", 應(yīng)該轉(zhuǎn)移到哪?呢??先只有遇到 "C" 才能推進狀態(tài), 遇到 "A" 顯然只能進?狀態(tài)重啟。 狀態(tài)j 會把這個字符委托給狀態(tài) X 處理, 也就是 dp[j]['A'] = dp[X]['A'] :
為什么這樣可以呢? 因為既然 j 這邊已經(jīng)確定字符 "A" ?法推進狀態(tài),只能回退, ?且 KMP 就是要盡可能少的回退, 以免多余的計算。 那么 j就可以去問問和??具有相同前綴的 X , 如果 X 遇? “A” 可以進?「狀態(tài)推進」 , 那就轉(zhuǎn)移過去, 因為這樣回退最少。
當(dāng)然, 如果遇到的字符是 “B”, 狀態(tài) X 也不能進?「狀態(tài)推進」 , 只能回退, j 只要跟著 X 指引的?向回退就?了:
你也許會問, 這個 X 怎么知道遇到字符 “B” 要回退到狀態(tài) 0 呢? 因為 X永遠跟在 j 的?后, 狀態(tài) X 如何轉(zhuǎn)移, 在之前就已經(jīng)算出來了。動態(tài)規(guī)劃算法不就是利?過去的結(jié)果解決現(xiàn)在的問題嗎?
結(jié)合下面的過程理解影子狀態(tài)的更新:
這樣, 我們就細(xì)化?下剛才的框架代碼:
int X # 影?狀態(tài) for 0 <= j < M:for 0 <= c < 256:if c == pat[j]:# 狀態(tài)推進dp[j][c] = j + 1else:# 狀態(tài)重啟# 委托 X 計算重啟位置dp[j][c] = dp[X][c]五、 代碼實現(xiàn)
如果之前的內(nèi)容你都能理解, 恭喜你, 現(xiàn)在就剩下?個問題: 影?狀態(tài) X是如何得到的呢? 下?先直接看完整代碼吧:
public class KMP {private int[][] dp;private String pat;public KMP(String pat) {this.pat = pat;int M = pat.length();// dp[狀態(tài)][字符] = 下個狀態(tài)dp = new int[M][256];// base casedp[0][pat.charAt(0)] = 1;// 影?狀態(tài) X 初始為 0int X = 0;// 當(dāng)前狀態(tài) 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;elsedp[j][c] = dp[X][c];} // 更新影?狀態(tài)X = dp[X][pat.charAt(j)];}} public int search(String txt) {...} }代碼解釋:
先解釋?下這??代碼:
// base case dp[0][pat.charAt(0)] = 1;這?代碼是 base case, 只有遇到 pat[0] 這個字符才能使?fàn)顟B(tài)從 0 轉(zhuǎn)移到 1,遇到其它字符的話還是停留在狀態(tài) 0(Java 默認(rèn)初始化數(shù)組全為 0) 。
影?狀態(tài) X 是先初始化為 0, 然后隨著 j 的前進?不斷更新的。 下?看看到底應(yīng)該如何更新影?狀態(tài) X :
int X = 0; for (int j = 1; j < M; j++) {...// 更新影?狀態(tài)// 當(dāng)前是狀態(tài) X, 遇到字符 pat[j],// pat 應(yīng)該轉(zhuǎn)移到哪個狀態(tài)?X = dp[X][pat.charAt(j)]; }更新 X 其實和 search 函數(shù)中更新狀態(tài) j 的過程是?常相似的:
int j = 0; for (int i = 0; i < N; i++) {// 當(dāng)前是狀態(tài) j, 遇到字符 txt[i],// pat 應(yīng)該轉(zhuǎn)移到哪個狀態(tài)?j = dp[j][txt.charAt(i)];... }其中的原理?常微妙, 注意代碼中 for 循環(huán)的變量初始值, 可以這樣理解:
后者是在 txt 中匹配 pat , 前者是在 pat 中匹配 pat[1..end] , 狀態(tài)X 總是落后狀態(tài) j ?個狀態(tài), 與 j 具有最?的相同前綴。 所以我把 X?喻為影?狀態(tài), 似乎也有?點貼切。另外, 構(gòu)建 dp 數(shù)組是根據(jù) base case dp[0][…] 向后推演。 這就是我認(rèn)為KMP 算法就是?種動態(tài)規(guī)劃算法的原因。
最后看KMP算法的完整代碼:
public class KMP {private int[][] dp;private String pat; ?public KMP(String pat) {this.pat = pat;int M = pat.length();// dp[狀態(tài)][字符] = 下個狀態(tài)dp = new int[M][256];// base casedp[0][pat.charAt(0)] = 1;// 影子狀態(tài) X 初始為 0int X = 0;// 構(gòu)建狀態(tài)轉(zhuǎn)移圖(稍改的更緊湊了)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;// 更新影子狀態(tài)X = dp[X][pat.charAt(j)];}} ?public int search(String txt) {int M = pat.length();int N = txt.length();// pat 的初始態(tài)為 0int j = 0;for (int i = 0; i < N; i++) {// 計算 pat 的下一個狀態(tài)j = dp[j][txt.charAt(i)];// 到達終止態(tài),返回結(jié)果if (j == M) return i - M + 1;}// 沒到達終止態(tài),匹配失敗return -1;} }C++代碼
//封裝一個KMP算法用來快速查找字符串匹配 class KMP {public:KMP(const std::string& pat):_pat(pat){int M = _pat.size();//dp[狀態(tài)][字符] = 下一個狀態(tài);把dp初始化為全0dp = std::vector<std::vector<int>>(M,std::vector<int>(256,0));//base case :一開始dp中存的是0,那么規(guī)定最開始為0狀態(tài),遇到第一個字符為pat轉(zhuǎn)換為1狀態(tài)dp[0][_pat[0]] = 1;//博客中說的影子狀態(tài),初始化為0狀態(tài)int X = 0;//開始循環(huán)構(gòu)造狀態(tài)轉(zhuǎn)換圖即dpfor(int j = 1;j < M;j++){for(int c = 0;c < 256;c++){dp[j][c] = dp[X][c];}dp[j][_pat[j]] = j + 1;//更新影子狀態(tài)X = dp[X][_pat[j]];}}int Search(const std::string& txt){int M = _pat.size();int N = txt.size();//pat的初始狀態(tài)為0狀態(tài)int j = 0;//循環(huán)for(int i = 0;i < N;i++){j = dp[j][txt[i]];//如果經(jīng)過狀態(tài)轉(zhuǎn)換圖dp可以到達終態(tài)M,就說明在txt中成功找到匹配的字符串,直接返回其在txt中的下標(biāo)索引if(j == M){return i - M + 1;}}//代碼走到這里代表遍歷完txt還沒有找到匹配的return出去,就直接return -1;return -1;}private://dp存放狀態(tài)轉(zhuǎn)換圖;dp[1]['A'] = 2;代表 狀態(tài)1 如果遇到字符'A',pat 就轉(zhuǎn)換為2狀態(tài)std::vector<std::vector<int>> dp;//用戶輸入pat待匹配的字符串std::string _pat; };六、 最后總結(jié)
傳統(tǒng)的 KMP 算法是使??個?維數(shù)組 next 記錄前綴信息, ?本?是使??個?維數(shù)組 dp 以狀態(tài)轉(zhuǎn)移的?度解決字符匹配問題, 但是空間復(fù)雜度仍然是 O(256M)=O(M)O(256M) = O(M)O(256M)=O(M)。
在 pat 匹配 txt 的過程中, 只要明確了「當(dāng)前處在哪個狀態(tài)」 和「遇到的字符是什么」 這兩個問題, 就可以確定應(yīng)該轉(zhuǎn)移到哪個狀態(tài)(推進或回退) 。
對于?個模式串 pat , 其總共就有 M 個狀態(tài), 對于 ASCII 字符, 總共不會超過 256 種。 所以我們就構(gòu)造?個數(shù)組 dp[M][256] 來包含所有情況, 并且明確 dp 數(shù)組的含義:
dp[j][c] = next 表?, 當(dāng)前是狀態(tài) j , 遇到了字符 c , 應(yīng)該轉(zhuǎn)移到狀態(tài)next 。
明確了其含義, 就可以很容易寫出 search 函數(shù)的代碼。
對于如何構(gòu)建這個 dp 數(shù)組, 需要?個輔助狀態(tài) X , 它永遠?當(dāng)前狀態(tài)j 落后?個狀態(tài), 擁有和 j 最?的相同前綴, 我們給它起了個名字叫「影?狀態(tài)」 。
在構(gòu)建當(dāng)前狀態(tài) j 的轉(zhuǎn)移?向時, 只有字符 pat[j] 才能使?fàn)顟B(tài)推進( dp[j][pat[j]] = j+1 ) ; ?對于其他字符只能進?狀態(tài)回退, 應(yīng)該去請教影?狀態(tài) X 應(yīng)該回退到哪?( dp[j][other] = dp[X][other] , 其中other 是除了 pat[j] 之外所有字符) 。
對于影?狀態(tài) X , 我們把它初始化為 0, 并且隨著 j 的前進進?更新,更新的?式和 search 過程更新 j 的過程?常相似( X = dp[X][pat[j]] )
總結(jié)
以上是生活随笔為你收集整理的动态规划之KMP字符匹配算法的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 无重叠区间及用最少的箭射爆气球
- 下一篇: LeetCode 股票买卖问题