獨樹一幟的字符串匹配算法——RK算法


參加了雅虎2015校招,筆試成績還不錯,誰知初面第一題就被問了個字符串匹配,要求不能使用KMP,但要和KMP一樣優,當時瞬間就呵呵了。后經過面試官的一再提示,也還是沒有成功在面試現場寫得。現將該算法記錄如下,思想絕對是字符串匹配中獨樹一幟的

字符串匹配

存在長度為n的字符數組S[0...n-1],長度為m的字符數組P[0...m-1],是否存在i,使得SiSi+1...Si+m-1等於P0P1...Pm-1,若存在,則匹配成功,若不存在則匹配失敗。

RK算法思想

假設我們有某個hash函數可以將字符串轉換為一個整數,則hash結果不同的字符串肯定不同,但hash結果相同的字符串則很有可能相同(存在小概率不同的可能)。

算法每次從S中取長度為m的子串,將其hash結果與P的hash結果進行比較,若相等,則有可能匹配成功,若不相等,則繼續從S中選新的子串進行比較。

假設進行下面的匹配:

S0 S1 ... Si-m+1 Si-m+2 ... Si-1 Si Si+1 ... Sn-1
      P0 P1   Pm-2 Pm-1
     

情況一、hash(Si-m+1...Si) == hash(P0...Pm-1),此時Si-m+1...Si與P0...Pm-1有可能匹配成功。只需要逐字符對比就可以判斷是否真的匹配成功,若匹配成功,則返回匹配成功點的下標i-m+1,若不成功,則繼續取S的子串Si-m+2...Si+1進行hash

情況二、hash(Si-m+1...Si) != hash(P0...Pm-1),此時Si-m+1...Si與P0...Pm-1不可能匹配成功,所以繼續取S的子串Si-m+2...Si+1進行hash

可以看出,不論情況一還是情況二,都涉及一個共同的步驟,就是繼續取S的子串Si-m+2...Si+1進行hash。如果每次都重新求hash結果的話,復雜度為O(m),整體復雜度為O(mn)。如果可以利用上一個子串的hash結果hash(Si-m+1...Si),在O(1)的時間內求出hash(Si-m+2...Si+1),則可以將整體復雜度降低到線性時間

至此,問題的關鍵轉換為如何根據hash(Si-m+1...Si),在O(1)的時間內求出hash(Si-m+2...Si+1)

設計hash函數為:hash(Si-m+1...Si) = Si-m+1*xm-1 + Si-m+2*xm-2 + ... + Si-1*x + Si

則 hash(Si-m+2...Si+1) = Si-m+2*xm-1 + Si-m+3*xm-2 + ... + Si*x + Si+1 

            = (hash(Si-m+1...Si) - Si-m+1*xm-1) * x + Si+1

hash結果過大怎么辦?對某個大素數取余數即可(經典方法),稱其為HASHSIZE

所以,hash函數更新為:hash(Si-m+1...Si) = (Si-m+1*xm-1 + Si-m+2*xm-2 + ... + Si-1*x + Si) % HASHSIZE

則 hash(Si-m+2...Si+1) = (Si-m+2*xm-1 + Si-m+3*xm-2 + ... + Si*x + Si+1) % HASHSIZE 

            = ((hash(Si-m+1...Si) - Si-m+1*xm-1) * x + Si+1) % HASHSIZE

設計算法時需要注意的幾點:

1、可提前計算出hash(P0...Pm-1)和xm-1並保存

2、char c 的取值范圍為0~255,計算hash結果時會自動類型提升為int,為避免符號位擴展,使用 (unsigned int)c & 0x000000FF

3、hash(Si-m+1...Si) - Si-m+1*xm-1 的結果可能為負數,需先加上 Si-m+1*HASHSIZE 並最后 % HASHSIZE 來保證結果非負

具體代碼如下:

 1 #define UNSIGNED(x) ((unsigned int)x & 0x000000FF)
 2 #define HASHSIZE 10000019
 3 
 4 int hashMatch(char* s, char* p) {
 5     int n = strlen(s);
 6     int m = strlen(p);
 7     if (m > n || m == 0 || n == 0)
 8         return -1;
 9     // sv為S子串的hash結果,pv為字符串p的hash結果,base為x的m-1次方
10     unsigned int sv = UNSIGNED(s[0]), pv = UNSIGNED(p[0]), base = 1;
11     int i, j;
12     // 初始化 sv, pv, base
13     for (i = 1; i < m; i++) {
14         pv = (pv * 10 + UNSIGNED(p[i])) % HASHSIZE;
15         sv = (sv * 10 + UNSIGNED(s[i])) % HASHSIZE;
16         base = (base * 10) % HASHSIZE;
17     }
18     i = m - 1;
19     do {
20         // 情況一、hash結果相等
21         if (sv == pv) {
22             for (j = 0; j < m && s[i - m + 1 + j] == p[j]; j++)
23                 ;
24             if (j == m)
25                 return i - m + 1;
26         }
27         i++;
28         if (i >= n)
29             break;
30         // O(1)時間更新S子串的hash結果
31         sv = (sv + UNSIGNED(s[i - m]) * (HASHSIZE - base)) % HASHSIZE;
32         sv = (sv * 10 + UNSIGNED(s[i])) % HASHSIZE;
33     } while (i < n);
34 
35     return -1;
36 }

