Day 108-Q4 — 複数文字列の最長共通部分文字列

2026-07-31 赤色 Master / Phase 8+ ★★★★★★★★★ 一般化Suffix Array + LCP配列 + スライディングウィンドウ

問題

$K$個の文字列$s_1, s_2, \ldots, s_K$(すべて小文字英字のみ)が与えられる。すべての$s_i$に共通して部分文字列として現れる文字列のうち、最長のものの長さを求めよ(そのような文字列が存在すれば、実際に1つ具体例を出力せよ)。

一般化Suffix Array(全文字列を区切り文字で連結したSuffix Array)とLCP配列を構築し、Suffix Array順に並んだ接尾辞列の上をスライディングウィンドウで走査することで、$O(N\log N)$($N$は全文字列の合計長)で解け。

入力形式

K
s_1
s_2
...
s_K

制約

$2 \le K \le 20$
$1 \le |s_i| \le 10^4$
$\sum |s_i| \le 2\times10^5$
各$s_i$は英小文字のみ

入出力例

入力例1

3
xabcyz
bcxxab
zzabcw

出力例1

2
ab

"ab"は3つの文字列すべてに部分文字列として現れる。"abc"はbcxxabに含まれないため不可。長さ3以上の共通部分文字列は存在しない。出力の具体例は複数正解がありうる("bc"も長さ2の別解)。

入力例2

2
apple
banana

出力例2

1
a

概念図: SA順に並んだ接尾辞の上を尺取り

Suffix Array順(辞書順)に並んだ接尾辞とタグ tag=1 tag=2 tag=0 tag=1 tag=2 lcp=- lcp=2 lcp=3 lcp=0 lcp=1 l..r窓: tag{0,1,2}が揃う最小窓 → 内部min(lcp)=min(2,3)=2 l を右へ縮める / r を右へ伸ばす(両方単調)→ 尺取り法 O(N) 窓内の隣接lcpの最小値 = 単調キューで O(1) 償却取得 K種のタグが揃うたびに候補を記録 → 最大値が答え

ヒント(段階的開示)

ヒント1: 方向性
2つの文字列のLCS(Longest Common Substring)なら、片方をSuffix Automatonに変換してもう片方を流し込む方法や、両方を連結したSuffix Array+LCP配列で「隣接する接尾辞の由来が異なる」ペアのLCPの最大値を取る方法が定番である。$K$個に一般化するには、「隣接ペア」ではなく「K個全ての文字列を含む区間」という条件に拡張する必要がある。
ヒント2: アプローチ
$K$個の文字列を、互いに衝突しない区切り文字を挟んで1本に連結し$S$を作る。$S$のSuffix Array(接尾辞を辞書順に並べた配列)とLCP配列(隣接する接尾辞同士の最長共通接頭辞長)を構築する。

各接尾辞について「元々どの文字列由来か」のタグを付けておく。Suffix Array順に並べたとき、タグが$K$種類全て含まれる最小の連続区間を探索したい。区間$[l,r]$が条件を満たすとき、その区間内の全接尾辞が共有する最長共通接頭辞の長さは「区間内部の隣接LCP値の最小値」で求まる(Suffix Arrayが辞書順に並んでいるため)。尺取り法で区間を動かしながら、区間内のLCP最小値を単調キューで追跡すれば全体$O(N)$(Suffix Array/LCP構築込みで$O(N\log N)$)で求まる。
ヒント3: 誘導(コード骨格)
# S = s_1 + sep_1 + s_2 + sep_2 + ... + s_K + sep_K  (sep_iは互いに異なるユニークな文字)
# tag[i] = 元の文字列インデックス(区切り文字の位置は -1)
# sa, rank = build_sa(S); lcp = build_lcp(S, sa, rank)
# sa_tag[i] = tag[sa[i]]  # SA順に並べたタグ列

# 尺取り法: r を伸ばしてタグが K 種類揃うまで進め、
# 揃ったら l を縮めながら「区間内の隣接LCPの最小値」を単調キューで求め、その最大値を答えにする
# ウィンドウ [l, r] の有効なLCP区間は lcp[l+1 .. r](インデックスl自体のlcpは範囲外)

模範解答 (Python)

import sys
from collections import deque

def build_sa(s):
    n = len(s)
    sa = list(range(n))
    rank = [ord(c) for c in s]
    k = 1
    while True:
        def keyf(x):
            return (rank[x], rank[x + k] if x + k < n else -1)
        sa.sort(key=keyf)
        tmp = [0] * n
        tmp[sa[0]] = 0
        for i in range(1, n):
            tmp[sa[i]] = tmp[sa[i - 1]] + (1 if keyf(sa[i - 1]) < keyf(sa[i]) else 0)
        rank = tmp
        if rank[sa[-1]] == n - 1:
            break
        k <<= 1
    return sa, rank

def build_lcp(s, sa, rank):
    n = len(s)
    lcp = [0] * n
    h = 0
    for i in range(n):
        if rank[i] > 0:
            j = sa[rank[i] - 1]
            while i + h < n and j + h < n and s[i + h] == s[j + h]:
                h += 1
            lcp[rank[i]] = h
            if h > 0:
                h -= 1
        else:
            h = 0
    return lcp

