Day 175-Q5 — 2026-10-11 | 文字列の runs をすべて列挙する(2 つの順序の Lyndon 配列から候補 2N 個を作り、LCE で延ばす)

2026-10-11 赤色 Master (Phase 8+) ★★★★★★★★★ 文字列(焦点帯 M4)。Day148-Q2 の Main-Lorentz 法は「周期 $p$ ごとに走査する簡略版」で $O(N^2)$ に留まり、文献の runs とは別物だと断っていた。今回は runs 定理(Bannai ほか)の構成をそのまま実装する: すべての run は、2 つの文字順序のどちらかで「最長の Lyndon 語」を根に持つ。Lyndon 配列 2 本で候補が $2N$ 個に絞られ、各候補は LCE(最長共通接頭辞)2 回で確かめられる

問題

英小文字からなる長さ $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 個だった

概念図

入力例 3: abaababaabaab の 7 個の run(帯の長さ = 区間、ラベル = 周期 p)a1b2a3a4b5a6b7a8a9b10a11a12b13[1,6] p=3[1,11] p=5[3,4] p=1[4,8] p=2[6,13] p=3[8,9] p=1[11,12] p=1例: [1,11] p=5 は通常の順で位置 3 の最長 Lyndon 接頭辞 aabab から、[1,6] p=3 は逆の順(b<a)で位置 2 の baa からだけ見つかる候補 (i, p=λ[i]−i) を LCE で左右に延ばし、長さ ≥ 2p なら run。2 つの文字順序で計 2N 個の候補

ヒント(クリックで段階的に開示)

ヒント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 の置き換え、指数の総和、論文の確認

自己評価

自分の回答

気づき・メモ