メインコンテンツまでスキップ

Rolling Hash

Rolling Hash とは

Rolling Hash とは文字列検索を高速で行うことのできるアルゴリズムであり、基本となる考え方は文字列を 1 つの値(hash 値)にすることである。

アルゴリズム

aは 1, bは 2 の様に a \sim z に 1 \sim 26 の番号をつけるとする。abという文字列を数字で表すことを考えると様々な形で表すことができる。

ab \rightarrow 1 + 2 = 3として考えるとab3と表すことができる。

しかし、このように数値化を行うとbaという文字列も2 + 1 = 3となり、ba3となり、3がどちらの文字列を表しているのか分からなくなってしまう。 そのため、文字列 \rightarrow 数字への変化をする際には数字が一意になるように選択する必要がある。

そこで文字列 \rightarrow 数字(ハッシュ化)への変化の式を次のように定義する。

Nn={0ifn=0(文字を数値化+Nn1m)%modotherwiseN_{n} = \begin{cases} 0 &\text{if} \,\, n = 0 \\ (文字を数値化 + N_{n-1} * m) \% mod &\text{otherwise} \end{cases}

ここでNnN_{n}は n 文字を数値化した値である。mmは基準値であり、modmodは数が大きくなるのを防ぎ、ハッシュ値に変換した際に取りうる範囲を表している。 modmodが小さいとハッシュ化したい際に取りうる範囲が小さいため、上記のabbaが同じ値をとるような衝突が起こる。 上記の式のmmを 31, modmod109+710^{9}+7 としてab, baを考えた場合、ab

  1. a = 1 + 0 * 31 = 1
  2. ab = 2 + 1 * 31 = 33

ba

  1. b = 2 + 0 * 31 = 2
  2. ba = 1 + 2 * 31 = 63

となり、違う値になっていることがわかる(数が小さいのでmodmodを省略)。 また、この様に文字列 \rightarrow 数値のハッシュ関数を定義すると、長い文字列 N の文字の途中の a 番目から b 番目の文字列のハッシュ値も高速で求めることが可能となっている。

上記のハッシュ値を求めるの式の文字を数値化部分に文字を入れて順に式を書いてみると次の様になる(以下の式の A,B,C,D,E には文字列を数値化した値が代入される)。

N0=0N1=A+0×m=AN2=B+A×m=B+AmN3=C+(B+Am)×m=C+Bm+Am2N4=D+(C+Bm+Am2)×m=D+Cm+Bm2+Am3N5=E+(D+Cm+Bm2+Am3)×m=E+Dm+Cm2+Bm3+Am4\begin{align*} &N_{0} = 0 \\ &N_{1} = A + 0 \times m = A \\ &N_{2} = B + A \times m = B + Am \\ &N_{3} = C + (B + Am) \times m = C + Bm + Am^{2} \\ &N_{4} = D + (C + Bm + Am^{2}) \times m = D + Cm + Bm^{2} + Am^{3} \\ &N_{5} = E + (D + Cm + Bm^{2} + Am^{3}) \times m = E + Dm + Cm^{2} + Bm^{3} + Am^{4} \end{align*}

N5N_{5}の時とはabcdeのような前から 5 文字のハッシュ値がN5N_{5}となることを意味している。この時にcdeのハッシュ値を求めることを考えた際に上記のように

N0=0N1=C+0×m=CN2=D+C×m=D+CmN3=E+(D+Cm)×m=E+Dm+Cm2\begin{align*} &N'_{0} = 0 \\ &N'_{1} = C + 0 \times m = C \\ &N'_{2} = D + C \times m = D + Cm \\ &N'_{3} = E + (D + Cm) \times m = E + Dm + Cm^{2} \\ \end{align*}

前から順に計算してN3N'_{3}を求める必要はなく、NnN_{n}をうまく活用することで求めることができる。N5N_{5}にはN5=E+Dm+Cm2+Bm3+Am4N_{5} = E + Dm + Cm^{2} + Bm^{3} + Am^{4}となっているので求めたいN3N'_{3}が含まれていることがわかる。そのため、余分なBm3+Am4Bm^{3} + Am^{4}を引くことで

N3=N5(Bm3+Am4)=E+Dm+Cm2N'_{3} = N_{5} - (Bm^{3} + Am^{4}) = E + Dm + Cm^{2}

となる。ここでBm3+Am4Bm^{3} + Am^{4}N2×m3=(B+Am)×m3=Bm3+Am4N_{2} \times m^{3} = (B + Am) \times m^{3} = Bm^{3} + Am^{4}で求めることができる。 N2N_{2}はすでに求めているので問題ないが、m3m^{3}はどうすれば良いかを考えると、N2N_{2} \rightarrow N5N_{5} まで増加させる際に掛け合わせるmmの個数は52=35 - 2 = 3である。 このN2N_{2} \rightarrow N5N_{5}N3,4,5N_{3,4,5}cdeの様な計算に使用した部分の個数であると見ることができ、その個数分mmの累乗を取れば良い。

この様に一度NNのハッシュ値を求めていると任意の区間のハッシュ値を O(1)で求めることが可能となる。

比較

abcabcdabcabcの文字列の中にabcdが存在するか確認する場合に、ハッシュ値を使う場合と総当たりで確認する場合で検索回数に違いがあるかを比較する。 ハッシュ値を行う方法についてはabcabcdabcabcのハッシュ値N13N_{13}まではすでに求めているものとして考える。

総当たり

総当たりでの確認方法はabcabcdabcabcの 1 文字目のaabcdの 1 文字目が同じか判定する。ともにaとなっているので一致していることが分かる。

一致していたので、続けて 2 文字目を比較するとともにbであり、一致している。同様に 3 文字目もcで一致しているが、4 文字で異なっている。

総当たり確認1

abcabcdabcabcの 1~4 文字目にはabcdが存在していないことがわかったので、次に開始地点を 2 文字目から確認する。 abcabcdabcabcの 2 文字目もbであり、abcdの 1 文字目はaで異なっている。

総当たり確認2

そのため、abcabcdabcabcの 2 文字目スタートする文字列にはabcdが含まれていないことがわかる。 同様に 3 文字目開始も含まれていないことがわかる。

総当たり確認3

abcabcdabcabcの 4 文字目から開始するとabcdの文字列が存在することがわかる。

総当たり確認4

総当たりでの計算量は 0(NM)となる。

Rolling Hash

N13N_{13}まではすでに求められている(m=31,mod=109+7m=31, mod = 10^{9} + 7, a \sim z = 1 \sim 26 として計算)ので、abcdのハッシュ値を求めると、31810(m=31,mod=109+7m=31, mod = 10^{9} + 7, a \sim z = 1 \sim 26 として計算)となる。

Nハッシュ値
11
233
31026
431807
5986019
630566592
7947564356
8374494834
9609339779
10889533026
11575523618
12841232041
1378193092

最初の文字からは一致していないことがわかる。

Rolling Hash1

2 文字目スタート, 3 文字目スタートも一致していないことがわかる。

Rolling Hash2

Rolling Hash3

4 文字目スタートだとハッシュ値が一致していることがわかる。

Rolling Hash4

Rolling Hash を用いると全体計算量が O(N + M)となる。(それぞれの文字列をハッシュ化するの時間がかかる。)

プログラム

rolling-hash.py
class RollingHash():
def __init__(self, S, base=317, mod=1 << 61 - 1):
self.S = S
self.mod = mod
self.pow_base = [1]
self.hash = [0]
for i in range(len(self.S)):
self.hash.append((self.hash[-1] * base + ord(self.S[i])) % self.mod)
self.pow_base.append((self.pow_base[-1] * base) % self.mod)

def get(self, l, r):
return (self.hash[r] - self.hash[l]*self.pow_base[r-l]) % self.mod