Back to Technology

DP Optimization, Part 2: Convex Hull Trick, SMAWK & the Aliens Trick

September 9, 2026 Wasil Zafar 35 min read

Some DP transitions can't be sped up by monotonicity alone -- they need the cost function's own shape. This lesson covers three techniques that exploit that shape: the Convex Hull Trick and Li Chao Tree for convex costs, SMAWK for Monge matrices, and the Aliens Trick for turning an extra constraint into a binary search.

Table of Contents

  1. Introduction & Motivation
  2. Prerequisites
  3. Convex Hull Trick: Core Insight
  4. A Concrete Example
  5. CHT Implementation & Li Chao Tree
  6. Monge Arrays & SMAWK
  7. The Aliens Trick
  8. Complexity Analysis
  9. Applications & Alternatives
  10. Exercises and Mastery
  11. Summary
Mastery target: Level A recognition of when a DP transition's cost function has exploitable structure, progressing toward Level B implementation of the Convex Hull Trick, Li Chao Tree, SMAWK, and the Aliens Trick.

1. Introduction & Motivation

This is Part 2 of a two-part pairing on DP-transition acceleration. Part 1: Monotonic Queue, Divide & Conquer, and Knuth Optimization covered techniques that exploit monotonicity: as $i$ grows, the optimal $j$ that minimizes $dp[i]$ only moves forward, so a sliding window, a divide-and-conquer split, or Knuth's quadrangle inequality can skip transitions that monotonicity proves can never win.

That argument works when the optimal decision point is monotone in $i$. But plenty of transitions have a cost function whose shape -- not its monotonicity -- is the exploitable structure. This lesson covers three such shapes:

  • Convexity: when $dp[i] = \min_j\big(dp[j] + b[j]\cdot a[i]\big)$, each $j$ defines a line $y = b[j]x + dp[j]$, and the transition is really "evaluate the lower envelope of these lines at $x=a[i]$." The Convex Hull Trick (and its dynamic cousin, the Li Chao Tree) maintain that envelope directly.
  • The Monge property: a matrix-shaped cost function that satisfies the same quadrangle inequality as Knuth optimization, but reframed as a property of an entire $n\times m$ cost matrix. SMAWK exploits it to compute every row's minimum in linear total time, not quadratic.
  • A tunable penalty: when the real problem asks for "minimum cost using exactly $k$ operations," but only the unconstrained "any number of operations" version is fast, the Aliens Trick (Lagrangian relaxation / WQS binary search) trades the hard count constraint for a binary search over a penalty term.

None of these three techniques requires the other two, and none requires Part 1's monotonicity argument -- but all three answer the same higher-level question Part 1 raised: once you've written the $O(n^2)$ (or $O(nk)$) transition, what property of the cost function lets you compute it faster?

2. Prerequisites

This lesson assumes comfort with basic dynamic programming transitions, arrays, and recursion -- see Part 14: Dynamic Programming Foundations for the state/transition/base-case framing this lesson builds on. It also assumes the general "accelerating a DP transition" mindset introduced in Part 1: Monotonic Queue, Divide & Conquer & Knuth Optimization: writing the naive $O(n^2)$ transition first, then asking what property of the cost function makes most of those $O(n^2)$ comparisons redundant. Part 1 is not a hard dependency for the algorithms themselves, but its vocabulary (quadrangle inequality, decision monotonicity) is used here by comparison.

3. Convex Hull Trick: Core Insight

Many DP transitions reduce to the form:

$$dp[i] = \min_{j \lt i}\Big(dp[j] + b[j]\cdot a[i]\Big)$$

Fix $j$ and look at this as a function of $x = a[i]$: it is a line, $y = b[j]\cdot x + dp[j]$, with slope $b[j]$ and intercept $dp[j]$. Computing $dp[i]$ means evaluating every line $j \lt i$ at $x = a[i]$ and keeping the minimum -- exactly the $O(n)$-per-query naive approach, $O(n^2)$ overall.

Core insight: at any given $x$, only one line can be the minimum, and as $x$ sweeps left to right, the winning line changes a bounded number of times. The lower envelope of all the lines -- the pointwise minimum function they trace out -- is a convex, piecewise-linear curve made of only the lines that are ever optimal for some $x$. Any line whose entire range of "winning" $x$-values is empty can be discarded the moment it becomes irrelevant.

The Convex Hull Trick maintains this envelope as a stack (or deque) of surviving lines. Two rules make it work:

  • Insertion order: if lines are added in order of monotonically decreasing slope, a new line can only ever displace lines at the end of the current envelope -- never in the middle. This lets insertion use a simple stack instead of a general search structure.
  • Popping irrelevant lines: when a new line is added, check whether the second-to-last line in the stack is ever optimal anywhere given its neighbors. If the new line and the line before the second-to-last already cross at an $x$ no larger than where the second-to-last line would have started winning, the second-to-last line never wins against both of its neighbors and can be discarded. This check repeats until the stack stabilizes.

Once the envelope is built, a query for the minimum at a given $x$ is a search over the envelope's lines. If queries also arrive in monotonically increasing (or decreasing) $x$ order, a single forward-moving pointer answers all queries in amortized $O(1)$ total. If queries arrive in arbitrary order, a binary search over the envelope (which is sorted by slope, and therefore by winning interval) answers each query in $O(\log n)$.

4. A Concrete Example for the Convex Hull Trick

Worked Example

Building the lower envelope, one pop included

Add four lines in decreasing slope order: $L_1: y=3x$, $L_2: y=x+3$, $L_3: y=-x+9$, $L_4: y=-3x+11$.

StepLine addedEnvelope before checkPop?Envelope after
1$L_1: y=3x$[ ]too few lines to check[$L_1$]
2$L_2: y=x+3$[$L_1$]too few lines to check[$L_1$, $L_2$]
3$L_3: y=-x+9$[$L_1$, $L_2$]no -- $L_2$ still wins somewhere[$L_1$, $L_2$, $L_3$]
4$L_4: y=-3x+11$[$L_1$, $L_2$, $L_3$]yes -- $L_3$ is popped[$L_1$, $L_2$, $L_4$]

Why does $L_3$ get popped? $L_2$ and $L_3$ cross at $x=3$ (both equal $6$), and $L_2$ and $L_4$ cross at $x=2$ (both equal $5$). Since the $L_2$/$L_4$ crossing happens before the $L_2$/$L_3$ crossing, $L_4$ overtakes $L_2$ while $L_2$ is still beating $L_3$ -- so $L_3$ never gets a chance to be the minimum against both neighbors and is safe to discard.

Querying the final envelope [$L_1$, $L_2$, $L_4$] at a few points confirms it: at $x=0$, $L_1=0$ is smallest; at $x=2$, $L_2=5$ and $L_4=5$ tie (their crossing point); at $x=4$, $L_4=-1$ is smallest. The discarded line $L_3$ would have given $5$ at $x=4$ -- never the minimum, exactly as predicted.

5. Convex Hull Trick Implementation (Monotone Case)

The classic application is a DP where the cost of "jumping" from index $j$ to index $i$ is a squared difference: $dp[i] = \min_{j\lt i}\big(dp[j] + (h[i]-h[j])^2\big)$ for a strictly increasing sequence $h$. Expanding the square:

$$dp[i] = \min_{j\lt i}\Big(dp[j] + h[j]^2 - 2h[j]\cdot h[i]\Big) + h[i]^2$$

Each $j$ contributes a line with slope $-2h[j]$ and intercept $dp[j]+h[j]^2$, evaluated at $x=h[i]$. Because $h$ is increasing, the slopes $-2h[j]$ are automatically added in decreasing order and the queries $x=h[i]$ automatically arrive in increasing order -- the monotone case applies with no extra sorting.

class ConvexHullTrick:
    """Maintains the lower envelope of lines y = m*x + b for MINIMUM queries.
    Lines must be added in decreasing order of slope m.
    Queries must use non-decreasing x for O(1) amortized time per query."""

    def __init__(self):
        self.m = []
        self.b = []
        self.ptr = 0

    def _bad(self, i1, i2, i3):
        # True when line i2 is never optimal, given i1 (added earlier) and i3 (added later).
        m1, b1 = self.m[i1], self.b[i1]
        m2, b2 = self.m[i2], self.b[i2]
        m3, b3 = self.m[i3], self.b[i3]
        return (b3 - b1) * (m1 - m2) <= (b2 - b1) * (m1 - m3)

    def add_line(self, m, b):
        self.m.append(m)
        self.b.append(b)
        while len(self.m) >= 3 and self._bad(len(self.m) - 3, len(self.m) - 2, len(self.m) - 1):
            del self.m[-2]
            del self.b[-2]
        if self.ptr >= len(self.m):
            self.ptr = len(self.m) - 1

    def query(self, x):
        if self.ptr >= len(self.m):
            self.ptr = len(self.m) - 1
        while self.ptr + 1 < len(self.m) and \
                self.m[self.ptr + 1] * x + self.b[self.ptr + 1] <= self.m[self.ptr] * x + self.b[self.ptr]:
            self.ptr += 1
        return self.m[self.ptr] * x + self.b[self.ptr]


def frog_squared_cost(heights):
    """dp[i] = min_j<i dp[j] + (heights[i]-heights[j])**2, for strictly increasing heights."""
    n = len(heights)
    dp = [0] * n
    hull = ConvexHullTrick()
    hull.add_line(-2 * heights[0], dp[0] + heights[0] ** 2)
    for i in range(1, n):
        x = heights[i]
        dp[i] = hull.query(x) + x * x
        hull.add_line(-2 * heights[i], dp[i] + heights[i] ** 2)
    return dp


sample = [0, 2, 5, 9]
print("dp:", frog_squared_cost(sample))  # dp: [0, 4, 13, 29]
#include <iostream>
#include <vector>
using namespace std;

class ConvexHullTrick {
public:
    vector<long long> m, b;
    size_t ptr = 0;

    bool bad(size_t i1, size_t i2, size_t i3) {
        long long m1 = m[i1], b1 = b[i1];
        long long m2 = m[i2], b2 = b[i2];
        long long m3 = m[i3], b3 = b[i3];
        return (b3 - b1) * (m1 - m2) <= (b2 - b1) * (m1 - m3);
    }

    void addLine(long long slope, long long intercept) {
        m.push_back(slope);
        b.push_back(intercept);
        while (m.size() >= 3 && bad(m.size() - 3, m.size() - 2, m.size() - 1)) {
            m.erase(m.end() - 2);
            b.erase(b.end() - 2);
        }
        if (ptr >= m.size()) ptr = m.size() - 1;
    }

    long long query(long long x) {
        if (ptr >= m.size()) ptr = m.size() - 1;
        while (ptr + 1 < m.size() && m[ptr + 1] * x + b[ptr + 1] <= m[ptr] * x + b[ptr]) {
            ptr++;
        }
        return m[ptr] * x + b[ptr];
    }
};

vector<long long> frogSquaredCost(const vector<long long>& heights) {
    int n = heights.size();
    vector<long long> dp(n, 0);
    ConvexHullTrick hull;
    hull.addLine(-2 * heights[0], dp[0] + heights[0] * heights[0]);
    for (int i = 1; i < n; i++) {
        long long x = heights[i];
        dp[i] = hull.query(x) + x * x;
        hull.addLine(-2 * heights[i], dp[i] + heights[i] * heights[i]);
    }
    return dp;
}

