DSA DSA In Depth
0%
lesson convex-hull-trickli-chao-treedp-optimization

Convex Hull Trick (CHT) & Li Chao Segment Tree: Optimizing dp[i] = min_j (m_j x_i + b_j)

7 min read Module 6.3: Advanced DP State Spaces & Algebraic Optimizations Updated Oct 23, 2026
📄 Cheat Sheet
Dynamic Programming Optimizations: Convex Hull Trick (CHT) & Segment Tree DP
Colin Galen Dynamic Programming Optimizations: Convex Hull Trick (CHT) & Segment Tree DP Watch on YouTube ↗

Explains the geometric duality between algebraically expanding quadratic DP transitions into y = mx + b lines and maintaining their lower envelope with a deque or Li Chao Segment Tree.

0:00 / --:--

1. Formal Invariant — Lower Envelope & Li Chao Midpoint Dominance

Invariant 1: Monotonic Deque CHT Redundancy Condition

Given three lines $L_1, L_2, L_3$ with strictly decreasing slopes $m_1 > m_2 > m_3$ (for minimum queries), the middle line $L_2$ is completely redundant (never strictly minimal at any $x$) if and only if the intersection $x(L_1, L_3)$ lies to the left of or at $x(L_1, L_2)$:

$$\frac{b_3 - b_1}{m_1 - m_3} \le \frac{b_2 - b_1}{m_1 - m_2} \iff (b_3 - b_1)(m_1 - m_2) \le (b_2 - b_1)(m_1 - m_3)$$

Invariant 2: Li Chao Segment Tree Midpoint Property

Each node covering coordinate range $[L, R]$ with midpoint $M = \lfloor (L + R) / 2 \rfloor$ stores a line $\text{seg}$ that is dominant at $M$. When inserting a new line $\text{nw}$, we swap $\text{seg}$ and $\text{nw}$ if $\text{nw}(M) < \text{seg}(M)$. Because two distinct lines intersect at most once, the loser at $M$ can beat the winner on at most one half ($[L, M]$ or $[M+1, R]$), so we recurse into only that single child in $O(\log X)$ time!

SOS DP (Sum Over Subsets) Hypercube, Digit DP & Broken Profile Tiling
O(N·2^N) SOS / Broken Profile

Trace dimension-by-dimension SOS DP bit-flips F[mask] += F[mask ^ (1<<i)] without overcounting, Broken Profile staircase wavefront domino tiling, and Digit DP tight-prefix automata.

Step 1 / 7
Custom Input:
Timeline

Invariant & State

SOS DP Invariant: After outer loop dimension i, F[mask] aggregates all submasks that may differ from mask only in bits 0..i (zero duplicate paths!).
🔗 Live Pointer & State Transition Graph — SOS DP (Sum Over Subsets) Hypercube, Digit DP & Broken Profile Tiling Active Pointer Jump/Transition Matched/Optimal
00₂ F[00]… [0] Dim i=0 F[01]… [1] 10₂ F[10]… [2] Dim i=1 F[11]… [3]
[0]F[00]=a₀00₂
[1]F[01]=a₀+a₁ (bit 0)Dim i=0 merged
[2]F[10]=a₂10₂
[3]F[11]=a₂+a₃ (bit 0) → +F[01] (bit 1)Dim i=1 → ∑ all 4
3^N Submask Loop
O(3^N) = 59,049 (N=10)
SOS DP N·2^N
O(N·2^N) = 10,240 (N=10)
Update Rule
if (mask & (1<<i)) F[mask] += F[mask^(1<<i)]
[Step 1] Swept dimensions i = 0..N-1; each hypercube edge is traversed once in O(N · 2^N).

2. Li Chao Segment Tree Single-Branch Recursion

flowchart TD Insert["Insert New Line nw on [L, R], Mid M"] --> Eval["Compare nw(M) vs seg(M)"] Eval -->|"nw(M) < seg(M)"| Swap["Swap(nw, seg)
seg is now best at M"] Eval -->|"nw(M) >= seg(M)"| Keep["Keep seg at M
nw is loser at M"] Swap --> CheckLeft Keep --> CheckLeft CheckLeft{"Where can loser nw
cross winner seg?"} CheckLeft -->|"nw(L) < seg(L)"| GoLeft["Recurse ONLY Left [L, M]"] CheckLeft -->|"nw(R) < seg(R)"| GoRight["Recurse ONLY Right [M+1, R]"] CheckLeft -->|"Neither"| Done["Terminate Early O(1)"]

3. Choosing the Right Envelope Data Structure

Depending on monotonicity of inserted slopes $m_j$ and query coordinates $x_i$, four regimes arise:

  1. Slopes $m_j$ monotonic + Queries $x_i$ monotonic: Use a std::deque with a moving front pointer and back-hull pruning in $O(1)$ amortized per step ($O(N)$ total).
  2. Slopes $m_j$ monotonic + Queries $x_i$ arbitrary: Maintain the hull in a std::vector and binary search the optimal line at $x_i$ in $O(\log N)$ per query.
  3. Slopes $m_j$ arbitrary + Queries $x_i$ arbitrary (Integer/Bounded Domain): Use a Li Chao Segment Tree (or dynamic implicit Li Chao Tree) in $O(N \log(\text{range}))$ time—completely free of division or floating-point intersection bugs!
  4. Slopes $m_j$ arbitrary + Floating-Point Domain: Use KACTL's LineContainer (std::multiset ordered by slope maintaining right-intersection endpoints) in $O(N \log N)$ time.

4. Hardware & Cache Behavior

Overflow Prevention & Implicit Pointer Allocation

In cross-multiplication $(b_3 - b_1)(m_1 - m_2) \le (b_2 - b_1)(m_1 - m_3)$, if $b_j \sim 10^{15}$ and $m_j \sim 10^9$, the product reaches $10^{24}$, overflowing int64_t! Always cast cross-products to __int128_t (or long double). By contrast, a Li Chao Tree only evaluates $m \cdot x + b$ at coordinate $x \in [L, R]$, which fits cleanly in int64_t whenever $|m \cdot x + b| < 9 \times 10^{18}$. Pre-allocating a flat std::vector<Node> pool for implicit Li Chao nodes eliminates new heap fragmentation.

5. Complexity Matrix

CHT Variant Insert Line Time Query $x_i$ Time Space Complexity
Monotonic Slopes + Monotonic Queries (Deque) O(1) amortized O(1) amortized O(N)
Monotonic Slopes + Arbitrary Queries (Binary Search) O(1) amortized O(log N) O(N)
Li Chao Tree (Coordinate Range $[-X, X]$) O(log X) O(log X) O(N log X)
Dynamic Hull (LineContainer / std::multiset) O(log N) O(log N) O(N)

6. Production-Grade C++20 Implementation (Dynamic Implicit Li Chao Tree)

#include <vector>
#include <cstdint>
#include <limits>
#include <algorithm>

class DynamicLiChaoTree {
public:
    static constexpr int64_t INF = std::numeric_limits<int64_t>::max() / 4;

    struct Line {
        int64_t m = 0;
        int64_t b = INF;
        int64_t eval(int64_t x) const { return m * x + b; }
    };

private:
    struct Node {
        Line line;
        int left = -1;
        int right = -1;
    };

    int64_t x_min_, x_max_;
    std::vector<Node> pool_;

    int new_node() {
        pool_.push_back(Node{});
        return static_cast<int>(pool_.size()) - 1;
    }

    void add_line(int u, int64_t l, int64_t r, Line nw) {
        int64_t mid = l + (r - l) / 2;
        bool left_better = nw.eval(l) < pool_[u].line.eval(l);
        bool mid_better  = nw.eval(mid) < pool_[u].line.eval(mid);

        if (mid_better) {
            std::swap(pool_[u].line, nw);
        }
        if (l == r) return;

        if (left_better != mid_better) {
            if (pool_[u].left == -1) {
                int c = new_node();
                pool_[u].left = c;
            }
            add_line(pool_[u].left, l, mid, nw);
        } else {
            if (pool_[u].right == -1) {
                int c = new_node();
                pool_[u].right = c;
            }
            add_line(pool_[u].right, mid + 1, r, nw);
        }
    }

    int64_t query(int u, int64_t l, int64_t r, int64_t x) const {
        if (u == -1) return INF;
        int64_t res = pool_[u].line.eval(x);
        if (l == r) return res;
        int64_t mid = l + (r - l) / 2;
        if (x <= mid) {
            return std::min(res, query(pool_[u].left, l, mid, x));
        } else {
            return std::min(res, query(pool_[u].right, mid + 1, r, x));
        }
    }

public:
    DynamicLiChaoTree(int64_t x_min, int64_t x_max, int reserve_nodes = 200000)
        : x_min_(x_min), x_max_(x_max) {
        pool_.reserve(reserve_nodes);
        new_node(); // root is index 0
    }

    void insert(int64_t m, int64_t b) {
        add_line(0, x_min_, x_max_, Line{m, b});
    }

    int64_t query(int64_t x) const {
        return query(0, x_min_, x_max_, x);
    }
};

Production Pitfall — Reference Invalidations When Allocating Nodes

In a std::vector<Node> pool, never write pool_[u].left = new_node() as a single expression! In C++, the left-hand side pool_[u].left reference may be evaluated before new_node() triggers a std::vector reallocation, leaving a dangling reference and corrupting memory. Always store int c = new_node(); first and then assign pool_[u].left = c;.

Check yourself Question 1 of 4
Concept Check Core Invariant Check recall

In a Li Chao Segment Tree covering coordinate interval $[L, R]$ with midpoint $M = \lfloor(L+R)/2\rfloor$, why does inserting a new line `nw` against the current node's line `seg` recurse into **at most one** child?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

In a Dynamic Implicit Li Chao Tree backed by `std::vector pool_`, why does the statement `pool_[u].left = new_node();` cause intermittent segmentation faults or silent memory corruption in C++?

Key Takeaways

Whenever a DP transition can be factored into f(i) + min_j (m(j) * x(i) + b(j)), it reduces to evaluating the lower envelope of lines y = m_j x + b_j.
With monotonic slopes and monotonic queries, a deque maintains the lower envelope in O(1) amortized time using __int128_t cross-products to check intersection ordering.
A Li Chao Segment Tree stores at each node the line dominant at the interval midpoint M; since two lines cross at most once, the loser at M can only win on one half, bounding insertion to O(log X).
In dynamic node pools backed by std::vector, always capture the index returned by new_node() in a local variable before indexing pool_[u] to prevent reallocation UB.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue