Day 139-Q1 — Weiner's Algorithm(右から左へのオンライン接尾辞構築)

2026-08-31 赤色 Master / Phase 8+ ★★★★★★★★★ 右から左へのオンライン接尾辞トライ構築(Ukkonen法とは逆方向)

問題

文字列 $S$(長さ $N$、英小文字のみ)が与えられる。

Weiner のアルゴリズムは、接尾辞木を右から左へ、すなわち「$S$ の最後の文字だけからなる接尾辞」→「最後の2文字」→ … → 「$S$ 全体」の順に、1文字ずつ先頭に追加しながらオンラインに構築していく古典的な手法である(Ukkonen法が左から右に1文字ずつ「末尾に追加」するのと対照的な方向性を持つ)。

この問題では、簡略化のため「圧縮されていない接尾辞トライ(各エッジが1文字に対応する素朴なトライ木)」を、Weiner 法と同じ右から左への挿入順序で構築する。すなわち、

  • ステップ1: $S[N-1:]$(最後の1文字)をトライに挿入する
  • ステップ2: $S[N-2:]$(最後の2文字)をトライに挿入する
  • …
  • ステップN: $S[0:]$($S$ 全体)をトライに挿入する

とし、各ステップ終了時点でのトライの総ノード数(根を除く)を出力せよ。同じパスが既に存在する場合はノードを再利用し、新しく分岐する部分のみノードを追加する。

入力形式

S

制約

$1 \le |S| \le 2000$
$S$ は英小文字のみ

入出力例

入力例1

aab

出力例1

1 3 5

入力例2

aaa

出力例2

1 2 3

概念図

S="aab" を右から左へ挿入(Weiner順) Step1: "b" 挿入 → node数1 b Step2: "ab" 挿入 → node数3(+2) b a b Step3: "aab" 挿入 → node数5(+2) 根-a-a-b の経路で新規2ノード追加 (根-a は既存パスを再利用) 最終ノード数 = Sに含まれる相異なる非空部分文字列の総数 = 5

ヒント(段階的開示)

ヒント1(方向性)

トライ木への文字列挿入は、通常「左から右」に1文字ずつ辿りながら子ノードを作る操作として習う。この問題ではそれを「短い接尾辞から長い接尾辞へ」という時間軸の上で繰り返し行う点が特徴である。過去に挿入した接尾辞と共通する接頭辞がある場合、途中まではノードが再利用され、共通しなくなった地点から新しいノードが分岐する。

ヒント2(アプローチ)

各ステップで挿入する文字列は「現在のトライのルートから、その接尾辞の文字を1文字ずつ順にたどる」ことで処理できる。たどる途中で既存の子ノードがあればそれを使い、なければ新規ノードを1つ作ってノード数カウンタを1増やす、という操作を素直に実装すればよい。$N \le 2000$ なので、1回の挿入に $O(N)$ かけても全体で $O(N^2)$ となり、十分高速に動作する。

ヒント3(誘導)
children = [dict()]  # children[0] がルート
node_count = 0
results = []
for i in range(n - 1, -1, -1):
    cur = 0
    for ch in s[i:]:
        if ch in children[cur]:
            cur = children[cur][ch]
        else:
            children.append(dict())
            new_idx = len(children) - 1
            children[cur][ch] = new_idx
            cur = new_idx
            node_count += 1
    results.append(node_count)

最終的な node_count(最後のステップの値)は、文字列 $S$ に含まれる相異なる非空部分文字列の総数と一致する。これは「トライの各ノードが根からの経路として表す文字列と1対1対応する」ことから従う自然な帰結である。

模範解答 (Python)

import sys


def main():
    s = sys.stdin.readline().strip()
    n = len(s)

    children = [dict()]
    node_count = 0
    results = []

    for i in range(n - 1, -1, -1):
        cur = 0
        suffix = s[i:]
        for ch in suffix:
            nxt = children[cur].get(ch)
            if nxt is None:
                children.append(dict())
                nxt = len(children) - 1
                children[cur][ch] = nxt
                node_count += 1
            cur = nxt
        results.append(node_count)

    print(' '.join(map(str, results)))


