Rolling Hash(Rabin-Karp算法)匹配字符串


您可以在我的個人博客中訪問此篇文章:

http://acbingo.cn/2015/08/09/Rolling%20Hash(Rabin-Karp%E7%AE%97%E6%B3%95)%E5%8C%B9%E9%85%8D%E5%AD%97%E7%AC%A6%E4%B8%B2/

該算法常用的場景

字符串中查找子串,字符串中查找anagram形式的子串問題。

關於字符串查找與匹配

字符串可以理解為字符數組。而字符可以被轉換為整數,他們具體的值依賴於他們的編碼方式(ASCII/Unicode)。這意味着我們可以把字符串當成一個整形數組。找到一種方式將一組整形數字轉化為一個數字,就能夠使得我們借助一個預期的輸入值來Hash字符串。
既然字符串被看成是數組而不是單個元素,比較兩個字符串是否想到就沒有比較兩個數值來得簡單直接。去檢查A和B是否相等,我們不得不通過枚舉所有的A和B的元素來確定對於所有的i來講A[i]=B[i]。這意味着字符串比較的復雜度依賴於字符串的長度。比較兩個長度為n的字符串,需要的復雜度為O(n)。另外,去hash一個字符串是通過枚舉整個字符串的元素,所以hash一個長度為n的字符串也需要O(n)的時間復雜度。

做法

  1. hash P 得到 h(p) 。時間復雜度:O(L)
  2. 從S的索引為0開始來枚舉S里長度為L的子串,hash子串並計算出h(P)’。時間復雜度為O(nL)。
  3. 如果一個子串的hash值與h(P)匹配,將該子串與P進行比較,如果不匹配則停止,如果匹配則繼續進行步驟2。時間復雜度:O(L)

這個做法的時間復雜度為O(nL)。我們可以通過使用rollinghash來優化這種做法。在步驟2中,我們看到對於O(n)的子串,都花費了O(L)來hash他們(你可以想象成,找了一個長度為L的框,框住了S,每迭代一次向前移動一位,所以會移動n次,而對於每次每個框中的子串都需要迭代這個子串來算哈希值,所以復雜度為nL)。然而你可以看到這些子串中很多字符都是重復的。比如,看一個字符串“algorithms”中長度為5的子串,最開始的兩個子串長度為“algor”和“lgori”。如果我們能利用這兩個子串又有共同的子串“lgor”這個事實,將會為我們省去很多時間來處理每一個字符串。看起來我們應該使用rollinghash。

“數值”示例

讓我們回到字符串上去,假如我們有P和S都被轉化為了兩個整形數組:
P=[9,0,2,1,0] (1)
S=[4,8,9,0,2,1,0,7] (2)
長度為5的S的子串被列舉在下面:
S0=[4,8,9,0,2] (3)
S1=[8,9,0,2,1] (4)
S2=[9,0,2,1,0] (5)
… (6)
我們想知道P是否能與S的某個子串匹配,可以使用上面的“做法”中的三個步驟。我們的Hash函數可以是:

或者換句話說,我們將長度為5的整形數組中的每個數值都映射到一個5位數的每一位上,然后用這個數值跟m做“mod”運算。h(P)=90210mod m,h(S0)=48902mod m,以及h(S1)=98021mod m。注意這個哈希函數,我們可以是用h(S0)來幫助計算h(S1)。我們從48902開始,去除第一位得到8902,乘以10得到89020,然后加上下一位數值得到:89021.更通用的公式是:

我們可以想象為這是在所有的S的子串上一個滑動的窗口。計算下一個子串的hash值其是值關系到兩個元素,這兩個元素正好是在這個滑動窗口的兩端(一個進來一個出去)。這里與上面有很大的不同,這里我們除了第一次去計算長度為L的第一個子串之后,我們將不在依賴這長度為L的元素集合了,我們只依賴兩個元素,這使得計算子串hash值的復雜度變成了O(1)的操作。
在這個數值的示例中,我們看到了簡單的按位存放整數,並且設置了“底”為10,因此我們可以很輕易得分離出其中的每個數字。為了通用話,我們可以采用如下通用公式:

並且計算下一個子串的hash值就是:

感覺他解釋的不是很清楚。
這里給出個我自己的理解,當n=5,b=10
h(Si+1)=(h(Si)mod(b^n)*b+S[i+L])mod m

而另一位大神是這樣描述的:
Rabin-Karp算法的關鍵思想是 某一子串的hash值可以根據上一子串的hash在常數時間內計算出來,這樣比對的時間復雜度可以降為O(n-k)。Rabin-Karp對字符串的hash算法和上面描述的一樣(按整數進制解析再求模),假設原字符串為s,H(i)表示第i個字符開始的k個子字符串的hash值,即
,(先不考慮%M),則,時間為常數。
又由%的性質可得:



即 i+1 處子串的 hash 可以由 i 處子串的 hash 直接計算而得,在中間結果 %M 主要是為了防止溢出。
M 一般選取一個非常大的數字,子串的數目相對而言非常少,產生散列碰撞的概率為 1/M,可以忽略不計。
代碼實現如下,這里當hash一致時沒有再回退檢查。可以看到 Rabin-Karp 的瓶頸在於每個內循環都進行了乘和模運算,模運算是比較耗時的,而其他算法大部分只需要進行字符比對.

回到字符串的問題上

既然字符串可以被轉換為數字,我們可以在字符串上也像跟數值的示例一樣用同樣的方法來提高運行效率。算法實現如下:

  1. Hash P 得到h(P) 時間復雜度為O(L)
  2. Hash S中長度為L的第一個子串 時間復雜度為O(L)
  3. 使用rolling hash 方法來計算S 所有的子串 O(n),並以計算出的hash值與h(P)進行比較 時間復雜度為O(n)
  4. 如果一個子串的hash值與h(p)相等,那么將該子串與P進行比較,如果匹配則繼續,否則則中斷當前匹配 時間復雜度為O(L)

這加快了整個算法的效率,只要所有做比較的總時間為O(n),那么整個算法的時間復雜度為O(n)。我們進入一個問題,如果我們在我們的hashtable中假設產生了O(n)次“哈希碰撞”(指由於哈希函數的問題,導致多個key對應到同一個值),那么步驟4的總復雜度就為O(nL)。因此我們不得不確保我們的hashtable的大小為n(也就是必須保證每個子串都能唯一對應一個哈希key,這取決於hash函數的設計),這樣我們就可以期待子串可以被一次命中,所以我們只需要走步驟4O(1)次。而我們步驟4的時間復雜度為O(L),在這種情況下,我們仍然可以保證整個問題的時間復雜度為O(n)

代碼實現

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iostream>
#include <string>
using namespace std;
void Rabin_Karp(string p,string s,int b,int m){
int hash_p=0;//目標串的hash值
int hash_i=0;//當前串的hash值
int h=1;
for (int i=0;i<p.size();i++){//h==pow(b,p.size());
h=(h*b)%m;
}
for (int i=0;i<p.size();i++){
hash_p=(b*hash_p+p[i])%m;
hash_i=(b*hash_i+s[i])%m;
}
for (int i=0;i<=s.size()-p.size();i++){
if (hash_i==hash_p){
int j;
for (j=0;j<p.size();j++){
if (s[i+j]!=p[j]) break;
}
if (j==p.size()) cout<<"yes "<<i<<endl;
}
if (i<s.size()-p.size()){
hash_i=(hash_i%m*b+s[i+p.size()]+m-s[i]*h%m)%m;//算出下一個hash值
if (hash_i<0) hash_i=hash_i+m;//其實這一步在該程序下是沒有實際意義的。主要是提醒自己以后涉及到取余問題的時候可能會發生取到負數及0
}
}
}
int main () {
string p,s;
p="Rabin";
s="Rabin–Karp string search algorithm: Rabin-Karp";
int m=101;//素數
int base=26;//基數,這里取26好了
Rabin_Karp(p,s,base,m);
return 0;
}

自身匹配問題

給定一個長度為n的串s,求其子串中是否存在相同的且長度都為l的串,若存在,輸出其出現次數以及出現位置。
注意此處要求子串長度是一定的,數據小的話暴力就可以搞。

  1. hash S的第一個長度為L的子串 時間復雜度為:O(L),放入map表
  2. 使用rolling hash 來計算S的所有O(n)個子串,每算出一個然后和map表進行比對,並更新map表,時間為O(nlogn)
    注意可能會發生“哈希碰撞”。總的來說,m值的大小決定了map表的大小,而map表的大小又決定了哈希碰撞的概率。若是發生了碰撞,個人認為采用緩存區法或者再哈希都比較容易實現。

代碼實現

