Algorithm Search⌘K

Bird-Baker algorithm in Python

Two-dimensional matching that names the pattern rows with Aho-Corasick and then matches the columns of row names with a second string matcher.

File
bird_baker.py, 122 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
bird_baker.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/bird-baker/. The authors are listed in prov/WZE4CC.jsonl.
"""Bird-Baker two-dimensional matching for a set of rectangular patterns.

A text or a pattern is a list of rows, all of the same length. The result is the sorted
list of triples (r, c, j) such that pattern j occurs with its top-left corner at row r
and column c of the text. Indices are 0-based and occurrences may overlap. Patterns of
different widths are handled by one pass per width.
"""

from __future__ import annotations

from collections import deque
from typing import Hashable, Sequence

Grid = Sequence[Sequence[Hashable]]


class _Automaton:
    """Aho-Corasick automaton over sequences of hashable symbols."""

    def __init__(self, words: Sequence[Sequence[Hashable]]) -> None:
        self.goto: list[dict[Hashable, int]] = [{}]
        self.depth: list[int] = [0]
        self.word: list[int] = [-1]  # index of the word that ends at this node, or -1
        self.out: list[list[int]] = [[]]  # all words that end at this node (with failure links)
        for index, w in enumerate(words):
            q = 0
            for a in w:
                nxt = self.goto[q].get(a)
                if nxt is None:
                    nxt = len(self.goto)
                    self.goto[q][a] = nxt
                    self.goto.append({})
                    self.depth.append(self.depth[q] + 1)
                    self.word.append(-1)
                    self.out.append([])
                q = nxt
            if self.word[q] < 0:
                self.word[q] = index
            self.out[q].append(index)
        self.fail = [0] * len(self.goto)
        queue: deque[int] = deque(self.goto[0].values())
        while queue:
            v = queue.popleft()
            for a, u in self.goto[v].items():
                f = self.fail[v]
                while f and a not in self.goto[f]:
                    f = self.fail[f]
                g = self.goto[f].get(a, 0)
                self.fail[u] = g if g != u else 0
                self.out[u] = self.out[u] + self.out[self.fail[u]]
                queue.append(u)

    def step(self, q: int, a: Hashable) -> int:
        while q and a not in self.goto[q]:
            q = self.fail[q]
        return self.goto[q].get(a, 0)


def _shape(grid: Grid, name: str) -> tuple[int, int]:
    rows = len(grid)
    cols = len(grid[0]) if rows else 0
    if any(len(row) != cols for row in grid):
        raise ValueError(f"{name} is not rectangular")
    return rows, cols


def _one_width(text: Grid, n2: int, patterns: list[tuple[int, Grid]], w: int,
               out: list[tuple[int, int, int]], labels: list[list[int]] | None) -> None:
    # @line L2
    row_ids: dict[tuple[Hashable, ...], int] = {}
    for _, p in patterns:
        for row in p:
            row_ids.setdefault(tuple(row), len(row_ids))
    rows_ac = _Automaton(list(row_ids))
    # @line L3
    columns = [[row_ids[tuple(row)] for row in p] for _, p in patterns]
    # @line L4
    cols_ac = _Automaton(columns)
    heights = [len(p) for _, p in patterns]
    # @line L5
    state = [0] * n2
    # @line L6
    for r, text_row in enumerate(text):
        # @line L7
        label = [-1] * n2
        q = 0
        for c, a in enumerate(text_row):
            q = rows_ac.step(q, a)
            if rows_ac.depth[q] == w:
                label[c] = rows_ac.word[q]
        if labels is not None:
            labels.append(label)
        # @line L8
        for c in range(w - 1, n2):
            # @line L9
            state[c] = cols_ac.step(state[c], label[c])
            # @line L10
            for i in cols_ac.out[state[c]]:
                out.append((r - heights[i] + 1, c - w + 1, patterns[i][0]))


def bird_baker(text: Grid, patterns: Sequence[Grid],
               labels: list[list[int]] | None = None) -> list[tuple[int, int, int]]:
    """Return all (r, c, j) with pattern j occurring at row r, column c of the text."""
    n1, n2 = _shape(text, "text")
    out: list[tuple[int, int, int]] = []
    by_width: dict[int, list[tuple[int, Grid]]] = {}
    # @line L1
    for j, p in enumerate(patterns):
        m1, m2 = _shape(p, f"pattern {j}")
        if m1 == 0 or m2 == 0:
            for r in range(n1 - m1 + 1):
                for c in range(n2 + 1):
                    out.append((r, c, j))
        else:
            by_width.setdefault(m2, []).append((j, p))
    for w in sorted(by_width):
        _one_width(text, n2, by_width[w], w, out, labels)
    out.sort()
    return out
