Cairn
data structures · trees · O(log U) insert / O(log U) query, U = domain size, any order

back to Array-Backed Trees

Li Chao Tree

Convex Hull Trick answers "given many linear functions, which one is largest at query x?" in O(1) amortized time — but only if every line is known and inserted in decreasing-slope order up front, and only for a non-decreasing stream of queries. A Li Chao Tree (introduced by Li Chao at ZJOI 2012) answers the exact same question with neither restriction: lines can arrive in any order, queries can arrive in any order, interleaved however you like, each still costing O(log U) — where U is the size of the fixed coordinate domain the tree was built over, not the number of lines stored. The mechanism is a segment tree over the x-axis instead of over an array: every node owns a range of x, and holds at most one line — whichever one is currently winning at that range's own midpoint.

Try it

Four flat-rate courier contracts, each paying y = m·x + b for x stops on today's route: B (y = x + 7), K (y = -3x + 11), S (y = -x + 10), and L (y = 3x + 2). The domain is fixed at x = 0..7 stops. Press Step or Run to watch all four get inserted in the scrambled order S, L, B, K — deliberately not sorted by slope — then a run of stop counts get queried in the scrambled order 5, 0, 6, 2, 7, 1, 4, 3. Nothing about either order matters to the result; that's the whole point of building the tree this way instead of Convex Hull Trick's deque.

tree over x ∈ [0,7] — node 1 is the root, nodes 8-15 are the leaves; each node shows the one line currently kept there, or · if none yet
Press Step or Run.

Why it works

Insert walks down from the root, and at each node compares the incoming line against whatever the node already holds — but only at that node's own midpoint. Whichever line wins at the midpoint stays at that node; the loser gets pushed one level down, into whichever child it could still possibly win in. On this page's own run: S arrives first into the empty root. L arrives second — at the root's midpoint (x = 3) L scores 11 against S's 7, so L takes the root and S gets pushed into the left child, node 2 (range [0,3]), which is empty, so S settles there directly. B arrives third: loses to L at the root's midpoint (10 vs 11), so it's pushed toward node 2; there it loses to S at node 2's own midpoint (x = 1: 8 vs 9), so it's pushed once more, landing in node 5 (range [2,3]), empty, done. K follows the identical root → node 2 path and settles into node 4 (range [0,1]). Four lines, four different nodes, no node ever holding more than one — and which child a loser gets pushed into is decided by comparing the loser against the node's original occupant at the range's two endpoints, not by anything about future inserts.

Query just walks one root-to-leaf path for the given x⌈log₂ U⌉ + 1 nodes, always, regardless of how many lines are stored — and takes the maximum of every line found along the way. That's correct because insert's own invariant guarantees whichever line is truly the best answer for any given x sits somewhere on that x's own root-to-leaf path: either it's still winning at some ancestor's midpoint and got kept there, or it lost at every midpoint on the way down and got pushed all the way to the one leaf where it's never compared against anything else again. Querying x = 2 on this page's tree visits node 1 (L = 8), node 2 (S = 8), and node 5 (B = 9) — B wins, correctly, despite never once being compared directly against L at any point during either line's insertion.

Reference implementation

class LiChaoTree {
  constructor(lo, hi) {          // fixed integer domain, inclusive
    this.lo = lo;
    this.hi = hi;
    this.tree = new Map();       // node index -> line; absent means "no line yet"
  }

  evalAt(line, x) { return line.m * x + line.b; }

  insert(line) { this._insert(1, this.lo, this.hi, line); }

  _insert(idx, lo, hi, line) {
    const cur = this.tree.get(idx);
    if (!cur) { this.tree.set(idx, line); return; }
    const mid = (lo + hi) >> 1;
    const leftNewBetter = this.evalAt(line, lo) > this.evalAt(cur, lo);
    const midNewBetter = this.evalAt(line, mid) > this.evalAt(cur, mid);
    // the node keeps whichever line wins at its own midpoint
    if (midNewBetter) { this.tree.set(idx, line); line = cur; }
    if (lo === hi) return;       // leaf -- nothing left to push down
    // push the midpoint's loser toward whichever half it could still win in
    if (leftNewBetter !== midNewBetter) this._insert(2 * idx, lo, mid, line);
    else this._insert(2 * idx + 1, mid + 1, hi, line);
  }

  query(x) { return this._query(1, this.lo, this.hi, x); }

  _query(idx, lo, hi, x) {
    const cur = this.tree.get(idx);
    let best = cur ? this.evalAt(cur, x) : -Infinity;
    if (lo === hi) return best;
    const mid = (lo + hi) >> 1;
    const child = x <= mid
      ? this._query(2 * idx, lo, mid, x)
      : this._query(2 * idx + 1, mid + 1, hi, x);
    return Math.max(best, child);
  }
}

