Algorithm Search⌘K

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
tango_tree.pyRawGitHub
# 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

Read the full page about Tango tree