main()

Step-by-Step 解説

1なぜ「右から左」なのか
Weiner のアルゴリズムの本質は、「$S$ の接尾辞すべて」を扱う構造を、1文字を文字列の先頭に追加するという操作だけでオンラインに更新し続けられる点にある。$S'=c+S$ としたとき、$S'$ の接尾辞集合は「$S$ の接尾辞集合すべて」と「$S'$ 自身(新しい接尾辞)」の和集合になる。右端から左へ処理を進めることは、常に「今処理した部分文字列全体を新しい接尾辞として追加する」ことに対応し、過去に追加した接尾辞は不変のまま保持される。
2トライへの挿入は接尾辞ごとに独立
各ステップで行うのは「現在のステップの接尾辞1本を、既存のトライに(可能な限り既存パスを再利用しながら)挿入する」という操作のみ。過去に追加した接尾辞のパスを壊す必要は一切ない(トライは追記専用の構造)。
3ノード数カウントの意味
新規ノードを作るのは「今までにこの接頭辞パスが一度も出現していなかった」場合のみである。したがって、ある時点でのノード総数は「これまでに挿入したすべての接尾辞に含まれる、相異なる非空接頭辞(=相異なる部分文字列)の総数」に正確に一致する。
4圧縮版(真の接尾辞木)との違い
真の Weiner 法は、単純なパス(分岐のない一本道)を1つの辺のラベル(範囲 [l, r))にまとめることで、ノード数を $O(N)$ に抑える。さらに「narrow search」「wide search」と呼ばれる、既存の木構造上の特定位置を高速に見つけるための手続きを使うことで、全体を償却 $O(N)$(アルファベットサイズ固定)で構築する。本問では学習のためにあえて圧縮を行わず、素朴なトライのままノード数の増加を観察することで、「右から左へのオンライン挿入」という構築順序の直感を掴むことを目的としている。
5計算量
1回の挿入に $O(|S[i:]|)$、全体で $\sum_{i=0}^{N-1}(N-i)=O(N^2)$。$N\le2000$ の制約下では最悪でも約 $2\times10^6$ 回程度の操作で済み、十分高速。

よくあるミス

ミス原因正しい書き方
挿入順序を左から右(Ukkonen式)にしてしまう接尾辞木というと Ukkonen 法のイメージが強く、方向を意識しない本問は「短い接尾辞→長い接尾辞」の順、すなわち文字列の右端から左端へ向かう順序で処理する
各ステップのノード数を「そのステップで新規作成した数」だけ出力してしまう累積カウントと増分カウントを混同するnode_count は毎ステップ加算されていく累積値であり、それをそのまま出力する
ルートノードもノード数に含めてしまうカウント初期値を1(ルート込み)にしてしまうルートは常にカウント対象外とし、初期値は0にする
同じ文字が連続する文字列(例: "aaa")で毎回新規ノードを作ってしまう子ノード探索を dict ではなく毎回新規作成のロジックで書いてしまうchildren[cur].get(ch) で既存ノードの有無を必ず確認してから新規作成する
出力例1が合わない場合、文字コードの扱いミスを疑いがち実際は「累積ノード数の数え方」を誤っていることが多い小さな例("aab" など)を手で1ステップずつトレースして確認する

次のステップ

  • 発展: 本問のトライを「一本道のパスを1つの辺にまとめる」形に圧縮し、真の接尾辞木(ノード数 $O(N)$)を構築するプログラムに拡張せよ。圧縮後のノード数が、Ukkonen法で構築した場合と一致することを確認せよ。
  • 発展2: 出力される最終値(全ステップ終了後のノード数)が、Suffix Automaton や Suffix Array + LCP 配列から計算した「相異なる部分文字列の総数」の公式と一致することを別解として確認し、3通りの手法の答えが一致することを検証せよ。

自己評価

自分の回答

気づき・メモ