Algorithm Search⌘K

Persistent segment tree in Python

A segment tree that stores each update as O(log n) new nodes, so queries on any past version run in O(log n).

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

"""A persistent segment tree of integer sums, built by path copying.

Each version is the index of a root node in a shared pool of nodes. An
update copies the O(log n) nodes on one root-to-leaf path and shares the
rest, so every old version stays valid and can still be queried or
updated.
"""

from typing import List, Sequence

_NIL = -1


class PersistentSegmentTree:
    """Fully persistent array of n integers with range-sum queries.

    Versions are numbered 0, 1, 2, ... in the order they are made.
    Version 0 is the array given to the constructor.
    """

    def __init__(self, values: Sequence[int]) -> None:
        """Build version 0 from ``values``. Cost: Θ(n) time and space."""
        if len(values) == 0:
            raise ValueError("a segment tree needs at least one position")
        self._n = len(values)
        self._left: List[int] = []
        self._right: List[int] = []
        self._sum: List[int] = []
        self._roots: List[int] = [self._build(values, 0, self._n)]

    def _new(self, left: int, right: int, total: int) -> int:
        self._left.append(left)
        self._right.append(right)
        self._sum.append(total)
        return len(self._sum) - 1

    def _build(self, a: Sequence[int], lo: int, hi: int) -> int:
        # Recursion depth is ceil(lg n).
        if hi - lo == 1:  # @line L1
            return self._new(_NIL, _NIL, a[lo])
        mid = (lo + hi) // 2  # @line L2
        left = self._build(a, lo, mid)  # @line L3
        right = self._build(a, mid, hi)
        return self._new(left, right, self._sum[left] + self._sum[right])  # @line L4

    def __len__(self) -> int:
        """Number of positions n. Cost: O(1)."""
        return self._n

    def num_versions(self) -> int:
        """Number of versions made so far. Cost: O(1)."""
        return len(self._roots)

    def num_nodes(self) -> int:
        """Number of nodes in the shared pool. Cost: O(1)."""
        return len(self._sum)

    def _root(self, version: int) -> int:
        if not 0 <= version < len(self._roots):
            raise IndexError("no such version")
        return self._roots[version]

    def _check_index(self, i: int) -> None:
        if not 0 <= i < self._n:
            raise IndexError("position out of range")

    def _update(self, t: int, lo: int, hi: int, i: int, x: int) -> int:
        if hi - lo == 1:  # @line L5
            return self._new(_NIL, _NIL, x)
        mid = (lo + hi) // 2  # @line L6
        if i < mid:  # @line L7
            left = self._update(self._left[t], lo, mid, i, x)
            right = self._right[t]
        else:  # @line L8
            left = self._left[t]
            right = self._update(self._right[t], mid, hi, i, x)
        return self._new(left, right, self._sum[left] + self._sum[right])  # @line L9

    def update(self, version: int, i: int, x: int) -> int:
        """Make a new version equal to ``version`` with a[i] = x.

        Returns the number of the new version. Cost: Θ(log n) time and
        ceil(lg n) + 1 new nodes.
        """
        root = self._root(version)
        self._check_index(i)
        self._roots.append(self._update(root, 0, self._n, i, x))
        return len(self._roots) - 1

    def add(self, version: int, i: int, delta: int) -> int:
        """Make a new version equal to ``version`` with a[i] += delta.

        Cost: Θ(log n) time and ceil(lg n) + 1 new nodes.
        """
        return self.update(version, i, self.get(version, i) + delta)

    def _query(self, t: int, lo: int, hi: int, l: int, r: int) -> int:
        if r <= lo or hi <= l:  # @line L10
            return 0
        if l <= lo and hi <= r:  # @line L11
            return self._sum[t]
        mid = (lo + hi) // 2  # @line L12
        return self._query(self._left[t], lo, mid, l, r) + self._query(  # @line L13
            self._right[t], mid, hi, l, r
        )

    def query(self, version: int, l: int, r: int) -> int:
        """Sum of a[l], ..., a[r - 1] in ``version``; 0 if l >= r.

        Cost: Θ(log n) time, no new nodes.
        """
        root = self._root(version)
        if l < 0 or r > self._n:
            raise IndexError("range out of bounds")
        if l >= r:
            return 0
        return self._query(root, 0, self._n, l, r)

    def get(self, version: int, i: int) -> int:
        """The value a[i] in ``version``. Cost: Θ(log n)."""
        root = self._root(version)
        self._check_index(i)
        t, lo, hi = root, 0, self._n
        while hi - lo > 1:
            mid = (lo + hi) // 2
            if i < mid:
                t, hi = self._left[t], mid
            else:
                t, lo = self._right[t], mid
        return self._sum[t]

    def kth(self, before: int, after: int, k: int) -> int:
        """Position of the k-th unit of the difference ``after - before``.

        Treat the tree as counts over positions. Return the least position
        p such that the sum of (after - before) over [0, p] is at least k,
        for k >= 1. The difference must have no negative entry. Cost:
        Θ(log n) time, no new nodes.
        """
        u = self._root(before)
        v = self._root(after)
        if k < 1 or k > self._sum[v] - self._sum[u]:  # @line K1
            raise IndexError("k out of range")
        lo, hi = 0, self._n
        while hi - lo > 1:  # @line K2
            mid = (lo + hi) // 2
            c = self._sum[self._left[v]] - self._sum[self._left[u]]  # @line K3
            if k <= c:  # @line K4
                u, v, hi = self._left[u], self._left[v], mid
            else:  # @line K5
                k -= c
                u, v, lo = self._right[u], self._right[v], mid
        return lo  # @line K6

    def values(self, version: int) -> List[int]:
        """The array of ``version``. Cost: Θ(n)."""
        root = self._root(version)
        out: List[int] = []
        stack = [(root, 0, self._n)]
        while stack:
            t, lo, hi = stack.pop()
            if hi - lo == 1:
                out.append(self._sum[t])
                continue
            mid = (lo + hi) // 2
            stack.append((self._right[t], mid, hi))
            stack.append((self._left[t], lo, mid))
        return out

    def check_invariant(self) -> None:
        """Check the shape and sums of every node reachable from a root.

        A node reached for the range [lo, hi) must be a leaf iff hi - lo = 1,
        and an inner node must hold the sum of its two children, which cover
        [lo, mid) and [mid, hi). Children must be older than their parent,
        so the pool has no cycle. Cost: O(total nodes).
        """
        seen = set()
        for root in self._roots:
            stack = [(root, 0, self._n)]
            while stack:
                t, lo, hi = stack.pop()
                if (t, lo, hi) in seen:
                    continue
                seen.add((t, lo, hi))
                assert 0 <= t < len(self._sum)
                if hi - lo == 1:
                    assert self._left[t] == _NIL and self._right[t] == _NIL
                    continue
                left, right = self._left[t], self._right[t]
                assert 0 <= left < t and 0 <= right < t
                assert self._sum[t] == self._sum[left] + self._sum[right]
                mid = (lo + hi) // 2
                stack.append((left, lo, mid))
                stack.append((right, mid, hi))
test_persistent_segment_tree.py 116 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/persistent-segment-tree/. The authors are listed in prov/KB52KZ.jsonl.

import random

import pytest

from persistent_segment_tree import PersistentSegmentTree


def test_example() -> None:
    t = PersistentSegmentTree([5, 3, 7, 1])
    assert t.num_nodes() == 7
    assert t.query(0, 0, 4) == 16
    v1 = t.update(0, 2, 10)
    assert t.num_nodes() == 10
    v2 = t.update(0, 0, 0)
    assert t.num_nodes() == 13
    assert t.values(0) == [5, 3, 7, 1]
    assert t.values(v1) == [5, 3, 10, 1]
    assert t.values(v2) == [0, 3, 7, 1]
    assert t.query(v1, 1, 3) == 13
    assert t.query(0, 1, 3) == 10
    assert t.query(v2, 0, 2) == 3
    assert t.query(v1, 0, 4) == 19
    v3 = t.update(v1, 0, 0)
    assert t.values(v3) == [0, 3, 10, 1]
    t.check_invariant()


def test_kth_example() -> None:
    b = [3, 1, 2, 3, 0]
    t = PersistentSegmentTree([0] * 4)
    vs = [0]
    for x in b:
        vs.append(t.add(vs[-1], x, 1))
    # b[1..4) = [1, 2, 3]
    assert [t.kth(vs[1], vs[4], k) for k in (1, 2, 3)] == [1, 2, 3]
    # whole array sorted is [0, 1, 2, 3, 3]
    assert [t.kth(vs[0], vs[5], k) for k in range(1, 6)] == [0, 1, 2, 3, 3]
    # count of values < 3 in b[0..5) is 3
    assert t.query(vs[5], 0, 3) - t.query(vs[0], 0, 3) == 3
    t.check_invariant()


