ローリングハッシュ法
2024/07/18 C++
ローリングハッシュ法について
考え方
ローリングハッシュは文字列のハッシュ値(要約値)を高速に計算することで高速な文字列検索を可能にするアルゴリズムです。 ハッシュとは元のデータを適当な関数を用いて短く変換したもので、元のデータが変化すると変換結果も変わります。
ローリングハッシュハッシュでは文字列を単一の整数値に変換します。このとき同一の文字列は常に同一の整数に変換されるため、変換先が同じ数字なら元の文字列も同じ可能性が高い、ということになります。もちろんたまたま一致する可能性もありますが、変換先の整数がうまくばらけていればそのような確率は十分に下げることができます。
ローリングハッシュでハッシュ値を求める方法
基数bと除数Mとを決めると、文字列S=(s1,s2,…,sn)に対してそのハッシュ値H(S)は以下の通り計算されます。
H(S)=(i=1∑nsibn−i) mod M
s1からsnは各文字に対して割り当てられた整数値で、例えば文字コード等が使えます。 bやMは任意に選ぶことができますが、後述するハッシュ値の衝突問題を防ぐためにMはある程度大きい素数とするのが一般的です。 ただし、実装上M未満の整数二つの積がoverflowせず正しく計算できる必要があります。
重要な性質
このようなハッシュ値を使うと例えば長さnの文字列Sと、長さmの文字列Tがあったとして
H(S) bm+H(T)=H(S+T) mod M が成りたちます。
(SとTをこの順に並べた文字列をS+Tとします。X mod Mを 0≤X′<Mを満たし、Xとの差がMの倍数であるような整数X′を求める演算とします。)
この等式よりH(S)とH(T)から容易にH(S+T)が求められ、 また逆に H(S)とH(S+T)から容易にH(T)が求められます。
高速な文字列検索
長さnの文字列Sの中から、長さmの文字列Tを検索したいとします。
愚直にやるとSの1文字目からm文字目がTと一致するか調べ、Sの2文字目からm+1文字目がTと一致するか調べ...というように一つづつずらしていく方法が考えられます。一致判定をする際に最初の方で違うと分かればすぐにやめることができますが、最悪m文字目まで確認することになります。そうした場合にかかる時間はO(nm)となります。
ローリングハッシュではこれを確率的にではありますがO(n)で計算できます。確率的にというのはハッシュ値がたまたまかぶってしまう可能性(衝突の可能性)があるからです。
具体的にどうするかというと、まずSの先頭i文字からなる文字列をSi′として、S0′, S1′, S2′ ... Sn′のハッシュ値を求めておきます。 H(Si+1)=(H(Si) b +si+1) mod M を使って計算すればこの前計算はO(n)でできます。
C++による実装例
long long M = 1000000007;
long long b = 10007;
vector<long long> H_S_prime(string S) {
int n = S.size();
vector<long long> hash_S_prime;
hash_S_prime.push_back(0);
for (int i = 0; i < n; i++) {
hash_S_prime.push_back((hash_S_prime[i] * b + S[i]) % M);
}
return hash_S_prime;
}
次にSの i+1文字目から i+m文字目までがTと一致するかを調べます。 H(T)は定義に従って計算することでO(m)で求められます。 Sの i+1文字目から i+m文字目までで構成される文字列のハッシュ値はローリングハッシュの性質から
(H(Si+m)−H(Si) bm) mod M と求めることができます。
bmを先に計算すればこの計算はO(1)でできるので、iを動かしても全体でO(n)で一致判定を終わらせることができます。
C++による実装例
#include <iostream>
#include <vector>
using namespace std;
long long M = 1000000007;
long long b = 10007;
long long Hash(string T) {
int m = T.size();
long long hash_T = 0;
for (int i = 0; i < m; i++) {
hash_T = (hash_T * b + T[i]) % M;
}
return hash_T;
}
vector<long long> H_S_prime(string S) {
int n = S.size();
vector<long long> hash_S_prime;
hash_S_prime.push_back(0);
for (int i = 0; i < n; i++) {
hash_S_prime.push_back((hash_S_prime[i] * b + S[i]) % M);
}
return hash_S_prime;
}
int main(void) {
string S = "hello";
string T = "el";
vector<long long> S_prime = H_S_prime(S);
long long T_hash = Hash(T);
long long bm = 1;
for (int i = 0; i < T.size(); i++) {
bm = bm * b % M;
}
for (int i = 0; i <= S.size() - T.size(); i++) {
if (T_hash == (S_prime[i + T.size()] - S_prime[i] * bm % M + M) % M) {
cout << i + 1 << "文字目から" << i + T.size() << "文字目" << endl;
}
}
}
ハッシュ値衝突の危険性
ハッシュ値による一致判定は、異なる文字列に対するハッシュ値が被ってしまうことで正しい結果が得られなくなるという問題点があります。 文字列が衝突を意図しないものであることが保証される場合、bやMは固定してかまいません。衝突確率はn個のハッシュ値を計算したときにO(n2/M)となります。Mを大きくすれば衝突率は下がりますが、それでも十分でない場合はbとMの組を複数用意することで時間はその分かかるものの、さらに衝突率を下げることができます。 注意点として、bとMが互いに素でないと衝突率が上がってしまいます。例えばb=2,M=230とすると、文字列の末尾30文字でハッシュ値が決定してしまうことになりそれより先頭側の一致判定ができなくなります。またそれぞれの文字に対応するする数値が小さいとき、bをあまり小さくとると文字列が短いときにハッシュ値が小さい値に偏ります。bがMに近いときも似た問題が生じます。 衝突を意図した文字列が含まれうる場合は、これら注意点に加えてbとM、特にbを乱択するのが良いです。