Algorithm Search⌘K

Lyndon-based runs computation in Python

Computes all runs in linear time from Lyndon roots and longest common extensions.

File
lyndon_based_runs_computation.py, 123 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
lyndon_based_runs_computation.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/lyndon-based-runs-computation/. The authors are listed in prov/B8YJN8.jsonl.

from typing import List, Sequence, Set, Tuple


def suffix_array(key: Sequence[int]) -> List[int]:
    """Suffixes of key in lexicographic order, a proper prefix being smaller.

    Prefix doubling: O(n log^2 n) time with Python's sort.
    """
    n = len(key)
    sa = list(range(n))
    rank = list(key)
    k = 1
    while n > 1:
        def sort_key(i: int) -> Tuple[int, int]:
            return (rank[i], rank[i + k] if i + k < n else -1)
        sa.sort(key=sort_key)
        new = [0] * n
        for t in range(1, n):
            new[sa[t]] = new[sa[t - 1]] + (sort_key(sa[t]) != sort_key(sa[t - 1]))
        rank = new
        if rank[sa[-1]] == n - 1:
            break
        k *= 2
    return sa


class LCE:
    """Longest common prefix of two suffixes in O(1) time after O(n log n) preprocessing."""

    def __init__(self, key: Sequence[int]) -> None:
        n = self.n = len(key)
        sa = suffix_array(key)
        self.rank = rank = [0] * n
        for t, i in enumerate(sa):
            rank[i] = t
        lcp = [0] * n  # lcp[t] = lcp of the suffixes sa[t-1] and sa[t] (Kasai)
        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 key[i + h] == key[j + h]:
                    h += 1
                lcp[rank[i]] = h
                if h > 0:
                    h -= 1
            else:
                h = 0
        self.table = [lcp]  # sparse table of minima
        w = 1
        while 2 * w <= n:
            prev = self.table[-1]
            self.table.append([min(prev[t], prev[t + w]) for t in range(n - 2 * w + 1)])
            w *= 2

    def query(self, i: int, j: int) -> int:
        """Length of the longest common prefix of the suffixes at i and j (either may be n)."""
        if i == self.n or j == self.n:
            return 0
        if i == j:
            return self.n - i
        a, b = sorted((self.rank[i], self.rank[j]))
        a += 1  # minimum of lcp[a..b]
        lev = (b - a + 1).bit_length() - 1
        row = self.table[lev]
        return min(row[a], row[b - (1 << lev) + 1])


def lyndon_array(key: Sequence[int]) -> List[int]:
    """lam[i] = length of the longest Lyndon word (for the order of the keys) starting at i.

    It ends just before the next suffix that is smaller than the suffix at i.
    """
    n = len(key)
    sa = suffix_array(key)
    rank = [0] * n
    for t, i in enumerate(sa):
        rank[i] = t
    lam = [0] * n
    stack: List[int] = []  # indices to the right, ranks increasing from the top down
    for i in range(n - 1, -1, -1):
        while stack and rank[stack[-1]] > rank[i]:
            stack.pop()
        lam[i] = (stack[-1] if stack else n) - i
        stack.append(i)
    return lam


def runs(s: Sequence) -> List[Tuple[int, int, int]]:
    """All runs (i, j, p) of s, with s[i..j] inclusive of smallest period p and j - i + 1 >= 2p.

    Letters must be totally ordered. Indices are 0-based. The result is sorted.
    """
    n = len(s)
    if n == 0:
        return []
    letters = sorted(set(s))
    code = {c: r for r, c in enumerate(letters)}
    key = [code[c] for c in s]
    # @line L1
    found: Set[Tuple[int, int, int]] = set()
    fwd = LCE(key)
    bwd = LCE(key[::-1])  # common suffixes of prefixes of s
    # @line L2
    for order in (0, 1):
        # @line L3
        lam = lyndon_array(key if order == 0 else [len(letters) - 1 - c for c in key])
        # @line L4
        for i in range(n):
            # @line L5
            p = lam[i]
            j = i + p
            # @line L6
            left = i - (bwd.query(n - i, n - j) if i > 0 else 0)
            # @line L7
            right = j + fwd.query(i, j)
            # @line L8
            if right - left >= 2 * p:
                found.add((left, right - 1, p))
    # @line L9
    return sorted(found)
test_lyndon_based_runs_computation.py 106 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/lyndon-based-runs-computation/. The authors are listed in prov/B8YJN8.jsonl.

import itertools
import random

from lyndon_based_runs_computation import LCE, lyndon_array, runs, suffix_array


def smallest_period(w):
    return next(p for p in range(1, len(w) + 1) if all(w[k] == w[k + p] for k in range(len(w) - p)))


def brute_runs(s):
    n = len(s)
    found = set()
    for i in range(n):
        for j in range(i + 1, n):
            w = s[i:j + 1]
            p = smallest_period(w)
            if len(w) >= 2 * p and (i == 0 or s[i - 1] != s[i - 1 + p]) and (j == n - 1 or s[j + 1] != s[j + 1 - p]):
                found.add((i, j, p))
    return sorted(found)


def is_lyndon(u):
    return all(u < u[k:] for k in range(1, len(u)))


def brute_lyndon_array(s):
    return [max(j - i for j in range(i + 1, len(s) + 1) if is_lyndon(s[i:j])) for i in range(len(s))]


def check(s):
    assert runs(s) == brute_runs(s), s
    if s:
        key = list(s)
        assert lyndon_array([ord(c) for c in key]) == brute_lyndon_array(s), s
        assert suffix_array([ord(c) for c in key]) == sorted(range(len(s)), key=lambda i: s[i:]), s


def test_example() -> None:
    assert runs("aabaabaa") == [(0, 1, 1), (0, 7, 3), (3, 4, 1), (6, 7, 1)]
    assert lyndon_array([0, 0, 1, 0, 0, 1, 0, 0]) == [3, 2, 1, 3, 2, 1, 1, 1]
    assert lyndon_array([1, 1, 0, 1, 1, 0, 1, 1]) == [1, 1, 3, 1, 1, 3, 1, 1]
    assert runs("mississippi") == [(1, 7, 3), (2, 3, 1), (5, 6, 1), (8, 9, 1)]


def test_edge_cases() -> None:
    assert runs("") == []
    assert runs("a") == []
    assert runs("ab") == []
    assert runs("aaaa") == [(0, 3, 1)]
    assert runs("abcacb") == []
    assert runs([3, 1, 3, 1, 3]) == [(0, 4, 2)]
    for s in ["abab", "abaababaab", "abacaba", "aabbaabb"]:
        check(s)


def test_lce() -> None:
    s = "abaababaab"
    key = [ord(c) for c in s]
    lce = LCE(key)
    for i in range(len(s) + 1):
        for j in range(len(s) + 1):
            k = 0
            while i + k < len(s) and j + k < len(s) and s[i + k] == s[j + k]:
                k += 1
            assert lce.query(i, j) == k


def test_fibonacci_word() -> None:
    a, b = "a", "ab"
    while len(b) < 300:
        a, b = b, b + a
    rs = runs(b)
    assert len(rs) < len(b)  # the runs theorem
    check(b[:89])


def test_exhaustive_small() -> None:
    for sigma, top in ((1, 10), (2, 11), (3, 7)):
        for n in range(top + 1):
            for w in itertools.product("abc"[:sigma], repeat=n):
                check("".join(w))


def test_random() -> None:
    rng = random.Random(2017)
    for _ in range(600):
        sigma = rng.choice((1, 2, 3))
        n = rng.randint(0, 40)
        s = "".join(rng.choice("abc"[:sigma]) for _ in range(n))
        if rng.random() < 0.3 and n > 0:
            s = s[: rng.randint(1, n)] * rng.randint(2, 4)
        check(s)


if __name__ == "__main__":
    test_example()
    test_edge_cases()
    test_lce()
    test_fibonacci_word()
    test_exhaustive_small()
    test_random()
    print("ok")

What the next level needs

  • the tests did not run in this build

Read the full page about Lyndon-based runs computation