Day 166-Q3 — 2026-09-30 | 回文への分割の個数(回文木と series link で O(N log N))

2026-09-30 赤色 Master (Phase 8+) ★★★★★★★★★ 9/30 のバックフィル分 DP の高速化(焦点帯 M1)。回文接尾辞が等差数列をなす区間にまとまることを使い、直前の等差数列の和を使い回して総和を取る。素朴な全接尾辞の走査は $a^N$ で $O(N^2)$ になる

問題

英小文字からなる文字列 $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 解説

1回文木を作る

節点 0 を長さ $-1$、節点 1 を長さ 0 の根とし、文字 $c$ を足すたびに、最長の回文接尾辞 last から接尾辞リンクをたどって、前に $c$ をつけて回文になる最初の節点 $v$ を探す。その節点から $c$ の辺が無ければ新しい節点を作り、接尾辞リンクは $v$ の接尾辞リンクの先からもう一度同じ探索をして決める。節点数は高々 $N$ で、追加の償却は $O(N)$。

2等差数列の塊(diff と series link)

節点 $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}$ はその数列を飛び越した先。

3塊の寄与を使い回す

塊の先頭が $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]$ だけ、というのが更新式の意味。

4位置ごとの総和

位置 $i$ で last から slink をたどり、塊ごとに $\mathrm{series}[v]$ を求めて足す。塊の数は $O(\log N)$。$\mathrm{ans}[0]=1$(空の分割)。$a^N$ の答えは $2^{N-1}$ なので、実際の値 pow(2, 199999, 998244353)=895248717 と一致することも確認した。

5$\mathrm{series}[\mathrm{link}[v]]$ の項を落とすとどう誤るか

落とすと塊の先頭 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 ら)の確認、最小分割版、偶数長版

自己評価

自分の回答

気づき・メモ