13.17 Convex Hull Trick

Many dynamic programming solutions are correct but too slow.

13.17 Convex Hull Trick

Many dynamic programming solutions are correct but too slow. The recurrence is easy to write, the state is well defined, and the base cases are clean, yet each state scans too many previous states. Convex hull trick is one of the standard techniques for reducing such transition costs.

The technique applies when a recurrence can be rewritten as a minimum or maximum over a set of linear functions. Instead of checking every previous state, we maintain a collection of lines and query the best line at a given point.

This optimization appears in scheduling, partitioning, sequence optimization, transportation planning, and cost minimization problems. It is not a general-purpose DP accelerator. It works only when the recurrence has the right algebraic shape.

Problem

Given a dynamic programming recurrence that scans many previous states, how can we reduce transition time when each candidate can be expressed as a line?

The Slow Pattern

A common recurrence has the form:

$$ dp[i] = \min_{j<i} ( dp[j] + cost(j,i) ) $$

A direct implementation is:

for i := 1; i <= n; i++ {

    dp[i] = inf

    for j := 0; j < i; j++ {

        dp[i] = min(
            dp[i],
            dp[j]+cost(j, i),
        )
    }
}

If there are (n) states and each state scans (O(n)) previous states, the total cost is:

$$ O(n^2) $$

Convex hull trick is useful when the expression inside the minimum can be transformed into:

$$ m_j x_i + b_j $$

where:

  • (m_j) is a slope depending on (j)
  • (b_j) is an intercept depending on (j)
  • (x_i) is the query point depending on (i)

Each previous state becomes a line.

Each current state asks for the lowest line at (x_i).

Rewriting a Recurrence

Suppose the recurrence is:

$$ dp[i] = x_i^2 + \min_{j<i} ( dp[j] + a_j^2 - 2a_jx_i ) $$

The term:

$$ x_i^2 $$

does not depend on (j), so it can be added after the minimum.

The candidate part is:

$$ (-2a_j)x_i + (dp[j]+a_j^2) $$

This is a line:

$$ y = mx + b $$

where:

$$ m=-2a_j $$

and:

$$ b=dp[j]+a_j^2 $$

Now each previous state (j) contributes one line. To compute (dp[i]), query the minimum line at (x_i), then add (x_i^2).

The Data Structure View

Convex hull trick maintains a set of lines and supports two operations:

add line y = mx + b

query minimum y-value at x

Depending on assumptions, these operations can be implemented in different ways.

Assumption Typical Structure
Slopes added in sorted order, queries monotonic Deque hull
Arbitrary insertion and query Li Chao tree
Offline queries Sorted hull plus binary search

The deque version is the simplest and fastest, but it requires monotonicity.

Why Lines Can Be Removed

Suppose we are minimizing.

If one line is never lower than another line for any future query, it is useless and can be discarded.

For example:

line A is always above line B

Then line A will never produce an optimal value.

The hull stores only lines that may be optimal for some interval of (x)-values.

This is why the technique is called a hull: it keeps the lower envelope of a set of lines.

Line Intersection Intuition

Two lines:

$$ y=m_1x+b_1 $$

and:

$$ y=m_2x+b_2 $$

intersect at:

$$ x= \frac{b_2-b_1}{m_1-m_2} $$

For minimization, one line is better on one side of the intersection and the other line is better on the other side.

The hull keeps lines in the order in which they become optimal.

A Deque-Based Hull

When slopes are added in monotonic order and query points are also monotonic, a deque works well.

Each line is stored in order.

When a new line is added, remove lines from the back that become irrelevant.

When querying, remove lines from the front while the next line is better at the current (x).

Implementation

The following implementation uses integer arithmetic and compares line values directly. It avoids floating-point intersection calculations.

type Line struct {
    M int64
    B int64
}

func (l Line) Value(x int64) int64 {
    return l.M*x + l.B
}

type Hull struct {
    lines []Line
}

func bad(a, b, c Line) bool {
    return (b.B-a.B)*(a.M-c.M) >=
        (c.B-a.B)*(a.M-b.M)
}

