KMP 算法 Java 代碼講解及 leetcode 對應題目

什麼是 KMP 算法?

該算法因爲其優秀的簡稱獲得江湖稱號 "看毛片算法",簡單來說 KMP 算法就是解決字符串匹配的一種算法,它通常用來解決主字符串和模式字符串的匹配問題,如存在字符串 A = "ababcde" 和 字符串 B = "abc",那麼可以延伸出如下幾個問題:

  1. 判斷字符串 B 是否存在於字符串 A 中 (相當於實現 java 字符串的 contains() 方法)
  2. 判斷字符串 B 在字符串 A 中第一次出現的位置 (相當於實現 java 字符串的 indexOf() 方法)
  3. 求出字符串 B 和字符串 A 連續最長重複字符串的個數 (如 "abde" 與 "abc" 最長連續重複字符串爲 "ab", 長度爲 2)

爲什麼需要 KMP 算法?

拿上面第一個問題舉例,判斷字符串 B = "abcabb" 是否存在於 A = "abcabcabb" 中;

常規的解題思路是暴力解法,首先從 A 的第一個字符爲開始結點,然後判斷從該節點開始,後面連續 B.length() 個字符,是否跟 B 中的字符是一樣的,如果不是,那就從 A 的第二個字符爲開始結點再往後查找,一直遍歷到 A 的第 A.length() - B.length() 個
字符(因爲再往後的話就沒有 B 那麼多字符了),代碼如下:

for (int i = 0; i <= A.length() - B.length(); i++) {
    int b = 0;
    for (int a = i; b < B.length(); b++, a++) {
        if (A.charAt(a) != B.charAt(b)) {
            break;
        }
    }
    if (b == B.length()) {
        return true;
    }
}

可以看出來,上面算法的時間複雜度爲 O(A.length()*B.length())。並不是很高效的算法。這也就引出了 KMP 算法

KMP 算法的原理

如圖所示,當遍歷到字符串 A 中的字符 c 和 字符串中最後一個字符 b 的時候,發生了不相等的情況,很可惜對不對,按照上面那種暴力的解法是要怎麼做呢,就要從字符串 A 中的第 2 個字符 b 開始,字符串 B 的第一個字符 a 開始重新遍歷,很生氣對不對?明明已經都快成功了,結果你告訴我要重來,那能不氣麼,好,這個時候 KMP 就跳了出來語重心長地告訴你:不重來,你之前的努力沒有白費!

那 KMP 是怎麼是怎麼做的呢?當我們發現 B 中最後一個字符 b 跟 A 中的 c 不一a致的時候,不是全部否定之前的努力推倒重來,而是直接轉變爲下一種狀態,相當於字符串 A 中的 "ab" 和 B 中的 "ab" 已經比較過了,在此基礎上繼續比較:

往這個狀態轉換的原則就是,從字符串 A 的字符 c 開始往前,字符串 B 的從首字母往後(這兩個都是從前往後的順序,比如上面都是"ab"),找到最長的相同的序列,那麼拿到的這個序列相當於比較過,不需要再次進行比較,下次從後面的字符開始比較即可,(可以想想爲什麼可以這樣,動動你的小腦瓜),然後你可能會問,剛剛說的那個過程不還是要遍歷,也是需要時間的麼,KMP做的就是再這個過程不需要再次遍歷,用 O(1) 的時間就能拿得到結果,這就是 KMP 算法的原理了。

簡單來說,KMP 算法就是兌現你曾經努力的一個算法,讓你之前的積累能在以後用得上。是不是感覺有點像 DP ? 你懂我意思把?

KMP 算法的具體實現

相關概念介紹

在講具體實現之前,先講一個概念:最長公共前後綴,即前綴和後綴共同的長度,是不是一臉懵逼?所以說只看概念是不會懂的,這輩子都不可能懂的,懂我意思吧?所以還是看例子吧

比如:

字符串 "a" 的前綴和後綴都爲空集, 故最長公共前後綴爲空

字符串 "ab" 的前綴爲 "a",後綴爲 "b", 最長公共前後綴空

字符串 "aba" 的前綴爲 "a" 和 "ab", 後綴爲 "a" 和 "ba",最長公共前後綴爲 "a"

字符串 "abab" 的前綴爲 "a", "ab" 和 "aba", 後綴爲 "b", "ab" 和 "bab", 最長公共前後綴爲 "ab"

