ローリングハッシュ法

2024/07/18 C++

ローリングハッシュ法について

考え方

ローリングハッシュは文字列のハッシュ値(要約値)を高速に計算することで高速な文字列検索を可能にするアルゴリズムです。 ハッシュとは元のデータを適当な関数を用いて短く変換したもので、元のデータが変化すると変換結果も変わります。

ローリングハッシュハッシュでは文字列を単一の整数値に変換します。このとき同一の文字列は常に同一の整数に変換されるため、変換先が同じ数字なら元の文字列も同じ可能性が高い、ということになります。もちろんたまたま一致する可能性もありますが、変換先の整数がうまくばらけていればそのような確率は十分に下げることができます。

ローリングハッシュでハッシュ値を求める方法

基数bbと除数MMとを決めると、文字列S=(s1,s2,,sn)S=(s_1,s_2,…,s_n)に対してそのハッシュ値H(S)H(S)は以下の通り計算されます。

H(S)=(i=1nsibni) mod MH(S)=(\sum_{i=1}^{n} s_i b^{n-i})\ mod \ M

s1s_1からsns_nは各文字に対して割り当てられた整数値で、例えば文字コード等が使えます。 bbMMは任意に選ぶことができますが、後述するハッシュ値の衝突問題を防ぐためにMMはある程度大きい素数とするのが一般的です。 ただし、実装上MM未満の整数二つの積がoverflowせず正しく計算できる必要があります。

重要な性質

このようなハッシュ値を使うと例えば長さnnの文字列SSと、長さmmの文字列TTがあったとして

H(S)H(S) bm+H(T)=H(S+T) mod Mb^m+H(T)=H(S+T) \ mod\ M が成りたちます。

(SSTTをこの順に並べた文字列をS+TS+Tとします。X mod MX \ mod \ M0X<M0 \le X' < M を満たし、XXとの差がMMの倍数であるような整数XX'を求める演算とします。)

この等式よりH(S)H(S)H(T)H(T)から容易にH(S+T)H(S+T)が求められ、 また逆に H(S)H(S)H(S+T)H(S+T)から容易にH(T)H(T)が求められます。

高速な文字列検索

長さnnの文字列SSの中から、長さmmの文字列TTを検索したいとします。

愚直にやるとSSの1文字目からmm文字目がTと一致するか調べ、SSの2文字目からm+1m+1文字目がTTと一致するか調べ...というように一つづつずらしていく方法が考えられます。一致判定をする際に最初の方で違うと分かればすぐにやめることができますが、最悪mm文字目まで確認することになります。そうした場合にかかる時間はO(nm)O(nm)となります。

ローリングハッシュではこれを確率的にではありますがO(n)O(n)で計算できます。確率的にというのはハッシュ値がたまたまかぶってしまう可能性(衝突の可能性)があるからです。

具体的にどうするかというと、まずSSの先頭ii文字からなる文字列をSiS'_iとして、S0, S1, S2 ... SnS'_0, \ S'_1, \ S'_2 \ ... \ S'_nのハッシュ値を求めておきます。 H(Si+1)=(H(Si) b +si+1) mod MH(S_{i+1})=(H(S_i) \ b \ +s_{i+1}) \ mod \ M を使って計算すればこの前計算はO(n)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;
}
C++

次にSS i+1 \ i+1文字目から i+m \ i+m文字目までがTTと一致するかを調べます。 H(T)H(T)は定義に従って計算することでO(m)O(m)で求められます。 SS i+1 \ i+1文字目から i+m \ i+m文字目までで構成される文字列のハッシュ値はローリングハッシュの性質から

(H(Si+m)H(Si) bm) mod M(H(S_{i+m})-H(S_i) \ b^m) \ mod \ M と求めることができます。

bmb^mを先に計算すればこの計算はO(1)O(1)でできるので、iiを動かしても全体でO(n)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;
        }
    }
}
C++

ハッシュ値衝突の危険性

ハッシュ値による一致判定は、異なる文字列に対するハッシュ値が被ってしまうことで正しい結果が得られなくなるという問題点があります。 文字列が衝突を意図しないものであることが保証される場合、bbMMは固定してかまいません。衝突確率はnn個のハッシュ値を計算したときにO(n2/M)O(n^2 / M)となります。MMを大きくすれば衝突率は下がりますが、それでも十分でない場合はbbMMの組を複数用意することで時間はその分かかるものの、さらに衝突率を下げることができます。 注意点として、bbMMが互いに素でないと衝突率が上がってしまいます。例えばb=2,M=230b=2,M=2^{30}とすると、文字列の末尾30文字でハッシュ値が決定してしまうことになりそれより先頭側の一致判定ができなくなります。またそれぞれの文字に対応するする数値が小さいとき、bbをあまり小さくとると文字列が短いときにハッシュ値が小さい値に偏ります。bbMMに近いときも似た問題が生じます。 衝突を意図した文字列が含まれうる場合は、これら注意点に加えてbbMM、特にbbを乱択するのが良いです。