int main() {
    vector<long long> sample = {0, 2, 5, 9};
    vector<long long> dp = frogSquaredCost(sample);
    cout << "dp:";
    for (long long v : dp) cout << " " << v;
    cout << '\n';  // dp: 0 4 13 29
}
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class FrogSquaredCost {
    static class ConvexHullTrick {
        List<Long> m = new ArrayList<>();
        List<Long> b = new ArrayList<>();
        int ptr = 0;

        boolean bad(int i1, int i2, int i3) {
            long m1 = m.get(i1), b1 = b.get(i1);
            long m2 = m.get(i2), b2 = b.get(i2);
            long m3 = m.get(i3), b3 = b.get(i3);
            return (b3 - b1) * (m1 - m2) <= (b2 - b1) * (m1 - m3);
        }

        void addLine(long slope, long intercept) {
            m.add(slope);
            b.add(intercept);
            while (m.size() >= 3 && bad(m.size() - 3, m.size() - 2, m.size() - 1)) {
                m.remove(m.size() - 2);
                b.remove(b.size() - 2);
            }
            if (ptr >= m.size()) ptr = m.size() - 1;
        }

        long query(long x) {
            if (ptr >= m.size()) ptr = m.size() - 1;
            while (ptr + 1 < m.size() && m.get(ptr + 1) * x + b.get(ptr + 1) <= m.get(ptr) * x + b.get(ptr)) {
                ptr++;
            }
            return m.get(ptr) * x + b.get(ptr);
        }
    }

    static long[] frogSquaredCost(long[] heights) {
        int n = heights.length;
        long[] dp = new long[n];
        ConvexHullTrick hull = new ConvexHullTrick();
        hull.addLine(-2 * heights[0], dp[0] + heights[0] * heights[0]);
        for (int i = 1; i < n; i++) {
            long x = heights[i];
            dp[i] = hull.query(x) + x * x;
            hull.addLine(-2 * heights[i], dp[i] + heights[i] * heights[i]);
        }
        return dp;
    }

    public static void main(String[] args) {
        long[] sample = {0, 2, 5, 9};
        System.out.println("dp: " + Arrays.toString(frogSquaredCost(sample))); // dp: [0, 4, 13, 29]
    }
}

Li Chao Tree: The Dynamic/Non-Monotone Case

The monotone Convex Hull Trick needs both lines and queries to arrive in a cooperative order. Many problems don't offer that: slopes may be inserted in arbitrary order, and queries may be interleaved with insertions in arbitrary order too. The Li Chao Tree handles this generally, at the cost of $O(\log n)$ per operation instead of amortized $O(1)$.

A Li Chao Tree is a segment tree over the $x$-coordinate domain (the same mechanics covered in Segment Tree Variants and the Union-Find, Trie & Segment Tree decision guide -- this section does not re-derive segment-tree basics, only the twist specific to lines). Each node owns a range of $x$-values and stores the single line that is currently "winning" somewhere in that range. Inserting a new line compares it against the node's current line at the range's left endpoint and midpoint: if the new line wins at the midpoint, it replaces the stored line (and the old line is pushed down to recurse into whichever half it might still win); if the new line only wins on one side of the midpoint, it recurses into that half directly. Querying a point walks root-to-leaf, taking the best value seen at each node along the way.

class LiChaoNode:
    __slots__ = ("m", "b", "left", "right")

    def __init__(self, m, b):
        self.m = m
        self.b = b
        self.left = None
        self.right = None


class LiChaoTree:
    """Segment tree over the x-coordinate domain [lo, hi]. Each node stores the
    line that currently wins (gives the minimum) somewhere in its range.
    Insert and query both run in O(log(hi - lo)), no ordering required."""

    def __init__(self, lo, hi):
        self.lo = lo
        self.hi = hi
        self.root = None

    def _value(self, m, b, x):
        return m * x + b

    def _insert(self, node, lo, hi, m, b):
        if node is None:
            return LiChaoNode(m, b)
        mid = (lo + hi) // 2
        left_better = self._value(m, b, lo) < self._value(node.m, node.b, lo)
        mid_better = self._value(m, b, mid) < self._value(node.m, node.b, mid)
        if mid_better:
            node.m, m = m, node.m
            node.b, b = b, node.b
        if lo == hi:
            return node
        if left_better != mid_better:
            node.left = self._insert(node.left, lo, mid, m, b)
        else:
            node.right = self._insert(node.right, mid + 1, hi, m, b)
        return node

    def add_line(self, m, b):
        self.root = self._insert(self.root, self.lo, self.hi, m, b)

    def _query(self, node, lo, hi, x):
        if node is None:
            return float("inf")
        best = self._value(node.m, node.b, x)
        if lo == hi:
            return best
        mid = (lo + hi) // 2
        if x <= mid:
            return min(best, self._query(node.left, lo, mid, x))
        return min(best, self._query(node.right, mid + 1, hi, x))

    def query(self, x):
        return self._query(self.root, self.lo, self.hi, x)


tree = LiChaoTree(0, 10)
tree.add_line(-1, 0)   # y = -x        (inserted in no particular slope order)
tree.add_line(2, -6)   # y = 2x - 6
tree.add_line(0, 0)    # y = 0
for x in range(0, 11, 2):
    print(x, tree.query(x))
# 0 -6   2 -2   4 -4   6 -6   8 -8   10 -10
#include <iostream>
#include <climits>
using namespace std;

struct LiChaoNode {
    long long m, b;
    LiChaoNode* left = nullptr;
    LiChaoNode* right = nullptr;
    LiChaoNode(long long m, long long b) : m(m), b(b) {}
};

long long lineValue(long long m, long long b, long long x) {
    return m * x + b;
}

LiChaoNode* insertLine(LiChaoNode* node, long long lo, long long hi, long long m, long long b) {
    if (node == nullptr) return new LiChaoNode(m, b);
    long long mid = lo + (hi - lo) / 2;
    bool leftBetter = lineValue(m, b, lo) < lineValue(node->m, node->b, lo);
    bool midBetter = lineValue(m, b, mid) < lineValue(node->m, node->b, mid);
    if (midBetter) {
        swap(node->m, m);
        swap(node->b, b);
    }
    if (lo == hi) return node;
    if (leftBetter != midBetter) {
        node->left = insertLine(node->left, lo, mid, m, b);
    } else {
        node->right = insertLine(node->right, mid + 1, hi, m, b);
    }
    return node;
}

long long queryPoint(LiChaoNode* node, long long lo, long long hi, long long x) {
    if (node == nullptr) return LLONG_MAX / 2;
    long long best = lineValue(node->m, node->b, x);
    if (lo == hi) return best;
    long long mid = lo + (hi - lo) / 2;
    long long child = (x <= mid) ? queryPoint(node->left, lo, mid, x)
                                  : queryPoint(node->right, mid + 1, hi, x);
    return min(best, child);
}

int main() {
    LiChaoNode* root = nullptr;
    root = insertLine(root, 0, 10, -1, 0);   // y = -x
    root = insertLine(root, 0, 10, 2, -6);   // y = 2x - 6
    root = insertLine(root, 0, 10, 0, 0);    // y = 0
    for (long long x = 0; x <= 10; x += 2) {
        cout << x << " " << queryPoint(root, 0, 10, x) << '\n';
    }
    // 0 -6   2 -2   4 -4   6 -6   8 -8   10 -10
}
import java.util.*;

public class LiChaoDemo {
    static class LiChaoNode {
        long m, b;
        LiChaoNode left, right;
        LiChaoNode(long m, long b) { this.m = m; this.b = b; }
    }

    static long lineValue(long m, long b, long x) { return m * x + b; }

    static LiChaoNode insertLine(LiChaoNode node, long lo, long hi, long m, long b) {
        if (node == null) return new LiChaoNode(m, b);
        long mid = lo + (hi - lo) / 2;
        boolean leftBetter = lineValue(m, b, lo) < lineValue(node.m, node.b, lo);
        boolean midBetter = lineValue(m, b, mid) < lineValue(node.m, node.b, mid);
        if (midBetter) {
            long tm = node.m, tb = node.b;
            node.m = m; node.b = b;
            m = tm; b = tb;
        }
        if (lo == hi) return node;
        if (leftBetter != midBetter) {
            node.left = insertLine(node.left, lo, mid, m, b);
        } else {
            node.right = insertLine(node.right, mid + 1, hi, m, b);
        }
        return node;
    }

    static long queryPoint(LiChaoNode node, long lo, long hi, long x) {
        if (node == null) return Long.MAX_VALUE / 2;
        long best = lineValue(node.m, node.b, x);
        if (lo == hi) return best;
        long mid = lo + (hi - lo) / 2;
        long child = (x <= mid) ? queryPoint(node.left, lo, mid, x) : queryPoint(node.right, mid + 1, hi, x);
        return Math.min(best, child);
    }

    public static void main(String[] args) {
        LiChaoNode root = null;
        root = insertLine(root, 0, 10, -1, 0);   // y = -x
        root = insertLine(root, 0, 10, 2, -6);   // y = 2x - 6
        root = insertLine(root, 0, 10, 0, 0);    // y = 0
        for (long x = 0; x <= 10; x += 2) {
            System.out.println(x + " " + queryPoint(root, 0, 10, x));
        }
        // 0 -6   2 -2   4 -4   6 -6   8 -8   10 -10
    }
}

6. Monge Arrays and Totally Monotone Matrices

Core insight: a matrix (or an implicit cost function $\text{cost}(a,c)$) is a Monge array if, for every $a \le b$ and $c \le d$: $$\text{cost}(a,c) + \text{cost}(b,d) \le \text{cost}(a,d) + \text{cost}(b,c)$$ This is the same quadrangle inequality that justifies Knuth's optimization, reframed as a property of a whole matrix rather than a single DP recurrence.

A Monge matrix is automatically totally monotone: if $\text{argmin}_c\, M[r][c]$ denotes the column holding row $r$'s minimum, then $\text{argmin}_c\, M[r][c]$ is non-decreasing as $r$ increases. Intuitively, the quadrangle inequality says that moving to a later row never makes an earlier column look relatively better -- so the row-minimizing column can only drift rightward.

Why this matters: naively computing the minimum of every row of an $n\times m$ matrix costs $O(nm)$. If the matrix is totally monotone, SMAWK (Shor, Moran, Aggarwal, Wilber, Klawe) computes every row's minimum in only $O(n+m)$ total matrix accesses -- without ever materializing the matrix -- using two cooperating phases:

  • Reduce: discard columns that can never be a row-minimum for any row. Scan columns left to right, maintaining a stack of survivors capped at (at most) one column per row: while the top survivor loses to the incoming column at the row matching the survivor's position, pop it (it is dominated); otherwise the incoming column joins the stack. This mirrors a monotone-stack argument and leaves at most $\min(n, m)$ candidate columns.
  • Interpolate: recursively solve the same problem restricted to only the odd-indexed rows against the reduced columns. Because total monotonicity guarantees the answer columns are non-decreasing across all rows (not just the odd ones), each even-indexed row's answer column must lie between its two odd-indexed neighbors' answer columns -- so each even row only needs a short linear scan bounded by that window, not a scan of every reduced column.

The recursion halves the number of rows each level (odd rows recurse, even rows interpolate), giving $O(n)$ total interpolation work across $O(\log n)$ levels, plus $O(m)$ total reduce work -- $O(n+m)$ overall.

Worked Example

Row minima without building the matrix

Let $a=[1,2,5]$ (rows) and $b=[0,3,4,10]$ (columns), with $\text{cost}(i,j) = (a_i - b_j)^2$ -- a classic Monge cost function. The full matrix, if you materialized it, would be:

$b_0=0$$b_1=3$$b_2=4$$b_3=10$
$a_0=1$14981
$a_1=2$41464
$a_2=5$254125

The row-minimizing column is $0, 1, 2$ for rows $0, 1, 2$ -- non-decreasing, as total monotonicity guarantees. SMAWK finds these three answers using only $O(n+m)=O(7)$ matrix accesses (up to constants), never comparing every one of the $12$ entries.

def smawk(rows, cols, lookup):
    """Return {row: argmin column} for every row, for a totally monotone
    matrix accessed only through lookup(row, col). Runs in roughly
    O(len(rows) + len(cols)) lookup calls, not O(len(rows) * len(cols))."""
    if not rows:
        return {}

    # --- REDUCE: discard columns that can never be a row-minimum ---
    reduced_cols = []
    for c in cols:
        while reduced_cols:
            r_index = len(reduced_cols) - 1
            if r_index >= len(rows):
                break
            if lookup(rows[r_index], reduced_cols[-1]) > lookup(rows[r_index], c):
                reduced_cols.pop()
            else:
                break
        reduced_cols.append(c)

    if len(rows) == 1:
        best_c = reduced_cols[0]
        best_v = lookup(rows[0], best_c)
        for c in reduced_cols[1:]:
            v = lookup(rows[0], c)
            if v < best_v:
                best_v, best_c = v, c
        return {rows[0]: best_c}

    # --- recurse on the odd-indexed rows only ---
    odd_rows = rows[1::2]
    result = smawk(odd_rows, reduced_cols, lookup)

    # --- INTERPOLATE: fill in the even-indexed rows using the odd rows as bounds ---
    col_index = {c: i for i, c in enumerate(reduced_cols)}
    ptr = 0
    for i in range(0, len(rows), 2):
        r = rows[i]
        right_bound = (col_index[result[rows[i + 1]]] if i + 1 < len(rows)
                       else len(reduced_cols) - 1)
        best_c = reduced_cols[ptr]
        best_v = lookup(r, best_c)
        for j in range(ptr + 1, right_bound + 1):
            v = lookup(r, reduced_cols[j])
            if v <= best_v:
                best_v, best_c = v, reduced_cols[j]
        result[r] = best_c
        ptr = col_index[best_c]
    return result


a = [1, 2, 5]
b = [0, 3, 4, 10]


def cost(i, j):
    return (a[i] - b[j]) ** 2


minima = smawk(list(range(len(a))), list(range(len(b))), cost)
for r in sorted(minima):
    print(f"row {r}: best column {minima[r]}, cost {cost(r, minima[r])}")
# row 0: best column 0, cost 1
# row 1: best column 1, cost 1
# row 2: best column 2, cost 1
#include <iostream>
#include <vector>
#include <unordered_map>
#include <functional>
using namespace std;

unordered_map<int,int> smawk(const vector<int>& rows, const vector<int>& cols,
                              const function<long long(int,int)>& lookup) {
    unordered_map<int,int> result;
    if (rows.empty()) return result;

    // --- REDUCE ---
    vector<int> reducedCols;
    for (int c : cols) {
        while (!reducedCols.empty()) {
            size_t rIndex = reducedCols.size() - 1;
            if (rIndex >= rows.size()) break;
            if (lookup(rows[rIndex], reducedCols.back()) > lookup(rows[rIndex], c)) {
                reducedCols.pop_back();
            } else {
                break;
            }
        }
        reducedCols.push_back(c);
    }

    if (rows.size() == 1) {
        int bestC = reducedCols[0];
        long long bestV = lookup(rows[0], bestC);
        for (int c : reducedCols) {
            long long v = lookup(rows[0], c);
            if (v < bestV) { bestV = v; bestC = c; }
        }
        result[rows[0]] = bestC;
        return result;
    }

    // --- recurse on odd rows ---
    vector<int> oddRows;
    for (size_t i = 1; i < rows.size(); i += 2) oddRows.push_back(rows[i]);
    result = smawk(oddRows, reducedCols, lookup);

    // --- INTERPOLATE ---
    unordered_map<int,int> colIndex;
    for (size_t i = 0; i < reducedCols.size(); i++) colIndex[reducedCols[i]] = (int)i;

    int ptr = 0;
    for (size_t i = 0; i < rows.size(); i += 2) {
        int r = rows[i];
        int rightBound = (i + 1 < rows.size())
                ? colIndex[result[rows[i + 1]]]
                : (int)reducedCols.size() - 1;
        int bestC = reducedCols[ptr];
        long long bestV = lookup(r, bestC);
        for (int j = ptr + 1; j <= rightBound; j++) {
            long long v = lookup(r, reducedCols[j]);
            if (v <= bestV) { bestV = v; bestC = reducedCols[j]; }
        }
        result[r] = bestC;
        ptr = colIndex[bestC];
    }
    return result;
}

int main() {
    vector<int> a = {1, 2, 5};
    vector<int> b = {0, 3, 4, 10};
    auto cost = [&](int i, int j) -> long long {
        long long d = a[i] - b[j];
        return d * d;
    };

    vector<int> rows = {0, 1, 2};
    vector<int> cols = {0, 1, 2, 3};
    unordered_map<int,int> minima = smawk(rows, cols, cost);
    for (int r : {0, 1, 2}) {
        cout << "row " << r << ": best column " << minima[r]
             << ", cost " << cost(r, minima[r]) << '\n';
    }
    // row 0: best column 0, cost 1
    // row 1: best column 1, cost 1
    // row 2: best column 2, cost 1
}
import java.util.*;
import java.util.function.BiFunction;

public class SmawkDemo {

    static Map<Integer, Integer> smawk(List<Integer> rows, List<Integer> cols,
                                        BiFunction<Integer, Integer, Long> lookup) {
        if (rows.isEmpty()) return new HashMap<>();

        // --- REDUCE ---
        List<Integer> reducedCols = new ArrayList<>();
        for (int c : cols) {
            while (!reducedCols.isEmpty()) {
                int rIndex = reducedCols.size() - 1;
                if (rIndex >= rows.size()) break;
                if (lookup.apply(rows.get(rIndex), reducedCols.get(reducedCols.size() - 1))
                        > lookup.apply(rows.get(rIndex), c)) {
                    reducedCols.remove(reducedCols.size() - 1);
                } else {
                    break;
                }
            }
            reducedCols.add(c);
        }

        if (rows.size() == 1) {
            int bestC = reducedCols.get(0);
            long bestV = lookup.apply(rows.get(0), bestC);
            for (int c : reducedCols) {
                long v = lookup.apply(rows.get(0), c);
                if (v < bestV) { bestV = v; bestC = c; }
            }
            Map<Integer, Integer> single = new HashMap<>();
            single.put(rows.get(0), bestC);
            return single;
        }

        // --- recurse on odd rows ---
        List<Integer> oddRows = new ArrayList<>();
        for (int i = 1; i < rows.size(); i += 2) oddRows.add(rows.get(i));
        Map<Integer, Integer> result = new HashMap<>(smawk(oddRows, reducedCols, lookup));

        // --- INTERPOLATE ---
        Map<Integer, Integer> colIndex = new HashMap<>();
        for (int i = 0; i < reducedCols.size(); i++) colIndex.put(reducedCols.get(i), i);

        int ptr = 0;
        for (int i = 0; i < rows.size(); i += 2) {
            int r = rows.get(i);
            int rightBound = (i + 1 < rows.size())
                    ? colIndex.get(result.get(rows.get(i + 1)))
                    : reducedCols.size() - 1;
            int bestC = reducedCols.get(ptr);
            long bestV = lookup.apply(r, bestC);
            for (int j = ptr + 1; j <= rightBound; j++) {
                long v = lookup.apply(r, reducedCols.get(j));
                if (v <= bestV) { bestV = v; bestC = reducedCols.get(j); }
            }
            result.put(r, bestC);
            ptr = colIndex.get(bestC);
        }
        return result;
    }

    public static void main(String[] args) {
        int[] a = {1, 2, 5};
        int[] b = {0, 3, 4, 10};
        BiFunction<Integer, Integer, Long> cost = (i, j) -> (long) (a[i] - b[j]) * (a[i] - b[j]);

        List<Integer> rows = List.of(0, 1, 2);
        List<Integer> cols = List.of(0, 1, 2, 3);
        Map<Integer, Integer> minima = smawk(rows, cols, cost);
        for (int r : new int[]{0, 1, 2}) {
            System.out.println("row " + r + ": best column " + minima.get(r)
                    + ", cost " + cost.apply(r, minima.get(r)));
        }
        // row 0: best column 0, cost 1
        // row 1: best column 1, cost 1
        // row 2: best column 2, cost 1
    }
}

7. The Aliens Trick (Lagrangian Relaxation / WQS Binary Search)

Core insight: some problems ask for "the minimum cost using exactly $k$ operations" (segments, groups, colors, edges...), where the unconstrained version -- "minimum cost using any number of operations" -- has a fast DP, but adding a hard count constraint seems to need an extra DP dimension for the count, multiplying the complexity by $k$. The Aliens Trick avoids that dimension entirely: add a tunable penalty $\lambda$ to the cost of every operation, solve the unconstrained problem (which now implicitly prefers some particular operation count depending on $\lambda$), and binary search over $\lambda$ until the unconstrained solution happens to use exactly $k$ operations.

Why does this work? As $\lambda$ increases, using more operations becomes more expensive, so the operation count the unconstrained solver "wants" to use is non-increasing in $\lambda$. This only produces a correct answer when the true cost-as-a-function-of-$k$ curve is convex -- each additional operation buys a diminishing (but still non-negative) reduction in cost. Concretely: fix $\lambda$, solve $g(\lambda) = \min_{\text{any } t}\big(\text{cost}(t \text{ operations}) + \lambda t\big)$, and if the achieving $t$ equals $k$, then $\text{cost}(k) = g(\lambda) - \lambda k$ -- the penalty term cancels out exactly.

The concrete example here is "partition an array into exactly $k$ contiguous segments, minimizing the sum of each segment's squared sum." The unconstrained inner DP is $g[i] = \min_{j\lt i}\big(g[j] + \text{cost}(j,i) + \lambda\big)$, which drops the extra count dimension; a binary search over $\lambda$ recovers the exactly-$k$ answer.

The exact-count recovery subtlety: at the boundary value of $\lambda$ the binary search converges to, multiple operation counts can tie for the same penalized cost -- $k$, $k+1$, and others might all be equally good at that exact $\lambda$. If the inner DP breaks ties arbitrarily, it might report a count near $k$ but not exactly $k$, silently returning the wrong answer. The fix is a consistent tie-break rule -- for example, always prefer the option using more operations when penalized costs are equal -- combined with binary searching for the boundary $\lambda$ where the achieved count crosses $k$. Because the tie is genuine (both counts really do cost the same at that $\lambda$), the formula $\text{cost}(k) = g(\lambda) - \lambda k$ is still valid for the target $k$ even though the DP's own tie-break happened to report a different count.
def segment_cost(prefix, l, r):
    """Squared cost of one contiguous segment covering elements l..r-1."""
    s = prefix[r] - prefix[l]
    return s * s