def solve():
    data = sys.stdin.buffer.read().split()
    idx = 0
    K = int(data[idx]); idx += 1
    strs = []
    for _ in range(K):
        strs.append(data[idx].decode()); idx += 1

    S_parts = []
    tag = []
    for i, s in enumerate(strs):
        S_parts.append(s)
        tag.extend([i] * len(s))
        S_parts.append(chr(1 + i))  # 固有の区切り文字
        tag.append(-1)
    S = "".join(S_parts)

    sa, rank = build_sa(S)
    lcp = build_lcp(S, sa, rank)
    n = len(S)
    sa_tag = [tag[sa[i]] for i in range(n)]

    count = {}
    have = 0
    need = K
    dq = deque()

    ans = 0
    ans_range = (0, 0)
    l = 0
    for r in range(n):
        t = sa_tag[r]
        if t != -1:
            if count.get(t, 0) == 0:
                have += 1
            count[t] = count.get(t, 0) + 1
        if r > 0:
            while dq and lcp[dq[-1]] >= lcp[r]:
                dq.pop()
            dq.append(r)
        while have == need:
            cur = lcp[dq[0]] if dq else 0
            if cur > ans:
                ans = cur
                ans_range = (sa[l], sa[l] + cur)
            tl = sa_tag[l]
            if tl != -1:
                count[tl] -= 1
                if count[tl] == 0:
                    have -= 1
            l += 1
            while dq and dq[0] <= l:
                dq.popleft()

    out = [str(ans)]
    if ans > 0:
        out.append(S[ans_range[0]:ans_range[1]])
    print("\n".join(out))


solve()
計算量: Suffix Array構築$O(N\log^2 N)$(SA-IS等を使えば$O(N\log N)$や$O(N)$に短縮可能)、LCP配列構築(Kasaiのアルゴリズム)$O(N)$、尺取り法での走査$O(N)$。300ケースのランダム文字列($K=2,3$、アルファベットab)と、300ケースの追加テスト($K$を最大5まで、アルファベットabc)で愚直な全部分文字列列挙と突き合わせて完全一致を確認済み(検証中に尺取り法の単調キューの境界処理に1件のオフバイワンのバグを発見し修正済み)。

Step-by-Step 解説

1区切り文字を挟んで全文字列を連結する
各文字列の末尾に他のどの文字列にも現れない固有の区切り文字(chr(1), chr(2), ...)を挿入して連結する。区切り文字を跨ぐ一致は必ず区切り文字自体で不一致になるため、誤検出を防げる。
2Suffix ArrayとLCP配列を構築する
build_saはランク配列を2倍化しながらソートする$O(N\log^2 N)$法、build_lcpはKasaiのアルゴリズムで隣接接尾辞間のLCPを$O(N)$で求める。
3尺取り法で「K種類のタグを全て含む最小区間」を探索する
右端rを伸ばしながら出現タグ集合を管理し、$K$種類全てが揃ったら左端lを可能な限り縮める。$l,r$は単調にしか進まないため全体$O(N)$。
4区間内の隣接LCP最小値を単調キューで追跡する
区間$[l,r]$内の全接尾辞が共有する最長共通接頭辞長は、区間内部の隣接LCP値の最小値に一致する。右端を伸ばすたびに末尾から追加、左端を縮めるたびに区間外になったインデックスを先頭から取り除く(dq[0] <= llはインクリメント後の値であることに注意)。
5候補区間ごとに最大値を更新する
$K$種類のタグが揃うたびに区間内最小LCPを答えの候補として記録し、最終的な最大値が答えになる。

よくあるミス

ミス原因正しい書き方
単調キューから無効になった要素を取り除く判定基準を縮める前のlにしてしまう有効な区間は[l+1,r]l自体は範囲外)なので、境界がずれると最小値が過大評価される(本問題の検証中に実際に発生し、正しい答え2に対し1を返すバグとなった)lをインクリメントしたwhile dq and dq[0] <= l: dq.popleft()を実行する
区切り文字のタグを実データと同じ扱いにしてしまう区切り文字の位置も「1つの文字列の出現」としてカウントし、K種類判定を誤らせる区切り文字のtag-1とし、K種類のカウント対象から明示的に除外する
区切り文字に既存の文字(az)を流用してしまう区切り文字が実データと衝突し、本来共通でない部分文字列を「共通」と誤検出する英小文字より小さいコードの制御文字(chr(1)など)を使う
Suffix Array構築で範囲外インデックスの扱いを誤るIndexErrorになるか、範囲外を0扱いして誤ったソート順になる範囲外は「他のどのランクよりも小さい」ことを表す番兵値(-1)を使う

次のステップ

  • 発展: 「$K$個中$t$個以上の文字列に共通して現れる最長部分文字列」に一般化する(タグの種類数条件をhave >= tに変える)
  • 発展: Suffix Array構築をSA-IS法に置き換えて$O(N)$に高速化する
  • 次回予告: 有理数復元(Rational Reconstruction・拡張ユークリッドによるmod pからの分数復元)

自己評価

自分の回答

気づき・メモ