Algorithm Search⌘K

Main-Lorentz algorithm in Python

A divide and conquer algorithm that finds all repetitions (squares) in a string in O(n log n) time using Z-function computations.

File
main_lorentz_algorithm.py, 91 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
main_lorentz_algorithm.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/main-lorentz-algorithm/. The authors are listed in prov/W9BQDM.jsonl.

from typing import Hashable, List, Sequence, Tuple

_SEP = object()  # a separator equal to no letter


def z_function(t: Sequence[object]) -> List[int]:
    """z[i] = length of the longest common prefix of t and t[i:], with z[0] = len(t)."""
    n = len(t)
    z = [0] * n
    if n == 0:
        return z
    z[0] = n
    left = right = 0  # t[left:right] is a prefix of t, right maximal so far
    for i in range(1, n):
        k = min(right - i, z[i - left]) if i < right else 0
        while i + k < n and t[k] == t[i + k]:
            k += 1
        z[i] = k
        if i + k > right:
            left, right = i, i + k
    return z


def main_lorentz(s: Sequence[Hashable]) -> List[Tuple[int, int, int]]:
    """Return triples (lo, hi, m): s[i:i+2m] is a square for each lo <= i <= hi.

    Every occurrence of a square (a pair (i, m) with s[i:i+m] == s[i+m:i+2m],
    m >= 1) lies in exactly one triple. Letters are only tested for equality.
    Indices are 0-based. Cost: O(n log n) time, O(n) extra space besides the output.
    """
    n = len(s)
    # @line L1
    out: List[Tuple[int, int, int]] = []
    stack = [(0, n)]
    # @line L2
    while stack:
        # @line L3
        lo, hi = stack.pop()
        if hi - lo <= 1:
            continue
        # @line L4
        mid = (lo + hi) // 2
        u = list(s[lo:mid])
        v = list(s[mid:hi])
        nu, nv = len(u), len(v)
        stack.append((lo, mid))
        stack.append((mid, hi))
        # @line L5
        ru = u[::-1]
        rv = v[::-1]
        z_ru = z_function(ru)
        z_vu = z_function(v + [_SEP] + u)
        z_rurv = z_function(ru + [_SEP] + rv)
        z_v = z_function(v)
        # @line L6
        for m in range(1, nu + 1):
            # @line L7
            k1 = z_ru[m] if m < nu else 0          # common suffix of s[lo:mid-m] and s[lo:mid]
            k2 = z_vu[nv + 1 + nu - m]             # common prefix of s[mid-m:mid] and s[mid:hi]
            # @line L8
            a1 = max(0, m - k2)
            a2 = min(m - 1, k1)
            if a1 <= a2:
                out.append((mid - m - a2, mid - m - a1, m))
        # @line L9
        for m in range(1, nv + 1):
            # @line L10
            k1 = z_rurv[nu + 1 + nv - m]           # common suffix of s[lo:mid] and s[mid:mid+m]
            k2 = z_v[m] if m < nv else 0           # common prefix of s[mid:hi] and s[mid+m:hi]
            # @line L11
            a1 = max(1, m - k2)
            a2 = min(m - 1, k1)
            if a1 <= a2:
                out.append((mid - a2, mid - a1, m))
    # @line L12
    return out


def square_occurrences(s: Sequence[Hashable]) -> List[Tuple[int, int]]:
    """All pairs (i, m) with s[i:i+m] == s[i+m:i+2m], m >= 1, sorted."""
    pairs = [(i, m) for lo, hi, m in main_lorentz(s) for i in range(lo, hi + 1)]
    pairs.sort()
    return pairs


def is_square_free(s: Sequence[Hashable]) -> bool:
    """True if s has no factor uu with u nonempty."""
    return not main_lorentz(s)
test_main_lorentz_algorithm.py 79 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/main-lorentz-algorithm/. The authors are listed in prov/W9BQDM.jsonl.

import itertools
import random

from main_lorentz_algorithm import is_square_free, main_lorentz, square_occurrences, z_function


def brute(s):
    n = len(s)
    return sorted(
        (i, m)
        for m in range(1, n // 2 + 1)
        for i in range(n - 2 * m + 1)
        if s[i:i + m] == s[i + m:i + 2 * m]
    )


def check(s):
    triples = main_lorentz(s)
    occ = [(i, m) for lo, hi, m in triples for i in range(lo, hi + 1)]
    assert len(occ) == len(set(occ)), s  # each square is reported once
    assert sorted(occ) == brute(s), s
    assert all(lo <= hi for lo, hi, _ in triples), s


def test_example() -> None:
    assert sorted(main_lorentz("abaabaa")) == [(0, 0, 3), (1, 1, 3), (2, 2, 1), (5, 5, 1)]
    assert square_occurrences("abaabaa") == [(0, 3), (1, 3), (2, 1), (5, 1)]


def test_z_function() -> None:
    assert z_function("aabxaab") == [7, 1, 0, 0, 3, 1, 0]
    assert z_function("") == []


def test_edge_cases() -> None:
    for s in ["", "a", "ab", "aa", "aaaa", "abcacb", "abacaba"]:
        check(s)
    assert is_square_free("abcacb") and is_square_free("") and is_square_free("abacaba")
    assert not is_square_free("aa")
    assert square_occurrences([1, 2, 1, 2]) == [(0, 2)]
    assert len(square_occurrences("a" * 10)) == 25  # sum over m of 10 - 2m + 1


def test_triples_count() -> None:
    # The number of triples is O(n log n) even when the squares are quadratic.
    for n in (16, 64, 256):
        t = main_lorentz("a" * n)
        assert len(t) <= 2 * n * max(1, n.bit_length())


def test_exhaustive_small() -> None:
    for sigma, top in ((1, 12), (2, 12), (3, 8)):
        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(1984)
    for _ in range(600):
        sigma = rng.choice((1, 2, 3))
        n = rng.randint(0, 60)
        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_z_function()
    test_edge_cases()
    test_triples_count()
    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 Main-Lorentz algorithm