Algorithm Search⌘K

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
karp_miller_rosenberg_algorithm.pyRawGitHub
# 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

Read the full page about Karp-Miller-Rosenberg algorithm