D - Pre-Palindrome 解説 by iastm

高速化について

Rolling hash を使った高速化を解説します。Rolling hash に関する資料は以下があります。

参考資料

以下を定義しておきます。

  • 文字列 \(s\) の長さを \(|s|\) と表す
  • 文字列 \(s\)\(i\) 文字目を \(s_i\) と表す
  • 文字列 \(s\) の部分文字列 \(s_\ell s_{\ell+1}\dots s_r\)\(s[\ell:r]\) と表す
  • 文字列 \(s\) を反転して得られる文字列を \(\text{rev}(s)\) と表す
  • 文字列 \(s\) の非空部分文字列であって、良い文字列であるものを \(s\) の良い部分文字列と呼ぶ

Manacher’s algorithm と同様に、\(S\) の両端と文字の間に番兵文字|を挿入して得られる文字列 \(T\) を考えます。例えば \(S=\)ababaの場合、\(T=\)|a|b|a|b|a|です。\(S\) の各良い部分文字列 \(s\) は、\(T\) の次の \(2\) 通りの部分文字列に対応します。

  • \(s_1\)|\(s_2\)|\(\dots\)|\(s_{|s|}\)
  • |\(s_1\)|\(s_2\)|\(\dots\)|\(s_{|s|}\)|

したがって、\(s\) に対応する良い部分文字列は \(T\) において \(2\) 回ずつ数えられるため、総数から重複を引けば答えが求まります。空文字列は|にも対応することに注意してください。また、\(T\) の良い部分文字列は全て奇数長であるため、場合分けを省略することができます。

\(T\) に対し、次のアルゴリズムが考えられます。

  • 良い部分文字列の総数を表す変数 \(\mathtt{ans}\gets0\) を初期化する。
  • \(i\in\lbrace1, 2, \dots, |T|\rbrace\) について、\(i\) を中心とする良い部分文字列を以下の手順で数える。
    1. \(T[i+1:i+k_1]=\text{rev}(T[i-k_1:i-1])\) を満たす最大半径 \(k_1\) を二分探索で求める。\(\mathtt{ans}\)\(k_1+1\) を加算する。
      • ここで半径 \(k_1\) 以下の部分文字列は全て回文である。
    2. \(\ell=i-k_1-1\)\(r=i+k_1+1\) とする。\(1\leq\ell\) かつ \(r\leq|T|\) なら、\(T[r+1:r+k_2]=\text{rev}(T[\ell-k_2:\ell-1])\) を満たす最大の整数 \(k_2\) を二分探索で求め、\(\mathtt{ans}\)\(k_2+1\) を加算する。
      • ここで半径 \(k_1+k_2+1\) 以下の部分文字列は、 \(1\) 文字を書き換えることで回文にすることができる。
  • \(\mathtt{ans}\) を出力する。

文字列 \(s, t\) に対し \(s=\text{rev}(t)\) の判定は rolling hash を使えば効率的にできます。Rolling hash に関しては以下のような前計算が必要です。

  • 素数 \(P\) と正整数 \(K\) を選び、\(P\) 未満のランダムな非負整数 \(b_1, \dots, b_K\) を一様かつ独立に選ぶ。
  • \(T\)\(\text{rev}(T)\) を連結して得られる文字列を \(X\) とする。
  • \(k\in\lbrace1, 2, \dots, K\rbrace\) について、以下を行う。
    • \(h_{k, 0}=0\)\(p_{k, 0}=1\) とする。
    • \(i\in\lbrace1, 2, \dots, |X|\rbrace\) について、\(h_{k, i}=(h_{k, i-1}\cdot b_k+X_i)\text{ mod }P\)\(p_{k, i}=p_{k, i-1}\cdot b_k\text{ mod }P\) を求める。ここで \(X_i\) は文字を適当な整数に置き換えたものとする。
  • \(X\) の部分文字列 \(s=X[\ell:r]\) のハッシュを \((H_{s, 1}, \dots, H_{s, K})\) として定義する。ただし、\(H_{s, k}=(h_{k, r}-h_{k, \ell-1}\cdot p_{k, r-\ell+1})\text{ mod }P\) とする。
  • \(X\) の部分文字列 \(s, t\) のハッシュが一致するなら \(s=t\) とみなし、そうでないなら \(s\neq t\) と判定する。

以上より、全体の計算量は \(O(|S|K\log |S|)\) です。

実装例 (C++)


ハッシュ衝突の確率を評価します。長さ \(|T|\) 以下の異なる文字列 \(s, t\) が衝突を起こす場合、各 \(k\in\lbrace1, \dots, K\rbrace\) について \((H_{s, k}-H_{t, k})\text{ mod }P=0\) を満たす必要があります。ここで \(H_{s, k}-H_{t, k}\)\(b_k\) に関する \(|T|-1\) 次以下の非零多項式であり、その解は \(|T|-1\) 個以下であるため、一様かつ独立に選ばれたランダム整数 \(b_1, \dots, b_K\) が全て解である確率は \(\left(\frac{|T|-1}{P}\right)^K\) 以下です。

さらに、二分探索の途中で比較されるハッシュのペアは \(2|T|\lceil\log_2|T|\rceil\) 通り以下であるため、union bound より、アルゴリズム全体でハッシュ衝突が起きる確率は \(2|T|\lceil\log_2|T|\rceil\left(\frac{|T|-1}{P}\right)^K\) 以下です。

例えば \(|T|=10^6,P\gt10^9,K=10\) の場合、衝突の確率は \(10^{-20}\) 未満と見積もることができます。


余談ですが、次のように LCP array と sparse table(または segment tree)を使用した決定的なアルゴリズムも存在します。計算量は \(O(|S|\log|S|)\) です。

実装例 (C++, LCP + sparse table)

実装例 (C++, LCP + segment tree)

投稿日時:
最終更新: