Algorithm Search⌘K

Chtholly tree in Python

Ordered set of constant-valued intervals that gives amortized efficient range assignment.

File
chtholly_tree.py, 115 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
chtholly_tree.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/chtholly-tree/. The authors are listed in prov/MQYKYZ.jsonl.

from bisect import bisect_right
from typing import Callable, Sequence


class ChthollyTree:
    """Runs (start, value) of an integer array A[0..n-1]; run i covers
    [starts[i], starts[i+1]) with starts[R] read as n.

    The runs live in two sorted Python lists, so a split is a binary
    search plus a list insert. assign deletes the runs it covers in one
    slice. Range operations cost O(k + log n) searches for k runs, plus
    the list shifts of Python.
    """

    def __init__(self, a: Sequence[int]) -> None:
        """Make one run per maximal block of equal values of a; n >= 1."""
        if len(a) == 0:
            raise ValueError("the array must be non-empty")
        self.n = len(a)
        self.starts: list[int] = []
        self.vals: list[int] = []
        for i, x in enumerate(a):
            if i == 0 or x != a[i - 1]:
                self.starts.append(i)
                self.vals.append(x)

    def _split(self, x: int) -> int:
        """Make a run start at x, 0 <= x <= n; return its index (R if x = n)."""
        if x == self.n:  # @line L1
            return len(self.starts)
        i = bisect_right(self.starts, x) - 1  # @line L2
        if self.starts[i] == x:  # @line L3
            return i
        self.starts.insert(i + 1, x)  # @line L4
        self.vals.insert(i + 1, self.vals[i])
        return i + 1  # @line L5

    def _check_range(self, l: int, r: int) -> None:
        if not 0 <= l < r <= self.n:
            raise IndexError("need 0 <= l < r <= n")

    def assign(self, l: int, r: int, v: int) -> None:
        """Set A[k] = v for l <= k < r. O(log n) amortized searches."""
        self._check_range(l, r)
        hi = self._split(r)  # @line L6
        before = len(self.starts)
        lo = self._split(l)  # @line L7
        hi += len(self.starts) - before  # an index shift, as l < r
        del self.starts[lo:hi]  # @line L8
        del self.vals[lo:hi]
        self.starts.insert(lo, l)  # @line L9
        self.vals.insert(lo, v)

    def each(self, l: int, r: int, f: Callable[[int, int, int], int]) -> None:
        """Call v = f(s, e, v) on each run (s, e, v) that tiles [l, r)."""
        self._check_range(l, r)
        self._split(r)  # @line L10
        lo = self._split(l)  # @line L11
        i = lo
        while i < len(self.starts) and self.starts[i] < r:  # @line L12
            e = self.starts[i + 1] if i + 1 < len(self.starts) else self.n
            self.vals[i] = f(self.starts[i], e, self.vals[i])  # @line L13
            i += 1

    def add(self, l: int, r: int, d: int) -> None:
        """Add d to A[k] for l <= k < r."""
        self.each(l, r, lambda s, e, v: v + d)

    def range_sum(self, l: int, r: int) -> int:
        """Return A[l] + ... + A[r-1]."""
        total = 0

        def f(s: int, e: int, v: int) -> int:
            nonlocal total
            total += v * (e - s)
            return v

        self.each(l, r, f)
        return total

    def kth_smallest(self, l: int, r: int, k: int) -> int:
        """The k-th smallest of A[l..r-1], 1 <= k <= r - l."""
        if not 1 <= k <= r - l:
            raise ValueError("k out of range")
        parts: list[tuple[int, int]] = []
        self.each(l, r, lambda s, e, v: (parts.append((v, e - s)), v)[1])
        parts.sort()
        for v, c in parts:
            if k <= c:
                return v
            k -= c
        raise AssertionError("unreachable")

    def to_list(self) -> list[int]:
        """The array A."""
        out: list[int] = []
        for i, s in enumerate(self.starts):
            e = self.starts[i + 1] if i + 1 < len(self.starts) else self.n
            out.extend([self.vals[i]] * (e - s))
        return out

    def runs(self) -> list[tuple[int, int, int]]:
        """The runs (s, e, v) in order."""
        ends = self.starts[1:] + [self.n]
        return list(zip(self.starts, ends, self.vals))

    def check_invariant(self) -> None:
        """Check (C): starts strictly increase from 0 and stay below n."""
        assert len(self.starts) == len(self.vals) >= 1
        assert self.starts[0] == 0
        for i in range(1, len(self.starts)):
            assert self.starts[i - 1] < self.starts[i] < self.n
test_chtholly_tree.py 71 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/chtholly-tree/. The authors are listed in prov/MQYKYZ.jsonl.

import random

import pytest

from chtholly_tree import ChthollyTree


def test_example() -> None:
    t = ChthollyTree([1, 1, 2, 2, 2, 3, 3, 3, 3, 1])
    assert t.runs() == [(0, 2, 1), (2, 5, 2), (5, 9, 3), (9, 10, 1)]
    t.assign(3, 7, 5)
    assert t.runs() == [(0, 2, 1), (2, 3, 2), (3, 7, 5), (7, 9, 3), (9, 10, 1)]
    assert t.range_sum(1, 8) == 26
    t.add(0, 4, 1)
    assert t.to_list() == [2, 2, 3, 6, 5, 5, 5, 3, 3, 1]
    assert t.runs()[:5] == [(0, 1, 2), (1, 2, 2), (2, 3, 3), (3, 4, 6), (4, 7, 5)]
    assert t.kth_smallest(0, 10, 5) == 3
    t.check_invariant()


def test_edges() -> None:
    with pytest.raises(ValueError):
        ChthollyTree([])
    t = ChthollyTree([7])
    t.assign(0, 1, 3)
    assert t.runs() == [(0, 1, 3)]
    assert t.range_sum(0, 1) == 3 and t.kth_smallest(0, 1, 1) == 3
    with pytest.raises(IndexError):
        t.assign(0, 0, 1)
    with pytest.raises(IndexError):
        t.add(0, 2, 1)
    with pytest.raises(ValueError):
        t.kth_smallest(0, 1, 2)
    u = ChthollyTree(list(range(50)))
    assert len(u.runs()) == 50
    u.assign(0, 50, 0)
    assert u.runs() == [(0, 50, 0)]
    u.assign(10, 20, 1)
    assert len(u.runs()) == 3
    u.check_invariant()


def test_random_against_brute_force() -> None:
    rng = random.Random(896)
    for _ in range(500):
        n = rng.randint(1, 30)
        a = [rng.randint(0, 5) for _ in range(n)]
        t = ChthollyTree(a)
        for _ in range(rng.randint(1, 30)):
            l = rng.randrange(n)
            r = rng.randint(l + 1, n)
            op = rng.randrange(4)
            if op == 0:
                v = rng.randint(-5, 5)
                t.assign(l, r, v)
                a[l:r] = [v] * (r - l)
            elif op == 1:
                d = rng.randint(-3, 3)
                t.add(l, r, d)
                for k in range(l, r):
                    a[k] += d
            elif op == 2:
                assert t.range_sum(l, r) == sum(a[l:r])
            else:
                k = rng.randint(1, r - l)
                assert t.kth_smallest(l, r, k) == sorted(a[l:r])[k - 1]
            t.check_invariant()
            assert t.to_list() == a

What the next level needs

  • the tests did not run in this build

Read the full page about Chtholly tree