字符串 "aaa" 的前綴爲 "a" 和 "aa", 後綴爲 "a" 和 "aa",最長公共前後綴爲 "aa"

好,我猜你現在應該懂了,那這公共前綴有什麼用呢?讓我們回到上面的圖:

 

開始實操

上面怎麼說的來着?上面分析的時候是這麼說的:"從字符串 A 的字符 c 開始往前,字符串 B 的從首字母往後(這兩個都是從前往後的順序,比如上面都是"ab"),找到最長的相同的序列",按照這句話的意思是找到了 X 區和 Z 區,找到 X 和 Z 之後,直接從這兩個區的後面再進行比較,能省下不少事,那麼怎麼能快速找到 X 和 Z 呢?

首先我們此時已經遍歷到字符串 A 中的 c 和字符串 B 中的 b, 那麼說明 c 之前的序列和 b 之前的序列是完全相同的,即 X 和 Y 一定是相同的,那麼問題從 X 找 Z 變成了從 Y 找 Z, 你那機靈的小眼睛(大眼睛)有沒有發現什麼?,這不就是最長公共前後綴嗎?問題不久轉化爲了求下面字符串的公共前後綴麼?所以現在我們就可以完全不再考慮主字符串 A, 把精力全放在模式串 B 上就好了,由此引出了另一個概念,就是 next[] 數組,next[i] 表示字符串下標 0 到下標 i-1  的最長公共前後綴的長度,以字符串 B 爲例,當 Y 區域後面的字符 b 發生失配的時候,我們的關注點應該是其前面字符串 "abcab" 的最長公共前後綴,品,你細品,那這樣的話,還是以上邊字符串 B 爲例,初始化 next[0] = -1 (當然因爲其實next[0]就沒有實際意義了,你讓它等於什麼都可以,只要你知道並能和其它的 next[i] 區別開來就好了),next[1] 對應 "a" 故 next[1] = 0, next[4] 對應 "abca",故 next[4] =  1, 同理,next[5] 對應字符串 "abcab", 故 next[5] = 2, 另外,字符串 "aaaa" 的 next[] 數組值爲 next[1] = 0, next[2] = 1, next[3] = 2。

跟着上面那個例子走一遍匹配流程吧:首先第一步求出模式串 B 的 next 數組,next[0] = -1, next[1] = 0, next[2] = 0, next[3] = 0,next[4] = 1, next[5] = 2。第二步就是匹配了,另 i = 0, j = 0, i 和 j 分別代表字符串 A 和字符串 B 當前匹配的位置,我們發現,當 i = 0, j = 0時,A 和 B 匹配, 然後就另 i = 1, j = 1, 發現也匹配,然後 i 和 j 繼續增加,當 i = 5,j = 5 時,發現不匹配的情況出現,此時 i 不變,j = next[j], 因爲 next[5] = 2, 故 j 被置爲 2, 此時,i = 5, j = 2, 也就變成了下面這個狀態:

然後發現此時匹配了,然後 i = 6,j = 3 發現也匹配,繼續 i++, j++, 最後發現 i = 8, j = 5 時也匹配,而此時字符串 B 已經遍歷結束了,說明已經在 A 中找到了字符串 B。如果需要找在字符串 A 中出現的位置,i - j 就是結果了,即 下標 3 是 A 開始匹配 B 的起始位置。當然也可以根據 j 的變化不斷更新最大值,來判斷 B 在 A 中最多連續匹配了多少字符。

怎麼求 next 數組?

其實這也是 KMP 算法的核心了,從上面的分析來看,求出 next 數組之後,一切都水到渠成了,那麼怎麼能求出 next 數組呢?

首先,從上面的分析來看,next[0] = -1, next[1] = 0, 我們引入變量 i, 它表示當前要求的 next 的下標,也是當前遍歷到的位置下標,那我們想一下,求 next[i] 需要什麼呢?小夥伴們先別往下看,可以試着自己想一下,你一定可以噠,可以自己舉例子用筆來比比劃劃!!!

 

 

答案揭曉,求 next[i] 的時候需要 next[i - 1], 但是,可能只有 next[i - 1] 還不夠, 舉個例子吧,比如對於字符串 str = "abcdabcad", 我們在求 next[6] 的時候即要求 "abcdab" 的最長公共前後綴,那麼我們可以把該字符串拆成兩部分,分別爲 "abcda" 和 "b",我們可以先求出 "abcda" 的公共前後綴,即 next[5],然後看該公共前後綴後面的字符與字符 'b' 是否相等,


