參加了雅虎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億個子串基本不會發生沖突。