問題
英小文字からなる長さ $N$ の文字列 $S$ が与えられる。$S$ の run(極大反復)をすべて列挙せよ。
run とは、区間 $[l,r]$(1 始まり、両端を含む)と周期 $p$ の組で、次を満たすものである。
- $S[l..r]$ の最小周期が $p$ で、長さ $r-l+1\ge2p$
- 周期 $p$ を保ったまま左にも右にも延ばせない($S[l-1]\ne S[l-1+p]$、$S[r+1]\ne S[r+1-p]$。端は延ばせないとみなす)
入力形式
S
出力形式
1 行目に run の個数 $k$。続く $k$ 行に $l\ r\ p$ を、$(l,r)$ の辞書順で出力する。
制約
- $1\le N\le10^5$
入出力例
入力例1
mississippi出力例1
4
2 8 3
3 4 1
6 7 1
9 10 1(ississi は周期 3 で長さ 7、ss ss pp は周期 1)
入力例2
aabaabaab出力例2
4
1 2 1
1 9 3
4 5 1
7 8 1入力例3
abaababaabaab出力例3
7
1 6 3
1 11 5
3 4 1
4 8 2
6 13 3
8 9 1
11 12 1(Fibonacci 語の接頭辞。3 例とも全周期の走査と一致)
---
背景と出典
- runs 定理: H. Bannai, T. I, S. Inenaga, Y. Nakashima, M. Takeda, K. Tsuruta, "The "Runs" Theorem", SIAM J. Comput. 46(5), 2017。長さ $n$ の文字列の runs は $n$ 個未満であることを、Lyndon 根による単射で示した(Kolpakov–Kucherov 1999 の予想)。論文本文は本セッションで未確認で、以下は私の理解を実装と全探索の照合で確かめたもの
- 鍵の補題: run $(l,r,p)$ について、$r+1$ の文字が $r+1-p$ の文字より小さい順序(または大きい順序)を $<_0$ とする。その順序で、run の中の位置 $i>l$ から始まる長さ $p$ の Lyndon 語は、$S[i..]$ の最長 Lyndon 接頭辞に一致する。だから「各位置の最長 Lyndon 接頭辞(Lyndon 配列)」を 2 つの順序で求めれば、すべての run の周期 $p$ と位置が候補に入る
- Fibonacci 語の runs の個数は長さの約 $0.764$ 倍($2/\varphi^2$)と記憶している(未確認)。本解の出力では長さ $10^5$ で 76387 個(0.7639 倍)、$2\times10^5$ で 152781 個だった
概念図
ヒント(クリックで段階的に開示)
ヒント1(方向性)
周期 $p$ ごとに走査すると $O(N^2)$。run の数は $N$ 未満なので、各 run を「代表 1 個」から見つけたい。代表として、run の中にあるLyndon 語(自分のどの回転より真に小さい語)を使う。
ヒント2(アプローチ)
各位置 $i$ について、$S[i..]$ の最長 Lyndon 接頭辞の終わり $\lambda[i]$ を求める(Lyndon 配列)。$p=\lambda[i]-i$ として、$S[i..i+p)$ を周期 $p$ で左右に延ばし、長さが $2p$ 以上なら run。文字の大小を通常の順と逆の順の 2 通りで行う。
ヒント3(誘導)
- Lyndon 配列は右から: $j=i+1$ から始め、$S[i..]<S[j..]$ の間 $j\leftarrow\lambda[j]$ と飛ぶ(Lyndon 語どうしの連結)。比較は LCE で 1 回
- LCE は接尾辞配列(SA-IS)+LCP 配列+Sparse Table で $O(1)$。左への延長は反転文字列の LCE
- 同じ run が両方の順序・複数の位置から見つかるので、集合で重複を除く
模範解答 (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 main():
s = sys.stdin.readline().strip()
n = len(s)
a = [ord(c) - 96 for c in s]
fw = LCE(a)
bw = LCE(a[::-1]) # 後ろ向きの共通部分(最長共通接尾辞)
runs = set()
for inv in (False, True):
end = lyndon_ends(a, fw, inv)
for i in range(n):
j = end[i]
p = j - i
# Lyndon 根 s[i:j] を右へ延ばす: s[i:] と s[i+p:] の共通接頭辞
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 # 左にも延びないので長さ 2p に届かない
# 左へ延ばす: s[:i] と s[:i+p] の共通接尾辞
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))
res = sorted(runs)
out = [str(len(res))]
out += [f"{l + 1} {r} {p}" for l, r, p in res]
sys.stdout.write('\n'.join(out) + '\n')
main()
計算量: SA-IS $O(N)$、LCP(Kasai)$O(N)$、Sparse Table $O(N\log N)$、Lyndon 配列は 2 順序で比較 $O(N)$ 回、候補 $2N$ 個に LCE 2 回ずつ。全体 $O(N\log N)$(Sparse Table が律速)。$N=10^5$ で 0.53〜1.73 s(Python 3.14.7、M1 MacBook Pro 8GB、負荷平均 10〜18 での実測、起動込み)。
Step-by-Step 解説
1Lyndon 配列を右から作る
$S[i..]$ の最長 Lyndon 接頭辞は「$S[i]$ から始め、次の Lyndon 語 $S[j..\lambda[j])$ の方が大きい限り連結する」ことで得られる(Lyndon 語 $u<v$ なら $uv$ も Lyndon 語)。比較は接尾辞どうし: $S[j..]$ が $S[i..]$ の接頭辞なら $S[j..]$ の方が小さい(短い方が小さい)ので止まる。それ以外は LCE $l$ の次の文字で決める。逆の順序では、この文字比較だけを反転する。
2候補を延ばす
候補 $(i,p)$($p=\lambda[i]-i$)について、右へは $\mathrm{LCE}(i,i+p)$ 文字、左へは「$S[..i)$ と $S[..i+p)$ の共通接尾辞」の長さだけ延びる。延ばした区間 $[l,r)$ の長さが $2p$ 以上なら run $(l,r,p)$。$p$ が最小周期であることは、Lyndon 語が原始的(自分の真の冪でない)ことから従う。
32 つの順序が要る理由
run の右端の次の文字が、周期 $p$ だけ前の文字より大きいか小さいかで、Lyndon 根が見える順序が決まる。通常の順だけで探した版は、a と b だけのランダム文字列 300 個中 290 個で runs を取りこぼした(実測)。入力例 3 でも、run $[1,6]$(周期 3)は逆の順($b<a$)で位置 2 の baa からしか見つからず、run $[1,11]$(周期 5)は通常の順で位置 3 の aabab から見つかる(実行して確認)。
4LCE を引く回数を減らす
1 文字目が違えば LCE は 0 で、比較も延長も 1 文字で決まる。Lyndon 配列の比較と左右の延長でこれを先に確かめ、さらに「右に延びず、左の 1 文字目も違う」候補は長さ $p<2p$ で捨てる。26 文字のランダム列($N=2\times10^5$)で LCE の呼び出しが 159 万 9927 回 → 13 万 6917 回に減り、3.64 s → 2.95〜3.03 s(実測)。
5重複の除去と出力
同じ run は、2 つの順序・run の中の複数の位置から見つかる。集合に入れて重複を除き、$(l,r)$ の順に並べる。
迷った他案と採らなかった理由
| 案 | 計算量 | 採否 | 理由 |
|---|---|---|---|
| 周期 $p$ ごとに等号の並びを走査(Day148-Q2 の簡略版) | $O(N^2)$ | 検算用のみ | 長さ 300 までの照合に使った |
| LCE をローリングハッシュ+二分探索で | 1 回 $O(\log N)$ | 不採用 | LCE は最大 $8N$ 回で、$N=10^5$ でも 1300 万回級のハッシュ計算になる見込み(本セッションでは未計測) |
| Lyndon 配列を 2 つの順序の SA から NSV(次の小さい値)で作る | $O(N)$ | 不採用 | 逆順序の SA がもう 1 本要る。本解は通常の SA の LCE だけで 2 順序とも比較できる |
| 通常の順序だけで候補を作る | $O(N\log N)$ | 不採用(誤り) | 300 個中 290 個で取りこぼし(実測) |
| SA-IS+Sparse Table の LCE、2 順序の Lyndon 配列(本解) | $O(N\log N)$ | 採用 | $N=10^5$ で 0.53〜1.73 s |
前提が変わったら
- **$N=2\times10^5$** なら 2.9〜3.6 s(実測)。SA-IS 2 本と Sparse Table 2 本で半分以上を使う。Sparse Table を「LCP の区間最小を $O(1)$ で引く別の構造」(ブロック分割など)に替えるのが次の手
- runs の個数だけが欲しくても、重複を除くため run は一度作る必要がある。ただし出力の文字列化(最大 10 万行)は省ける
- 2 次元の繰り返しや、近似的な繰り返し(1 文字の違いを許す)なら、runs 定理は使えない
よくあるミス
| ミス | 原因 | 正しい書き方 |
|---|---|---|
| 片方の順序だけで探す | 「Lyndon 語は 1 つの順序で決まる」と思う | 2 つの順序。300 個中 290 個で取りこぼし(実測) |
| 接尾辞が接頭辞になる場合を「大きい」とする | 長さの扱いを逆にする | $S[j..]$ が $S[i..]$ の接頭辞なら $S[j..]$ の方が小さい。逆の順序でもこの規則は変えない |
| 左への延長を忘れる | Lyndon 根が run の先頭にあると思う | 根は run の 2 周期目以降にもある。左へも延ばす |
| 長さの条件を $\ge p$ にする | 「反復」は 2 回以上 | $r-l\ge2p$ |
| 重複を除かない | 同じ run が何度も見つかる | 集合で除く |
次のステップ
- 発展1: 各 run の指数(長さ / 周期)の総和を求め、ランダム列と Fibonacci 語で比べる
- 発展2: runs から「平方($uu$ の形の部分文字列)」の個数を数える
- 発展3: 論文(Bannai ほか 2017)の補題と本解の Step 1〜3 を照らし合わせる(本セッションでは未確認)
検証ログ(本セッションで実行した数値)
- 全周期の走査との照合(実測): 長さ 1〜300 の文字列(2 文字・3 文字・26 文字のランダム、Fibonacci 語の接頭辞、短い周期の繰り返しに 3% の雑音)を本解の最終版で 300 個、LCE の近道を入れる前の版で 800 個。不一致 0。runs の個数/長さの最大は 0.751
- 誤った方法(実測): 通常の順序だけ: 2 文字のランダム列 300 個中 290 個で不一致
- 改良前後の一致(実測): $N=10^5$ と $2\times10^5$ の 5 種類(Fibonacci 語、
aだけ、2 文字ランダム、26 文字ランダム、aabの繰り返し)で出力が一致 - 実行時間(実測、起動込み、$N=10^5$): Fibonacci 語 1.46 s(76387 個)、
aだけ 0.53 s(1 個)、2 文字ランダム 1.73 s(41186 個)、26 文字ランダム 1.25 s(3884 個)、aabの繰り返し 0.84 s(33334 個)。$N=2\times10^5$: Fibonacci 語 2.89〜3.41 s(152781 個)、26 文字ランダム 2.95〜3.03 s - 計測時の負荷: 負荷平均 10〜18。±30% 程度ぶれる
- 扱っていない範囲: Sparse Table の置き換え、指数の総和、論文の確認