Day 024-Q1 — Suffix Automaton 応用 (部分文字列数・辞書順 k 番目)

2026-05-07 赤色 Master / Phase 8+ ★★★★★★★★★ SAM / DAG DP

問題

文字列 $S$ に対して以下のクエリを処理:

  • COUNT: 異なる部分文字列の総数
  • KQUERY k: 辞書順 $k$ 番目の部分文字列

制約

$1 \le |S| \le 2 \times 10^5$
$1 \le Q \le 2 \times 10^5$
$1 \le k \le 10^{18}$

入出力例

入力例 1

aab
3
COUNT
KQUERY 1
KQUERY 5

出力例 1

5
a
b

aab の異なる部分文字列: a, aa, aab, ab, b の5個。

ヒント (段階的開示)

ヒント1: 方向性
Suffix Automaton (SAM) を構築。各状態 $v$ が表す部分文字列数 = $len[v] - len[link[v]]$。
ヒント2: アプローチ
SAM を DAG とみなし、トポロジカル順(len 降順)に dp[v] = v 以降到達可能な文字列数を計算。$k$ 番目は遷移を a→z 順に試して貪欲に降りる。
ヒント3: 誘導
root の dp[0] は空文字列を含むため -1 調整。

模範解答 (Python)

import sys
from sys import stdin
input = stdin.readline

def solve():
    S = input().strip()
    n = len(S)
    MAXN = 2 * n + 5

    nxt = [[-1] * 26 for _ in range(MAXN)]
    link = [-1] * MAXN
    length = [0] * MAXN
    last = 0
    size = 1

    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
                nxt[clone] = nxt[q][:]
                link[clone] = link[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) - ord('a'))

    order = sorted(range(size), key=lambda v: -length[v])
    dp = [0] * size
    for v in order:
        dp[v] = 1
        for c in range(26):
            nv = nxt[v][c]
            if nv != -1:
                dp[v] += dp[nv]
    dp[0] -= 1

    total = sum(dp[nxt[0][c]] for c in range(26) if nxt[0][c] != -1)

    def kth_query(k):
        v = 0
        result = []
        while k > 0:
            for c in range(26):
                nv = nxt[v][c]
                if nv == -1:
                    continue
                if dp[nv] >= k:
                    result.append(chr(ord('a') + c))
                    v = nv
                    k -= 1
                    break
                else:
                    k -= dp[nv]
        return ''.join(result)

    Q = int(input())
    out = []
    for _ in range(Q):
        line = input().split()
        if line[0] == 'COUNT':
            out.append(str(total))
        else:
            out.append(kth_query(int(line[1])))
    print('\n'.join(out))

solve()

Step-by-Step 解説

1SAM 構築
各文字を sa_extend で追加。状態は $O(|S|)$、各状態は等価クラスの部分文字列を代表。
2DAG DP
length 降順に dp[v] = 1 + sum dp[nxt[v][c]]。root は空文字列を含むため $-1$。
3COUNT
root の遷移先の dp 和。または $\sum_{v \ne 0} (len[v] - len[link[v]])$。
4KQUERY
root から a→z 順に試し、dp[nv] >= k なら進んで k -= 1、そうでなければ k -= dp[nv]

よくあるミス

ミス原因正しい書き方
root の dp を -1 しない空文字列を含むdp[0] -= 1
k 減算 off-by-one遷移時の消費忘れ遷移後 k -= 1
clone 作成時の nxt コピー忘れclone は q の遷移を引き継ぐnxt[clone] = nxt[q][:]

次のステップ

  • Generalized SAM で複数文字列の共通部分文字列数
  • Suffix Array + LCP との比較

自己評価

自分の回答

気づき・メモ