Algorithm Search⌘K

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

Read the full page about FM-index