Tango tree in Python
Online BST with O(log log n) competitive ratio against the best offline BST.
- File
tango_tree.py, 151 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/tango-tree/. The authors are listed in prov/MK70WC.jsonl.
"""A tango tree over the keys 1..n, with each preferred path kept as a sorted list.
The reference tree P is the balanced BST on 1..n whose root is the middle key.
Each node has a preferred child (None, 'L' or 'R'). The preferred paths are
stored as sorted Python lists of keys; a search in a path is a binary search,
which makes O(log log n) comparisons as in a real tango tree. Cut and join are
list slices here, which cost O(log n) element moves instead of the O(log log n)
of the split and concatenate of red-black trees in the real structure.
"""
from bisect import bisect_left
from typing import Dict, List, Optional, Tuple
class Path:
__slots__ = ("keys",)
def __init__(self, keys: List[int]) -> None:
self.keys = keys # sorted keys of the nodes on the path
class TangoTree:
def __init__(self, n: int) -> None:
if n < 1:
raise ValueError("n must be at least 1")
self.n = n
self.lo: Dict[int, int] = {}
self.hi: Dict[int, int] = {}
self.depth: Dict[int, int] = {}
self.left: Dict[int, Optional[int]] = {}
self.right: Dict[int, Optional[int]] = {}
self.root = (1 + n) // 2
stack = [(1, n, 0)]
while stack:
a, b, d = stack.pop()
m = (a + b) // 2
self.lo[m], self.hi[m], self.depth[m] = a, b, d
self.left[m] = (a + m - 1) // 2 if a <= m - 1 else None
self.right[m] = (m + 1 + b) // 2 if m + 1 <= b else None
if a <= m - 1:
stack.append((a, m - 1, d + 1))
if m + 1 <= b:
stack.append((m + 1, b, d + 1))
self.pref: Dict[int, Optional[str]] = {y: None for y in self.lo}
self.top: Dict[int, Path] = {y: Path([y]) for y in self.lo} # path by its top node
self.comparisons = 0
self.lr_switches = 0 # preferred child changes between L and R: the interleave bound
def _child(self, u: int, side: Optional[str]) -> Optional[int]:
if side == "L":
return self.left[u]
if side == "R":
return self.right[u]
return None
def _cut(self, path: Path, u: int) -> None:
"""Remove from path the nodes below u; they become a path of their own."""
c = self._child(u, self.pref[u])
if c is None:
return
ks = path.keys
i, j = bisect_left(ks, self.lo[c]), bisect_left(ks, self.hi[c] + 1)
self.top[c] = Path(ks[i:j])
del ks[i:j]
def _join(self, path: Path, c: Optional[int]) -> None:
"""Append to path (which ends at the parent of c) the path whose top is c."""
if c is None:
return
lower = self.top.pop(c)
ks = path.keys
i = bisect_left(ks, self.lo[c])
ks[i:i] = lower.keys
def _search(self, ks: List[int], x: int) -> int:
"""Binary search; counts the comparisons, which are O(log log n)."""
a, b = 0, len(ks)
while a < b:
self.comparisons += 1
m = (a + b) // 2
if ks[m] < x:
a = m + 1
else:
b = m
return a
def access(self, x: int) -> Tuple[int, int]:
"""Access key x. Return (paths visited, L-R switches of this access)."""
if x not in self.lo:
raise KeyError(x)
path = self.top[self.root] # @line L1
switches: List[Tuple[int, int]] = []
while True: # @line L2
ks = path.keys
i = self._search(ks, x) # @line L3
if i < len(ks) and ks[i] == x: # @line L4
break
cands = [ks[j] for j in (i - 1, i) if 0 <= j < len(ks)] # @line L5
u = max(cands, key=lambda y: self.depth[y])
side = "L" if x < u else "R" # @line L6
c = self._child(u, side)
switches.append((u, c))
path = self.top[c] # @line L7
before = self.lr_switches
p = self.top[self.root] # each join moves the lower path into this one
for u, c in switches: # @line L8
if self.pref[u] is not None:
self.lr_switches += 1
self._cut(p, u) # @line L9
self._join(p, c) # @line L10
self.pref[u] = "L" if c == self.left[u] else "R"
if self.pref[x] != "L": # @line L11
if self.pref[x] == "R":
self.lr_switches += 1
self._cut(p, x)
self._join(p, self.left[x])
self.pref[x] = "L"
return len(switches) + 1, self.lr_switches - before
def paths(self) -> List[List[int]]:
return [p.keys for p in self.top.values()]
def check_invariant(self) -> None:
seen = set()
for t, p in self.top.items():
assert p.keys == sorted(p.keys)
# the path is the chain of preferred children from t
chain = []
y: Optional[int] = t
while y is not None:
chain.append(y)
y = self._child(y, self.pref[y])
assert sorted(chain) == p.keys, (t, chain, p.keys)
for y in chain:
assert y not in seen
seen.add(y)
# the top is the root or a child that is not its parent's preferred child
if t != self.root:
par = self._parent(t)
assert self._child(par, self.pref[par]) != t
assert seen == set(self.lo)
def _parent(self, y: int) -> int:
v = self.root
while True:
c = self.left[v] if y < v else self.right[v]
if c == y:
return v
v = c # type: ignore[assignment]
test_tango_tree.py 88 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/tango-tree/. The authors are listed in prov/MK70WC.jsonl.
import math
import random
import pytest
from tango_tree import TangoTree
def interleave_bound(n, seq):
"""Wilber's interleave bound on the reference tree with root (1 + n) // 2."""
total = 0
stack = [(1, n)]
while stack:
a, b = stack.pop()
if a > b:
continue
y = (a + b) // 2
labels = ["L" if x <= y else "R" for x in seq if a <= x <= b]
total += sum(1 for s, t in zip(labels, labels[1:]) if s != t)
stack.append((a, y - 1))
stack.append((y + 1, b))
return total
def test_small_example() -> None:
t = TangoTree(7)
assert t.root == 4
t.check_invariant()
assert t.access(1) == (3, 0)
assert sorted(t.paths()) == [[1, 2, 4], [3], [5], [6], [7]]
assert t.access(3) == (2, 1)
assert sorted(t.paths()) == [[1], [2, 3, 4], [5], [6], [7]]
assert t.access(6) == (2, 1)
assert sorted(t.paths()) == [[1], [2, 3], [4, 5, 6], [7]]
t.check_invariant()
with pytest.raises(KeyError):
t.access(8)
def test_switches_equal_interleave_bound() -> None:
rng = random.Random(20251104)
for case in range(600):
n = rng.randrange(1, 70)
t = TangoTree(n)
seq = []
visited_total = 0
for _ in range(rng.randrange(1, 60)):
if seq and rng.random() < 0.3:
x = min(n, max(1, seq[-1] + rng.choice([-1, 1])))
else:
x = rng.randrange(1, n + 1)
seq.append(x)
visited, k = t.access(x)
assert 0 <= k <= visited
visited_total += visited
t.check_invariant()
# each path change below the top is an L-R switch or a first choice, which happens once per node
assert visited_total <= t.lr_switches + len(seq) + n
assert t.lr_switches == interleave_bound(n, seq)
def test_comparisons_are_log_log_per_path() -> None:
rng = random.Random(5)
n = 2 ** 12 - 1
t = TangoTree(n)
visited_total = 0
for _ in range(3000):
before = t.comparisons
v, _ = t.access(rng.randrange(1, n + 1))
visited_total += v
# each path has at most lg(n + 1) = 12 nodes, so a binary search makes at most 4 comparisons
assert t.comparisons - before <= v * (math.ceil(math.log2(13)))
t.check_invariant()
assert visited_total <= t.lr_switches + 3000 + n
def test_bit_reversal() -> None:
k = 8
n = 2 ** k - 1
seq = [int(format(i, "0%db" % k)[::-1], 2) for i in range(1, 2 ** k)]
seq = [x for x in seq if x != 0] * 3
t = TangoTree(n)
for x in seq:
t.access(x)
assert t.lr_switches == interleave_bound(n, seq)
What the next level needs
- the tests did not run in this build