Day 125-Q1 — Suffix Automaton + BFS(最短非出現部分文字列)

2026-08-17 赤色 Master / Phase 8+ ★★★★★★★★★ Suffix Automatonの遷移DAG上でBFSにより、Sの部分文字列でない最短かつ辞書順最小の文字列を探す(CF986E型)

問題

文字列 $S$(長さ $N$)と整数 $K$($1 \le K \le 26$)が与えられる。$S$ はアルファベットの先頭 $K$ 文字のみからなる。

$S$ の部分文字列として一度も出現しない文字列(アルファベットは同じく先頭 $K$ 文字のみ使用可)のうち、最短で、同じ長さの候補が複数あるなら辞書順最小のものを求めよ。

(長さ $L$ の文字列は $K^L$ 通り存在し、$S$ の相異なる部分文字列は高々 $\frac{N(N+1)}{2}$ 個しかないため、答えは必ず有限の長さで求まることが保証される)

入力形式

N K
S

制約

$1 \le N \le 200000$
$1 \le K \le 26$
$S$ は先頭 $K$ 文字の小文字のみからなる長さ $N$ の文字列

入出力例

入力例1

4 2
abab

出力例1

aa

長さ1の "a","b" はどちらも出現。長さ2の "ab","ba" は出現するが "aa","bb" は出現しない。辞書順最小は "aa"

入力例2

2 2
aa

出力例2

b

"b" 自体が1文字も出現していないので、長さ1で答えが決まる

入力例3

1 1
a

出力例3

aa

アルファベットは "a" のみ。長さ1の "a" は出現済みなので長さ2の "aa" が答え

概念図

S="abab", K=2 の Suffix Automaton 遷移DAG(root からBFS) root a "a" b "b" b "ab" a a→"aa" 遷移なし! BFS: root(深さ0)→"a","b"(深さ1、a→bで探索) → 状態"a"から文字'a'への遷移が存在しない → 答え = "a"+"a" = "aa"(深さ2で最初に発見) BFSは深さの浅い順・各状態で文字'a'から順に調べるため、最初に見つかった欠損遷移が最短かつ辞書順最小を保証する

ヒント(段階的開示)

ヒント1(方向性)

すべての部分文字列を陽に列挙して集合に入れ、短い文字列から順に $K^L$ 通り全探索して判定する方法は $O(N \cdot K^L)$ 程度かかり、$L$ が大きくなると間に合わない。$S$ の部分文字列全体を効率よく管理できるデータ構造として、Suffix Automaton(接尾辞オートマトン)の遷移構造そのものを「文字列 → 状態」の対応表とみなせないかを考える。

ヒント2(アプローチ)

Suffix Automatonは、初期状態(root)から文字を1つずつ辿る遷移 next[state][c] を持ち、root から辿り着ける遷移パスはちょうど $S$ の部分文字列全体(空文字列含む)に1対1で対応する。したがって、rootから幅優先探索(BFS)で遷移をたどり、ある状態からある文字 $c$ への遷移が存在しない瞬間を捉えれば、そこまでのパス文字列 + $c$ が「$S$ の部分文字列として出現しない」文字列になる。BFSなので最初に見つかったものが最短、かつ同じ深さの中で文字を 'a' から順に試せば辞書順最小も同時に保証される。

ヒント3(誘導)

BFSでは文字列そのものを持ち回らず、各状態に「どの状態から」「どの文字で」到達したか(parent, pchar)だけを記録し、見つかった時点で逆順にたどって復元するとメモリ・時間が節約できる。

from collections import deque

visited = [False] * size
visited[0] = True
parent = [-1] * size
pchar = [-1] * size
dq = deque([0])
while dq:
    u = dq.popleft()
    for c in range(K):
        v = nxt[u][c]
        if v == -1:
            # ここで S の部分文字列にない文字列が見つかる
            ...
        elif not visited[v]:
            visited[v] = True
            parent[v] = u
            pchar[v] = c
            dq.append(v)

模範解答 (Python)

import sys
from collections import deque


def solve():
    data = sys.stdin.read().split()
    n = int(data[0])
    k = int(data[1])
    s = data[2]

    MAXN = 2 * n + 5
    length = [0] * MAXN
    link = [-1] * MAXN
    nxt = [[-1] * k for _ in range(MAXN)]
    size = 1
    last = 0

    def sa_extend(c):
        nonlocal last, size
        cur = size
        size += 1
        length[cur] = length[last] + 1
        p = last
        while p != -1 and nxt[p][c] == -1:
            nxt[p][c] = cur
            p = link[p]
        if p == -1:
            link[cur] = 0
        else:
            q = nxt[p][c]
            if length[p] + 1 == length[q]:
                link[cur] = q
            else:
                clone = size
                size += 1
                length[clone] = length[p] + 1
                link[clone] = link[q]
                nxt[clone] = nxt[q][:]
                while p != -1 and nxt[p][c] == q:
                    nxt[p][c] = clone
                    p = link[p]
                link[q] = clone
                link[cur] = clone
        last = cur

    for ch in s:
        sa_extend(ord(ch) - 97)

    visited = [False] * size
    visited[0] = True
    parent = [-1] * size
    pchar = [-1] * size
    dq = deque([0])

    found_u = -1
    found_c = -1

    while dq and found_u == -1:
        u = dq.popleft()
        for c in range(k):
            v = nxt[u][c]
            if v == -1:
                found_u = u
                found_c = c
                break
            if not visited[v]:
                visited[v] = True
                parent[v] = u
                pchar[v] = c
                dq.append(v)

    path = [found_c]
    u = found_u
    while u != 0:
        path.append(pchar[u])
        u = parent[u]
    path.reverse()

    print(''.join(chr(97 + c) for c in path))


solve()

Step-by-Step 解説

1Suffix Automatonの遷移DAGと部分文字列の対応
Suffix Automaton は $S$ の全ての部分文字列(空文字列含む)を、rootから始まる遷移パスとして過不足なく表現するオートマトンである。状態数・遷移数はともに $O(N)$ に収まる。ある文字列 $T$ が $S$ の部分文字列である $\iff$ root から $T$ の各文字をたどって遷移が最後まで存在する。
2BFSで「遷移が途切れる最初の瞬間」を探す
「$S$ の部分文字列でない最短文字列」を求めることは、「root から遷移をたどっていって、ある文字で遷移が存在しなくなる、最も浅い(=最短)分岐点」を探すことと同値である。BFSは深さの浅い順に状態を処理するため、最初に遷移欠損を見つけた時点が最短の答えを与える。
3辞書順最小の保証
各状態の子を処理する際、文字を 'a' から順に試すことで、同じ深さのBFSレベル内で「どの文字列がキューに先に入るか」が辞書順と一致する。これにより、同じ長さの中で最初に見つかった遷移欠損が辞書順最小のものになる。
4パスの復元
文字列を都度連結すると計算コストがかさむため、parent(どの状態から来たか)と pchar(どの文字で来たか)だけを保存し、答えが見つかった時点で逆順にたどって $O(\text{答えの長さ})$ で復元する。
5計算量
Suffix Automaton構築は $O(N \cdot K)$、BFSも全状態・全文字を高々1回ずつ調べるので $O(N \cdot K)$。合計 $O(NK)$ で、$N \le 2\times10^5$, $K \le 26$ でも十分高速に動作する。

よくあるミス

ミス原因正しい書き方
文字列を都度連結してBFSする計算量を軽視parent/pchar で経路だけ保存し最後に復元する
DFSで探索してしまう「最短」を優先度探索と誤解必ずBFS(幅優先)を使う。深さの浅い順に処理することが最短性の根拠
クローン状態を遷移DAGの対象から除外してしまうクローン状態は「本物の文字列に対応しない」という誤解クローン状態も実在の部分文字列に対応するため、通常の状態と同様にBFSの対象に含める
$K$ 未満の文字だけ調べて次の文字を見落とすループ範囲を26固定にしてしまう必ず入力の $K$ の範囲でループする(range(K)

次のステップ

  • 発展: 答えの文字列の個数(同じ長さの非出現文字列が何通りあるか)を求める問題に拡張できる。BFSの最初のレベルで遷移欠損が複数見つかった場合にすべて数え上げればよい。
  • 次回予告: 区間グラフの彩色(Interval Graph Coloring・完全グラフ性質を用いた最小色数の構成)

自己評価

自分の回答

気づき・メモ