問題
小文字英字からなる $K$ 個のパターン文字列 $P_1,\dots,P_K$ と、小文字英字からなる文字列 $T$ が与えられる。それぞれのパターン $P_i$ が $T$ の中に(重なりを許して)何回出現するかを求めよ。
入力形式
K
P_1
P_2
...
P_K
T
制約
$1 \le K \le 10^5$
$\sum |P_i| \le 2\times10^5$
$1 \le |T| \le 2\times10^5$
全て小文字英字のみ
入出力例
入力例1
3
ab
b
bc
ababc
出力例1
2
2
1
T=ababc で ab は位置0-1,2-3の2回、b は位置1,3の2回、bc は位置3-4の1回出現。
入力例2
2
a
aa
aaa
出力例2
3
2
概念図: fail木の部分木和 = パターンの出現回数
ヒント(段階的開示)
ヒント1: 方向性
パターンごとに $T$ を愚直に走査すると $O(K\cdot|T|)$ で間に合わない。Aho-Corasickオートマトンで $T$ を1回だけなぞって全パターンの出現を同時に検出したいが、各文字位置で一致したパターンを全部数え上げると最悪 $O(|T|^2)$ になりうる。
ヒント2: アプローチ
Aho-Corasickの各ノードはfailリンク(最長の真の接尾辞ノードへのリンク)を持つ。failリンクを親として見たfail木では「あるノードの文字列は子孫ノードの文字列の接尾辞」という関係が成り立つ。$T$をなぞって到達ノードに$+1$しておき、fail木上で部分木和を取れば各パターンの出現回数が一括で求まる。部分木和はオイラーツアー$[tin,tout]$ + BITで実現する。
ヒント3: 誘導(コード骨格)
cur = 0
for ch in T:
cur = goto[cur][ch]
bit_add(tin[cur], 1)
# 各パターンiの終端ノードv:
# 出現回数 = bit_range_sum(tin[v], tout[v])
模範解答 (Python)
import sys
from collections import deque
def main():
data = sys.stdin.buffer.read().split()
idx = 0
K = int(data[idx]); idx += 1
patterns = []
for _ in range(K):
patterns.append(data[idx].decode()); idx += 1
T = data[idx].decode(); idx += 1
ALPHA = 26
goto = [[-1] * ALPHA]
fail = [0]
end_node = [0] * K
def new_node():
goto.append([-1] * ALPHA)
fail.append(0)
return len(goto) - 1
for i, p in enumerate(patterns):
cur = 0
for ch in p:
c = ord(ch) - 97
if goto[cur][c] == -1:
goto[cur][c] = new_node()
cur = goto[cur][c]
end_node[i] = cur
n_nodes = len(goto)
q = deque()
for c in range(ALPHA):
if goto[0][c] == -1:
goto[0][c] = 0
else:
fail[goto[0][c]] = 0
q.append(goto[0][c])
while q:
u = q.popleft()
for c in range(ALPHA):
v = goto[u][c]
if v == -1:
goto[u][c] = goto[fail[u]][c]
else:
fail[v] = goto[fail[u]][c]
q.append(v)
children = [[] for _ in range(n_nodes)]
for v in range(1, n_nodes):
children[fail[v]].append(v)
tin = [0] * n_nodes
tout = [0] * n_nodes
timer = 0
stack = [(0, False)]
while stack:
u, processed = stack.pop()
if processed:
tout[u] = timer - 1
else:
tin[u] = timer
timer += 1
stack.append((u, True))
for v in children[u]:
stack.append((v, False))
bit = [0] * (n_nodes + 1)
def bit_add(i, val):
i += 1
while i <= n_nodes:
bit[i] += val
i += i & (-i)
def bit_sum(i):
i += 1
s = 0
while i > 0:
s += bit[i]
i -= i & (-i)
return s
def range_sum(l, r):
if r < l:
return 0
return bit_sum(r) - bit_sum(l - 1)
cur = 0
for ch in T:
c = ord(ch) - 97
cur = goto[cur][c]
bit_add(tin[cur], 1)
out = []
for i in range(K):
v = end_node[i]
out.append(str(range_sum(tin[v], tout[v])))
print("\n".join(out))
main()
計算量: 構築 $O(\sum|P_i|\cdot26)$、$T$走査 $O(|T|\log N)$、各クエリ$O(\log N)$。アルファベット{a,b}・パターン長1〜3・T長1〜15のランダム200件で愚直な部分文字列カウントと全出力一致を確認済み。
Step-by-Step 解説
1トライ木の構築
各パターンを1文字ずつ辿り、終端ノード番号を
各パターンを1文字ずつ辿り、終端ノード番号を
end_node[i]に記録する。2goto関数とfailリンクをBFSで構築する
トライ上に遷移がなければfail[u]の同じ文字への遷移を使う(完全なgoto関数を作る)。
トライ上に遷移がなければfail[u]の同じ文字への遷移を使う(完全なgoto関数を作る)。
3fail木を構築しオイラーツアーで区間化する
反復DFSで各ノードの担当区間 $[tin,tout]$ を求める。
反復DFSで各ノードの担当区間 $[tin,tout]$ を求める。
4Tを1回なぞりながらBITに加算する
現在のオートマトン状態を1文字ずつ更新し、その位置でbit_add(tin[cur],1)する。
現在のオートマトン状態を1文字ずつ更新し、その位置でbit_add(tin[cur],1)する。
5部分木和クエリで出現回数を求める
range_sum(tin[v],tout[v])がパターンPiの出現回数と一致する。
range_sum(tin[v],tout[v])がパターンPiの出現回数と一致する。
よくあるミス
| ミス | 原因 | 正しい書き方 |
|---|---|---|
| ノードごとに一致パターンを都度全列挙する | 最悪O(|T|^2)になる | 印を1つ置いて後でまとめて部分木和を取るオフライン処理にする |
| fail木でのオイラーツアーを再帰で書く | 1文字ずつの鎖状トライで再帰深さがO(Σ|Pi|)に達しうる | 反復スタックでDFSを行う |
| toutを最後に訪問した子のtoutで求めようとする | 兄弟の処理順で区間が不連続になりうる | processedフラグ付きスタックでtimer-1をtoutとする |
| 同一文字列パターンを別ノード扱いしてしまう | トライ構築で新規ノードを作ると出現回数の対応が崩れる | 同じ文字列は必ず同じノードに帰着させる |
次のステップ
- 発展: 「Tの中で最も出現回数が多いパターン」をオンラインで求めるクエリに拡張する
- 発展: fail木の重心分解(Day017 Q2, Day033 Q5)でパターン集合が動的に追加される設定に拡張する
- 発展: Suffix Automaton(Day086 Q1)を使った類題との解法アプローチを比較する