KMP演算法超詳解與其應用

b1gx發表於2020-11-25

KMP演算法是由D.E.Knuth,J.H.Morris和V.R.Pratt三位大佬提出的一種改進的字串匹配演算法。

什麼是字串匹配呢?看下面的例子

假設我們有兩個字串,str1、str2
str1 = "ababababcbac"
str2 = "ababc"
求問:str2是否是str1的一個子串

類似上面這樣的問題,就是我們常說的字串匹配問題,這裡需要注意一個點:子串(一定連續)和子序列(不一定連續)的區別!
這樣的問題怎麼解決呢?首先我們可以想到的就是使用暴力匹配的方法了,如下:

private static int KMP(String str1, String str2) {
	String str1 = "ababababcbac";
	String  str2 = "ababc";
	if (str1 == null || str2 == null) {
	           return -1;
	}
	if (str1.length() == 0 && str2.length() == 0) return 0;
	if (str1.length() == 0 || str2.length() == 0 || str1.length() < str2.length()) {
	           return -1;
	}
	int res = -1;
	for (int i = 0; i < str1.length(); i++) {
          int j = 0;
           int tmp = i;
           for (j = 0; j < str2.length(); j++) {
               if (str1.charAt(tmp) != str2.charAt(j)) {
                   break;
               }
               tmp++;
           }
           if (j == str2.length()) {
               res = tmp - j;
               break;
           }
     }
	 return res;
  }

當我們第一輪配的時候,會在下標為4的時候匹配失敗;然後開始第二輪匹配,str1會從第二個字元開始與str2的第一個字元開始進行匹配,也就是每一次匹配失敗,str1都只會後移一位,並且str2都要從頭開始與str1進行匹配,不難看出這樣的時間複雜度是O(N * M)。匹配過程(標紅的地方表示匹配失敗):
在這裡插入圖片描述

那麼有沒有什麼可以優化的地方呢?
我們繼續來看這個例子:
首先對於str2中的c這個字元來說,它前面的字元剛好可以分為ab、ab兩個相等的子串,我們先叫前半部分為字元c的最長字首,後半部分叫做字元c的最長字尾(最長字首和最長字尾是完全一樣的,前後只是為了區分一個從前往後,一個從後往前)。現在的問題是我們匹配到c的時候匹配不上了,按照暴力匹配的思想我們只能一位一位的往後移動並進行匹配。這樣到我們能夠匹配成功,需要移動str1的下標指標5次才行。其實我們可以只需要移動3次即可,即上面的步驟1、3、5。為什麼呢?我們來看下面這張圖:
在這裡插入圖片描述看完圖片之後,又有新的疑問產生了,難道在i’-j之間不會存在一個位置能夠使str2完全匹配上嗎? 接著來看下面這張圖片:
在這裡插入圖片描述
到這,可以看看返回去看看利用這種思路是不是隻需要1,3,5就可以了。除此之外,我們也不難發現,KMP演算法中最重要的就是上面重複出現的最長字首和最長字尾了,那麼我們怎麼來維護這個最長字首和最長字尾的資訊呢?嘿嘿嘿,是不是覺得用個map不就好了,key是下標,value是最長字首和最長字尾,感覺自己是個天才,哈哈哈~
但是,通過前面的匹配過程我們不難發現,其實不管是最長字首還是最長字尾,我們實際上並沒有用到具體的最長字首和最長字尾的內容,僅僅是用到了它們的長度。所以呀,我們僅僅需要用一個陣列來儲存一下最長字首和最長字尾的長度即可。我們稱這個陣列為next陣列。

現在我們來看看這個next陣列怎麼求呢?

  1. 我們規定某一個字元的最長字首不能包含最後一個字元(即某一個字元的前一個字元),最長字尾不能包含第一個字元,
  2. 規定第一個字元的next陣列的值為-1,第二個字元的next陣列的值為0
    下面我們以aaaaa,ababc兩個字串為例來看一下它們的next陣列
aaaaa
[-1, 0, 1, 2, 3]
0位置   規定是 -1
1位置   規定是 0
2位置   前面為  aa  根據上面的第1條,前字尾都是字元a  ==>  所以next陣列對應的值為1
3位置   前面為  aaa  同理根據第1條,前字尾都是aa  ==> next陣列  2
4位置   前面為  aaaa  同理,前字尾都是 aaa  ==> next陣列 3

