問題
英小文字からなる文字列 $S$ を、いくつかの回文に分割する方法(連続する部分文字列の列に切って、各部分文字列が回文になるもの)は何通りあるか。$998244353$ で割った余りを出力せよ。分割は切り方(どの位置で切るか)で区別する。
入力形式
S
制約
| # | 条件 |
|---|---|
| 1 | $1\le |S|\le2\times10^5$ |
| 2 | $S$ は英小文字のみ |
入出力例
入力例1
aab出力例1
2(a|a|b と aa|b)
入力例2
aaaa出力例2
8(どの位置で切るかが自由なので $2^3=8$)
入力例3
abcd出力例3
1(1 文字ずつ切るしかない)
背景と出典
回文木(eertree)は Rubinchik と Shur の 2015 年ごろの論文で提案されたデータ構造と記憶している(原典未確認。節点が回文、辺が両端への 1 文字の追加、接尾辞リンクが最長の回文真接尾辞)。回文への分割を回文木の上で速く数える方法は、Fici・Gagie・Kärkkäinen・Kempa(2014)の「回文接尾辞は少数の等差数列にまとまる」という結果を元にしたもの、と記憶している(未確認)。Codeforces 932G(Palindrome Partition)が同じ手法を使う、とも記憶しているが、これも確認していない。
この問題で確かめたいのは次の 3 点。(1) series link の更新式が全接尾辞を足す $O(N^2)$ の DP と一致すること。(2) diff[v]==diff[link[v]] の項を落とすとほぼ全件で誤ること。(3) $a^N$ のような最悪入力でも $2\times10^5$ 文字が数秒以内に終わること。
ヒント(クリックで段階的に開示)
ヒント1(方向性)
$\mathrm{ans}[i]$ を「先頭から $i$ 文字目までの分割の個数」とすると、$\mathrm{ans}[i]=\sum \mathrm{ans}[i-|P|]$($P$ は位置 $i$ で終わる回文接尾辞すべて)。回文接尾辞は多いと $O(N)$ 個あるので、これを 1 つずつ足すと全体で $O(N^2)$。
ヒント2(アプローチ)
位置 $i$ の回文接尾辞を長い順に並べると、長さの差が一定の等差数列の塊に分かれ、塊の数は $O(\log N)$ 個。回文木の各節点 $v$ に、$\mathrm{diff}[v]=\mathrm{len}[v]-\mathrm{len}[\mathrm{link}[v]]$ と、「差が変わる最初の節点」への series link $\mathrm{slink}[v]$ を持たせる。
ヒント3(誘導)
塊の先頭 $v$ に対し、塊全体の寄与 $\mathrm{series}[v]=\mathrm{ans}[i-\mathrm{len}[\mathrm{slink}[v]]-\mathrm{diff}[v]]$。さらに $\mathrm{diff}[v]=\mathrm{diff}[\mathrm{link}[v]]$ なら、同じ塊が 1 つ前の位置でも出ていた(等差数列の続き)ので、その位置の値 $\mathrm{series}[\mathrm{link}[v]]$ を足す。$\mathrm{ans}[i]$ は塊ごとの $\mathrm{series}$ の和。
模範解答 (Python)
模範解答を表示する(まず自力で解いてから)
import sys
MOD = 998244353
def main():
s = sys.stdin.readline().strip()
n = len(s)
# 回文木(eertree)。節点 0 = 長さ -1 の根、節点 1 = 長さ 0 の根
ln = [-1, 0]; link = [0, 0]; diff = [0, 0]; slink = [0, 0]
nxt = [dict(), dict()]
series = [0, 0]
ans = [0]*(n+1); ans[0] = 1
last = 1
for i in range(1, n+1):
c = s[i-1]
v = last
while True:
l = ln[v]
if i-2-l >= 0 and s[i-2-l] == c: break
v = link[v]
if c in nxt[v]:
last = nxt[v][c]
else:
u = len(ln)
ln.append(ln[v]+2); nxt.append(dict())
if ln[u] == 1:
link.append(1)
else:
w = link[v]
while True:
l = ln[w]
if i-2-l >= 0 and s[i-2-l] == c: break
w = link[w]
link.append(nxt[w][c])
nxt[v][c] = u
diff.append(ln[u]-ln[link[u]])
slink.append(link[u] if diff[u] != diff[link[u]] else slink[link[u]])
series.append(0)
last = u
v = last
tot = 0
while ln[v] > 0:
# v から slink[v] の手前までの回文接尾辞の列(公差 diff[v])をまとめて足す
series[v] = ans[i-ln[slink[v]]-diff[v]]
if diff[v] == diff[link[v]]:
series[v] += series[link[v]]
tot += series[v]
v = slink[v]
ans[i] = tot % MOD
print(ans[n])
main()
計算量: 時間 $O(N\log N)$(series link をたどる回数が位置あたり $O(\log N)$ という既知の補題に依存。本セッションでは証明していない)、空間 $O(N)$(節点数は $\le N+2$、辺は辞書)。実測(Python 3.14、M1 Mac)で $N=2\times10^5$ の a×$N$ が 1.57 s、ab×$10^5$ が 1.61 s、ランダム 3 文字が 0.63 s。
Step-by-Step 解説
節点 0 を長さ $-1$、節点 1 を長さ 0 の根とし、文字 $c$ を足すたびに、最長の回文接尾辞 last から接尾辞リンクをたどって、前に $c$ をつけて回文になる最初の節点 $v$ を探す。その節点から $c$ の辺が無ければ新しい節点を作り、接尾辞リンクは $v$ の接尾辞リンクの先からもう一度同じ探索をして決める。節点数は高々 $N$ で、追加の償却は $O(N)$。
節点 $v$ について $\mathrm{diff}[v]=\mathrm{len}[v]-\mathrm{len}[\mathrm{link}[v]]$、$\mathrm{slink}[v]=\mathrm{link}[v]$($\mathrm{diff}[v]\ne\mathrm{diff}[\mathrm{link}[v]]$ のとき)か $\mathrm{slink}[\mathrm{link}[v]]$(等しいとき)。$v,\mathrm{link}[v],\mathrm{link}^2[v],\dots$ のうち $\mathrm{diff}$ が同じ間は 1 つの等差数列。$\mathrm{slink}$ はその数列を飛び越した先。
塊の先頭が $v$(長さ $L$、差 $d$)のとき、塊の回文接尾辞の長さは $L,L-d,L-2d,\dots,\mathrm{len}[\mathrm{slink}[v]]+d$。足したい値は $\mathrm{ans}[i-L]+\mathrm{ans}[i-L+d]+\cdots+\mathrm{ans}[i-\mathrm{len}[\mathrm{slink}[v]]-d]$。ここで、同じ塊が位置 $i-d$ でも $\mathrm{link}[v]$ を先頭として出ていて、その和が $\mathrm{series}[\mathrm{link}[v]]$ として手元にある。差は先頭の 1 項 $\mathrm{ans}[i-\mathrm{len}[\mathrm{slink}[v]]-d]$ だけ、というのが更新式の意味。
位置 $i$ で last から slink をたどり、塊ごとに $\mathrm{series}[v]$ を求めて足す。塊の数は $O(\log N)$。$\mathrm{ans}[0]=1$(空の分割)。$a^N$ の答えは $2^{N-1}$ なので、実際の値 pow(2, 199999, 998244353)=895248717 と一致することも確認した。
落とすと塊の先頭 1 項しか足さないので、aaaa のような塊が長い入力では不足する。実測で 300 件中 283 件が誤り。語彙ではなく項の有無で落ちることを数で確かめた。
迷った他案と採らなかった理由
| 案 | 計算量 | 採否 | 理由 |
|---|---|---|---|
| 回文木+series link(本解) | $O(N\log N)$ | 採用 | 全接尾辞を足す DP と 400 件一致。$N=2\times10^5$ が 1.6 s 以内 |
| 回文テーブルを作って全区間の DP | $O(N^2)$ | 検算用のみ | $N=3000$ でも 6.82 s(実測、文字列のスライス版) |
回文木で全回文接尾辞を link で 1 つずつ走査 | $O(N^2)$ | 不採用 | a×$N$ で接尾辞が $N$ 個。未実測だが、本解との差が series link の存在意義 |
| Manacher で回文の半径を出し、差分配列で足す | $O(N)$ か $O(N\log N)$ | 不採用 | 位置 $i$ で終わる回文の範囲は ans の値に依存して更新が難しい。実装を試していない(理由は推測) |
前提が変わったら:
- 回文への最小分割数を求める問題に変えても同じ枠(series link)で解けると記憶しているが、$\min$ に替えたときの $\mathrm{series}$ の更新は未検証。加算と違い等差数列の項を引けないため、本解の式をそのままは使えない
- 偶数長の回文だけで分割する(Codeforces 932G の形)なら、偶数番目の位置だけで $\mathrm{ans}$ を数える。未実装
- 文字種が多く辞書が重いなら、辺を配列にするか、文字をまとめて持つ。本解は辞書で $2\times10^5$ 文字が実測 0.6〜1.6 s
よくあるミス
| ミス | 原因 | 正しい書き方 |
|---|---|---|
| 塊の先頭 1 項だけを足す | series[link[v]] の項を忘れる | diff が等しいときに series[link[v]] を足す |
slink を link と取り違える | 差が同じ間は 1 つの塊 | diff[u] != diff[link[u]] のときだけ link[u] |
| $\mathrm{ans}[0]=1$ を忘れる | 空の分割を数えない | ans[0] = 1 |
series を MOD で取らずに足し続ける | Python では大きな整数で動くが、C++ などではあふれる | 実装によっては series も MOD で取る |
次のステップ
- 発展1: 回文への最小分割数($\min$ 版)を同じ枠で解く(更新式を自分で導く)
- 発展2: 偶数長の回文だけに分割する(Codeforces 932G 型)
- 次回予告: 焦点帯外(M2・M3)を含む通常のローテーション
検証ログ(本セッションで実行した数値)
- 全接尾辞を足す $O(N^2)$ の DP との照合(実測): 長さ 1〜60 の 400 件(3 種類以内のランダムと、周期的な文字列)で全件一致
- 誤った式(実測):
series[link[v]]の項を落とすと、長さ 40 以下の 300 件中 283 件で誤り - 入力例(実測):
aabは 2、aaaaは 8、abcdは 1(出力例どおり)。a×$N$ は $2^{N-1}$ と一致($N=2\times10^5$ で 895248717) - 実行時間(実測):
a×$2\times10^5$ が 1.57 s、ab×$10^5$ が 1.61 s、ランダム 3 文字が 0.63 s、aab×$66666$ が 0.93 s - 扱っていない範囲: series link の個数が $O(\log N)$ であることの証明、原典(Rubinchik–Shur、Fici ら)の確認、最小分割版、偶数長版