Pitfalls

A tempting shortcut — compare and swap at the midpoint, then stop — silently drops lines that are still the true best answer somewhere. It looks reasonable: if the incoming line loses at the midpoint, why keep chasing it further down? Two lines make the bug concrete: P (y = -x + 7) and Q (y = 4x - 8) over domain x = 0..7. Insert P into the empty root, then Q: at the root's midpoint (x = 3) both score exactly 4 — a tie, so Q isn't strictly better and a swap-only implementation discards it right there, never pushing it anywhere. But Q is the true maximum for every x from 4 to 7 (8, 12, 16, 20, against P's 3, 2, 1, 0) — the correct algorithm above would have pushed the midpoint's loser into the right child exactly to cover that case, since leftNewBetter and midNewBetter are equal here (both false) and the loser still belongs on the right. Checked directly: the broken version answers all four of those queries wrong, with no error of any kind. A 2,000-trial stress harness against random small line sets (inserted in shuffled order) generalizes this: the correct algorithm matched an independent brute-force max on every query across all trials; the swap-only version got at least one query wrong on 1,079 of 2,000 trials, 2,176 of 16,000 individual queries overall.

Querying outside the domain the tree was actually built for doesn't error — it just silently walks the wrong path and can return a real line's value while missing a better one entirely. Built over x = 0..7, insert A (y = 7x + 4) then B (y = -3x - 6): B ends up stored only in the tree's right half (it's never the winner anywhere in [0,3], so it's never pushed there). Query x = -10, well outside the built domain: the walk compares -10 against each midpoint using the ordinary rule (x ≤ mid goes left), and every midpoint in [0,7] is larger than -10 — so the walk goes left every time and never visits the right half at all. The tree reports -66 (A's value, the only line on the all-left path); the true maximum is 24 (B's value at x = -10), missed completely. A 20,000-trial stress harness quantifies how often this bites: querying every real in-domain x (0 through 7) never once produced a wrong answer, 0 of 160,000 checks, exactly as the correctness argument above predicts — but querying a random point up to 100 past either edge of the built domain came back wrong on 6,401 of 20,000 trials (32.0%). The fix isn't a bounds check inside query() — it's choosing [lo, hi] up front wide enough to cover every x that will ever actually be asked for, the same discipline a fixed-size array-backed segment tree already requires.

Where it shows up

The reason to reach for this over Convex Hull Trick's deque is almost always that one of its two ordering assumptions doesn't hold: lines that arrive interleaved with queries rather than all up front, lines whose slopes aren't known in sorted order in advance, or queries that jump around instead of moving monotonically — exactly the "fully online" shape competitive programming problems reach for this structure for, usually speeding up a dynamic program the same way Convex Hull Trick does, just without its ordering discipline. The trade is real, not free: this page's array-shaped storage allocates 2U node slots up front regardless of how many lines ever get inserted, where Convex Hull Trick's deque only ever holds as many entries as lines that actually survive. The Map-based reference implementation above sidesteps that for a sparse domain — it only ever creates a node the first time some insert's recursion actually reaches it, so a huge or unknown-in-advance domain costs real memory only along the O(log U) paths actually walked, not O(U) flat. Some competitive-programming implementations push this further still with dynamically-allocated child pointers and lazy node creation for a domain too large to fix in advance at all (arbitrary 64-bit coordinates, for instance) — a genuinely different memory strategy from either version built here, not just a bigger array.

Against this site's other structure built from the identical "one node per half of a fixed range" segment-tree shape: reach for a plain Segment Tree when what's stored at each node is a value combined by an associative operator (min, sum, gcd); reach for a Li Chao Tree when what's stored is a whole line (or, in fancier variants, any family of functions that only ever cross a bounded number of times) and the question is which one wins at a point, not what the combined value of a range is.

Complexity

Insert: O(log U) per line — the walk depth is fixed by the domain's size U = hi - lo + 1, not by how many lines are already stored, so the 1st insert and the 100th cost the same. Query: O(log U) per query, for the same reason — always exactly one root-to-leaf path. Both confirmed directly on this page's own run: every insert and every query touched exactly 4 nodes, ⌈log₂ 8⌉ + 1, regardless of position in either sequence. Space: O(U) for the array-backed form built here (2U node slots exist whether or not a line is ever placed in most of them); O(k log U) for the Map-based reference implementation above, where k is the number of lines actually inserted — never worse than the array form, better whenever U is large and few lines are ever inserted relative to it.

This site's guide, Choosing a Range Query Structure, sets this entry aside from the six it actually compares: it doesn't hold array values at all, it answers which of several inserted lines is largest at a query x, with no array or range-combining operation anywhere in sight.