如上圖所示,先把 “abcda” 的公共前後綴求出來,即爲 “a”, 然後判斷前綴 'a' 後面的字符跟後綴 'a' 後面的字符是否相等, 如果相等,那說明 next[6] = next[5] + 1,如上所示,我們看到確實是相等的,兩個字符都爲 'b',而 next[5] = 1, 故 next[6] = 2; 這個例子恰好是求了一次 next 就相等了,這樣來看是只需要求出來 next[i - 1] 就足夠了,但其實是不夠的,上述例子是順利的情況,求了一次 next[i - 1] 之後發現就匹配上了,可是如果求了一次 next 之後還是不相等呢,那還需要再繼續求對應的 next,往下循環。

如字符串 "abadababd", 此時需要求 next[8] ,即要求 "abadabab" 的最長公共前後綴,首先求出 next[7], 令 j = next[7], 故 j = 3,而此時 str[3] != str[7], 那麼此時需要令 j = next[j],即 j  = next[next[7]],求第二層的 next, 再次進行判斷,爲什麼呢?如下圖所示:


先盤點一下我們求了第一次 next 之後得到了什麼,首先,我們得到了最長公共前後綴 X 和 Y, 但是 X 後面的字符和 Y 後面的字符不匹配(即str[3] != str[7]),那麼我們接下來應該怎麼做呢?我們肯定是希望在 X 中找到一個前綴和在 Y 中找到一個後綴,並且這兩個前後綴是相同的,這樣的話就可以再次進行比較了,如上面 X 中的前綴 "a" 和 Y 中的後綴 "a" 是相同的,那麼只需要比較 X 前綴”a“後面的字母是否跟 Y後綴"a"後面的字符是否相等即可,而我們此時發現 X 前綴 "a" 後面的字符是 'b' 與 Y 後面的 'b' 是相同的,故此時可以求出來 next[7] = X 前綴的長度 + 1, 那麼 X 前綴的長度等於多少呢,因爲其實 X 跟 Y 是相同的,所以說求 X 的前綴跟 Y 的後綴相同的部分,其實就是求 X 的最長公共前後綴,故 X 的前綴的長度即爲 next[3]。

還原一下上面的過程,求 next[7] 的時候,我們先找 next[6], 然後發現不匹配, 因爲 next[6] = 3, 所以我們再找 next[3], 此時next[3] = 1, 我們發現匹配了,就可以繼續往下遍歷了。

所以這也就驗證了上面所說的,當求了第一次 next 之後發現還是不匹配的時候,那麼就繼續求 next。如果最後求到 next[0]還是沒有匹配,說明沒有與該後綴匹配的前綴,故直接設置 next[i] = 0 即可。

next 數組的求解代碼模板如下:

public int[] getNext(String str) {
    int[] next = new int[str.length()];
    next[0] = -1;
    char[] strArray = str.toCharArray();
    for (int i = 0; i < str.length() - 1;i++) {
        int j = next[i]; // 這裏取 next[i]是因爲針對第 0-i 個字符,我們求的是 next[i + 1], 所以跟前面分析的當求 next[i] 時要找 next[i - 1] 還是一致的。
        while (j != -1 && strArray[i] != strArray[j]) {
            j = next[j];
        }

        // 針對第 0 - i 個字符,我們求的是 next[i + 1]
        if (j == -1) {
            next[i + 1] = 0;
        } else {
            next[i + 1] = j + 1;
            
        }
    }
    return next;
}

// 另一個求 next 數組的代碼版本

public int[] getNext(String str) {
    int[] next = new int[str.length()];
    next[0] = -1;
    int j = -1; // j 代表 next[i - 1], 初始化爲 -1
    int i = 0;
    char[] array = str.toCharArray();
    //要注意,i 的結束條件是 i < array.length - 1, 而不是 i < array.length, 因爲我們前面也說過了,next[i] 是對應 0-i-1的字符串,故遍歷前 i 個字符的時候,我們求得是 next[i + 1], 所以當遍歷到 array.length - 2 的時候,我們就求得了 next[array.length - 1], 就可以停止循環了。
    while (i < array.length - 1) {
        //首先判斷 next[i - 1] 對應的字符是否跟當前字符相同。如果相同,則 next[i] = next[i - 1] + 1, 否則,繼續求 next
        if (j == -1 || array[i] == array[j]) {
            next[++i] = ++j;
        } else {
            j = next[j];
        }
    }
        
    return next;
}

既然 KMP 算法那麼好,爲什麼 Java 在實現字符串匹配的時候,沒有用 KMP 算法?

看了 java 的 String.contains()源碼之後,發現 contains() 方法內部調用 String.IndexOf(), 而該方法的實現邏輯就是普通的暴力解法(感興趣的小夥伴可以去看一下 jdk 的源碼),時間複雜度爲 O(m*n),其中 m,n分別爲主字符串和模式字符串的長度。爲什麼呢?其實這個 JDK 不說也就沒有標準的答案,大家可以留言區說下自己的想法,這裏我說下我的一些看法:

  1. 平時我們使用的字符串長度並不長,使用簡單的方法已經夠我們用了,時間複雜度分析對於數據量越大越有效,但是字符串比較短的時候,不能確定理論時間複雜度比較小的一定效率更高,而上述分析的時間複雜度爲 O(m*n) 也是最壞情況的時間複雜度,平均情況下其實夠我們用了。
  2. KMP 算法還需要額外算出 next 數組,多使用了 O(n) 的空間開銷
  3. 其實如果字符串真的很長,比如在一篇文章中查詢關鍵敏感字,那這個時候一般也不是僅僅只查一個關鍵字,這個時候就不應該使用循環反覆調用 contains 方法了,而應該用字典樹的方法來解決
  4. 說了這麼多好像學習 KMP 就沒什麼用和必要了,怎麼說呢,其一是我覺得是可以開闊眼界,拓展自己的思維,其二就是它不只是可以用到字符串匹配的問題上來,搞懂 next 數組的思想,他還能在其它地方大放異彩,比如可以解決最小循環節一類的問題,如下面例題 2, 然後其實 next 數組的求法就是用了動態規劃的思想,弄懂了 next 數組,對學習動態規劃也很有幫助。

 

LeetCode 對應題目:

題目1:28. Implement strStr()

https://leetcode.com/problems/implement-strstr/

該題的意思就是求一個字符串是否在另一個字符串中,如果存在的話就返回它第一次匹配到的位置


解答如下:

class Solution {
    
    public int strStr(String haystack, String needle) {
        if (needle.length() == 0) {
            return 0;
        }
        int[] next = getNext(needle);
        int i = 0, j = 0;
        
        while (i < haystack.length() && j < needle.length()) {
            if (j == -1 || haystack.charAt(i) == needle.charAt(j)) {
                i++;
                j++;
            } else {
                j = next[j];
            }
        }
        
        if (j == needle.length()) {
            return i - j;
        }
        
        return -1;
    }
    
    public int[] getNext(String str) {
        int[] next = new int[str.length()];
        next[0] = -1;
        int j = -1; // j 代表 next[i - 1], 初始化爲 -1
        int i = 0;
        char[] array = str.toCharArray();
        
        while (i < array.length - 1) {
            if (j == -1 || array[i] == array[j]) {
                next[++i] = ++j;
            } else {
                j = next[j];
            }
        }
        
        return next;
    }
}

題目2:459. Repeated Substring Pattern

https://leetcode.com/problems/repeated-substring-pattern/

題目大意:判斷一個給定的字符串是否可以由多個特定的字符串拼接而成。

代碼如下:

class Solution {
    public boolean repeatedSubstringPattern(String s) {
        int[] next = hasNext(s);
        
        if (next[s.length()] * 2 < s.length()) {
            return false;
        }
        
        int common = s.length() - next[s.length()];
        return s.length() % common == 0;
    }
    
    public int[] hasNext(String str) {
        int[] next = new int[str.length() + 1];
        next[0] = -1;
        int j = -1;
        int i = 0;
        char[] strArray = str.toCharArray();
        
        while (i < strArray.length) {
            if (j == -1 || strArray[i] == strArray[j]) {
                next[++i] = ++j;
            } else {
                j = next[j];
            }
        }
        
        return next;
    }
}

代碼解析:

1: 該方案借用了 next 數組,然後先判斷一下 next[s.length()]  * 2 是否小於 s.length(),如果小於的話,那說明該字符串肯定不是由某個字符串拼接起來的,如某個字符串爲 A, 那麼兩個 A 拼起來是爲 "AA", 此時 next[s.length()] * 2 == s.length(),當由多個 A (大於兩個)拼接時,結果爲 "AAA", "AAAA" ..... 根據最長公共前綴的性質很容易得到 next[s.length()]  * 2  > s.length() 的結論。