def penalized_dp(a, penalty):
    """Unconstrained problem: minimize (total segment cost) + penalty * (segment count).
    Returns (best_total_with_penalty, segment_count_used). Ties are broken toward MORE
    segments -- this consistent tie-break is what makes exact-k recovery possible."""
    n = len(a)
    prefix = [0] * (n + 1)
    for i in range(n):
        prefix[i + 1] = prefix[i] + a[i]

    g = [float("inf")] * (n + 1)
    count = [0] * (n + 1)
    g[0] = 0

    for i in range(1, n + 1):
        best_v, best_c = float("inf"), 0
        for j in range(i):
            if g[j] == float("inf"):
                continue
            v = g[j] + segment_cost(prefix, j, i) + penalty
            c = count[j] + 1
            if v < best_v - 1e-9:
                best_v, best_c = v, c
            elif abs(v - best_v) <= 1e-9 and c > best_c:
                best_v, best_c = v, c
        g[i], count[i] = best_v, best_c

    return g[n], count[n]


def min_cost_exactly_k_segments(a, k, lam_lo=-1.0, lam_hi=1e5, iterations=200):
    """Binary search over the Lagrange penalty (WQS binary search) until the
    unconstrained solution happens to use exactly k segments."""
    lo, hi = lam_lo, lam_hi
    for _ in range(iterations):
        mid = (lo + hi) / 2
        _, used = penalized_dp(a, mid)
        if used <= k:
            hi = mid
        else:
            lo = mid
    lam = hi
    total_with_penalty, _ = penalized_dp(a, lam)
    # At this boundary lambda, a k-segment split ties with the DP's own choice in
    # penalized cost, so removing k*lambda recovers the true unpenalized k-segment cost.
    return total_with_penalty - k * lam


a = [1, 1, 1, 1]
for k in range(1, 5):
    print(f"k={k}: min cost = {round(min_cost_exactly_k_segments(a, k), 6)}")
# k=1: min cost = 16.0   (one segment [1,1,1,1], sum=4, cost=16)
# k=2: min cost = 8.0    (two segments of size 2, cost 4+4=8)
# k=3: min cost = 6.0    (sizes 2,1,1 in some order, cost 4+1+1=6)
# k=4: min cost = 4.0    (four singletons, cost 1+1+1+1=4)
# k=2 and k=3 both resolve at the SAME boundary lambda -- a genuine tie, recovered
# correctly for each k only because of the tie-break rule above.
#include <iostream>
#include <vector>
#include <cmath>
using namespace std;

double segmentCost(const vector<long long>& prefix, int l, int r) {
    double s = (double)(prefix[r] - prefix[l]);
    return s * s;
}

// Returns {bestTotalWithPenalty, segmentCountUsed}
pair<double, int> penalizedDp(const vector<int>& a, double penalty) {
    int n = a.size();
    vector<long long> prefix(n + 1, 0);
    for (int i = 0; i < n; i++) prefix[i + 1] = prefix[i] + a[i];

    const double INF = 1e18;
    vector<double> g(n + 1, INF);
    vector<int> count(n + 1, 0);
    g[0] = 0;

    for (int i = 1; i <= n; i++) {
        double bestV = INF;
        int bestC = 0;
        for (int j = 0; j < i; j++) {
            if (g[j] >= INF) continue;
            double v = g[j] + segmentCost(prefix, j, i) + penalty;
            int c = count[j] + 1;
            if (v < bestV - 1e-9) {
                bestV = v; bestC = c;
            } else if (fabs(v - bestV) <= 1e-9 && c > bestC) {
                bestV = v; bestC = c;
            }
        }
        g[i] = bestV;
        count[i] = bestC;
    }
    return {g[n], count[n]};
}

double minCostExactlyKSegments(const vector<int>& a, int k) {
    double lo = -1.0, hi = 1e5;
    for (int iter = 0; iter < 200; iter++) {
        double mid = (lo + hi) / 2;
        auto result = penalizedDp(a, mid);
        int used = result.second;
        if (used <= k) hi = mid; else lo = mid;
    }
    double lambda = hi;
    auto result = penalizedDp(a, lambda);
    double totalWithPenalty = result.first;
    return totalWithPenalty - k * lambda;
}

int main() {
    vector<int> a = {1, 1, 1, 1};
    for (int k = 1; k <= 4; k++) {
        cout << "k=" << k << ": min cost = " << minCostExactlyKSegments(a, k) << '\n';
    }
    // k=1: min cost = 16   k=2: min cost = 8   k=3: min cost = 6   k=4: min cost = 4
}
import java.util.*;

public class AliensTrickDemo {

    static double segmentCost(long[] prefix, int l, int r) {
        double s = prefix[r] - prefix[l];
        return s * s;
    }

    // Returns {bestTotalWithPenalty, segmentCountUsed}
    static double[] penalizedDp(int[] a, double penalty) {
        int n = a.length;
        long[] prefix = new long[n + 1];
        for (int i = 0; i < n; i++) prefix[i + 1] = prefix[i] + a[i];

        double INF = Double.MAX_VALUE / 4;
        double[] g = new double[n + 1];
        int[] count = new int[n + 1];
        Arrays.fill(g, INF);
        g[0] = 0;

        for (int i = 1; i <= n; i++) {
            double bestV = INF;
            int bestC = 0;
            for (int j = 0; j < i; j++) {
                if (g[j] >= INF) continue;
                double v = g[j] + segmentCost(prefix, j, i) + penalty;
                int c = count[j] + 1;
                if (v < bestV - 1e-9) {
                    bestV = v; bestC = c;
                } else if (Math.abs(v - bestV) <= 1e-9 && c > bestC) {
                    bestV = v; bestC = c;
                }
            }
            g[i] = bestV;
            count[i] = bestC;
        }
        return new double[]{g[n], count[n]};
    }

    static double minCostExactlyKSegments(int[] a, int k) {
        double lo = -1.0, hi = 1e5;
        for (int iter = 0; iter < 200; iter++) {
            double mid = (lo + hi) / 2;
            double[] result = penalizedDp(a, mid);
            int used = (int) result[1];
            if (used <= k) hi = mid; else lo = mid;
        }
        double lambda = hi;
        double[] result = penalizedDp(a, lambda);
        double totalWithPenalty = result[0];
        return totalWithPenalty - k * lambda;
    }

