字串匹配一直是電腦領域熱門的研究問題之一,多種演算法層出不窮。字串匹配演算法有著很強的實用價值,應用於資訊搜尋,拼字檢查,生物資訊學等多個領域。
今天介紹幾種比較有名的演算法:
1. BF
2. BM
3. Sunday
4. KMP
—,BF演算法
BF(Brute Force)演算法又稱為暴力匹配演算法,是普通模式比對演算法。
其演算法思想很簡單,從主串S的第pos個字元開始,和模式串T的第一個字元進行比較,若相等,則主串和模式串都後移一個字元繼續比較;若不相同,則回溯到主串S的第pos+1個字元重新開始比較。
依次類推,直到模式串T中每個字元依次和主串S中的一個連續字串全部相等時,則匹配成功,返回模式串T的第一個字元在主串S中的位置;若主串遍曆完也沒有成功,則匹配失敗。
演算法步驟如下:
下標i 0 1 2 3 4 5 6 7 8 9 主串S a b a b c a b c a c 模式串T a b c a
第一次比較,從左至右,S[0] = T[0],計數器++;比較S[1] = T[1],i++;當s[2] != T[2],主串回溯,從S[1]重新開始比較。
下標i 0 1 2 3 4 5 6 7 8 9 主串S a b a b b a b c a c 模式串T a b c a
也就是說,主串i從0開始,每次比較失敗i++,重新比較,直到
下標i 0 1 2 3 4 5 6 7 8 9 主串S a b a b b a b c a c 模式串T a b c a
i = 5,匹配成功。返回 i=5。
可推得,BF演算法在最壞情況下需要比較,主串長度M 乘以 模式串長度N, 為-O(M * N)。
代碼實現如下:
int BF(const char *str1, const char *str2){ int str1_len = strlen(str1); int str2_len = strlen(str2); int i = 0; int j = 0; if(str1 == NULL || str2 == NULL){ return -1; } while(i < str1_len && j < str2_len){ if(str1[i] == str2[j]){ i++; j++; //相等則繼續逐個比較 } else{ i = i -j + 1; j = 0; //不相等則主串回溯,重新比較 } } if (j == str2_len){ return i - j; }else{ return -1; }}
BF演算法簡單易懂,但是這個演算法效率很差,原因在於每次失敗後回溯,浪費了之前的比較,導致了很多次的無用比較,時間損耗較大。
二,BM演算法
BM(Boyer Moore)演算法是1977年,Robert S.Boyer和J Strother Moore提出了一種在O(n)時間複雜度的匹配演算法。時間複雜度要低於BF。
BM演算法之所以能夠在模式比對中有更高的的效率,主要是因為BM演算法構造了兩個跳轉表,分別叫做 壞尾碼表 ,和 好尾碼表 。這兩個表涉及了BM演算法中的兩個規則:
壞字元規則:當文本串中的某個字元跟模式串的某個字元不匹配時,我們稱文本串中的這個失配字元為壞字元,此時模式串需要向右移動,移動的位元 = 壞字元在模式串中的位置 - 壞字元在模式串中最右出現的位置。此外,如果”壞字元”不包含在模式串之中,則最右出現位置為-1。
好尾碼規則:當字元失配時,後移位元 = 好尾碼在模式串中的位置 - 好尾碼在模式串上一次出現的位置,且如果好尾碼在模式串中沒有再次出現,則為-1。
演算法步驟如下:
1. 首先,”文本串”與”模式串”頭部對齊,從尾部開始比較。”S”與”E”不匹配。這時,”S”就被稱為”壞字元”(bad character),即不匹配的字元,它對應著模式串的第6位。且”S”不包含在模式串”EXAMPLE”之中(相當於最右出現位置是-1),這意味著可以把模式串後移6-(-1)=7位,從而直接移到”S”的後一位。
2. 依然從尾部開始比較,發現”P”與”E”不匹配,所以”P”是”壞字元”。但是,”P”包含在模式串”EXAMPLE”之中。因為“P”這個“壞字元”對應著模式串的第6位(從0開始編號),且在模式串中的最右出現位置為4,所以,將模式串後移6-4=2位,兩個”P”對齊。
3. 依次比較,得到 “MPLE”匹配,稱為”好尾碼”(good suffix),即所有尾部匹配的字串。注意,”MPLE”、”PLE”、”LE”、”E”都是好尾碼。
4. 發現“I”與“A”不匹配:“I”是壞字元。如果是根據壞字元規則,此時模式串應該後移2-(-1)=3位。問題是,有沒有更優的移法。
5. 更優的移法是利用好尾碼規則:當字元失配時,後移位元 = 好尾碼在模式串中的位置 - 好尾碼在模式串中上一次出現的位置,且如果好尾碼在模式串中沒有再次出現,則為-1。
所有的“好尾碼”(MPLE、PLE、LE、E)之中,只有“E”在“EXAMPLE”的頭部出現,所以後移6-0=6位。
可以看出,“壞字元規則”只能移3位,“好尾碼規則”可以移6位。每次後移這兩個規則之中的較大值。這兩個規則的移動位元,只與模式串有關,與原文本串無關。
6. 繼續從尾部開始比較,“P”與“E”不匹配,因此“P”是“壞字元”,根據“壞字元規則”,後移 6 - 4 = 2位。因為是最後一位就失配,尚未獲得好尾碼。
匹配完成,可見BM演算法根據好壞字元規則,使得失配時,文本串能夠後移更多位,使得效率高於BF按部就班的匹配。
BM演算法的時間複雜度為-O(N)。
代碼實現主要在於構建兩表。
壞字元表:
void BuildBadC(const char* pattern, size_t pattern_length, unsigned int* badc, size_t alphabet_size) { unsigned int i; for(i = 0; i < alphabet_size; ++i) { badc[i] = pattern_length; } for(i = 0; i < pattern_length; ++i) { badc[pattern[i] - 'A'] = pattern_length - 1 - i; } }
好尾碼表:
void BuildGoodS(const char* pattern, size_t pattern_length, unsigned int* goods) { unsigned int i, j, c; for(i = 0; i < pattern_length - 1; ++i) { goods[i] = pattern_length; } //初始化pattern最末元素的好尾碼值 goods[pattern_length - 1] = 1; //此迴圈找出pattern中各元素的pre值,這裡goods數組先當作pre數組使用 for(i = pattern_length -1, c = 0; i != 0; --i) { for(j = 0; j < i; ++j) { if(memcmp(pattern + i, pattern + j, (pattern_length - i) * sizeof(char)) == 0) { if(j == 0) { c = pattern_length - i; } else { if(pattern[i - 1] != pattern[j - 1]) { goods[i - 1] = j - 1; } } } } } //根據pattern中個元素的pre值,計算goods值 for(i = 0; i < pattern_length - 1; ++i) { if(goods[i] != pattern_length) { goods[i] = pattern_length - 1 - goods[i]; } else { goods[i] = pattern_length - 1 - i + goods[i]; if(c != 0 && pattern_length - 1 - i >= c) { goods[i] -= c; } } } }
構建完表BM演算法就很簡單了。
unsigned int BM(const char* text, size_t text_length, const char* pattern, size_t pattern_length, unsigned int* matches) { unsigned int i, j, m; unsigned int badc[ALPHABET_SIZE]; unsigned int goods[pattern_length]; i = j = pattern_length - 1; m = 0; //構建好尾碼和壞字元表 BuildBadC(pattern, pattern_length, badc, ALPHABET_SIZE); BuildGoodS(pattern, pattern_length, goods); while(j < text_length) { //發現目標傳與模式傳從後向前第1個不匹配的位置 while((i != 0) && (pattern[i] == text[j])) { --i; --j; } //找到一個匹配的情況 if(i == 0 && pattern[i] == text[j]) { matches[m++] = j; j += goods[0]; } else { //壞字元表用字典構建比較合適 j += goods[i] > badc[text[j]-'A'] ? goods[i] : badc[text[j]-'A']; } i = pattern_length - 1; } return m; }
BM演算法的思想在字串匹配中很重要,其中好尾碼與KMP演算法的思想不謀而合,而壞字元跳躍,跟Sunday演算法很相似。總的來說,BM是很高效的匹配演算法。
三,Sunday演算法
Sunday演算法是Daniel M.Sunday於1990年提出的字串模式比對。據網上說,Sunda演算法的效率高於BM和KMP(不太明白)。在我看來,Sunday演算法是BM演算法的最佳化,邏輯簡單易懂,代碼也實現更容易(無需構造兩表)。
Sunday演算法的思想是:
1. 從文本串S的第pos個字元開始,和模式串T的第一個字元進行比較,若相等,則主串和模式串都後移一個字元繼續比較;
2. 若不相同,則將文本串參與匹配的最末位字元的後一個字元與模式串逆著匹配。
3. 若匹配完模式串沒有該字元,則模式串直接跳過,即移動位元 = 匹配串長度 + 1。
4. 若模式串匹配到了該字元,則模式串中相同字元移動到文本串該字元下,與該字元對齊。其移動位元 = 模式串中最右端的該字元到末尾的距離+1。
演算法步驟如下:
下標i 0 1 2 3 4 5 6 7 8 9 10 11 12主串S c b a b d c b a c b b a d模式串T c b b a
從前往後比較,在i = 2處失配,關注i= 4( d )是否包含在模式串T中。遍曆比較完發現不包含,將模式T移動到i = 5繼續比較。(失配後,主串這輪參與比較的後一位(d)肯定要參與下一輪的比較,T中都沒有d,肯定匹配不成功啊。還比什麼,直接後移至(d)的下一位。)
下標i 0 1 2 3 4 5 6 7 8 9 10 11 12主串S c b a b d c b a c b b a d模式串T c b b a
在i = 7處失配,關注i = 9(b),在模式串中找b,發現i = 6, i = 7處都有b,那應該和誰對齊。。 應該和後面的(i = 7)處的對齊,這就是為什麼要倒著在模式串中找b了,找到了直接break,並將此處與i = 9對齊。(為什麼和後面的b對齊,大家思考)
下標i 0 1 2 3 4 5 6 7 8 9 10 11 12主串S c b a b d c b a c b b a d模式串T c b b a
在i=7處失配,關注i=11(a),匹配到模式串中最後一位為a,break;將模式串中的a與i = 17 處的 a 對齊。繼續比較。
下標i 0 1 2 3 4 5 6 7 8 9 10 11 12主串S c b a b d c b a c b b a d模式串T c b b a
匹配完成。
可見Sunday演算法的核心思想就是失配時,模式串能儘可能多的向後移動,使得匹配次數減少,效率提高。
Sunday和BM不同點在於:
1. BM從後往前匹配,Sunday從前往後。
2. BM演算法失配關注的是“最後一位”,Sunday關注的是“最後一位的下一位”。
3. BM有壞字串好字串之分,Sunday沒有。(但思想比較相似,BM中的好字元 和 壞字元包含在模式串內,可以類比於Sunday演算法找到了該字元;好字元和壞字串沒出現,則類比於沒在模式串中找到該字元)。
代碼實現:
int Sunday(const char *str1, const char *str2){ int str1_len = strlen(str1); int str2_len = strlen(str2); int i = 0; int j = 0; enum{FALSE,TRUE}; int Y = FALSE; if(str1 == NULL || str2 == NULL){ return -1; } while(i < str1_len && j < str2_len){ if(str1[i] == str2[j]){ i++; j++; }else{ //關注最後一位的後一位這個字元 int num = i - j + str2_len ; for(j = str2_len-1; j >= 0; j--){ //該字元與模式串比較,找到相同的則與該字元對齊。 if(str1[num] == str2[j]){ i = num - j; Y = TRUE; break; } } if(Y == FALSE){ //沒找到模式串就直接跳到該字元的下一位。 i = num + 1; } Y = FALSE; j = 0; } } if(i == str1_len){ return -1; }else return i - str2_len;;}
Sunday演算法的應用價值很強,(實際效率高於KMP和BM演算法),代碼實現也很簡單,希望大家能夠掌握。
四,KMP演算法
Knuth-Morris-Pratt 字串尋找演算法,簡稱為 “KMP演算法”由Donald Knuth、Vaughan Pratt、James H. Morris三人於1977年聯合發表,故取這3人的姓氏命名此演算法。
KMP演算法在網上說的非常麻煩,我覺得就是之前介紹的類似於BM演算法的好字元尾碼匹配規則,和next[]數組的推導兩點而已。
演算法的思想是:假設現在文本串S匹配到 i 位置,模式串P匹配到 j 位置如果j = -1(標記),或者當前字元匹配成功(即S[i] == P[j]),都令i++,j++,繼續匹配下一個字元;
如果j != -1,且當前字元匹配失敗(即S[i] != P[j]),則令 i 不變,j = next[j]。意味失配時,模式串P相對於文本串S向右移動了j - next [j] 位。換言之,當匹配失敗時,模式串向右移動的位元為:失配字元所在位置 - 失配字元對應的next 值。即移動的實際位元為:j - next[j],且此值大於等於1。
Next數組的值含義是:代表失配前的字串中,有多大長度的相同的首碼尾碼。比如Next[j] = k;表示 j 之前的字串中有最大長度為k 的相同首碼尾碼。
此也意味著在某個字元失配時,該字元對應的next 值會告訴你下一步匹配中,模式串應該跳到j-Next[j]這個位置上。所以重點在於求Next[]。
如下:
ABCDAB ABCDABC ABCDABCDABDABD 文本串
ABCDABD 模式串
最大首碼尾碼相同數:
A 左 右 AB A B 0 ABC A,AB C,BC 0 ABCD A,AB,ABC D,CD,BCD 0 ABCDA A,AB,ABC,ABCD A,DA,CDA,BCDA 1 ABCDAB A,AB,ABC,ABCD,ABCDA B,AB,DAB,CDAB,BCDAB 2 ABCDABD A,AB,ABC,ABCD,ABCDA,ABCDAB D,BD,ABD,DABD,CDABD,BCDABD 0 最大首碼尾碼公用元素長度對照表 A B C D A B D 0 0 0 0 1 2 0
Next 數組考慮的是除當前字元外的最長相同首碼尾碼,所以通過上步驟求得各個首碼尾碼的公用元素的最大長度後,只要稍作變形即可:將第求得的值整體右移一位,然後初值賦為-1,如下表格所示:
A B C D A B D -1 0 0 0 0 1 2
在匹配失配時,只需要用失配位置 j 減去 Next[j],就可以得到模式串移動到什麼位置了。
Next[]的求取代碼實現如下:
int *get_next(const char* str2){ int str2_len = strlen(str2); int *next = (int *)malloc(sizeof(int)*str2_len); next[0] = -1; int left = -1; int right = 0; while(right < str2_len - 1){ if(left == -1 || str2[left] == str2[right]){ left++; right++; next[right] = left; }else left = next[left]; } return next;}
這段代碼是為了求取Next[]對應的值,並沒有什麼實際意思,能得出正確的Next[]就行(求Next[]的值,網上好像就這一種代碼實現辦法)。
0 1 2 3 4 5 6 next -1 0 0 0 0 1 2
我們來根據代碼驗證下是否準確
str_len - 1~6
left = -1
right = 0
——————————————
0 1 2 3 4 5 6 next -1 0 left = 0 right = 1 next[1] = 0 -------------------------------------------- ABCDABD right = 1 left = next[0] = -1-------------------------------------------- left = 0 right = 2 next[2] = 0 0 1 2 3 4 5 6 next -1 0 0
可以看出是正確的(後來的就不用推演了),代碼設計的很巧妙,能恰好算出Next[]所對應的值。(這段代碼不需理解,記住就行,就是為了求Next[]而專門設計的演算法)。
求出Next[]的對應值,KMP演算法代碼就很容易了。
int KMP(const char* str1, const char* str2){ int str1_len = strlen(str1); int str2_len = strlen(str2); int *next = get_next(str2); int i = 0; int j = 0; if(str1 == NULL || str2 == NULL){ return -1; } while(i < str1_len && j < str2_len){ if(j == -1 || str1[i] == str2[j]){ i++; j++; }else{ //關鍵一步,失配時根據Next[]跳轉 j = next[j]; } } free(next); if(j == str2_len){ return (i-str2_len); } return -1;}
KMP的時間複雜度:
我們發現如果某個字元匹配成功,模式串首字元的位置保持不動,僅僅是i++、j++;如果匹配失配,i 不變(即 i 不回溯),模式串會跳過匹配過的next [j]個字元。整個演算法最壞的情況是,當模式串首字元位於i - j的位置時才匹配成功,演算法結束。
所以,如果文本串的長度為n,模式串的長度為m,那麼匹配過程的時間複雜度為-O(n),算上計算next的-O(m)時間,KMP的整體時間複雜度為-O(m + n)。
四種經典的字串匹配演算法介紹完畢,大家在紙上多畫多算,能更好的理解演算法思想。