LZ-index in Python
Full-text index built on a Lempel-Ziv parsing of the text.
- File
lz_index.py, 149 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/lz-index/. The authors are listed in prov/TGNZ8J.jsonl.
END = "\0"
class _Trie:
"""A plain trie; ids[x] is the phrase whose string node x spells, or -1."""
def __init__(self) -> None:
self.children: list[dict[str, int]] = [{}]
self.ids: list[int] = [0] # the root spells the empty phrase 0
def add(self, x: int, c: str, phrase: int) -> int:
y = self.children[x].get(c)
if y is None:
y = len(self.children)
self.children[x][c] = y
self.children.append({})
self.ids.append(-1)
if phrase >= 0:
self.ids[y] = phrase
return y
def locus(self, s: str) -> int:
x = 0
for c in s:
y = self.children[x].get(c)
if y is None:
return -1
x = y
return x
def subtree_ids(self, x: int) -> list[int]:
out, stack = [], [x]
while stack:
y = stack.pop()
if self.ids[y] >= 0:
out.append(self.ids[y])
stack.extend(self.children[y].values())
return out
class LZIndex:
"""Navarro's LZ-index over the LZ78 parsing of a text.
The text gets an end marker so that the last phrase is new. Phrase 0 is
empty; phrase j >= 1 is phrase parent[j] followed by the symbol last[j].
The original text is not kept: phrases are read back from parent and last.
"""
def __init__(self, text: str) -> None:
if END in text:
raise ValueError("the text must not contain the end marker")
t = text + END
self.n = len(text)
self.lz = _Trie() # the LZTrie, with a node per phrase
self.parent, self.last, self.start, self.length = [0], [""], [0], [0]
self.node = [0] # LZTrie node of each phrase
i = 0
while i < len(t): # @line L1
x = 0
while t[i] in self.lz.children[x]:
x = self.lz.children[x][t[i]]
i += 1
j = len(self.parent)
self.node.append(self.lz.add(x, t[i], j))
self.parent.append(self.lz.ids[x])
self.last.append(t[i])
self.start.append(i - self.length[self.lz.ids[x]])
self.length.append(self.length[self.lz.ids[x]] + 1)
i += 1
self.rev = _Trie() # the RevTrie, of the reversed phrases
for j in range(1, len(self.parent)): # @line L2
x = 0
s = self.phrase(j)[::-1]
for k, c in enumerate(s):
x = self.rev.add(x, c, j if k == len(s) - 1 else -1)
@property
def phrases(self) -> int:
return len(self.parent) - 1
def phrase(self, j: int) -> str:
out = []
while j:
out.append(self.last[j])
j = self.parent[j]
return "".join(reversed(out))
def ending_with(self, s: str) -> list[int]:
"""The phrases that end with s (s nonempty)."""
x = self.rev.locus(s[::-1])
return [] if x < 0 else self.rev.subtree_ids(x)
def starting_with(self, s: str) -> list[int]:
x = self.lz.locus(s)
return [] if x < 0 else [j for j in self.lz.subtree_ids(x) if j > 0]
def search(self, p: str) -> list[int]:
"""The sorted positions of the occurrences of p."""
if p == "" or END in p:
raise ValueError("the pattern must be nonempty and must not contain the end marker")
m, out = len(p), []
for k in self.ending_with(p): # @line L3
for j in self.lz.subtree_ids(self.node[k]):
out.append(self.start[j] + self.length[k] - m)
for i in range(1, m): # @line L4
right = set(self.starting_with(p[i:]))
for j in self.ending_with(p[:i]): # @line L5
if j + 1 in right:
out.append(self.start[j + 1] - i)
for i in range(1, m - 1): # @line L6
x, d = 0, i
while d < m - 1 and p[d] in self.lz.children[x]:
x = self.lz.children[x][p[d]]
d += 1
k = self.lz.ids[x]
if self._spans(p, i, d, k): # @line L7
out.append(self.start[k] - i)
return sorted(out)
def _spans(self, p: str, i: int, d: int, k: int) -> bool:
"""Phrase k is p[i:d]; check that k - 1 ends with p[:i] and the next phrases spell p[d:]."""
if k < 2 or not self.phrase(k - 1).endswith(p[:i]):
return False
j = k + 1
while d < len(p):
if j > self.phrases:
return False
b = self.phrase(j)
if not (p.startswith(b, d) or b.startswith(p[d:])):
return False
d, j = d + len(b), j + 1
return True
def check_invariant(self) -> None:
seen = set()
pos = 0
for j in range(1, self.phrases + 1):
b = self.phrase(j)
assert b not in seen, "phrases are distinct"
seen.add(b)
assert b[:-1] in seen or b[:-1] == "", "prefix closed"
assert self.start[j] == pos and self.length[j] == len(b)
assert self.lz.locus(b) == self.node[j] and self.lz.ids[self.node[j]] == j
assert j in self.rev.subtree_ids(self.rev.locus(b[::-1]))
pos += len(b)
assert pos == self.n + 1
test_lz_index.py 56 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/lz-index/. The authors are listed in prov/TGNZ8J.jsonl.
import random
import pytest
from lz_index import LZIndex
def occurrences(t: str, p: str) -> list[int]:
return [i for i in range(len(t) - len(p) + 1) if t.startswith(p, i)]
def test_example() -> None:
z = LZIndex("abababab")
z.check_invariant()
assert [z.phrase(j) for j in range(1, z.phrases + 1)] == ["a", "b", "ab", "aba", "b\0"]
assert z.start[1:] == [0, 1, 2, 4, 7]
assert z.ending_with("aba") == [4]
assert sorted(z.ending_with("a")) == [1, 4] and z.starting_with("ba") == []
assert sorted(z.starting_with("a")) == [1, 3, 4]
assert z.search("aba") == [0, 2, 4]
assert z.search("bab") == [1, 3, 5]
assert z.search("abababab") == [0]
assert z.search("bb") == []
def test_edges() -> None:
z = LZIndex("")
z.check_invariant()
assert z.phrases == 1 and z.search("a") == []
z = LZIndex("aaaaaaaaaaaaaaaaaaaa")
z.check_invariant()
assert z.search("aaa") == list(range(18))
with pytest.raises(ValueError):
z.search("")
with pytest.raises(ValueError):
LZIndex("a\0")
def test_random_against_scan() -> None:
rng = random.Random(2004)
for _ in range(600):
alpha = rng.choice(["ab", "abc"])
t = "".join(rng.choice(alpha) for _ in range(rng.randint(0, 40)))
z = LZIndex(t)
z.check_invariant()
assert "".join(z.phrase(j) for j in range(1, z.phrases + 1)) == t + "\0"
for _ in range(4):
if t and rng.random() < 0.7:
i = rng.randrange(len(t))
p = t[i: i + rng.randint(1, 8)]
else:
p = "".join(rng.choice(alpha) for _ in range(rng.randint(1, 5)))
assert z.search(p) == occurrences(t, p)
What the next level needs
- the tests did not run in this build