Both maintain sets of lines and answer “best value at ”. A Li Chao tree is a segment tree over where each node keeps the line that wins at its midpoint. Inserts and queries take , with no ordering requirement and no overflow-prone intersection math. A kinetic segment tree keeps one line per array position and answers range max at a time that only increases.
Li Chao tree (dynamic, minimum)
struct Line { ll k, b; ll operator()(ll x) const { return k * x + b; } };
struct LiChao {
struct Node { Line ln; int l = 0, r = 0; };
vector<Node> t{{{0, LLONG_MAX}}}; // node 0 unused as a null marker; root = 1
ll lo, hi; int root = 0;
LiChao(ll lo, ll hi) : lo(lo), hi(hi) {}
int insert(int v, ll l, ll r, Line ln) { // over integer x in [l, r)
if (!v) { t.push_back({ln}); return sz(t) - 1; }
ll m = l + (r - l) / 2;
bool lef = ln(l) < t[v].ln(l), mid = ln(m) < t[v].ln(m);
if (mid) swap(t[v].ln, ln);
if (r - l == 1) return v;
if (lef != mid) { int c = insert(t[v].l, l, m, ln); t[v].l = c; }
else { int c = insert(t[v].r, m, r, ln); t[v].r = c; }
return v;
}
void add(Line ln) { root = insert(root, lo, hi, ln); }
ll query(ll x) const {
ll res = LLONG_MAX, l = lo, r = hi; int v = root;
while (v) {
res = min(res, t[v].ln(x));
ll m = l + (r - l) / 2;
if (x < m) v = t[v].l, r = m; else v = t[v].r, l = m;
}
return res;
}
};Proof: Li Chao insert/query is correct
Setup. Say a node is “correct” if, for every in ‘s range, over all nodes on the path from down to ‘s leaf of (stored line) equals the true minimum, over every line ever inserted into ‘s subtree, evaluated at . We show by induction on the number of lines inserted that the root is always correct (the base case, zero lines beyond the sentinel, is trivial).
One insertion preserves correctness. Suppose the tree is correct before inserting a new line into node (range , midpoint ). Let before this call, (the one kept as the new ) and the other — note as a set, so for every , regardless of labelling. Two lines cross at most once; comparing at and tells us exactly which half contains that crossing (if any): if the comparison flips between and (), the crossing is in , so on the order at persists and there — this is exactly the right child, the one left untouched when we recurse left; symmetrically if , the order already agreed at and , so on all of — the untouched left child when we recurse right. So on the untouched child’s range, new , and omitting from consideration there is harmless: the new path-min there is , matching “old global min, updated by ” as required. On the touched child, the recursive call is the same lemma one level down (inserting there), so by induction its path-min correctly becomes , and combining with again gives . Both cases match the claim, so correctness propagates to all of ‘s range, and by induction to the whole tree after any sequence of inserts.
Proof: per insert and query
insertrecurses into exactly one of its two children at every internal node (theif (lef != mid) ... else ...branch is strictly one-sided, never both), and the domain halves each level, so one insert call does work.querywalks a single root-to-leaf path, work per node, so it is too.
- Maximum: insert and negate the answer.
- Memory: one node per inserted line (at most). Preallocated array version over compressed query points is faster when all are known.
- Segment insertion (line valid only on ): descend to the canonical nodes and insert there. That’s per insert.
- Beyond lines: any family where two functions cross at most once on the domain works (for example , , or the cost functions of some DP transitions).
- Merging Li Chao trees (for DP on trees): merge like segment trees, re-inserting the lines of the smaller structure; the total is amortized because each line only moves down.
- Persistent Li Chao: path-copy on insert.
| Monotone CHT (deque) | Dynamic CHT (set) | Li Chao | |
|---|---|---|---|
| slope order | must be sorted | any | any |
| query order | sorted for , else binary search | any | any |
| cost | amortized | ||
| overflow risk | intersection products overflow; need __int128 | same | only evaluates lines |
| code | short | fiddly | short |
DP usage is in Convex Hull Trick.
Kinetic segment tree
Positions hold lines . Time only increases. Operations: replace a line, range max at the current , advance . Each node stores the winning line at the current time and melt = the earliest future time when some winner in its subtree changes. Advancing time only descends into nodes whose melt has passed.
struct KST {
struct L { ll k, b; ll at(ll x) const { return k * x + b; } };
int n; ll t = 0; vector<L> best; vector<ll> melt;
static constexpr ll INF = LLONG_MAX;
static ll fdiv(ll a, ll d) { return a / d - ((a % d != 0) && ((a < 0) != (d < 0))); }
void pull(int v) {
L a = best[2 * v], b = best[2 * v + 1];
if (a.at(t) < b.at(t) || (a.at(t) == b.at(t) && a.k < b.k)) swap(a, b); // a wins now
best[v] = a; melt[v] = min(melt[2 * v], melt[2 * v + 1]);
if (b.k > a.k) melt[v] = min(melt[v], max(t + 1, fdiv(a.b - b.b, b.k - a.k) + 1));
}
void build(int v, int l, int r, const vector<L>& a) {
if (r - l == 1) { best[v] = a[l]; melt[v] = INF; return; }
int m = (l + r) / 2; build(2 * v, l, m, a); build(2 * v + 1, m, r, a); pull(v);
}
KST(const vector<L>& a) : n(sz(a)), best(4 * sz(a)), melt(4 * sz(a), INF) { build(1, 0, n, a); }
void set(int v, int l, int r, int i, L x) {
if (r - l == 1) { best[v] = x; return; }
int m = (l + r) / 2; i < m ? set(2 * v, l, m, i, x) : set(2 * v + 1, m, r, i, x); pull(v);
}
ll query(int v, int l, int r, int ql, int qr) const {
if (qr <= l || r <= ql) return LLONG_MIN;
if (ql <= l && r <= qr) return best[v].at(t);
int m = (l + r) / 2;
return max(query(2 * v, l, m, ql, qr), query(2 * v + 1, m, r, ql, qr));
}
void heat(int v, int l, int r) {
if (melt[v] > t) return;
int m = (l + r) / 2; heat(2 * v, l, m); heat(2 * v + 1, m, r); pull(v);
}
void advance(ll dt) { t += dt; heat(1, 0, n); }
};Advancing costs amortized per winner change. Range-restricted time advances (“heaten” only ) need lazy time tags and cost amortized. Typical uses: “add to all in a range, query range max of ” with (the KTT technique), and DP where each candidate’s value grows linearly over time.
Proof:
meltcorrectly bounds how long a cached winner stays validLet , at the moment of a
pull, with selected as the current winner (so , or they’re equal with — the comparator’s tie-break). If , can never overtake as increases (the gap is non-decreasing), so no finite melt from this pair is needed, matching the code (no update tomelt[v]from this branch unless ). If , solve for the real crossing time (exactlyfdiv’s argument as a real number); for , still ( correctly stays cached), and for , ( must take over). The code’s candidate is the first integer strictly greater than when is not itself an integer (correct: that is exactly the first integer time strictly overtakes), and is — one step late — in the knife-edge case where is itself an integer. This apparent off-by-one never produces a wrong query value: at exactly, by definition of , so returning the stale gives the identical number would have; the cached identity only matters for slope information an ancestor might use in its own melt computation, and that correction arrives on the very nextadvanceregardless, before any strictly-later crossing could depend on it. Somelt[v]is always a safe bound:best[v]is guaranteed correct in value for every , andheat’smelt[v] > t: returncheck never skips a node that actually needs reprocessing.
Proof: amortized per winner change, between
setcallsFix a stretch of time with no
setcalls. Each node ‘s winner, over the whole stretch, can only change to a line of strictly greater slope than the line it replaces (that is the only waypulllets overtake — the branch above). So the sequence of distinct winners at over the stretch has strictly increasing slopes, and there are at most distinct lines ever present in ‘s range to draw slopes from, giving at most melt-triggered re-pulls at for the whole stretch. Summing over all nodes of the segment tree, total re-pulls for the entire stretch. Eachheatcall visits only nodes with (genuinely triggered) plus, for each such node, its two children checked and immediately returned if not triggered — an -per-triggered-node overhead — so total work across the whole stretch is , i.e. amortized per winner change. Interleavingsetcalls breaks the clean “slopes only increase” budget at the ancestors it touches (a replaced line can re-open room for new, unrelated future melts there), and bounding that interaction costs an extra factor — this is the standard, folklore-cited result (matching the note’s figure); it is not re-derived here, consistent with how this repo cites Ji Ruyi’s original result for beats-with-range-add rather than reproving it from scratch.