Algorithm Search⌘K

r-index in Python

A full-text index whose size is proportional to the number of runs in the BWT.

File
r_index.py, 168 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
r_index.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/r-index/. The authors are listed in prov/D8XXKM.jsonl.

from bisect import bisect_left, bisect_right
from collections.abc import Hashable, Iterable, Sequence
from typing import Any


def _suffix_array(t: Sequence[int]) -> list[int]:
    """Suffix array of t (ending with a unique smallest 0) by prefix doubling; no recursion."""
    n = len(t)
    rank = list(t)
    sa = list(range(n))
    k = 1
    while True:
        key = [(rank[i], rank[i + k] if i + k < n else -1) for i in range(n)]
        sa.sort(key=key.__getitem__)
        new = [0] * n
        for j in range(1, n):
            new[sa[j]] = new[sa[j - 1]] + (key[sa[j]] != key[sa[j - 1]])
        rank = new
        if n == 0 or rank[sa[-1]] == n - 1:
            return sa
        k *= 2


class RIndex:
    """The r-index of Gagie, Navarro and Prezza over a text S.

    Symbols are hashable and mutually comparable; they are mapped to ranks
    1..sigma, and a sentinel 0, smaller than all symbols, is appended, so the
    index is built on t = S$ of length n + 1. Positions are 0-based. After
    construction only O(r) values are kept, where r is the number of runs of
    the Burrows-Wheeler transform of t: the run heads and starts, rank
    tables per symbol, the suffix array samples at the run ends, and the phi
    samples at the run starts. The suffix array is built naively here (the
    real index is built in compressed space); queries use binary search,
    so each step costs O(log r) instead of the O(log log) of predecessor
    structures.
    """

    def __init__(self, text: Iterable[Hashable] = ()) -> None:
        s = list(text)
        alphabet = sorted(set(s))
        self.code: dict[Any, int] = {c: i + 1 for i, c in enumerate(alphabet)}
        t = [self.code[c] for c in s] + [0]
        n1 = len(t)
        self.n = n1 - 1
        sa = _suffix_array(t)
        bwt = [t[i - 1] for i in sa]  # t[-1] is the sentinel
        # runs of the BWT
        self.heads: list[int] = []
        self.starts: list[int] = []
        for i, c in enumerate(bwt):
            if i == 0 or c != bwt[i - 1]:
                self.heads.append(c)
                self.starts.append(i)
        r = len(self.starts)
        self.r = r
        ends = [self.starts[k + 1] - 1 for k in range(r - 1)] + [n1 - 1]
        # per symbol: the indices of its runs, their starts and the counts before each run
        sigma = len(alphabet) + 1
        self.run_starts: list[list[int]] = [[] for _ in range(sigma)]
        self.run_ends: list[list[int]] = [[] for _ in range(sigma)]
        self.before: list[list[int]] = [[] for _ in range(sigma)]
        self.end_sample: list[list[int]] = [[] for _ in range(sigma)]
        total = [0] * sigma
        for k in range(r):
            c = self.heads[k]
            self.run_starts[c].append(self.starts[k])
            self.run_ends[c].append(ends[k])
            self.before[c].append(total[c])
            self.end_sample[c].append(sa[ends[k]])  # SA at the end of each run
            total[c] += ends[k] - self.starts[k] + 1
        self.C = [0] * (sigma + 1)
        for c in range(sigma):
            self.C[c + 1] = self.C[c] + total[c]
        self.last_sample = sa[n1 - 1]
        # phi(j) = SA[ISA[j] - 1]; sampled at j = SA[p] - 1 for the run starts p
        isa = [0] * n1
        for i, j in enumerate(sa):
            isa[j] = i
        pairs = []
        for p in self.starts:
            key = sa[p] - 1
            if key >= 0 and isa[key] > 0:
                pairs.append((key, sa[isa[key] - 1]))
        pairs.sort()
        self.phi_keys = [a for a, _ in pairs]
        self.phi_vals = [b for _, b in pairs]

    def _rank(self, c: int, i: int) -> int:
        """Number of c in BWT[0:i]."""
        k = bisect_right(self.run_starts[c], i - 1) - 1
        if k < 0:
            return 0
        return self.before[c][k] + min(i, self.run_ends[c][k] + 1) - self.run_starts[c][k]

    def _bwt_at(self, i: int) -> int:
        return self.heads[bisect_right(self.starts, i) - 1]

    def _phi(self, x: int) -> int:
        k = bisect_left(self.phi_keys, x)  # @line L10: the smallest key >= x
        return self.phi_vals[k] - (self.phi_keys[k] - x)

    def _search(self, p: Sequence[Hashable]) -> tuple[int, int, int]:
        """Backward search with the toehold: (sp, ep, SA[ep - 1]), or ep <= sp if absent."""
        sp, ep, s = 0, self.n + 1, self.last_sample  # @line L1
        for k in range(len(p) - 1, -1, -1):  # @line L2
            c = self.code.get(p[k])  # @line L3
            if c is None:
                return 0, 0, -1
            if self._bwt_at(ep - 1) == c:  # @line L4: the toehold moves with LF
                q_sa = s
            else:  # @line L5: the last c before ep ends a run, whose SA value is sampled
                j = bisect_right(self.run_starts[c], ep - 1) - 1
                if j < 0 or self.run_ends[c][j] < sp:
                    return 0, 0, -1
                q_sa = self.end_sample[c][j]
            sp, ep = self.C[c] + self._rank(c, sp), self.C[c] + self._rank(c, ep)  # @line L6
            if sp >= ep:  # @line L7
                return 0, 0, -1
            s = q_sa - 1  # @line L8
        return sp, ep, s

    def count(self, p: Sequence[Hashable]) -> int:
        sp, ep, _ = self._search(p)
        return max(0, ep - sp)

    def contains(self, p: Sequence[Hashable]) -> bool:
        return self.count(p) > 0

    def locate(self, p: Sequence[Hashable]) -> list[int]:
        """All starting positions of p in S, from the last row of its range up.

        The empty pattern occurs at the n + 1 positions 0..n.
        """
        sp, ep, s = self._search(p)
        out = []
        if ep > sp:
            out.append(s)  # @line L9: SA[ep - 1], then phi for the rows above
            for _ in range(ep - sp - 1):
                s = self._phi(s)
                out.append(s)
        return out

    def stored_values(self) -> int:
        """The number of integers kept after construction."""
        per_symbol = sum(len(x) for x in self.run_starts) * 4
        return 2 * self.r + per_symbol + len(self.C) + 2 * len(self.phi_keys) + 1

    def check_invariant(self) -> None:
        """Runs are maximal and nonempty; the tables are consistent and of size O(r)."""
        r = self.r
        assert len(self.heads) == len(self.starts) == r
        assert r == 0 or self.starts[0] == 0
        for k in range(1, r):
            assert self.starts[k] > self.starts[k - 1] and self.heads[k] != self.heads[k - 1]
        assert sum(len(x) for x in self.run_starts) == r
        for c in range(len(self.run_starts)):
            for j in range(len(self.run_starts[c])):
                assert self.run_starts[c][j] <= self.run_ends[c][j]
                if j:
                    run = self.run_ends[c][j - 1] - self.run_starts[c][j - 1] + 1
                    assert self.before[c][j] == self.before[c][j - 1] + run
        assert self.C[-1] == self.n + 1
        assert len(self.phi_keys) <= r and self.phi_keys == sorted(self.phi_keys)
        assert self.stored_values() <= 8 * r + len(self.C) + 1
test_r_index.py 87 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/r-index/. The authors are listed in prov/D8XXKM.jsonl.

import random

from r_index import RIndex


def occurrences(s: str, p: str) -> list[int]:
    return [i for i in range(len(s) - len(p) + 1) if s[i:i + len(p)] == p]


def bwt_runs(s: str) -> int:
    t = s + "\0"
    sa = sorted(range(len(t)), key=lambda i: t[i:])
    b = [t[i - 1] for i in sa]
    return sum(1 for i in range(len(b)) if i == 0 or b[i] != b[i - 1])


def test_example() -> None:
    idx = RIndex("banana")
    idx.check_invariant()
    assert idx.r == 5 and idx.heads == [1, 3, 2, 0, 1] and idx.starts == [0, 1, 3, 4, 5]
    assert idx.locate("ana") == [1, 3] and idx.count("an") == 2 and idx.count("a") == 3
    assert idx.locate("nab") == [] and not idx.contains("x")
    assert idx.phi_keys == [0, 3, 4, 5] and idx.phi_vals == [1, 5, 0, 6]


def test_edges() -> None:
    idx = RIndex("")
    idx.check_invariant()
    assert idx.r == 1 and idx.count("a") == 0 and idx.locate("") == [0]
    for n in (1, 2, 9, 300):
        s = "a" * n
        idx = RIndex(s)
        idx.check_invariant()
        assert idx.r == 2
        assert sorted(idx.locate("aa")) == occurrences(s, "aa")
        assert idx.count("a" * n) == 1 and idx.count("a" * (n + 1)) == 0
    s = "ab" * 200
    idx = RIndex(s)
    idx.check_invariant()
    assert idx.r == bwt_runs(s) and idx.r <= 4
    assert sorted(idx.locate("bab")) == occurrences(s, "bab")
    assert sorted(RIndex("abcab").locate("")) == list(range(6))
    idx = RIndex([3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5])
    assert idx.locate([5]) and sorted(idx.locate([5])) == [4, 8, 10]


def test_against_naive() -> None:
    rng = random.Random(2018)
    for trial in range(1500):
        sigma = "abcd"[: rng.choice([1, 2, 4])]
        n = rng.randint(0, 18)
        s = "".join(rng.choice(sigma) for _ in range(n))
        if trial % 25 == 0:
            s = rng.choice(["a" * n, "ab" * (n // 2), ""])
        idx = RIndex(s)
        idx.check_invariant()
        assert idx.r == bwt_runs(s)
        for _ in range(8):
            if s and rng.random() < 0.6:
                i = rng.randrange(len(s))
                p = s[i:i + rng.randint(1, 5)]
            else:
                p = "".join(rng.choice(sigma + "e") for _ in range(rng.randint(1, 4)))
            occ = occurrences(s, p)
            assert sorted(idx.locate(p)) == occ
            assert idx.count(p) == len(occ) and idx.contains(p) == bool(occ)


def test_repetitive_text_is_small() -> None:
    """On a highly repetitive text the index keeps far fewer values than n."""
    rng = random.Random(3)
    base = "".join(rng.choice("acgt") for _ in range(50))
    copies = []
    for _ in range(40):
        c = list(base)
        c[rng.randrange(50)] = rng.choice("acgt")
        copies.append("".join(c))
    s = "".join(copies)
    idx = RIndex(s)
    idx.check_invariant()
    assert idx.r < len(s) // 4
    assert idx.stored_values() < 8 * idx.r + 10
    p = base[10:30]
    assert sorted(idx.locate(p)) == occurrences(s, p)

What the next level needs

  • the tests did not run in this build

Read the full page about r-index