Algorithm Search⌘K

Li Chao tree in Python

A segment tree over x coordinates storing a line per node, supporting line insertion and minimum or maximum queries at a point.

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

from typing import Optional

Line = tuple[int, int]  # (a, b) for y = a*x + b


def _val(f: Line, x: int) -> int:
    return f[0] * x + f[1]


class LiChaoTree:
    """Minimum of lines y = a*x + b at integer points of [lo, hi).

    Nodes are created only when a line is placed, so n insertions make at
    most n nodes. insert and query take O(log(hi - lo)) time.
    """

    def __init__(self, lo: int, hi: int) -> None:
        """The domain is the integers lo <= x < hi; requires lo < hi."""
        if lo >= hi:
            raise ValueError("need lo < hi")
        self.lo = lo
        self.hi = hi
        self.line: list[Optional[Line]] = [None]  # node 0 is the root
        self.left: list[int] = [-1]
        self.right: list[int] = [-1]

    def _child(self, v: int, go_left: bool) -> int:
        kids = self.left if go_left else self.right
        if kids[v] == -1:
            kids[v] = len(self.line)
            self.line.append(None)
            self.left.append(-1)
            self.right.append(-1)
        return kids[v]

    def insert(self, a: int, b: int) -> None:
        """Add the line y = a*x + b."""
        f: Line = (a, b)
        v, lo, hi = 0, self.lo, self.hi  # @line L1
        while True:  # @line L2
            g = self.line[v]
            if g is None:  # @line L3
                self.line[v] = f
                return
            mid = (lo + hi) // 2  # @line L4
            if _val(f, mid) < _val(g, mid):  # @line L5
                self.line[v], f = f, g
                g = self.line[v]
                assert g is not None
            if hi - lo == 1:  # @line L6
                return
            if _val(f, lo) < _val(g, lo):  # @line L7
                v, hi = self._child(v, True), mid
            elif _val(f, hi - 1) < _val(g, hi - 1):  # @line L8
                v, lo = self._child(v, False), mid
            else:  # @line L9
                return

    def query(self, x: int) -> Optional[int]:
        """The least value of the lines at x, or None if there are none."""
        if not self.lo <= x < self.hi:
            raise IndexError("x outside the domain")
        v, lo, hi = 0, self.lo, self.hi  # @line L10
        best: Optional[int] = None
        while v != -1 and self.line[v] is not None:  # @line L11
            y = _val(self.line[v], x)  # type: ignore[arg-type]
            best = y if best is None or y < best else best  # @line L12
            if hi - lo == 1:  # @line L13
                break
            mid = (lo + hi) // 2  # @line L14
            if x < mid:  # @line L15
                v, hi = self.left[v], mid
            else:
                v, lo = self.right[v], mid
        return best  # @line L16

    def stored(self) -> list[Optional[Line]]:
        """The line of each node, in order of creation."""
        return list(self.line)

    def check_invariant(self) -> None:
        """Check that no filled node has an empty parent and that every
        stored line is lowest at the middle among the lines of its subtree."""
        stack = [(0, self.lo, self.hi)]
        while stack:
            v, lo, hi = stack.pop()
            g = self.line[v]
            mid = (lo + hi) // 2
            for c, a, b in ((self.left[v], lo, mid), (self.right[v], mid, hi)):
                if c == -1:
                    continue
                h = self.line[c]
                if h is not None:
                    assert g is not None, "filled node below an empty one"
                    assert _val(g, mid) <= _val(h, mid), "node line not lowest at mid"
                if a < b:
                    stack.append((c, a, b))
test_li_chao_tree.py 60 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/li-chao-tree/. The authors are listed in prov/E3PVVE.jsonl.

import random

import pytest

from li_chao_tree import LiChaoTree


def test_example() -> None:
    t = LiChaoTree(0, 8)
    for a, b in [(0, 5), (1, 0), (-1, 7), (-2, 12)]:
        t.insert(a, b)
    assert t.line[0] == (-1, 7)
    assert t.line[t.left[0]] == (1, 0)
    assert t.line[t.right[0]] == (-2, 12)
    assert len(t.line) == 3
    assert t.query(2) == 2 and t.query(5) == 2 and t.query(7) == -2
    lines = [(0, 5), (1, 0), (-1, 7), (-2, 12)]
    for x in range(8):
        assert t.query(x) == min(a * x + b for a, b in lines)
    t.check_invariant()


def test_edges() -> None:
    with pytest.raises(ValueError):
        LiChaoTree(3, 3)
    t = LiChaoTree(-5, -4)
    assert t.query(-5) is None
    t.insert(2, 1)
    t.insert(3, 0)
    assert t.query(-5) == -15
    with pytest.raises(IndexError):
        t.query(-4)
    big = LiChaoTree(-(10**9), 10**9)
    big.insert(10**9, 10**18)
    big.insert(-(10**9), 10**18)
    assert big.query(0) == 10**18
    assert big.query(-(10**9)) == 0


def test_random_against_brute_force() -> None:
    rng = random.Random(2013)
    for _ in range(500):
        lo = rng.randint(-30, 30)
        hi = lo + rng.randint(1, 40)
        t = LiChaoTree(lo, hi)
        lines: list[tuple[int, int]] = []
        for _ in range(rng.randint(0, 25)):
            if rng.random() < 0.5:
                a, b = rng.randint(-6, 6), rng.randint(-50, 50)
                t.insert(a, b)
                lines.append((a, b))
            else:
                x = rng.randrange(lo, hi)
                want = min((a * x + b for a, b in lines), default=None)
                assert t.query(x) == want
        assert len(t.line) <= max(1, len(lines))
        t.check_invariant()

What the next level needs

  • the tests did not run in this build

Read the full page about Li Chao tree