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
# 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