Muthukrishnan's document listing algorithm in Python
Report all documents containing a pattern in time proportional to the output size, using the document array and range-minimum queries.
- File
muthukrishnan_document_listing.py, 93 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/muthukrishnan-document-listing/. The authors are listed in prov/QK2HK3.jsonl.
SEP = "\x01"
class DocumentListing:
"""Muthukrishnan's document listing over a collection of strings.
The documents are concatenated with a separator after each one. D is the document
array (the document of each suffix in suffix array order) and C[r] is the largest
r' < r with D[r'] = D[r], or -1. A range minimum structure on C lists the distinct
documents of a suffix array range with one query per document reported.
"""
def __init__(self, docs: list[str]) -> None:
for d in docs:
if SEP in d:
raise ValueError("documents must not contain the separator")
self.docs = docs
self.text = "".join(d + SEP for d in docs)
t = self.text
self.sa = sorted(range(len(t)), key=lambda i: t[i:])
owner: list[int] = []
for k, d in enumerate(docs):
owner += [k] * (len(d) + 1)
self.d = [owner[p] for p in self.sa] # @line L1
last: dict[int, int] = {}
self.c: list[int] = []
for r, k in enumerate(self.d): # @line L2
self.c.append(last.get(k, -1))
last[k] = r
self._build_rmq() # @line L3
self.rmq_calls = 0
def _build_rmq(self) -> None:
"""A sparse table: table[j][i] is the position of the minimum of C[i .. i + 2^j - 1]."""
c = self.c
self.table: list[list[int]] = [list(range(len(c)))]
j = 1
while (1 << j) <= len(c):
prev, half = self.table[-1], 1 << (j - 1)
row = []
for i in range(len(c) - (1 << j) + 1):
a, b = prev[i], prev[i + half]
row.append(a if c[a] <= c[b] else b)
self.table.append(row)
j += 1
def _rmq(self, a: int, b: int) -> int:
self.rmq_calls += 1
j = (b - a + 1).bit_length() - 1
x, y = self.table[j][a], self.table[j][b - (1 << j) + 1]
return x if self.c[x] <= self.c[y] else y
def sa_range(self, p: str) -> tuple[int, int]:
"""The suffix array range [lo, hi) of the suffixes that start with p."""
t, sa, m = self.text, self.sa, len(p)
lo, hi = 0, len(sa)
while lo < hi:
mid = (lo + hi) // 2
if t[sa[mid]: sa[mid] + m] < p:
lo = mid + 1
else:
hi = mid
start, hi = lo, len(sa)
while lo < hi:
mid = (lo + hi) // 2
if t[sa[mid]: sa[mid] + m] <= p:
lo = mid + 1
else:
hi = mid
return start, lo
def list_documents(self, p: str) -> list[int]:
"""The documents that contain p, each once, in the order they are found."""
if p == "" or SEP in p:
raise ValueError("the pattern must be nonempty and must not contain the separator")
lo, hi = self.sa_range(p) # @line L4
left = lo
out: list[int] = []
stack = [(lo, hi - 1)]
while stack: # @line L5
a, b = stack.pop()
if a > b:
continue
m = self._rmq(a, b) # @line L6
if self.c[m] >= left: # @line L7
continue
out.append(self.d[m]) # @line L8
stack.append((m + 1, b))
stack.append((a, m - 1))
return out
test_muthukrishnan_document_listing.py 46 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/muthukrishnan-document-listing/. The authors are listed in prov/QK2HK3.jsonl.
import random
import pytest
from muthukrishnan_document_listing import DocumentListing
def test_example() -> None:
x = DocumentListing(["banana", "ananas", "cabana"])
assert x.sa_range("an") == (6, 11)
assert x.d[6:11] == [2, 0, 0, 1, 1]
assert x.c[6:11] == [5, 4, 7, 2, 9]
x.rmq_calls = 0
assert x.list_documents("an") == [1, 0, 2]
assert x.rmq_calls == 5
assert sorted(x.list_documents("ban")) == [0, 2]
assert x.list_documents("s") == [1]
assert x.list_documents("xyz") == []
def test_edges() -> None:
x = DocumentListing(["", "a", "aaaa"])
assert sorted(x.list_documents("a")) == [1, 2]
assert x.list_documents("aa") == [2]
assert DocumentListing([]).list_documents("a") == []
with pytest.raises(ValueError):
x.list_documents("")
with pytest.raises(ValueError):
DocumentListing(["a\x01b"])
def test_random_against_scan() -> None:
rng = random.Random(2002)
for _ in range(600):
docs = ["".join(rng.choice("ab") for _ in range(rng.randint(0, 8))) for _ in range(rng.randint(0, 6))]
x = DocumentListing(docs)
for _ in range(3):
p = "".join(rng.choice("ab") for _ in range(rng.randint(1, 3)))
x.rmq_calls = 0
got = x.list_documents(p)
want = [k for k, d in enumerate(docs) if p in d]
assert sorted(got) == want and len(got) == len(set(got))
assert x.rmq_calls <= 2 * len(got) + 1
What the next level needs
- the tests did not run in this build
Read the full page about Muthukrishnan's document listing algorithm