FM-index in Python
A compressed full-text index built from the Burrows-Wheeler transform, with backward search.
- File
fm_index.py, 117 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/fm-index/. The authors are listed in prov/BSBQWW.jsonl.
from collections.abc import Hashable, Sequence
def _suffix_array(t: list[int]) -> list[int]:
"""Suffix array of t by prefix doubling with library sorts; t ends with a unique smallest code."""
n = len(t)
sa = list(range(n))
rank = t[:]
h = 1
while True:
key = [(rank[i], rank[i + h] if i + h < n else -1) for i in range(n)]
sa.sort(key=key.__getitem__)
new = [0] * n
for r in range(1, n):
new[sa[r]] = new[sa[r - 1]] + (key[sa[r]] != key[sa[r - 1]])
rank = new
if n == 0 or rank[sa[-1]] == n - 1:
return sa
h *= 2
class FMIndex:
"""FM-index of a text with a sentinel appended.
The symbols of the text need a total order and are coded 1 .. sigma; the
sentinel is the code 0, smaller than all symbols and unique. The index stores
the BWT, the C array, rank checkpoints every `block` rows, and the suffix
array values that are multiples of `step` (and the inverse at those positions
and at n), so it can count, locate and extract. Positions are 0-based.
"""
def __init__(self, text: Sequence[Hashable], step: int = 4, block: int = 8) -> None:
self.n = n = len(text)
self.step, self.block = step, block
self.symbols = sorted(set(text))
self.code = {c: r + 1 for r, c in enumerate(self.symbols)}
sigma = len(self.symbols)
t = [self.code[c] for c in text] + [0]
sa = _suffix_array(t)
self.bwt = [t[p - 1] if p > 0 else 0 for p in sa]
count = [0] * (sigma + 1)
for x in t:
count[x] += 1
self.c = [0] * (sigma + 2) # c[x] = number of codes smaller than x in t
for x in range(sigma + 1):
self.c[x + 1] = self.c[x] + count[x]
self.checkpoints: list[list[int]] = [] # checkpoints[j][x] = rank of x in bwt[0 .. j*block - 1]
run = [0] * (sigma + 1)
for i, x in enumerate(self.bwt):
if i % block == 0:
self.checkpoints.append(run[:])
run[x] += 1
self.checkpoints.append(run[:])
self.sa_sample = {i: p for i, p in enumerate(sa) if p % step == 0}
self.isa_sample = {p: i for i, p in enumerate(sa) if p % step == 0 or p == n}
def rank(self, x: int, i: int) -> int:
"""Number of rows r < i with bwt[r] = x."""
j = i // self.block
r = self.checkpoints[j][x]
for k in range(j * self.block, i):
r += self.bwt[k] == x
return r
def lf(self, i: int) -> int:
"""Row of the suffix that starts one position before the suffix of row i."""
x = self.bwt[i]
return self.c[x] + self.rank(x, i)
def _range(self, pattern: Sequence[Hashable]) -> tuple[int, int]:
sp, ep = 0, self.n + 1 # @line L1
for c in reversed(pattern): # @line L2
if c not in self.code: # @line L3
return 0, 0
x = self.code[c]
sp, ep = self.c[x] + self.rank(x, sp), self.c[x] + self.rank(x, ep) # @line L4
if sp >= ep: # @line L5
return 0, 0
return sp, ep
def count(self, pattern: Sequence[Hashable]) -> int:
"""Number of occurrences of pattern in the text; the empty pattern occurs n + 1 times."""
sp, ep = self._range(pattern)
return ep - sp
def locate(self, pattern: Sequence[Hashable]) -> list[int]:
"""Sorted start positions of the occurrences of a nonempty pattern."""
if len(pattern) == 0:
raise ValueError("the pattern must be nonempty")
sp, ep = self._range(pattern)
out = []
for i in range(sp, ep): # @line L6
steps = 0 # @line L7
while i not in self.sa_sample: # @line L8
i = self.lf(i)
steps += 1
out.append(self.sa_sample[i] + steps) # @line L9
return sorted(out) # @line L10
def extract(self, left: int, right: int) -> list[Hashable]:
"""The text symbols at positions left .. right - 1, read backward from a sampled position."""
if not 0 <= left <= right <= self.n:
raise ValueError("bad range")
p = min(-(-right // self.step) * self.step, self.n)
i = self.isa_sample[p]
out = []
while p > left:
x = self.bwt[i] # x is the code of text[p - 1]
if p <= right:
out.append(self.symbols[x - 1])
i = self.c[x] + self.rank(x, i)
p -= 1
out.reverse()
return out
test_fm_index.py 67 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/fm-index/. The authors are listed in prov/BSBQWW.jsonl.
import random
from fm_index import FMIndex
def naive_bwt(text: str) -> str:
t = text + "\0"
rows = sorted(range(len(t)), key=lambda i: t[i:])
return "".join("$" if t[i - 1] == "\0" else t[i - 1] for i in rows)
def naive_locate(text: str, pattern: str) -> list[int]:
return [i for i in range(len(text) - len(pattern) + 1) if text[i:i + len(pattern)] == pattern]
def bwt_string(fm: FMIndex) -> str:
return "".join("$" if x == 0 else fm.symbols[x - 1] for x in fm.bwt)
def test_banana() -> None:
fm = FMIndex("banana", step=2, block=4)
assert bwt_string(fm) == "annb$aa"
assert fm.c == [0, 1, 4, 5, 7]
assert fm._range("ana") == (2, 4)
assert fm.count("ana") == 2 and fm.locate("ana") == [1, 3]
assert fm.count("nab") == 0 and fm.count("x") == 0
assert fm.extract(1, 4) == list("ana")
def test_mississippi() -> None:
fm = FMIndex("mississippi")
assert bwt_string(fm) == naive_bwt("mississippi") == "ipssm$pissii"
assert fm.locate("ssi") == [2, 5]
assert fm.locate("i") == [1, 4, 7, 10]
assert "".join(fm.extract(0, 11)) == "mississippi"
def test_empty_and_special() -> None:
fm = FMIndex("")
assert fm.count("") == 1 and fm.count("a") == 0 and fm.extract(0, 0) == []
for n in range(1, 30):
for text in ("a" * n, "ab" * n):
fm = FMIndex(text, step=3, block=5)
assert bwt_string(fm) == naive_bwt(text)
for pat in ("a", "aa", "ab", "ba", "aba", "b" * 2):
assert fm.locate(pat) == naive_locate(text, pat)
def test_random() -> None:
rng = random.Random(7)
for trial in range(800):
sigma = (1, 2, 4)[trial % 3]
n = rng.randrange(0, 40)
text = "".join(rng.choice("acgt"[:sigma]) for _ in range(n))
fm = FMIndex(text, step=rng.randrange(1, 6), block=rng.randrange(1, 9))
assert bwt_string(fm) == naive_bwt(text)
for _ in range(5):
m = rng.randrange(1, 6)
pat = "".join(rng.choice("acgt"[:sigma + 1] if sigma < 4 else "acgt") for _ in range(m))
assert fm.count(pat) == len(naive_locate(text, pat))
assert fm.locate(pat) == naive_locate(text, pat)
a = rng.randrange(0, n + 1)
b = rng.randrange(a, n + 1)
assert "".join(fm.extract(a, b)) == text[a:b]
What the next level needs
- the tests did not run in this build