時間復雜度分析:循環復雜度O(n),hash結果相等時的逐字符匹配復雜度為O(m),整體時間復雜度為O(m+n)。空間復雜度為O(1)

運行時間PK

隨機生成10億字節(1024*1024*1023)的字符串保存到文件num.txt中,讀出到字符串S中,P長度為1024*10字節,分別使用RK算法和KMP算法進行實驗

從文件num.txt中讀取字符串到S中所需時間為:

匹配成功時,RK算法匹配所需時間為:

匹配成功時,KMP算法匹配所需時間為:

匹配不成功時,RK算法匹配所需時間為:

匹配不成功時,KMP算法匹配所需時間為:

可以看出,RK算法和KMP算法均可以在線性時間內完成匹配,RK算法時間稍慢的原因主要有兩點,一是數學取模運算,二是hash結果相同不一定完全匹配,需要再逐字符進行對比。統計hash結果相等但字符串不一定匹配的情況發現,匹配不成功時有105次hash結果相等但字符串不匹配的情況。S中長度為10239的子串個數大約為10億,所以hash結果相等但不匹配的概率大約為一千萬分之一(剛好約等於1/HASHSIZE),所以時間復雜度精確值應為O(n) + O(m*n/HASHSIZE)。

算法優化

在上面的測試中RK算法還是慢於KMP的,優化從兩點出發:一是用其他運算代替取模運算,二是降低hash沖突。

先解決降低沖突的問題,在之前的代碼中,我們使用了x=10,假設存在char值為2,20,200的三個字符a,b,c,可以發現a*1000,b*100,c*10的hash結果是相同的,也就是發生了沖突,所以取大於等於256的數做x則可以避免這種沖突。另外HASHSIZE的大小也會決定沖突發生的概率,HASHSIZE最大可以多大呢?對於unsigned int來說,總共有2^32次方個,所以可以取HASHSIZE為2^32次方。而計算機對於大於等於2^32次方的數會自動舍棄高位,其剛好等價於對2^32次方取模,即對HASHSIZE取模,所以便可以從代碼中去掉取模運算。

優化后的代碼如下(代碼中d即上文中的x):

 1 #define UNSIGNED(x) ((unsigned char)x)
 2 #define d 257
 3 
 4 int hashMatch(char* s, char* p) {
 5     int n = strlen(s);
 6     int m = strlen(p);
 7     if (m > n || m == 0 || n == 0)
 8         return -1;
 9     // sv為s子串的hash結果,pv為p的hash結果,base為d的m-1次方
10     unsigned int sv = UNSIGNED(s[0]), pv = UNSIGNED(p[0]), base = 1;
11     int i, j;
12     int count = 0;
13     // 初始化sv, pv, base
14     for (i = 1; i < m; i++) {
15         pv = pv * d + UNSIGNED(p[i]);
16         sv = sv * d + UNSIGNED(s[i]);
17         base = base * d;
18     }
19     i = m - 1;
20     do {
21         // 情況一,hash結果相等
22         if (!(sv ^ pv)) {
23             for (j = 0; j < m && s[i - m + 1 + j] == p[j]; j++)
24                 ;
25             if (j == m)
26                 return i - m + 1;
27         }
28         i++;
29         if (i >= n)
30             break;
31         // O(1)時間內更新sv, sv + UNSIGNED(s[i - m]) * (~base + 1)等價於sv - UNSIGNED(s[i - m]) * base
32         sv = (sv + UNSIGNED(s[i - m]) * (~base + 1)) * d + UNSIGNED(s[i]);
33     } while (i < n);
34 
35     return -1;
36 }

匹配成功時,優化后RK算法匹配所需時間為:

匹配不成功時,優化后RK算法匹配所需時間為:

可以看出,優化后的RK算法已經在時間上優於KMP了。而且大小為2^32次方的HASHSIZE也保證了S的10億個子串基本不會發生沖突。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM