Karp-Miller-Rosenberg algorithm in Python
Assigns equal integer names to equal substrings of doubling length, giving an O(n log n) suffix sort.
- File
karp_miller_rosenberg_algorithm.py, 120 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/karp-miller-rosenberg-algorithm/. The authors are listed in prov/NP7V4M.jsonl.
from collections.abc import Sequence
def _radix_sort_pairs(items: list[int], first: list[int], second: list[int], buckets: int) -> list[int]:
"""Stable LSD radix sort of items by (first[i], second[i]), keys in 0 .. buckets - 1."""
for key in (second, first):
count = [0] * (buckets + 1)
for i in items:
count[key[i] + 1] += 1
for b in range(buckets):
count[b + 1] += count[b]
out = [0] * len(items)
for i in items:
out[count[key[i]]] = i
count[key[i]] += 1
items = out
return items
def kmr(text: Sequence) -> list[list[int]]:
"""Return the dictionary of basic factors: names[j][i] for 0 <= i <= n - 2^j.
names[j][i] is the rank of the factor text[i : i + 2^j] among the distinct
factors of length 2^j, so equal factors get equal names and the order of
names is the lexicographic order of the factors. Symbols need a total order.
Positions are 0-based; no sentinel is used. Time and space O(n log n).
"""
n = len(text)
if n == 0:
return []
code = {c: r for r, c in enumerate(sorted(set(text)))}
names = [[code[c] for c in text]] # @line L1
j = 0 # @line L2
while 2 ** (j + 1) <= n: # @line L3
half, cur = 2**j, names[j]
m = n - 2 * half + 1
second = [cur[i + half] for i in range(m)] # @line L4
order = _radix_sort_pairs(list(range(m)), cur, second, n) # @line L5
new = [0] * m
r = -1
prev = None
for i in order: # @line L6
if (cur[i], second[i]) != prev: # @line L7
r += 1
prev = (cur[i], second[i])
new[i] = r # @line L8
names.append(new)
j += 1 # @line L9
return names # @line L10
def substring_compare(names: list[list[int]], p: int, q: int, length: int) -> int:
"""Compare text[p : p + length] with text[q : q + length] in O(1): -1, 0 or 1."""
if length == 0:
return 0
j = length.bit_length() - 1
a = (names[j][p], names[j][p + length - 2**j])
b = (names[j][q], names[j][q + length - 2**j])
return (a > b) - (a < b)
def suffix_array(text: Sequence) -> list[int]:
"""Suffix array by naming the prefixes of length 2^j of the suffixes; a missing half sorts first."""
n = len(text)
if n == 0:
return []
code = {c: r + 1 for r, c in enumerate(sorted(set(text)))}
rank = [code[c] for c in text]
k = 1
while True:
second = [rank[i + k] if i + k < n else 0 for i in range(n)]
order = _radix_sort_pairs(list(range(n)), rank, second, n + 2)
new = [0] * n
r = 0
for t, i in enumerate(order):
if t == 0 or (rank[i], second[i]) != (rank[order[t - 1]], second[order[t - 1]]):
r += 1
new[i] = r
rank = new
if r == n or k >= n:
return order
k *= 2
def longest_repeat(text: Sequence) -> tuple[int, int, int]:
"""Return (length, p, q) of a longest factor that occurs at two positions p < q; (0, -1, -1) if none.
Binary lifting over the basic factors: the length is built from the highest
power of two down, and a name for each tested length is a pair of names.
"""
names = kmr(text)
n = len(text)
length, cur = 0, [0] * (n + 1) # the empty factor has one name
for j in range(len(names) - 1, -1, -1):
step = 2**j
m = n - (length + step) + 1
if m < 2:
continue
pairs = [(cur[i], names[j][i + length]) for i in range(m)]
seen: dict[tuple[int, int], int] = {}
for i, key in enumerate(pairs):
if key in seen:
break
seen[key] = i
else:
continue
ids = {key: r for r, key in enumerate(sorted(set(pairs)))}
cur = [ids[key] for key in pairs]
length += step
if length == 0:
return 0, -1, -1
first: dict[int, int] = {}
for i in range(n - length + 1):
if cur[i] in first:
return length, first[cur[i]], i
first[cur[i]] = i
raise AssertionError("unreachable")
test_karp_miller_rosenberg_algorithm.py 74 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/karp-miller-rosenberg-algorithm/. The authors are listed in prov/NP7V4M.jsonl.
import random
from karp_miller_rosenberg_algorithm import kmr, longest_repeat, substring_compare, suffix_array
def texts(seed: int, count: int = 600, maxn: int = 18) -> list[str]:
rng = random.Random(seed)
out = ["", "a", "aa", "ab", "ba"]
for n in range(1, 14):
out += ["a" * n, "ab" * n, "aab" * n]
for _ in range(count):
sigma = rng.choice([1, 2, 4])
out.append("".join(rng.choice("abcd"[:sigma]) for _ in range(rng.randrange(maxn + 1))))
return out
def naive_names(t: str, length: int) -> list[int]:
factors = [t[i : i + length] for i in range(len(t) - length + 1)]
rank = {f: r for r, f in enumerate(sorted(set(factors)))}
return [rank[f] for f in factors]
def naive_longest_repeat(t: str) -> int:
best = 0
for i in range(len(t)):
for j in range(i + 1, len(t)):
k = 0
while j + k < len(t) and t[i + k] == t[j + k]:
k += 1
best = max(best, k)
return best
def test_examples() -> None:
names = kmr("banana")
assert names == [[1, 0, 2, 0, 2, 0], [1, 0, 2, 0, 2], [1, 0, 2]]
assert suffix_array("banana") == [5, 3, 1, 0, 4, 2]
assert suffix_array("mississippi") == [10, 7, 4, 1, 0, 9, 8, 6, 3, 5, 2]
assert longest_repeat("mississippi") == (4, 1, 4)
assert longest_repeat("abcd") == (0, -1, -1)
def test_names_against_brute_force() -> None:
for t in texts(1):
names = kmr(t)
assert len(names) == (len(t).bit_length() if t else 0)
for j, row in enumerate(names):
assert row == naive_names(t, 2**j)
def test_suffix_array_against_brute_force() -> None:
for t in texts(2):
assert suffix_array(t) == sorted(range(len(t)), key=lambda i: t[i:])
def test_compare_and_repeat_against_brute_force() -> None:
rng = random.Random(3)
for t in texts(4):
n = len(t)
names = kmr(t)
for _ in range(10):
if n == 0:
break
length = rng.randint(0, n)
p, q = rng.randint(0, n - length), rng.randint(0, n - length)
a, b = t[p : p + length], t[q : q + length]
assert substring_compare(names, p, q, length) == (a > b) - (a < b)
length, p, q = longest_repeat(t)
assert length == naive_longest_repeat(t)
if length:
assert p < q and t[p : p + length] == t[q : q + length]
What the next level needs
- the tests did not run in this build