KMP演算法詳解

mrmrwjk發表於2021-04-30

KMP演算法是解決字串匹配的常用演算法之一,也就是在主串(比如aabbccdd)中的子串(bc)定位問題。子串稱為P,如果它在一個主串稱為T中出現,就返回它的具體位置,我們先來看看普通的字串匹配是怎麼做的

最基礎的匹配

思路:從左到右一個個匹配,如果這個過程中有某個字元不匹配,將子串向右移動一位,繼續從左到右一一匹配。

當匹配到如圖第四個字元位置後,匹配失敗,子串後移,繼續匹配

KMP演算法詳解
第一位匹配失敗,繼續後移...
KMP演算法詳解
KMP演算法詳解
直到匹配成功
KMP演算法詳解
程式碼如下:

public class Normal {
	
	public static void main(String[] args) {
		int index = bf("ABCABCEFG", "ABCE");
		System.out.println(index);
	}
	
	public static int bf(String ts, String ps) {
		char[] t = ts.toCharArray();
		char[] p = ps.toCharArray();
		int i = 0; // 主串的位置
		int j = 0; // 子串的位置

		while (i < t.length && j < p.length) {
			if (t[i] == p[j]) { // 當兩個字元相同,就比較下一個
				i++;
				j++;
			} else {
				i = i - j + 1; // 一旦不匹配,i後退
				j = 0; // j歸0
			}
		}

		if (j == p.length) {
			return i - j;
		} else {
			return -1;
		}
	}
}
複製程式碼

這種方式是效率最低,匹配次數最多的情況,接下來看KMP的解決思路

KMP中的PMT

KMP在遇到下圖位置時,不會很無腦的把子串的j移動到第0位,主串的i移動到第1位,然後進行T[i]==P[j]的比較

KMP演算法詳解
因為從圖上可以看得出後移一位後子串前三位(ABC)和主串的T[1-4](BCA)肯定是不匹配的,無需白白浪費這幾次比較,最好應該是直接讓i不變,j==0,如下圖

KMP演算法詳解
從這裡開始匹配,省去了前面的幾次無用匹配。

KMP思想:利用前面匹配的資訊,保持i指標不變,通過修改j指標,讓子串儘量地移動到有效的位置。

整個KMP的重點就在於當某一個字元與主串不匹配時,我們應該知道j指標要移動到哪?

先用肉眼來看一下規律:

KMP演算法詳解
如圖:C和D不匹配了,我們要把j移動到哪?顯然是第1位。為什麼?因為前面有一個A相同可以用:

KMP演算法詳解
再看一種:

KMP演算法詳解
可以把j指標移動到第2位,因為前面有兩個字母是一樣的:

KMP演算法詳解
我們可以看出來,匹配失敗的時候,j變為k,j前面的的n個字元等於子串開頭到k位置的n個字元的值

KMP演算法詳解
即:P[0 ~ k-1] == P[j-k ~ j-1]

這時我們發現規律了,其實就是要求當前j之前的字串也就是ABCAB它的首尾對稱的長度最大長度也就是PMT值。

PMT中的值是字串的字首集合與字尾集合的交集中最長元素的長度。

例如,對於”aba”,它的字首集合為{”a”, ”ab”},字尾集合為{”ba”, ”a”}。
兩個集合的交集為{”a”},
那麼長度最長的元素就是字串”a”了,長度為1,所以對於”aba”而言,它在PMT表中對應的值就是1。
再比如,對於字串”ababa”,它的字首集合為{”a”, ”ab”, ”aba”, ”abab”},
它的字尾集合為{”baba”, ”aba”, ”ba”, ”a”}, 
兩個集合的交集為{”a”, ”aba”},其中最長的元素為”aba”,長度為3。
複製程式碼

所以上面最後一個圖的情況下,j位置之前的字串的PMT值為2,所以j的值變成2。

KMP之next陣列

那麼好了接下來核心就是求得P串每個下標元素對應的k值即可,因為在P的每一個位置都可能發生不匹配,我們要計算每一個位置j對應的k,所以用一個陣列next來儲存,next[j] = k,表示當T[i] != P[j]時,j應該變為k。

求next陣列程式碼如下

public class Next {
	
	public static int[] getNext(String ps) {
		char[] p = ps.toCharArray();
		int[] next = new int[p.length];
		next[0] = -1;
		int j = 0;
		int k = -1;
		while (j < p.length - 1) {
			if (k == -1 || p[j] == p[k]) {
				next[++j] = ++k;
			} else {
				k = next[k];
			}
		}
		return next;
	}
}

複製程式碼

通過上面程式碼可以直接算出j為0和1時的k,當j為0時,已經無法後退了所以設定為-1初始化值,當j為1時,它的前面只有下標0了,所以next[0]=-1,next[1]=0.

接下來就是兩種主要情況了

if (k == -1 || p[j] == p[k]) {   第一種p[j] == p[k]
    next[++j] = ++k;
} else {                         第二種p[j] != p[k]
    k = next[k];
}
複製程式碼

第一種p[j] == p[k]

KMP演算法詳解

p[j] == p[k]時,有next[++j] = ++k; 因為當在p[j-1]處匹配失敗後,j-1變為k-1,從k-1處重新開始匹配,原因就是他們共同有一個字首A,所以當p[j] == p[k]後,他們就擁有了字首AB所以k++;

第二種p[j] != p[k]

KMP演算法詳解
此時程式碼是:k = next[k];原因看下圖

KMP演算法詳解
像上邊的例子,我們已經不可能找到[ A,B,A,B ]這個最長的字尾串了,但我們還是可能找到[ A,B ]、[ B ]這樣的字首串的。所以這個過程就像在定位[ A,B,A,C ]這個串,當C和主串不一樣了(也就是k位置不一樣了),那當然是把指標移動到next[k]。

有了next陣列之後就一切好辦了,我們可以動手寫KMP演算法了:

public class Kmp {
	public static int KMP(String ts, String ps) {
		char[] t = ts.toCharArray();
		char[] p = ps.toCharArray();
		int i = 0; // 主串的位置
		int j = 0; // 模式串的位置
		int[] next = getNext(ps);

		while (i < t.length && j < p.length) {
			if (j == -1 || t[i] == p[j]) { // 當j為-1時,要移動的是i,當然j也要歸0
				i++;
				j++;
			} else {
				// i不需要回溯了
				// i = i - j + 1;
				j = next[j]; // j回到指定位置
			}
		}

		if (j == p.length) {
			return i - j;
		} else {
			return -1;
		}
	}
}
複製程式碼

KMP改進

KMP演算法是存在缺陷的,來看一個例子:比如主串是aaaabcde,子串是aaaaax,next值為012345,當i=5時,如下圖:

KMP演算法詳解
我們發現,當中的②③④⑤步驟,其實是多餘的判斷。由於子串的第二、三、四、五位置的字元都與首位的“a”相等,那麼可以用首位next[1]的值去取代與它相等的字元後續next[j]的值,這是個很好的辦法。因此我們對求next函式進行了改良。

public class Next2 {
	public static int[] getNext(String ps) {
		char[] p = ps.toCharArray();
		int[] next = new int[p.length];
		next[0] = -1;
		int j = 0;
		int k = -1;
		while (j < p.length - 1) {
			if (k == -1 || p[j] == p[k]) {
				if (p[++j] == p[++k]) { // 當兩個字元相等時要跳過
					next[j] = next[k];
				} else {
					next[j] = k;
				}
			} else {
				k = next[k];
			}
		}
		return next;
	}
}
複製程式碼

相關文章