Algorithm Search⌘K

Range min-max tree in Python

A tree of min and max excess values over a balanced parenthesis sequence that speeds up tree navigation queries.

File
range_min_max_tree.py, 195 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
range_min_max_tree.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/range-min-max-tree/. The authors are listed in prov/EHY1SS.jsonl.
"""Range min-max tree over a balanced parenthesis sequence.

P is cut into blocks of b symbols. A complete binary tree over the
blocks stores, for each node, the total excess e of its range and the
minimum m and maximum M of the excess at the positions of the range,
relative to the excess just before the range. fwdsearch and bwdsearch
scan at most two blocks and walk O(log(n / b)) tree nodes.
"""

from __future__ import annotations

from typing import Optional, Sequence

_INF = 1 << 60


class RangeMinMaxTree:
    """fwdsearch, bwdsearch, findclose, findopen and enclose over parentheses."""

    def __init__(self, parens: Sequence[int] | str, b: int = 32) -> None:
        """``parens`` is a 0/1 sequence (1 = '(') or a string of '(' and ')'."""
        if isinstance(parens, str):
            parens = [1 if c == "(" else 0 for c in parens]
        self.P = list(parens)
        self.N = len(self.P)
        self.b = b
        nblocks = max(1, -(-self.N // b))
        size = 1
        while size < nblocks:
            size *= 2
        self.size = size
        self.e = [0] * (2 * size)
        self.m = [_INF] * (2 * size)
        self.M = [-_INF] * (2 * size)
        self.before = [0] * (nblocks + 1)  # excess before each block
        for k in range(nblocks):
            cur, lo, hi = 0, _INF, -_INF
            for j in range(k * b, min(self.N, (k + 1) * b)):
                cur += 1 if self.P[j] else -1
                lo, hi = min(lo, cur), max(hi, cur)
            v = size + k
            self.e[v], self.m[v], self.M[v] = cur, lo, hi
            self.before[k + 1] = self.before[k] + cur
        for v in range(size - 1, 0, -1):
            l, r = 2 * v, 2 * v + 1
            self.e[v] = self.e[l] + self.e[r]
            self.m[v] = min(self.m[l], self.e[l] + self.m[r])
            self.M[v] = max(self.M[l], self.e[l] + self.M[r])

    def _step(self, j: int) -> int:
        return 1 if self.P[j] else -1

    def excess(self, i: int) -> int:
        """E(i): opens minus closes in P[0..i]; E(-1) = 0."""
        if i < 0:
            return 0
        k = i // self.b
        cur = self.before[k]
        for j in range(k * self.b, i + 1):
            cur += self._step(j)
        return cur

    def _contains(self, v: int, base: int, t: int) -> bool:
        return base + self.m[v] <= t <= base + self.M[v]

    def fwdsearch(self, i: int, d: int) -> Optional[int]:
        """Least j > i with E(j) = E(i) + d, or None; 0 <= i < N."""
        t = self.excess(i) + d  # @line L1
        k = i // self.b
        cur = t - d
        for j in range(i + 1, min(self.N, (k + 1) * self.b)):  # @line L2
            cur += self._step(j)
            if cur == t:
                return j
        v, cur = self.size + k, self.before[k + 1]  # @line L3
        while v > 1:  # @line L4
            if v % 2 == 0 and self._contains(v + 1, cur, t):  # @line L5
                return self._descend_fwd(v + 1, cur, t)
            if v % 2 == 0:  # @line L6
                cur += self.e[v + 1]
            v //= 2  # @line L7
        return None  # @line L8

    def _descend_fwd(self, v: int, cur: int, t: int) -> int:
        """First position with excess t in the range of v; cur = excess before it."""
        while v < self.size:  # @line L9
            if self._contains(2 * v, cur, t):  # @line L10
                v = 2 * v
            else:  # @line L11
                cur += self.e[2 * v]
                v = 2 * v + 1
        k = v - self.size  # @line L12
        for j in range(k * self.b, min(self.N, (k + 1) * self.b)):
            cur += self._step(j)
            if cur == t:
                return j
        raise AssertionError("the min-max bounds promised a hit")

    def bwdsearch(self, i: int, d: int) -> Optional[int]:
        """Greatest j < i with E(j) = E(i) + d, or None; j = -1 stands for E(-1) = 0."""
        t = self.excess(i) + d
        k = i // self.b
        cur = t - d
        for j in range(i, k * self.b, -1):
            cur -= self._step(j)  # now cur = E(j - 1)
            if cur == t:
                return j - 1
        v, cur = self.size + k, self.before[k]  # cur = E(kb - 1)
        while v > 1:
            if v % 2 == 1 and self._contains(v - 1, cur - self.e[v - 1], t):
                return self._descend_bwd(v - 1, cur, t)
            if v % 2 == 1:
                cur -= self.e[v - 1]
            v //= 2
        return -1 if t == 0 else None

    def _descend_bwd(self, v: int, end_e: int, t: int) -> int:
        """Last position with excess t in the range of v; end_e = excess at its end."""
        while v < self.size:
            r = 2 * v + 1
            if self._contains(r, end_e - self.e[r], t):
                v = r
            else:
                end_e -= self.e[r]
                v = 2 * v
        k = v - self.size
        cur = end_e
        for j in range(min(self.N, (k + 1) * self.b) - 1, k * self.b - 1, -1):
            if cur == t:
                return j
            cur -= self._step(j)
        raise AssertionError("the min-max bounds promised a hit")

    def findclose(self, i: int) -> int:
        """The ')' matching the '(' at i."""
        j = self.fwdsearch(i, -1)  # @line L13
        assert j is not None
        return j

    def findopen(self, j: int) -> int:
        """The '(' matching the ')' at j."""
        i = self.bwdsearch(j, 0)  # @line L14
        assert i is not None
        return i + 1

    def enclose(self, i: int) -> Optional[int]:
        """The '(' of the tightest pair strictly containing the '(' at i, or None."""
        j = self.bwdsearch(i, -2)  # @line L15
        return None if j is None else j + 1

    def min_excess(self, i: int, j: int) -> int:
        """The minimum of E over positions i..j, 0 <= i <= j < N."""
        ki, kj = i // self.b, j // self.b
        cur = self.excess(i)
        best = cur
        if ki == kj:
            for x in range(i + 1, j + 1):
                cur += self._step(x)
                best = min(best, cur)
            return best
        for x in range(i + 1, (ki + 1) * self.b):
            cur += self._step(x)
            best = min(best, cur)
        nodes: list[int] = []
        self._cover(1, 0, self.size - 1, ki + 1, kj - 1, nodes)
        for v in nodes:
            best = min(best, cur + self.m[v])
            cur += self.e[v]
        for x in range(kj * self.b, j + 1):
            cur += self._step(x)
            best = min(best, cur)
        return best

    def _cover(self, v: int, lo: int, hi: int, a: int, z: int, out: list[int]) -> None:
        """Append, left to right, the canonical nodes covering blocks a..z."""
        if z < lo or hi < a or a > z:
            return
        if a <= lo and hi <= z:
            out.append(v)
            return
        mid = (lo + hi) // 2
        self._cover(2 * v, lo, mid, a, z, out)
        self._cover(2 * v + 1, mid + 1, hi, a, z, out)

    def rmq(self, i: int, j: int) -> int:
        """Leftmost position of the minimum of E over i..j."""
        mn = self.min_excess(i, j)
        e = self.excess(i)
        if e == mn:
            return i
        r = self.fwdsearch(i, mn - e)
        assert r is not None
        return r
test_range_min_max_tree.py 104 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/range-min-max-tree/. The authors are listed in prov/EHY1SS.jsonl.
"""Tests for the range min-max tree."""

from __future__ import annotations

import random
import unittest
from typing import Optional

from range_min_max_tree import RangeMinMaxTree

EXAMPLE = "((()())()((())))"


def random_balanced(rng: random.Random, n: int) -> list[int]:
    """A random balanced sequence of n pairs, built from a random tree."""
    ch: list[list[int]] = [[] for _ in range(n)]
    for v in range(1, n):
        ch[rng.randrange(v)].append(v)
    out: list[int] = []

    def walk(v: int) -> None:
        out.append(1)
        for c in ch[v]:
            walk(c)
        out.append(0)

    walk(0)
    return out


def excess_list(p: list[int]) -> list[int]:
    e, out = 0, []
    for x in p:
        e += 1 if x else -1
        out.append(e)
    return out


def naive_fwd(E: list[int], i: int, d: int) -> Optional[int]:
    for j in range(i + 1, len(E)):
        if E[j] == E[i] + d:
            return j
    return None


def naive_bwd(E: list[int], i: int, d: int) -> Optional[int]:
    for j in range(i - 1, -2, -1):
        if (E[j] if j >= 0 else 0) == E[i] + d:
            return j
    return None


class TestRmM(unittest.TestCase):
    def test_example(self) -> None:
        t = RangeMinMaxTree(EXAMPLE, b=4)
        self.assertEqual(t.e[t.size:t.size + 4], [2, 0, 2, -4])
        self.assertEqual(t.m[t.size:t.size + 4], [1, -1, -1, -4])
        self.assertEqual(t.M[t.size:t.size + 4], [3, 1, 2, -1])
        self.assertEqual((t.e[1], t.m[1], t.M[1]), (0, 0, 4))
        self.assertEqual((t.e[2], t.m[2], t.M[2]), (2, 1, 3))
        self.assertEqual((t.e[3], t.m[3], t.M[3]), (-2, -2, 2))
        self.assertEqual(t.findclose(1), 6)
        self.assertEqual(t.findclose(0), 15)
        self.assertEqual(t.findclose(9), 14)
        self.assertEqual(t.findopen(12), 11)
        self.assertEqual(t.findopen(15), 0)
        self.assertEqual(t.enclose(4), 1)
        self.assertEqual(t.enclose(11), 10)
        self.assertIsNone(t.enclose(0))
        self.assertEqual(t.rmq(4, 11), 6)
        self.assertEqual(t.min_excess(4, 11), 1)

    def test_tiny(self) -> None:
        t = RangeMinMaxTree("()", b=1)
        self.assertEqual(t.findclose(0), 1)
        self.assertEqual(t.findopen(1), 0)
        self.assertIsNone(t.enclose(0))

    def test_random(self) -> None:
        rng = random.Random(1155)
        for _ in range(500):
            p = random_balanced(rng, rng.randint(1, 40))
            t = RangeMinMaxTree(p, b=rng.choice([1, 2, 3, 4, 8, 16]))
            E = excess_list(p)
            N = len(p)
            for _ in range(8):
                i = rng.randrange(N)
                d = rng.randint(-4, 3)
                self.assertEqual(t.excess(i), E[i])
                self.assertEqual(t.fwdsearch(i, d), naive_fwd(E, i, d))
                self.assertEqual(t.bwdsearch(i, d), naive_bwd(E, i, d))
                a, z = sorted((rng.randrange(N), rng.randrange(N)))
                self.assertEqual(t.min_excess(a, z), min(E[a:z + 1]))
                self.assertEqual(t.rmq(a, z), a + E[a:z + 1].index(min(E[a:z + 1])))
            for i in range(N):
                if p[i]:
                    j = t.findclose(i)
                    self.assertEqual(t.findopen(j), i)


if __name__ == "__main__":
    unittest.main()

What the next level needs

  • the tests did not run in this build

Read the full page about Range min-max tree