test_bird_baker.py 95 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/bird-baker/. The authors are listed in prov/WZE4CC.jsonl.
import random

import pytest

from bird_baker import bird_baker


def naive(text, patterns):
    n1 = len(text)
    n2 = len(text[0]) if n1 else 0
    out = []
    for j, p in enumerate(patterns):
        m1 = len(p)
        m2 = len(p[0]) if m1 else 0
        for r in range(n1 - m1 + 1):
            for c in range(n2 - m2 + 1):
                if all(text[r + i][c:c + m2] == p[i] for i in range(m1)):
                    out.append((r, c, j))
    return sorted(out)


def test_example() -> None:
    text = ["ababa", "babab", "ababa", "bbaab"]
    patterns = [["ab", "ba"], ["ba", "ab", "ba"]]
    labels: list[list[int]] = []
    got = bird_baker(text, patterns, labels)
    assert got == [(0, 0, 0), (0, 1, 1), (0, 2, 0), (0, 3, 1), (1, 1, 0), (1, 3, 0)]
    # Row ids: ab = 0, ba = 1; -1 means that no pattern row ends there.
    assert labels == [[-1, 0, 1, 0, 1], [-1, 1, 0, 1, 0], [-1, 0, 1, 0, 1], [-1, -1, 1, -1, 0]]


def test_empty_and_degenerate() -> None:
    assert bird_baker([], []) == []
    assert bird_baker([], [[]]) == [(0, 0, 0)]
    assert bird_baker(["ab"], [[]]) == [(0, 0, 0), (0, 1, 0), (0, 2, 0), (1, 0, 0), (1, 1, 0), (1, 2, 0)]
    # One row of width 0: every row start, every column 0..n2.
    assert bird_baker(["ab", "ba"], [[""]]) == naive(["ab", "ba"], [[""]])
    assert bird_baker(["ab"], [["abc"]]) == []
    assert bird_baker(["ab"], [["a"], ["a"]]) == [(0, 0, 0), (0, 0, 1)]
    with pytest.raises(ValueError):
        bird_baker(["ab", "a"], [["a"]])
    with pytest.raises(ValueError):
        bird_baker(["ab"], [["ab", "a"]])


def test_tuples_as_symbols() -> None:
    text = [(1, 2, 1, 2), (2, 1, 2, 1)]
    assert bird_baker(text, [[(1, 2), (2, 1)]]) == [(0, 0, 0), (0, 2, 0)]


def test_periodic_text() -> None:
    text = ["ab" * 6] * 7
    patterns = [["ab", "ab"], ["ba"] * 3, ["aba"], ["a"], ["abab"] * 7]
    assert bird_baker(text, patterns) == naive(text, patterns)


def random_grid(rng: random.Random, alphabet: str, rows: int, cols: int) -> list[str]:
    return ["".join(rng.choice(alphabet) for _ in range(cols)) for _ in range(rows)]


def test_random_against_naive() -> None:
    rng = random.Random(20261106)
    cases = 0
    for alphabet in ("a", "ab", "abcd"):
        for _ in range(400):
            n1 = rng.randint(0, 7)
            n2 = rng.randint(1, 8) if n1 else 0
            text = random_grid(rng, alphabet, n1, n2)
            if rng.random() < 0.25 and n1:
                tile = random_grid(rng, alphabet, rng.randint(1, 2), rng.randint(1, 2))
                text = [(tile[i % len(tile)] * n2)[:n2] for i in range(n1)]
            patterns = []
            for _ in range(rng.randint(0, 5)):
                kind = rng.random()
                if kind < 0.4 and n1 and n2:
                    # A sub-rectangle of the text.
                    r = rng.randrange(n1)
                    c = rng.randrange(n2)
                    h = rng.randint(1, n1 - r)
                    w = rng.randint(1, n2 - c)
                    patterns.append([row[c:c + w] for row in text[r:r + h]])
                elif kind < 0.6 and patterns and patterns[-1] and patterns[-1][0]:
                    # Top or bottom part of the previous pattern, or a copy of it.
                    prev = patterns[-1]
                    cut = rng.randint(1, len(prev))
                    patterns.append(list(prev[:cut]) if rng.random() < 0.5 else list(prev[-cut:]))
                elif kind < 0.65:
                    patterns.append([] if rng.random() < 0.5 else [""] * rng.randint(1, 3))
                else:
                    patterns.append(random_grid(rng, alphabet, rng.randint(1, 3), rng.randint(1, 3)))
            assert bird_baker(text, patterns) == naive(text, patterns), (text, patterns)
            cases += 1
    assert cases >= 1200

What the next level needs

  • the tests did not run in this build

Read the full page about Bird-Baker algorithm