串的應用與kmp演算法講解
1. 寫作目的
平時學習總結的學習筆記,方便自己理解加深印象。同時希望可以幫到正在學習這方面知識的同學,可以相互學習。新手上路請多關照,如果問題還請不吝賜教。
2. 串的邏輯儲存
串指的是字串,是一種特殊的線性表,特殊性在於只能儲存字元,即可以使用順序儲存也可以使用鏈式儲存,簡單的談一下兩種儲存結構的優缺點。
順序儲存
順序儲存使用的是陣列,既然是陣列就是申請固定空間,當串需要拼接,替換時,可能會對陣列進行擴容,這種操作就比較耗時,而且有時陣列空間利用率很低,也浪費了一部分空間。但是優點也是顯而易見的,定位速度快。
鏈式儲存
鏈式儲存不拘束於空間大小,需要多少空間就鏈上多少個節點。但是如果每個節點都只存一個字元,那麼無疑是浪費了空間,如果存多個字元,那麼在字元查詢上需要花費更多的時間,而且連結串列本身查詢速度慢。
我的觀點
更傾向於順序儲存,畢竟字串更為頻繁的操作是查詢功能,相較於鏈式儲存,犧牲一點空間,換取更快查詢的速度。
3. 串的應用--暴力查詢
下面說說串的應用:子串的查詢。這個應該很熟悉,有好多應用場景是希望在一個主串中找到子串。可能想找到子串的位置,也可能想判斷是否存在,或者替換,全部替換等,這就需要我們能完成最基本的操作,找出子串。
下面這個例子可能是我們最容易想到的演算法,直接找:兩個迴圈巢狀,外層迴圈(i)逐一遍歷主串每個字元,判斷是否能和子串首字母匹配,如果匹配就繼續判斷第二個字元,直到把子串遍歷完,就是找到了。但是如果中途某個字元不匹配,就把 i 回溯,回到匹配最初的地方,來進行下一個字元的首字母匹配。基本過程如下:
`public static int matchPattern(String origin,String aim) {
char[] origins = origin.toCharArray();
char[] aims = aim.toCharArray();
for(int i = 0; i < origins.length; i++) {
int j = 0,k = i;
for( ; j < aims.length;k++,j++ ) {
if(origins[k] != aims[j]) {
break;
}
}
if(j == aims.length) {
return i - j;
}
}
return -1;
}`
這個方法應該都可以想到,但是這樣寫會有問題。問題就是:
似乎感覺很正常,但如果仔細檢視會發現,每當快要匹配成功的時候,因為一個字元的不匹配,就要回頭再來,如果字串很長,而目標字元相似度很高,就會一直重複這樣的比較,重要的是其實我們可以根據已知的比較結果來減少比較的次數,來優化這種演算法。
4. KMP演算法
簡單說一下這個演算法的背景,KMP的由來。
KMP演算法是一種改進的字串匹配演算法,由D.E.Knuth,J.H.Morris和V.R.Pratt同時發現,因此人們稱它為克努特——莫里斯——普拉特操作(簡稱KMP演算法)。
用我自己的話概述就是在匹配字串過程中,因為某個字元匹配失敗後,根據已有匹配成功的串推算出下次子串需要移動的位置,從而達到減少不必要匹配次數,實現快速匹配。那麼重點就來了,如何推算出子串需要移動的位置? 我怎麼知道這樣移動後能一定能保證不會有遺漏。那下面我們就一起來分析一下(還是使用上面的例子,為了描述方便,我們把主串稱為o(origin),子串稱為a(aim) )。
第一步這樣匹配沒問題。
- 第二步這樣匹配就出現問題了。 問題是上一步 i 已經移動到 4(陣列下標),就因為o[4] != a[4],本次匹配失敗,i 需要回溯到 1,j 需要回溯到 0 ,之前的匹配結果資訊根本沒有派上用場。
因為根據已經匹配資訊,o[0] = a[0],o[1] = a[1],o[2] = a[2],o[3] = a[3],前4位是對應相等的,而且o[2] = a[0],o[3] = a[1],我們完全可以這樣移動:
這樣的話可以不用比較o[1]與a[0],o[2]與a[0],o[3]與a[1],直接比較o[4]與a[2],省去了不必要的步驟。那麼我們繼續往下分析,顯然o[4] != a[2],因為o[2] = a[0],o[3] = a[1],而且a[0],a[1]不相等,我們可以推斷出,o[3] != a[0],所以這一比較步驟也可以省略,直接比較a[4]與a[0]。最終得到結果
這樣的比較方式,是我們可以接受的,根據已知匹配資訊省略了許多比較操作,提高效率。 - 那可能有人就要問了,你是怎麼知道確切的移動位置,可以讓 j 定位到合適的位置,這也就引出了KMP演算法的核心的部分,求next[]。next[]有什麼作用,next[0],next[1]....next[n]的含義是什麼?next是存放對應子串字元衝突後,需要移動 j 的位置。舉個例子:next[4] = 2,意思是在 j = 4的位置上子串和主串不匹配,那麼主串 i 不需要回溯,直接把 j 回溯到 2,進行比較,也就是把next[]的每個值都求出來,我們就可以輕鬆準確的更改 j 的位置。瞭解了next[]的用途,下面我們來學習next[]是如何求出來的。
拿之前的例子作為說明:
o[4]與a[4]衝突,前4位對應相等(首要條件),我們要是想根據前4位相等來推算位置,應該會出現2中情況。一:子串前4位互不相等(a[0],a[1],a[2],a[3]沒有一個相同的),根據以上2個條件(o,a對應相等),直接可以推算,a[0]下次應該直接和o[4]比,原因就是a[0]不會和o前4位任意字元相等。 二:子串前4位有字元相等,而且是前字尾對應相等,也是可以推算位置。 插播一下前字尾知識。
例如一串字元 "abadaba"
字首:{"a","ab","aba","abad","abada","abadab"}
字尾:{"a","ba","aba","daba","adaba","badaba"}
那麼前字尾最長匹配相等串"adaba"。
回到剛才的問題,對於串"abab",最長匹配相等串為"ab"。那麼我們是可以這樣理解的:
我們分析的是a的前字尾關係,但前提提交是o,a對應位置相等,所以a的字首就對應了o的字尾,這樣我們可以直接移動 j 2的位置。理論基本就這些,我們通過程式碼來實現一下:public static int[] getNext(String aim,int length) { char[] chars = aim.toCharArray(); int i = 0,j = -1; //next陣列長度和aim長度是相等的,因為每一個字元比較出衝突都需要有對應j的位置 int[] next = new int[length]; //第0個位置前是沒有前字尾的,所以next[0]我們規定為 -1. next[0] = -1; //第1個位置不匹配,前面只有一個字元,我們只能把j移動到0. next[1] = 0; //注意:這裡 i < length -1,而非是 i < length。原因就是 i在迴圈內部先++,再賦值,如果是 < length會陣列越界。 while(i < length-1) { //判斷如果對應字元相等,就累加前字尾相同字元長度 if( j == -1 || chars[i] == chars[j] ) { next[++i] = ++j; }else { //注意:就這一句話,困擾我了2天,也許2是比較菜,不過我在文章中,重點解釋一下,見文章。 j = next[j]; } } return next; }
重點來了
我們在求解next的時候並不需要o,只需要a就可以得出。我們只需要找出a中前字尾匹配長度,即可。那麼我們的方法就是讓a和a自己比較。比較的過程如下:
- a[0]與a[1]比較,不相等
- a[0]與a[2]比較,相等,next[ ++i ] = ++j, next[3] = 1。 意思是什麼呢? 可以這樣理解,在第3個字元前面的字元中,前字尾最大公共長度為1,也就是有1個公共字元,"a"。那麼我知道這個資訊有什麼用處呢?用處就是當j在3位置上匹配失敗,直接改變j為1來繼續比較。
- a[1]與a[3]比較,相等
- a[2]與a[4]比較,不相等
因為只是a[2]與a[4]不等,我們還不能確定具體的公共長度(next[4]的值),接下來我們需要回溯比較,a[4]與a[1],然後a[4]與a[0],如果這樣比較多話,就又成了普通的暴力比較 ,我們可以根據已有的結果來推算。下面我來說一下困惑我2天的問題,j = next[ j ]
現有我們已經推算的結果:next[ 0 ] = -1, next[ 1 ] = 0, next[ 2 ] = 0, next[ 3 ] = 1,next[ 4 ] = 2。按照演算法來說,就是, j = next[2],j = 0。
我之前一直認為 next[j] 的值代表的就是 j 需要移動的下標,所以很是不能理解把next[4]的值有放到next[]中(怎麼能把下標放入到next[]中),也就是next[next[4]],next[4]很清晰的說明是當 j = 4 的位置有字元衝突時,把 j 移動到next[4],也就是2,那next[2]又是啥意思? 也就是實在搞不懂next[next[4]]。2天總是想著這個事,也繼續在網上查閱資料,看有沒有人和我同樣有這個疑問。最後在不斷的瀏覽中,感覺有可以說通的答案了,next[j] 也表示 0 ~ j-1 字元中前字尾最大長度,那麼當next[4]失配後,next[4] = 2, 我們知道前面的4個字元,最大公共前字尾長度為2,那麼next[2]就是在前2個字元當中再尋找最大前字尾公共的長度,因為是自身和自身比較,公共前字尾字元"ab"和 i=2 時前面的字元是一樣的,所以在公共前字尾中再找相同前字尾即為在 i = 2之前找,也就是next[2]的值,相信到這裡已經對這個疑問得到解答了。
next[ ]的求解我認為是kmp的核心,這個瞭解完之後,所剩內容不多。引用程式碼來使用next[ ]完成主串與模式串的查詢吧。稍微改動了暴力求解的程式碼。
public static int kmp(String origin,String aim) {
char[] origins = origin.toCharArray();
char[] aims = aim.toCharArray();
int[] next = getNext(aim,aim.length());
for(int i = 0; i < origins.length; i++) {
int j=0;
for( ; j < aims.length; ) {
if(origins[i] != aims[j]) {
//改動
if(j == 0) {
break;
}
j = next[j];
}else {
i++;
j++;
}
}
if(j == aims.length) {
return i-j;
}
}
return -1;
}
5. 聊一下kmp的改進方案:
舉例說明,有一種情況,我們的next[ ]有待優化,是這樣一種情況。模式串:"aaaaaaab",顯然我們的結果是next[]{0,0,1,2,3,4,5,6}。當和我們的主串"aaaaaaacaa....",會導致以下情況:
那我們如何改進呢?就是發現模式串中出現連續相等字元就讓nextVal[ i ] = nextInt[ j ],解釋一下就是: 回溯到上一個字元需要回溯的位置,因為2組前字尾對應想等,那就和上一個做相同處理。
public static int[] getNextVal(String aim,int length) {
char[] chars = aim.toCharArray();
int i = 0,j = -1;
//next陣列長度和aim長度是相等的,因為每一個字元比較出衝突都需要有對應j的位置
int[] nextVal = new int[length];
//第0個位置前是沒有前字尾的,所以next[0]我們規定為 -1.
nextVal[0] = 0;
//第1個位置不匹配,前面只有一個字元,我們只能把j移動到0.
nextVal[1] = 0;
//注意:這裡 i < length -1,而非是 i < length。原因就是 i在迴圈內部先++,再賦值,如果是 < length會陣列越界。
while(i < length-1) {
//判斷如果對應字元相等,就累加前字尾相同字元長度
if( j == -1 || chars[i] == chars[j] ) {
i++;
j++;
//相較於getNext()改動的地方
if(chars[i] == chars[j]) { // i == j, i+1 == j+1
nextVal[i] = nextVal[j];
}else {
nextVal[i] = j;
}
}else {
//注意:就這一句話,困擾我了2天,也許是比較菜,不過我在文章中,重點解釋一下,見文章。
j = nextVal[j];
}
}
return nextVal;
}
參考文件:
《大話資料結構》