2: 該方案求出了 common = s.length() - next[s.length()]; 然後 s.length() % common 如果等於 0, 說明爲 true, 反之結果爲 false,爲什麼呢?

如上圖所示,原始字符串即字符串 s, 我們知道,能走到 2 這一步,說明步驟 1 已經成立了,故一定存在前綴的長度和後綴的長度都大於原始字符串長度的 1/2(這裏說的前綴後綴對應最長公共前後綴的前綴和後綴), 即會出現類似上圖的形狀,此時我們可以盤點一下現在得到了什麼,next[s.length()]  = 原始字符串前綴的長度 = 原始字符串後綴的長度。 common = 原始字符串的長度 - 前綴的長度 = a 區域的長度,好,那又因爲前綴和後綴是相同的,故前綴中的 a = 後綴中的 a ,(這也是後面用數學歸納法的時候k = 1的情況), 而參考前綴中 a 對應於原始字符串的區域和後綴中 x 對應原始字符串中的區域是一致的,故 x = a, 又因爲前後綴相同的原因,前綴中的 x 等於後綴中的 x, 而此時前綴中 x 對應於原始字符串的區域跟後綴中 y 對應原始字符串中的區域是相同的,故 x = y,此時就從後往前推出了 a = x = y, 故可以按照這個策略一致往後推下去,而如果字符串的總長度是 a 的長度的倍數,s.length() = k*a, 說明再繼續往前推,正好還有整數個長度爲 a 的區域,那麼正好可以推出前綴和後綴中是由整數個 a 拼起來的,可有數學歸納法證明出來,當目前只有 1 個 a 的時候, a = a 顯然成立 (上面其實已經推出來了三個 a 的時候成立),假設當拼成 k 個 a  的時候成立,那麼第 k + 1 個 a 的時候也是成立的(就跟上面證明的過程一樣,只是在 k 個 a 的基礎上往前又推了一個 a )。

上面證明了當 s.length() 爲 a 區域的長度的倍數時,s 就是可以由 k 個 a 區域拼接而成, 那麼怎麼知道如果不是 a 區域的倍數時他就不行呢?

證明:首先,a 區域是怎麼求出來的呢還記得不,a = common = s.length() - next[s.length()] 對吧,而 next[s.length()] 代表的是什麼呢?它代表的是最長公共前後綴,而 s.length() 又是固定的,故 common 一定是最小的,也就是說如果 common 不是最小的,那麼 next[s.length()] 求出來的就不是最長公共前後綴了,跟 next 數組的定義是有悖的。因此,如果原字符串可以由多個字符串拼接而成,那麼最小循環節一定是 a 區域,那麼問題就來了,現在只能通過多個 a 來拼接起來,而你整個字符串的長度又不是 a 區域長度的倍數,那你告訴我怎麼拼,故當字符串長度不是 a 的倍數的時候,它還真的就是不行。

 

上面我們已經知道了判斷字符串是不是可以由某個字符串循環拼接形成,進而也能知道最小循環節爲 len - next[len], 然後因此其實還能知道循環的次數 n = len / (len - next[len]), 其實還能引出另一個問題,一個不完整的循環串,需要補多少個字符使其變得完整呢?

證明:接着上面來說,如果此前不是循環串,那麼字符串的長度肯定不是 a 區域長度的倍數,也就是說,按照上面的推理過程,當從後往前推出一個一個 a 的時候,肯定最後一段是因爲長度不夠而沒有辦法拼成 a 對吧,如下圖:

注意上圖 a = x = y, 那麼我們面臨這種情況的時候,只需要把長度不夠的那段不完整,形成一個完整的 a 區域就好了,拿字符串 "bbcbbcb", 它的最長公共前後綴爲 "bbcb", a 區域爲 "bcb", 長度不夠的區域爲 "b", 那麼我們需要把 "b" 拼成 "bcb", 需要再添加兩個字符,源字符串就變成了 "bcbbcbbcb", 如下圖所示:

至此,我們可以知道需要添加的字符的個數爲 :(循環節長度) - 字符串長度%(循環節長度),其實如果你想的話,還能算出來具體要添加哪些字符。

發佈了3 篇原創文章 · 獲贊 0 · 訪問量 7977
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章