KMP算法(二):另一種求解思路(確定有限狀態自動機、動態規劃)

一、簡介

在上一篇KMP算法中已經介紹了KMP使用next數組進行求解的方法(https://blog.csdn.net/not_say/article/details/105291946),這一篇將講述另外一種求解思路--利用確定有限狀態自動機和動態規劃的思路進行求解。

主要是參考了知乎一個專欄的一篇文章,內容非常詳細,配有動態圖,建議大家去看這篇文章,我自己寫的這篇基本來源於它,然後是爲了幫助自己理解,所以用博客的形式寫出來,同時加入自己的理解,可能沒有那麼詳細。文章鏈接:https://zhuanlan.zhihu.com/p/83334559

 

二、如何理解這裏的狀態機

同樣沿用上一篇的字符串命名,父字符串名稱爲father,子字符串名稱爲son。這裏的狀態機和動態規劃的功能與上一篇的next數組相似,都是爲了確定子串回退的距離,減少不必要的對比。

爲什麼說 KMP 算法和狀態機有關呢?是這樣的,我們可以認爲 son 的匹配就是狀態的轉移。比如當 son = "ABABC":

preview

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

preview

另外,處於不同狀態時,son 狀態轉移的行爲也不同。比如說假設現在匹配到了狀態 4,如果遇到字符 A 就應該轉移到狀態 3,遇到字符 C 就應該轉移到狀態 5,如果遇到字符 B 就應該轉移到狀態 0:

好了,內容引用到這裏,按照我自己的理解來描述一下這個狀態機的流轉情況:

1、將子串的匹配程度理解爲狀態的轉移,開始的時候或者一個都沒匹配的時候狀態是0.

2、當匹配到1個、2個、3個、4個的時候,狀態分別爲1、2、3、4。

3、當匹配5個的時候狀態是5,數字也就是子串的長度。這時候就表示father中匹配到了son,起始下標就是此時A的位置。也就是i - son.length() + 1

4、這裏是將子串自身後面的字符作爲參照,假想匹配father時會遇到的字符,從而形成一個二維座標圖。例如下圖就是“ABABC的”狀態流轉二維圖:

二維圖是用一個二維數組表示,用ASCII碼0-255表示接下來可能遇到的字符,數值表示遇到此字符時的下一個狀態。當依次遇到A、B、A、B、C時,狀態纔會流轉到5,表示匹配成功。

5、匹配順利前進的情況就如上面所說,匹配不成功則需要回退,但是具體回退到什麼位置,則需要藉助一個輔助狀態,“labuladong”將其稱爲“影子狀態”。

 

三、影子狀態

所謂影子狀態,就是和當前狀態具有相同的前綴,用變量X表示,比如下圖:

preview

當前狀態 j = 4,其影子狀態爲 X = 2,它們都有相同的前綴 "AB"。因爲狀態 X 和狀態 j 存在相同的前綴,所以當狀態 j 準備進行狀態回退的時候(遇到的字符 c 和 son[j] 不匹配),可以通過 X 的狀態轉移圖來獲得最近的重啓位置。

那爲什麼可以這樣呢?

原文的回答是這樣的:

爲什麼這樣可以呢?因爲:既然 j 這邊已經確定字符 "A" 無法推進狀態,只能回退,而且 KMP 就是要儘可能少的回退,以免多餘的計算。那麼 j 就可以去問問和自己具有相同前綴的 X,如果 X 遇見 "A" 可以進行「狀態推進」,那就轉移過去,因爲這樣回退最少。

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

其實換句話來說,就是用 X 來跟隨 j 的腳步,當 j 遇到新的字符時,如果與預期的值一致,則狀態加 1 ,否則回退到影子。而同時,影子從 0 開始,當遇到一個字符的時候,X = dp[X][son.charAt(j), 意思是等於X此時位置如果遇到 j 當前遇到的字符時將要轉移的狀態。這個狀態是 j 之前所經歷過的。如果這句話還是不能很好理解的話,那就再使用上一篇說的next數組的方式來幫助理解,通過debug發現,X的狀態值,其實就是包含當前字符的最長相同前綴後綴(注意,這裏理解爲當前)。X的值其實就是爲了記錄當 j 的字符不匹配時應該回退到的位置。

四、代碼

public class KMP {

    private int[][] dp;
    private String son;

    /**
     * 使用二維數組,利用有限狀態機的思想--確定有限狀態自動機、動態規劃
     */
    public KMP(String son) {
        this.son = son;
        //son子串長度,也是狀態的最大值
        int M = son.length();
        //dp[狀態][字符--ASCII碼] = 下個狀態
        dp = new int[M][256];
        //遇到第一個字符則推進一步,否則其他的都是0
        dp[0][son.charAt(0)] = 1;
        //影子狀態,初始爲0 --所謂影子狀態,就是和當前狀態具有相同的前綴
        int X = 0;
        // 當前狀態j從1開始
        for (int j = 1; j < M; j++) {
            //c代表此時要遇到的字符,父串的字符, ASCII從0-256
            for (int c = 0; c < 256; c++) {
                if (son.charAt(j) == c) {
                    //遇到的字符跟此時子串的字符抑制,則推進一步
                    dp[j][c] = j + 1;
                } else {
                    //不是的話則重啓,回退到影子狀態
                    dp[j][c] = dp[X][c];
                }
            }
            //更新影子狀態
            X = dp[X][son.charAt(j)];
        }


    }

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

    public static void main(String[] args) {
        String son = "ABABCC";
        String father = "ABABEABABCABABA";
        KMP kmp = new KMP(son);
        int[][] dp = kmp.dp;
        System.out.println(kmp.search(father));
    }
}

五、自述

這篇文章到此結束了,大家可以去看我參考的那篇文章(https://zhuanlan.zhihu.com/p/83334559),自此我也只是大概瞭解了算法的思路,自我感覺並沒有完全理解透測。意思就是僅限於跟着思路和代碼來了解,但是並不能學以致用,遇到這個算法的變體或者另類的描述,我可能無法利用這個思路來寫出合格的代碼。因爲後續會找時間去找相關的題目做一下。

有問題的朋友可以通過留言來進行討論,描述錯誤或者不嚴謹的地方也請指出~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章