問題
英小文字からなる文字列 $S$(長さ $N$)と整数 $K\ge2$ が与えられる。空でない文字列 $u$ を $K$ 回並べた $u^K$ の形の部分文字列を $K$ 乗と呼ぶ。
次の 2 つを空白区切りで出力せよ。
- $K$ 乗の出現の数(組 $(i,\ell)$ で $S[i..i+K\ell)=S[i..i+\ell)^K$ となるものの数)
- 異なる $K$ 乗の数(文字列として異なるものの数)
入力形式
S K
制約
| # | 制約 |
|---|---|
| 1 | $1\le N\le2\times10^5$ |
| 2 | $2\le K\le N$ |
入出力例
入力例1
abaabaabaab 3出力例1
3 3(周期 3・長さ 11 の run から、長さ 9 の 3 乗が $11-9+1=3$ か所:$(aba)^3,(baa)^3,(aab)^3$。すべて異なる)
入力例2
abaabaabaabcabaabaaba 3出力例2
4 3(c の後ろの run にも $(aba)^3$ が 1 つあり、出現は 4。異なるものは前の run の $(aba)^3$ と同じなので 3。run ごとに数えて足すだけだと 4 と誤る)
入力例3
aabaabaabaab 2出力例3
12 5(runs は周期 3・長さ 12 の全体と、長さ 2 の $aa$ が 4 本。全体の run から長さ 6 が $12-6+1=7$ か所、長さ 12($j=2$)
背景と出典
- runs 定理(runs の数は $N$ 未満)と Lyndon 根による列挙は Bannai, I, Inenaga, Nakashima, Takeda, Tsuruta「The "Runs" Theorem」(SIAM J. Comput. 2017) と記憶している(本文は未確認)。runs の列挙のコードは Day175-Q5 のものをそのまま使った
- Fine–Wilf の定理(周期 $p,q$ を持ち長さ $\ge p+q-\gcd(p,q)$ なら $\gcd(p,q)$ も周期)は Fine–Wilf 1965 と記憶している(原典未確認)
- Thue–Morse 語に 3 乗が無いこと(Thue 1912、原典未確認)を、検算の 1 つに使った
- 本問の問題文・制約・入力例は自作
概念図
ヒント(クリックで段階的に開示)
ヒント1(方向性)
$K$ 乗 $w=u^K$($|u|=q$)は周期 $q$ を持ち長さ $Kq\ge2q$。$w$ の最小周期 $p'$ について Fine–Wilf から $p'\mid q$ が言える。だから $w$ は「周期 $p'$ の run」のちょうど 1 つに含まれる。
ヒント2(アプローチ)
run $(l,r,p)$(区間 $[l,r)$、最小周期 $p$、長さ $L=r-l\ge2p$)に含まれる $K$ 乗で最小周期が $p$ のものは、長さ $Kjp$($j=1,2,\dots$、$Kjp\le L$)で、出現は $L-Kjp+1$ 個。全部の run・全部の $j$ で足せば出現数。
ヒント3(誘導)
- 同じ run・同じ $j$ の中では、開始位置を $p$ ずらすと同じ文字列。先頭 $\min(p,\ L-Kjp+1)$ 個は互いに異なる
- 別の run で同じ文字列が出ることがある(入力例 2)。候補を(長さ, 開始位置の接尾辞配列のランク)で並べ、同じ長さで隣り合う 2 つは「ランクの間の LCP の最小 $\ge$ 長さ」なら同じ文字列
- 同じ文字列の出現は、接尾辞配列でランクが連続した区間になるので、隣どうしの比較だけで全部の重複が分かる
模範解答 (Python)
模範解答を表示する(まず自力で解いてから)
import sys
def sa_is(s, upper):
# SA-IS(AtCoder Library の Python 移植と同じ手順)。s は 0..upper の整数列
n = len(s)
if n == 0:
return []
if n == 1:
return [0]
if n == 2:
return [0, 1] if s[0] < s[1] else [1, 0]
sa = [0] * n
ls = [False] * n
for i in range(n - 2, -1, -1):
ls[i] = ls[i + 1] if s[i] == s[i + 1] else (s[i] < s[i + 1])
sum_l = [0] * (upper + 1)
sum_s = [0] * (upper + 1)
for i in range(n):
if not ls[i]:
sum_s[s[i]] += 1
else:
sum_l[s[i] + 1] += 1
for i in range(upper + 1):
sum_s[i] += sum_l[i]
if i < upper:
sum_l[i + 1] += sum_s[i]
def induce(lms):
for i in range(n):
sa[i] = -1
buf = sum_s[:]
for d in lms:
if d == n:
continue
sa[buf[s[d]]] = d
buf[s[d]] += 1
buf = sum_l[:]
sa[buf[s[n - 1]]] = n - 1
buf[s[n - 1]] += 1
for i in range(n):
v = sa[i]
if v >= 1 and not ls[v - 1]:
sa[buf[s[v - 1]]] = v - 1
buf[s[v - 1]] += 1
buf = sum_l[:]
for i in range(n - 1, -1, -1):
v = sa[i]
if v >= 1 and ls[v - 1]:
buf[s[v - 1] + 1] -= 1
sa[buf[s[v - 1] + 1]] = v - 1
lms_map = [-1] * (n + 1)
m = 0
for i in range(1, n):
if not ls[i - 1] and ls[i]:
lms_map[i] = m
m += 1
lms = [i for i in range(1, n) if not ls[i - 1] and ls[i]]
induce(lms)
if m:
sorted_lms = [v for v in sa if lms_map[v] != -1]
rec_s = [0] * m
rec_upper = 0
rec_s[lms_map[sorted_lms[0]]] = 0
for i in range(1, m):
l, r = sorted_lms[i - 1], sorted_lms[i]
end_l = lms[lms_map[l] + 1] if lms_map[l] + 1 < m else n
end_r = lms[lms_map[r] + 1] if lms_map[r] + 1 < m else n
same = True
if end_l - l != end_r - r:
same = False
else:
while l < end_l:
if s[l] != s[r]:
break
l += 1
r += 1
if l == n or s[l] != s[r]:
same = False
if not same:
rec_upper += 1
rec_s[lms_map[sorted_lms[i]]] = rec_upper
rec_sa = sa_is(rec_s, rec_upper)
for i in range(m):
sorted_lms[i] = lms[rec_sa[i]]
induce(sorted_lms)
return sa
class LCE:
# 接尾辞配列 + LCP 配列 + Sparse Table で、2 つの接尾辞の最長共通接頭辞を O(1)
def __init__(self, s):
n = len(s)
sa = sa_is(s, max(s) if s else 0)
rank = [0] * n
for i, v in enumerate(sa):
rank[v] = i
lcp = [0] * max(n - 1, 0)
h = 0
for i in range(n):
if h:
h -= 1
if rank[i] == 0:
continue
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] - 1] = h
table = [lcp]
k = 1
while 2 * k <= len(lcp):
prev = table[-1]
table.append([a if a < b else b for a, b in zip(prev, prev[k:])])
k *= 2
self.n, self.rank, self.table = n, rank, table
def query(self, i, j):
# s[i:] と s[j:] の共通接頭辞の長さ(i != j)
if i >= self.n or j >= self.n:
return 0
a, b = self.rank[i], self.rank[j]
if a > b:
a, b = b, a
k = (b - a).bit_length() - 1
t = self.table[k]
x, y = t[a], t[b - (1 << k)]
return x if x < y else y
def lyndon_ends(s, lce, inv):
# end[i] = s[i:] の最長 Lyndon 接頭辞の終わり。inv=True なら文字の大小を反転した順序で
n = len(s)
end = [0] * (n + 1)
for i in range(n - 1, -1, -1):
j = i + 1
while j < n:
if s[i] != s[j]: # 1 文字目で決まるときは LCE を引かない
if (s[i] < s[j]) != inv:
j = end[j]
continue
break
l = lce.query(i, j)
if j + l == n:
break # s[j:] が s[i:] の接頭辞 → s[i:] の方が大きい
a, b = s[i + l], s[j + l]
if (a < b) != inv:
j = end[j] # s[i:] < s[j:]: Lyndon 語を延ばす
else:
break
end[i] = j
return end
def compute_runs(a, fw, bw):
n = len(a)
runs = set()
for inv in (False, True):
end = lyndon_ends(a, fw, inv)
for i in range(n):
j = end[i]
p = j - i
r = j + (fw.query(i, j) if j < n and a[i] == a[j] else 0)
if r - i < 2 * p and (i == 0 or a[i - 1] != a[j - 1]):
continue
l = i - (bw.query(n - i, n - j) if i > 0 and a[i - 1] == a[j - 1] else 0)
if r - l >= 2 * p:
runs.add((l, r, p))
return runs
def main():
data = sys.stdin.read().split()
s, K = data[0], int(data[1])
n = len(s)
a = [ord(c) - 96 for c in s]
fw = LCE(a)
bw = LCE(a[::-1])
runs = compute_runs(a, fw, bw)
occ = 0
cand = [] # 長さ * (n+1) + 開始位置のランク
rank = fw.rank
N1 = n + 1
for l, r, p in runs:
L = r - l
q = K * p # 長さ K*j*p の K 乗(j = 1, 2, ...)
while q <= L:
c = L - q + 1
occ += c
base = q * N1
for i in range(l, l + (p if p < c else c)): # 同じ run では先頭 p 個だけが異なる
cand.append(base + rank[i])
q += K * p
cand.sort()
# 同じ長さで隣り合うランクの 2 つは、その区間の LCP の最小が長さ以上なら同じ文字列
table = fw.table
distinct = 0
prev_len = -1
prev_rank = -1
for x in cand:
ln, rk = divmod(x, N1)
if ln != prev_len:
distinct += 1
else:
k = (rk - prev_rank).bit_length() - 1
t = table[k]
u, v = t[prev_rank], t[rk - (1 << k)]
if (u if u < v else v) < ln:
distinct += 1
prev_len, prev_rank = ln, rk
print(occ, distinct)
main()
計算量: SA-IS・Kasai は $O(N)$、Sparse Table $O(N\log N)$、runs の列挙は Day175-Q5 と同じ。候補の数を $C$ とすると並べ替え $O(C\log C)$、重複の判定は 1 組 $O(1)$。実測(Python 3.14、Apple M5 Pro、起動込み)で $N=2\times10^5$、$K=2$ の Fibonacci 語が 1.04 s・233 MB(候補は $N=10^5$ で 766141 個)、乱択 2 文字 0.64 s、$a^N$ 0.38 s。
Step-by-Step 解説
1$K$ 乗はちょうど 1 つの run に入る
$w=u^K$、$|u|=q$、$w$ の最小周期を $p'$ とする。$w$ は周期 $q$ と $p'$ を持ち、長さ $Kq\ge2q\ge q+p'$ なので Fine–Wilf により $\gcd(p',q)$ も周期。$p'$ が最小なので $p'\mid q$。$|w|\ge2p'$ だから $w$ を周期 $p'$ のまま左右に延ばせるだけ延ばしたものが run で、それは一意。逆に、run $(l,r,p)$ の中の長さ $Kjp$ の部分文字列は周期 $p$・長さ $\ge2p$ で、最小周期は $p$(より小さい周期があると、run の根が原始的でなくなる)。だから「run・$j$・開始位置」と「$K$ 乗の出現」は 1 対 1。
2出現数
run ごとに $\sum_{j\ge1,\ Kjp\le L}(L-Kjp+1)$。$a^N$ のように 1 つの run から $\Theta(N^2/K)$ 個の出現が出るので、出現を 1 つずつ列挙してはいけない($N=2\times10^5$ の $a^N$ で $K=2$ の出現は $10^{10}$ 個)。
3run の中での重複
同じ run・同じ長さ $Kjp$ の出現は、開始位置が $p$ 違えば同じ文字列(周期 $p$)。開始位置の差 $d$ が $0<d<p$ なら、同じ文字列だと長さ $\ge2p$ の部分文字列が周期 $d$ と $p$ を持ち、Fine–Wilf で周期 $\gcd(d,p)<p$ になって矛盾。だから異なる候補は先頭 $\min(p,L-Kjp+1)$ 個。
4run をまたぐ重複をランクで除く
候補(長さ $\ell$, 開始 $i$)を整数 $\ell\cdot(N+1)+\mathrm{rank}(i)$ にして並べる。同じ $\ell$ で隣り合う 2 つ(ランク $a<b$)は、接尾辞 $S[\mathrm{sa}[a]..]$ と $S[\mathrm{sa}[b]..]$ の共通接頭辞の長さ $=\min(\mathrm{lcp}[a..b-1])\ge\ell$ のとき同じ文字列。同じ文字列を接頭辞に持つ接尾辞はランクが連続するので、隣どうしだけ比べれば十分。LCP の最小は runs の列挙に作った Sparse Table をそのまま使う。
5ハッシュとの違い
Day177-Q5 は(長さ, 法 $2^{61}-1$ のハッシュ)の集合で重複を除いた。衝突の確率は小さいが 0 ではない。本解は比較が厳密で、Fibonacci 語 $N=10^5$・$K=2$ の異なる平方の数 71341 が Day177-Q5 のハッシュ版の値と一致した。
迷った他案と採らなかった理由
| 案 | 計算量 | 採否 | 理由 |
|---|---|---|---|
| 全 $(i,\ell)$ を調べる | $O(N^3/K)$ | 検算用のみ | $N\le60$ の全探索 |
| run ごと・$j$ ごとの候補数を足すだけ(重複を除かない) | $O(\text{runs}\cdot N/Kp)$ | 不採用(誤り) | 800 入力中 362 で異なる数を誤る(実測) |
| 出現を $j=1$(長さ $Kp$)だけ数える | 同上 | 不採用(誤り) | 800 入力中 305 で出現数を誤る(実測)。$a^N$ の $a^{2K}$ などを落とす |
| 異なる数を(長さ, 最小周期)の組で数える | 同上 | 不採用(誤り) | 800 入力中 364 で誤り(実測)。同じ長さ・周期でも中身が違う |
| ハッシュで重複除去(Day177-Q5) | $O(C)$ 期待 | 不採用 | 衝突の可能性が残る。本解は厳密で、並べ替えの $O(C\log C)$ だけ重い |
| 接尾辞オートマトンで異なる $K$ 乗を数える | $O(N)$ 状態 | 不採用 | 状態ごとに「$K$ 乗か」を判定する方法が自明でない |
| runs+ランクの区間 LCP(本解) | $O(N\log N+C\log C)$ | 採用 | $N=2\times10^5$ で 1.04 s 以下 |
前提が変わったら
- $K$ が分数(指数 $e$ の「繰り返し」、例えば長さ $\ge e\cdot$周期)なら、run ごとに長さ $\lceil ep\rceil..L$ の部分文字列を数える形になり、異なる数は長さごとに先頭 $p$ 個の候補で同じ方法が使える
- 異なる $K$ 乗の文字列そのものを全部出力するなら、候補を重複除去した後に開始位置と長さを出すだけ
- $N=10^6$ なら、Python では runs の列挙(Lyndon 配列と Sparse Table)が律速。$N=5\times10^5$ の Fibonacci 語で 3.32 s・582 MB(実測)だった
よくあるミス
| ミス | 原因 | 正しい書き方 |
|---|---|---|
| 出現を 1 つずつ列挙する | $a^N$ で $\Theta(N^2)$ 個 | run ごとに閉じた式 $L-Kjp+1$ |
| $j\ge2$ を忘れる | 「最小周期の $K$ 倍」だけを考える | 長さ $Kjp$ の全部の $j$ |
| run の中の重複を除き忘れる | 開始位置を全部候補にする | 先頭 $\min(p,L-Kjp+1)$ 個 |
| run をまたぐ重複を除き忘れる | run ごとに独立と思う | ランクで並べて LCP で比較(入力例 2) |
| LCP の区間を $[a,b]$ にする | Kasai の配列の添字の取り違え | ランク $a<b$ の共通接頭辞は $\min(\mathrm{lcp}[a..b-1])$ |
次のステップ
- 発展1: 指数が分数の繰り返し(例えば指数 $\ge2.5$)の異なる数を同じ方法で数える
- 発展2: 異なる平方の数が $N$ 以下(Fraenkel–Simpson と記憶、未確認)であることを、本解で $N\le20$ の全文字列(2 文字)について確かめる
- 発展3: runs の列挙を $N=10^6$ で 3 秒以内にする(Lyndon 配列の作り方の改善)
検証ログ(本セッションで実行した数値)
- 全探索との一致(実測): $N\le60$、乱択(1〜4 文字)・Fibonacci 語・周期文字列に 1 文字混ぜたもの・
bをbaaに置き換えたもの、$K\in\{2,3,4,5\}$ の 1600 入力(乱数の種 2 つ)で、出現数・異なる数とも全探索と全件一致 - 誤った方法(実測、別の 800 入力): 重複を除かない 362、$j=1$ だけ 305、(長さ, 周期)で数える 364 で誤り
- 既知の値との照合(実測): Fibonacci 語 $N=10^5$・$K=2$ の異なる平方 71341 は Day177-Q5(ハッシュ)と一致。Thue–Morse 語 $N=10^5$・$K=3$ は出現 0。Fibonacci 語 $N=10^5$・$K=4$ も出現 0
- 時間(実測、起動込み、$N=10^5$、$K=2$/$3$): 乱択 2 文字 0.31/0.29 s、乱択 26 文字 0.25/0.24 s、Fibonacci 語 0.46/0.35 s、$a^N$ 0.19/0.19 s、長さ 300 の塊の繰り返し 0.30/0.29 s、Thue–Morse 0.33/0.30 s、Tribonacci 語 0.38/0.29 s。$N=2\times10^5$($K=2$): Fibonacci 1.04 s・233 MB、Tribonacci 0.83 s、乱択 2 文字 0.64 s、$a^N$ 0.38 s。$N=5\times10^5$: Fibonacci 3.32 s・582 MB、乱択 2 文字 1.89 s
- 候補の数(実測、$N=10^5$、$K=2$): Fibonacci 766141 個(runs 76387 本)、Tribonacci 428761 個、塊の繰り返し 116800 個、乱択 2 文字 61708 個
- 扱っていない範囲: runs 定理の原典の確認、分数の指数、$N=10^6$