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
# 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