代碼只實現了判斷是否存在相同的子串,╮(╯-╰)╭,沒辦法,lpl馬上開賽了,得趕緊干完呢~

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <iostream>
#include <string>
#include <map>
using namespace std;
struct Node{
int index;
int num;
};
map<int,Node> mymap;
void Rabin_Karp_Self(string s,int l,int b,int m)
{
int h=0;//注意初始化
int t=1;
for (int i=0;i<l;i++) t=(t*b)%m;
for (int i=0;i<l;i++){//計算第一個窗口的hash值
h=((b*h)+s[i])%m;
}
mymap[h].index=0;mymap[h].num++;
for (int i=1;i<=s.size()-l;i++){
//算初當前的hash
h=(h%m*b+s[i-1+l]+m-s[i-1]*t%m)%m;//滑動窗口,計算下一個hash值
//h=((h*b-s[i-1]*t)+s[i+l-1])%m;
//if (h<0) h+=m; //這里同上題
if (mymap.count(h)){
int j;
for (j=0;j<l;j++) {
if (s[j+mymap[h].index]!=s[i+j]) break;
}
if (j==l) cout<<"yes "<<mymap[h].index<<" "<<i<<endl;
}else {
mymap[h].index=i;mymap[h].index++;
}
}
}
int main () {
string s;
int n;
s="Rabin–Karp string search algorithm: Rabin-Karp";
//s="abcabc";
n=5;
int b;int m;
b=10;m=10001;
Rabin_Karp_Self(s,n,b,m);
return 0;
}

 

若想輸出次數和位置,也很簡單,node增加一個數組,然后修改下cout那就行了。另外注意哈希碰撞的處理。

不定長的子串

TODO
等對字符串匹配問題的各種算法理解都十分透徹后,再回頭考慮這個問題
個人認為求不定長子串匹配問題該算法不僅麻煩,時間也算不上最快的。

共同子串問題

剛才的算法被設計成:在一個字符串S中查找一個模式串P的匹配。然而,現在我們需要處理另一個問題:看看兩個長度為n的長字符串S和T,看他們是否擁有長度為L的共同子串。這看起來是一個更難處理的問題,但我們還是能有采用rollinghash使得其復雜度為O(n)。我們采用一個相似的策略:

  1. hash S的第一個長度為L的子串 時間復雜度為:O(L)
  2. 使用rolling hash 來計算S的所有O(n)個子串,然后把每個子串加入一個hash table中 時間復雜度為:O(n)
  3. hash T的第一個長度為L的子串 時間復雜度為:O(L)
  4. 使用rolling hash方法來計算T的所有O(n)個子串,對每個子串,檢查hashtable看是否能命中。
  5. 如果T的一個子串命中了S的一個子串,那么就進行匹配,如果相等則繼續,否則停止匹配。時間復雜度為:O(L)

然而,保持運行的次數為O(n),我們又再次需要注意限制“哈希碰撞”的次數,以減少我們進入步驟5來進行不必要的匹配。這次,如果我們的hashtable的大小為O(n),那么我們對於T的每個子串所期待的命中復雜度為O(1)(最壞的情況)。這樣的結果會導致字符串進行O(n)次比較,總共的復雜度為O(nL)次,這使得字符串的比較在這里成為了瓶頸。我們可以擴大hashtable的大小,同時修改我們的hash函數使得我們的hashtable有O(n的平方)個槽(槽指hash表中真正用於存儲數據的單元),來使得對於每個T的子串來講,可能的碰撞降低到O(1/n)。這可以解決我們的問題,並且使得整個問題的復雜度仍然為O(n),但我們可能沒有必要像這樣來創建這么大的hashtable消耗不必要的資源。
取而代之的是,我們將利用字符串簽名的優勢來替代消耗更多存儲資源的做法,我們將再為每個子串分配一個hash值,稱之為h(k)’。注意,這個h(k)’的hash 函數最終將字符串映射到0到n的平方的范圍而不是上面的0到n。現在當我們在hashtable中產生哈希碰撞時,在我們做最終“昂貴”的字符串比較之前,我們首先可以比較兩個字符串的簽名,如果簽名不匹配,那么我們就可以跳過字符串比較。對於兩個子串k1和k2,僅當h(k1)=h(k2)以及h(k1)’=h(k2)’時,我們才會做最終的字符串比較。對於一個好的h(k)’的哈希函數,這將大大減少字符串比對,使得比對的復雜度接近O(n),將共同子串問題的復雜度限制在O(n)。

二維擴展

http://novoland.github.io/%E7%AE%97%E6%B3%95/2014/07/26/Hash%20&%20Rabin-Karp%E5%AD%97%E7%AC%A6%E4%B8%B2%E6%9F%A5%E6%89%BE%E7%AE%97%E6%B3%95.html
參考自:
http://blog.csdn.net/yanghua_kobe/article/details/8914970
http://novoland.github.io/%E7%AE%97%E6%B3%95/2014/07/26/Hash%20&%20Rabin-Karp%E5%AD%97%E7%AC%A6%E4%B8%B2%E6%9F%A5%E6%89%BE%E7%AE%97%E6%B3%95.html
http://blog.csdn.net/chenhanzhun/article/details/39895077


注意!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系我们删除。



 
粤ICP备14056181号  © 2014-2020 ITdaan.com