def test_edges() -> None:
    with pytest.raises(ValueError):
        PersistentSegmentTree([])
    t = PersistentSegmentTree([4])
    assert t.query(0, 0, 1) == 4 and t.query(0, 0, 0) == 0 and t.query(0, 1, 1) == 0
    v = t.update(0, 0, -2)
    assert t.get(v, 0) == -2 and t.get(0, 0) == 4
    assert t.kth(0, t.add(0, 0, 3), 3) == 0
    with pytest.raises(IndexError):
        t.update(0, 1, 0)
    with pytest.raises(IndexError):
        t.query(5, 0, 1)
    with pytest.raises(IndexError):
        t.query(0, 0, 2)
    with pytest.raises(IndexError):
        t.kth(0, 0, 1)
    t.check_invariant()


def test_deep() -> None:
    n = 1 << 17
    t = PersistentSegmentTree([1] * n)
    v = 0
    for i in range(0, n, 997):
        v = t.update(v, i, 0)
    assert t.query(v, 0, n) == n - len(range(0, n, 997))
    assert t.query(0, 0, n) == n
    before = t.num_nodes()
    t.update(v, n - 1, 5)
    assert t.num_nodes() - before == 18  # ceil(lg n) + 1


def test_random_version_tree() -> None:
    rng = random.Random(2718)
    for _ in range(500):
        n = rng.randint(1, 12)
        start = [rng.randint(-5, 5) for _ in range(n)]
        t = PersistentSegmentTree(start)
        model = [list(start)]
        for _ in range(rng.randint(1, 25)):
            v = rng.randrange(len(model))
            op = rng.random()
            if op < 0.5:
                i, x = rng.randrange(n), rng.randint(-5, 5)
                assert t.update(v, i, x) == len(model)
                model.append(list(model[v]))
                model[-1][i] = x
            else:
                l = rng.randint(0, n)
                r = rng.randint(0, n)
                assert t.query(v, l, r) == sum(model[v][l:r])
        for v, arr in enumerate(model):
            assert t.values(v) == arr
        t.check_invariant()


def test_random_kth() -> None:
    rng = random.Random(3141)
    for _ in range(500):
        m = rng.randint(1, 9)
        b = [rng.randrange(m) for _ in range(rng.randint(1, 15))]
        t = PersistentSegmentTree([0] * m)
        vs = [0]
        for x in b:
            vs.append(t.add(vs[-1], x, 1))
        l = rng.randrange(len(b))
        r = rng.randint(l + 1, len(b))
        part = sorted(b[l:r])
        for k in range(1, len(part) + 1):
            assert t.kth(vs[l], vs[r], k) == part[k - 1]
        t.check_invariant()

What the next level needs

  • the tests did not run in this build

Read the full page about Persistent segment tree