ababc
[-1, 0, 0, 1, 2]

怎麼來求next陣列呢?
當我們要求i(i >= 2)位置的next陣列的值的時候,我們可以根據i-1位置的next陣列值來求,只需要判斷i-1位置字元是否與i-1位置的最長字首的後一個字元相等。

  • 如果相等,那麼i位置的next陣列的值就是i-1位置next陣列的值加1,如下圖:
    在這裡插入圖片描述
  • 如果不相等,那麼我們直接跳到m位置(也就是i-1位置的next陣列的值),接著比較i-1與m的最長字首的下一個位置是否相等,如果相等,i位置的next陣列的值就為m的next陣列的值加1。那麼,為什麼直接就跳到m位置了呢?
    在這裡插入圖片描述
    首先要說明的是,這根藍色的線沒有實際意義,它會有很多種可能,可能剛好分割為紅色和綠色,也可能紅色和綠色中間還有字元,也可能紅色和綠色是相交的。我們肯定要讓紅色區域和藍色區域相等,又由於綠色和藍色肯定是相等的,所以肯定就有紅色和綠色是相等的。既然紅色和綠色是相等的,那我們就可以推斷出來紅色和綠色其實就是m位置的最長字首和最長字尾。所以我們接著比較m的最長字首的後一個字元和i-1位置的字元是否相等。直到我們找到next陣列的值為-1的時候就代表i位置不存在最長字首和最長字尾。

KMP演算法實現

public static int[] getNextArray(char[] arr) {
    if (arr.length == 1) {
        return new int[] {-1};
    }
    int[] next = new int[arr.length];
    next[0] = -1;
    next[1] = 0;
    int i = 2;
    // 最長字首值
    int cn = 0;
    while (i < arr.length) {
        if (arr[i-1] == arr[cn]) {
            // i-1位置的字元和i-1的最長字首的下一個字元相同,那麼i位置的最長前字尾就是i-1的+1;
            next[i++] = ++cn;
        } else if (cn > 0) {
            // 這就是比對不上的時候,往前跳
            cn = next[cn];
        } else {
            // 跳到不能再往前跳了
            next[i++] = 0;
        }
    }
    return next;
}


public static int getIndexOf(String s, String m) {
    if (s == null || m == null || s.length() < 1 || m.length() < 1) {
        return -1;
    }
    char[] str1 = s.toCharArray();
    char[] str2 = m.toCharArray();
    int i = 0;
    int j = 0;
    int[] nextArray = getNextArray(str2);
    while (i < str1.length && j < str2.length) {
        if (str1[i] == str2[j]) {
            i++;
            j++;
        } else if (nextArray[j] == -1) {
            // str2已經是第一個字元了,但是依舊匹配不上,所以只能str1的指標往後移一位
            i++;
        } else {
            j = nextArray[j];
        }
    }
    return j == str2.length ? i - j : -1;
}

KMP演算法的應用:

  1. 給定一個原始串,要求使用這個原始串構成一個最短字串,且這個最短字串必須包含兩個原始串,這兩個原始串起始位置不能相同
    eg:abcabc =》 abcabcabc
    解法:求一個原始串的next陣列,且多求一位,如:
    abcabc =》 [-1, 0, 0, 0, 0, 0, 3]
    第一次使用的後三位和第二次使用的前三位即重合,也就是拼出來的最短串

  2. 給定兩棵樹,判斷第二棵樹是否是第一棵數的一棵子樹:
    將兩棵樹序列化為字串
    t1 => str1
    t2 => str2
    null也要用特定的符號佔位,這樣是可以唯一確定一棵樹的,以及一個節點結束之後也要用一個特殊符號標識
    如果str2是str1的一個子串就可以確定t2是t1的一棵子樹
    在這裡插入圖片描述
    第二棵樹是和黃色的框框住的子樹一樣的,而綠色的框是不對的,因為綠色的框還有一個右兒子節點沒框進去,所以不是一棵完整的子樹。

相關文章