func (h *Hull) Add(line Line) {

    for len(h.lines) >= 2 {
        n := len(h.lines)

        if !bad(h.lines[n-2], h.lines[n-1], line) {
            break
        }

        h.lines = h.lines[:n-1]
    }

    h.lines = append(h.lines, line)
}

func (h *Hull) Query(x int64) int64 {

    for len(h.lines) >= 2 &&
        h.lines[1].Value(x) <= h.lines[0].Value(x) {

        h.lines = h.lines[1:]
    }

    return h.lines[0].Value(x)
}

This version assumes:

slopes are added in sorted order

queries x are nondecreasing

we are minimizing

If those assumptions are false, this implementation is not valid.

Applying the Hull to DP

For the recurrence:

$$ dp[i] = x_i^2 + \min_{j<i} ( dp[j]+a_j^2-2a_jx_i ) $$

we add a line for each completed state:

$$ m=-2a_j $$

$$ b=dp[j]+a_j^2 $$

Then query at:

$$ x=x_i $$

Implementation skeleton:

dp[0] = base

hull.Add(Line{
    M: -2 * a[0],
    B: dp[0] + a[0]*a[0],
})

for i := 1; i < n; i++ {

    best := hull.Query(x[i])

    dp[i] = x[i]*x[i] + best

    hull.Add(Line{
        M: -2 * a[i],
        B: dp[i] + a[i]*a[i],
    })
}

The original transition scanned all previous states.

The optimized version queries a data structure.

Complexity

With the monotonic deque version:

$$ O(1) $$

amortized time per insertion and query.

Total time:

$$ O(n) $$

This is a major improvement over:

$$ O(n^2) $$

Memory:

$$ O(n) $$

for the stored lines.

If a Li Chao tree is required, operations are usually:

$$ O(\log X) $$

where (X) is the coordinate range.

Recognizing Convex Hull Trick

Look for recurrences of the form:

$$ dp[i] = \min_{j<i} ( A(j)B(i) + C(j) + D(i) ) $$

or:

$$ dp[i] = \max_{j<i} ( A(j)B(i) + C(j) + D(i) ) $$

The key is separability:

  • terms depending only on (i)
  • terms depending only on (j)
  • one product term combining (i) and (j)

If it can be rewritten as:

$$ m_jx_i+b_j $$

convex hull trick may apply.

Common Mistakes

Applying It Without Linear Form

If candidates cannot be expressed as lines, convex hull trick does not apply.

The recurrence must reduce to:

slope times query plus intercept

Ignoring Monotonicity Assumptions

The deque implementation requires ordered slopes and monotonic queries.

Without these properties, use another structure.

Integer Overflow

Line evaluation computes:

m*x + b

Values may exceed 32-bit or even 64-bit ranges.

Use appropriate numeric types.

Wrong Min/Max Direction

A lower hull solves minimization.

An upper hull solves maximization.

The comparison signs differ.

Floating-Point Intersections

Avoid floats when integer comparisons are enough.

Precision errors can corrupt hull construction.

Debugging Strategy

When implementing convex hull trick, first write the slow DP.

Then write the optimized version.

Run both on small random inputs and compare outputs.

This catches almost every implementation error.

for test := 0; test < 10000; test++ {
    gotSlow := SolveSlow(input)
    gotFast := SolveFast(input)

    if gotSlow != gotFast {
        panic("mismatch")
    }
}

Convex hull trick is algebraically elegant but easy to miscode. A brute-force verifier is the safest development tool.

Takeaway

Convex hull trick optimizes dynamic programming recurrences whose transitions can be represented as queries over linear functions. Each previous state contributes a line, each current state queries the best line at a point, and the hull discards lines that can never be optimal. Under monotonic slope and query assumptions, the deque implementation reduces many (O(n^2)) recurrences to (O(n)). The technique is powerful, but narrow: before using it, verify the recurrence has a true linear form and that the data structure assumptions match the input.