    public static void main(String[] args) {
        int[] a = {1, 1, 1, 1};
        for (int k = 1; k <= 4; k++) {
            double cost = minCostExactlyKSegments(a, k);
            System.out.printf("k=%d: min cost = %.6f%n", k, cost);
        }
        // k=1: min cost = 16.000000
        // k=2: min cost = 8.000000
        // k=3: min cost = 6.000000
        // k=4: min cost = 4.000000
    }
}

8. Complexity Analysis

Convex Hull Trick: $O(n)$ total (amortized $O(1)$ per query) when lines are added in monotone slope order and queries arrive in monotone $x$ order; $O(n \log n)$ with a Li Chao Tree when neither order is guaranteed.
SMAWK: $O(n+m)$ total matrix accesses for an implicitly-defined $n\times m$ totally monotone matrix, versus $O(nm)$ for the naive per-row scan.
Aliens Trick: adds an $O(\log(\text{range of } \lambda))$ multiplicative factor on top of whatever the unconstrained DP already costs -- if the unconstrained DP is $O(n)$ or $O(n\log n)$ per evaluation, the constrained exactly-$k$ answer costs $O(n\log(\text{range}))$ or $O(n\log n\log(\text{range}))$ overall, instead of paying an extra factor of $k$ for a count dimension.

All three techniques share a theme: they turn a factor that looks unavoidable in the naive transition -- checking every $j$, scanning every column, tracking every possible count -- into a factor that the cost function's own structure lets you skip.

9. Applications & Alternatives

Where these techniques show up

  • Convex Hull Trick / Li Chao Tree: task scheduling with per-batch linear costs, "minimum cost to reach the end" problems with quadratic transition costs, and any DP where the transition is literally a line evaluated at a query point.
  • SMAWK: optimal binary search tree construction, least-weight subsequence problems, and any DP whose transition cost matrix satisfies the quadrangle inequality but doesn't fit neatly into Knuth's single-recurrence framing.
  • Aliens Trick: "choose exactly $k$" variants of segment-partitioning problems, tree problems requiring exactly $k$ selected edges or nodes, and any problem where the unconstrained version is fast but a hard count constraint seems to demand an extra DP dimension.

These are not mutually exclusive: an Aliens Trick's unconstrained inner DP is itself often accelerated with the Convex Hull Trick or a D&C/Knuth optimization from Part 1. When none of the structural assumptions hold -- the cost function isn't convex, isn't Monge, or the count-vs-cost curve isn't convex -- fall back to the direct $O(n^2)$ or $O(n^2 k)$ DP, or look for a different structural property entirely.

10. Exercises and Mastery

Easy

  1. By hand, trace the lower envelope construction (insertions and any pops) for lines added in this decreasing-slope order: $y=5x$, $y=2x+4$, $y=-1x+9$, $y=-4x+16$. Which line, if any, gets popped?

Medium

  1. Take a DP whose transitions are lines but whose insertion order is not sorted by slope (for example, slopes derived from an unsorted input array) and whose queries are also unordered. Adapt the Li Chao Tree implementation above to answer a mixed sequence of insertions and queries, and confirm it matches a brute-force scan over all inserted lines at each query.

Hard

  1. Sketch (code optional) how the Aliens Trick applies to "minimum cost to paint a fence of $n$ boards using exactly $k$ distinct colors, where using color $c$ on consecutive boards is free but switching colors costs a fixed amount." Define the unconstrained inner problem (any number of color-switches allowed), describe the penalty term you would add per switch, and explain what binary search bounds on $\lambda$ would be reasonable. Then describe, in your own words, how the exact-count recovery subtlety from this lesson would apply if two different numbers of switches tied in penalized cost.
Mastery challenge: Given a DP transition $dp[i] = \min_{j\lt i}(dp[j] + \text{cost}(j,i))$, decide -- without being told -- which of Part 1's monotonicity techniques or this lesson's three techniques (if any) apply. Write down the check you would run: is $\text{cost}$ linear in a per-index slope/intercept pair (Convex Hull Trick candidate)? Does $\text{cost}$ satisfy the quadrangle inequality as a matrix (Knuth or SMAWK candidate)? Does the problem have a hard "exactly $k$" constraint whose unconstrained relaxation is fast (Aliens Trick candidate)? Then implement whichever applies.

Review rhythm: tomorrow, re-derive why lines added in decreasing slope order can only be popped from the back of the envelope, without notes. Next week, implement the Li Chao Tree from scratch against a randomized brute-force checker. Several weeks later, take an unfamiliar "exactly $k$" problem and determine, from first principles, whether its cost-vs-$k$ curve is convex enough for the Aliens Trick to apply.

11. Summary Cheat Sheet

  • If a transition is $dp[i]=\min_j(dp[j]+b[j]\cdot a[i])$, it's a line evaluated at a point -- reach for the Convex Hull Trick (monotone order) or a Li Chao Tree (arbitrary order).
  • A Monge matrix satisfies $\text{cost}(a,c)+\text{cost}(b,d)\le\text{cost}(a,d)+\text{cost}(b,c)$ and is automatically totally monotone, which SMAWK exploits to get every row's minimum in $O(n+m)$.
  • SMAWK's reduce step discards columns that can never win; its interpolate step uses odd rows' answers to bound even rows' search windows.
  • The Aliens Trick turns "exactly $k$ operations" into a binary search over a per-operation penalty $\lambda$, avoiding an extra DP dimension for the count.
  • Exact-$k$ recovery from the Aliens Trick requires a consistent tie-break rule (e.g., prefer more operations on ties) -- without it, a tied boundary $\lambda$ can silently recover the wrong count.
  • None of these three techniques require Part 1's monotonicity argument -- they exploit convexity, the Monge property, and a tunable penalty, respectively.