Recently, I encountered a problem in a LeetCode contest that I couldn't solve, so I decided to dig deeper into these patterns. While learning about the Convex Hull Trick, I took some notes and thought of sharing them with the community, as I couldn't find many good posts on this topic.
Please let me know if any statement or code is incorrect—I'll be happy to update it. I'll also be adding few new images soon. If you find this helpful, please consider upvoting; it’ll motivate me to spend more time sharing clean and well-organized notes.
Dynamic Programming is powerful, but sometimes it's not fast enough for large constraints. When your recurrence involves minimizing or maximizing over linear functions, Convex Hull Trick can bring your solution from TLE to AC.
It is a technique used to optimize a certain class of DP problems that involve the minimum (or maximum) of linear functions.
Imagine you have a set of points on a 2D plane. The convex hull is the smallest convex polygon that encloses all these points — like stretching a rubber band around the outermost points. It captures the "outline" of the shape formed by the points.

At first glance, geometry and dynamic programming seem like different worlds — one deals with shapes, the other with states and transitions. But geometry helps optimize DP when your recurrence has a linear structure.
Suppose your DP looks like:
dp[i] = min(dp[j] + cost[j] * x[i]) for j < iHere, for each i, you're trying to find the minimum value of a set of linear functions:
= cost[j]* + dp[j]
This is where the geometry kicks in:
Each is a straight line in the 2D plane.
For a given = x[i], you want to find the line with the smallest y value — in other words, the lower envelope of all lines at a specific x-coordinate.
The problem reduces to finding the minimum y-value among all lines at a particular x, which is a classic geometry problem.
This is what the Convex Hull Trick optimizes:
So instead of brute-force checking all j, you cleverly use the geometry to prune unnecessary comparisons.
The magic lies in convexity:
If the cost functions are linear and ordered (like monotonic slopes), then the optimal function at each point will appear in a convex order — just like points on a convex hull.
This structure allows for fast insertion and queries, sometimes even in amortized constant time.
Lets try to solve the following problem
You are given n cities located along a straight road. The k-th city is located at coordinate xₖ, and the cities are arranged such that x₁ < x₂ < ... < xₙ.
You start your journey in city 1 and aim to reach city n. Your car initially has an empty fuel tank, and it consumes 1 liter of gasoline per kilometer. At each city, you may purchase any amount of gasoline. The cost per liter of gasoline in the k-th city is given by costₖ.
Additionally, to enter any city k, you are required to pay a toll tollₖ.
Your goal is to determine the minimum possible total cost of traveling from city 1 to city n, including fuel purchases and tolls.
Input:
An integer n — the number of cities.
Arrays x[1..n], cost[1..n], and toll[1..n]:
: position of the city.
: cost per liter of fuel in the city.
: toll fee to enter the city.
Constraints:
1 ≤ n ≤
0 ≤ x₁ < x₂ < ... < xₙ ≤
1 ≤ , ≤
Output:
The minimum total cost to travel from city 1 to city n.
Dynamic Programming Formulation:
Intuition:
Let dp[i] represent the minimum cost to reach the city. We can define the recurrence as:
public int minCost(int[] x, int[] cost, int[] toll) {
int n = x.length;
int[] dp = new int[n];
dp[0] = toll[0]; // Starting at city 1
for (int i = 1; i < n; i++) {
dp[i] = Integer.MAX_VALUE;
for (int j = 0; j < i; j++) {
int fuelCost = (x[i] - x[j]) * cost[j];
dp[i] = Math.min(dp[i], dp[j] + fuelCost + toll[i]);
}
}
return dp[n - 1]; // cost to reach city n
}Naive approach will give you complexity which will mostly not give TLE for values <= but it will give TLE for
We now have the dynamic programming recurrence:
We can rearrange the equation to isolate terms that depend on j and i:
Now, observe this carefully: for a fixed j, this expression is linear with respect to — the term is linear, and the rest is constant for each j.
This means we are trying to compute the minimum value among several lines (functions of ) defined as:
which is equivalent to y = mx + c
Where:
So for each i, the value dp[i] becomes:
This structure is exactly what the Convex Hull Trick is designed to optimize:
Maintain a set of lines and efficiently query the minimum value at a given x.
We have 3 possible cases
Lets create a Line class or record to store a line object
// Represents a line: y = m * x + c
record Line (long slope, long intercept){
public long evaluate(long x) {
return slope * x + intercept;
}
}// Returns x-coordinate of intersection between two lines
private double intersectionX(Line line1, Line line2) {
return (double) (line2.intercept - line1.intercept) / (line1.slope - line2.slope);
}evaluate(long x): Computes y for the given x
intersectionX(Line other): Computes the x-coordinate of the intersection point between two lines. Used to maintain convexity of the hull.
Suppose we have two lines:
At the point of intersection:
We have two other important methods:
addLine(Line newLine) and query(long x) along with a pointer for efficent queries.
// Store lines in the convex hull as mx + c
private final List<Line> hull = new ArrayList<>();
// Pointer for efficient queries
private int pointer = 0;Note: We are not using a deque in this implementation, unlike many C++ implementations you may find online. This helps us avoid the complexity of managing (adding/removing) lines in addLine() and query().
The method maintains a lower convex hull of lines (for minimum queries).
It ensures:
// Add a new line to the convex hull (only if it's not dominated)
public void addLine(Line newLine) {
while (hull.size() >= 2) {
Line last = hull.get(hull.size() - 1);
Line secondLast = hull.get(hull.size() - 2);
// If newLine makes last line redundant, remove last line
if (intersectionX(secondLast, newLine) <= intersectionX(secondLast, last)) {
hull.remove(hull.size() - 1);
} else {
break;
}
}
hull.add(newLine);
}Lets try to add the following lines where lines are sorted in decreasing order of slopes.
L0: y = 3x + 10
L1: y = 2x + 20
L2: y = 1x + 50
L3: y = 0.9x + 45
Lets try to add the lines
For L0
hull = [L0]For L1
L1.hull = [L0, L1]For L2
hull.size() = 2, so we check redundancy:
secondLast = L0, last = L1, newLine = L2(L0, L2) is to the left of the intersection of (L0, L1)if intersectionX(L0, L2) <= intersectionX(L0, L1)L2 is not redundantL1L2hull = [L0, L1, L2], highlighted with yellow color.For L3
hull.size() = 3, so we check redundancy:
secondLast = L1, last = L2, newLine = L3
(L1, L3) is to the left of the intersection of (L1, L2)if intersectionX(L1, L3) <= intersectionX(L1, L2)L2 is redundantL2 because L3 will yield lower values for every x after the intersection point of L1 and L3hull = [L0, L1]
secondLast = L0, last = L1, newLine = L3(L0, L3) is to the left of the intersection of (L0, L1)if intersectionX(L0, L3) <= intersectionX(L0, L1)L3 is not redundantL1L3hull = [L0, L1, L3]L0, L1, and L3, which are visually represented in yellow (from the left up to X0) and continue along the dashed orange line after X0. This dashed orange segment indicates that L2 is no longer part of the convex hull—it was removed because it is completely dominated by L3 beyond the intersection point with L1.If slope are sorted in increasing order, you can modify the logic of
addLine()or you can negate the slope which has the effect of mirroring lines abount y axis, so you can use the same implemenation for both.
query(x) returns the minimum value of among all lines currently in the convex hull for the given input x.query(x)) x.// Query method using pointer (assumes monotonic queries)
public long query(long x) {
if (hull.isEmpty()) return Integer.MAX_VALUE;
// Ensures the pointer stays in bounds for the last line.
if (pointer >= hull.size()){
pointer = hull.size() - 1;
}
// Move pointer forward if next line gives a smaller value at x
while (pointer < hull.size() - 1 &&
hull.get(pointer + 1).evaluate(x) < hull.get(pointer).evaluate(x)) {
pointer++;
}
return hull.get(pointer).evaluate(x);
}// Convex Hull Trick for dynamic programming optimization
class ConvexHullTrick {
// Store lines in the convex hull as mx + c
private final List<Line> hull = new ArrayList<>();
// Pointer for efficient queries
private int pointer = 0;
// Returns x-coordinate of intersection between two lines
private double intersectionX(Line line1, Line line2) {
return (double) (line2.intercept - line1.intercept) / (line1.slope - line2.slope);
}
// Add a new line to the convex hull (only if it's not dominated)
public void addLine(Line newLine) {
while (hull.size() >= 2) {
Line last = hull.get(hull.size() - 1);
Line secondLast = hull.get(hull.size() - 2);
// If newLine makes last line redundant, remove last line
if (intersectionX(secondLast, newLine) <= intersectionX(secondLast, last)) {
hull.remove(hull.size() - 1);
} else {
break;
}
}
hull.add(newLine);
}
// Get minimum y-value at x among all lines in hull
public long query(long x) {
if (hull.isEmpty()) return Integer.MAX_VALUE;
// Ensures the pointer stays in bounds for the last line.
if (pointer >= hull.size()){
pointer = hull.size() - 1;
}
// Move pointer forward if next line gives a smaller value at x
while (pointer < hull.size() - 1 &&
hull.get(pointer + 1).evaluate(x) < hull.get(pointer).evaluate(x)) {
pointer++;
}
return hull.get(pointer).evaluate(x);
}
// query method using binary search for non-monotonic queries)
public long queryBinary(long x) {
if (hull.isEmpty()) return Integer.MAX_VALUE;
int lo = 0, hi = hull.size() - 1;
while (lo < hi) {
int mid = (lo + hi) / 2;
// Compare intersection of hull[mid] and hull[mid+1] with x
if (intersectionX(hull.get(mid), hull.get(mid + 1)) < x) {
lo = mid +1;
} else {
hi = mid ;
}
}
return hull.get(lo).evaluate(x);
}
}// query method using binary search for non-monotonic queries)
public long queryBinary(long x) {
if (hull.isEmpty()) return Integer.MAX_VALUE;
int lo = 0, hi = hull.size() - 1;
while (lo < hi) {
int mid = (lo + hi) / 2;
// Compare intersection of hull[mid] and hull[mid+1] with x
if (intersectionX(hull.get(mid), hull.get(mid + 1)) < x) {
lo = mid +1;
} else {
hi = mid ;
}
}
return hull.get(lo).evaluate(x);
}Prerequisite: Understanding of Segment Trees
For a given x, what is the optimal (minimum or maximum) value of all functions f(x) = mx + c in the set?
[-1e9, 1e9]), do not allocate a full array. Instead, use a dynamic tree (like a binary tree) to lazily create nodes only as needed.
class LiChaoTree {
// stores segment tree
private Line[] tree;
private int minX, maxX;
// Initialize LiChao Tree with a given x-range [minX, maxX]
public LiChaoTree(int minX, int maxX) {
this.minX = minX;
this.maxX = maxX;
// Allocate enough space for the segment tree (4 * number of discrete x values)
int size = 4 * (maxX - minX + 1);
this.tree = new Line[size];
}
// methods...
}Lets try to insert newLine in the tree. We have the following 5 major cases.
// if newLine is better on left
boolean leftBetter = newLine.evaluate(left) < tree[index].evaluate(left);
// if newline is better on mid
boolean midBetter = newLine.evaluate(mid) < tree[index].evaluate(mid);leftBetter = true and midBetter = falsenewLine is better only on the left, but worse or equal at the midpoint:
newLine into the left child [left, mid].leftBetter = false and midBetter = truenewLine) might still be better on the right side, so We recurse with it into the right child [mid+1,right]leftBetter = true and midBetter = truenewLine is strictly better at both points, so it must be better somewhere in the interval.mid, so we swap the current with newLine.newLine) might still be better on the right side, so we recurse with it into the right child [mid+1, right]:leftBetter = false and midBetter = falsenewLine is not better on the left and the mid point
newLine into the right child [mid+1, right].private void addLine(Line newLine, int index, int left, int right) {
int mid = (left + right) / 2;
// If no line is stored here, insert the new line.
if (tree[index] == null) {
tree[index] = newLine;
return;
}
// Early termination:
// If newLine is not better than the stored line at both endpoints,
// then it will never be the minimum in [left, right].
if (newLine.evaluate(left) >= tree[index].evaluate(left) &&
newLine.evaluate(right) >= tree[index].evaluate(right)) {
return;
}
// Determine whether newLine is better at the left endpoint and the mid point.
boolean leftBetter = newLine.evaluate(left) < tree[index].evaluate(left);
boolean midBetter = newLine.evaluate(mid) < tree[index].evaluate(mid);
// If newLine is better at mid, swap it with the current line.
if (midBetter) {
Line temp = tree[index];
tree[index] = newLine;
newLine = temp;
}
// If this is a leaf node, nothing more to do.
if (left == right)
return;
// Depending on where newLine is better, recursively update the left or right half.
if (leftBetter != midBetter) {
addLine(newLine, 2 * index + 1, left, mid);
} else {
addLine(newLine, 2 * index + 2, mid + 1, right);
}
}The query operation runs in time where K is the size of the x-coordinate range (or after coordinate compression).
private long query(int x, int index, int left, int right) {
if (index >= tree.length || tree[index] == null) {
return Long.MAX_VALUE;
}
int mid = (left + right) / 2;
long current = tree[index].evaluate(x);
if (left == right)
return current;
if (x <= mid) {
//since we are using mid = (left + right)/2, so x <= mid
return Math.min(current, query(x, 2 * index + 1, left, mid));
} else {
return Math.min(current, query(x, 2 * index + 2, mid + 1, right));
}
}
// LiChaoTree for minimum queries over a given x-range
class LiChaoTree {
// Stores segment tree
private Line[] tree;
private int minX, maxX;
// Initialize LiChao Tree with a given x-range [minX, maxX]
public LiChaoTree(int minX, int maxX) {
this.minX = minX;
this.maxX = maxX;
// Allocate enough space for the segment tree (4 * number of discrete x values)
int size = 4 * (maxX - minX + 1);
this.tree = new Line[size];
}
// Public method to add a new line to the tree
public void addLine(Line newLine) {
addLine(newLine, 0, (int) minX, (int) maxX);
}
// Recursive method to add a line over the segment [left, right] at tree index
private void addLine(Line newLine, int index, int left, int right) {
int mid = (left + right) / 2;
// If no line is stored here, insert the new line.
if (tree[index] == null) {
tree[index] = newLine;
return;
}
// Early termination:
// If newLine is not better than the stored line at both endpoints,
// then it will never be the minimum in [left, right].
if (newLine.evaluate(left) >= tree[index].evaluate(left) &&
newLine.evaluate(right) >= tree[index].evaluate(right)) {
return;
}
// Determine whether newLine is better at the left endpoint and the mid point.
boolean leftBetter = newLine.evaluate(left) < tree[index].evaluate(left);
boolean midBetter = newLine.evaluate(mid) < tree[index].evaluate(mid);
// If newLine is better at mid, swap it with the current line.
if (midBetter) {
Line temp = tree[index];
tree[index] = newLine;
newLine = temp;
}
// If this is a leaf node, nothing more to do.
if (left == right)
return;
// Depending on where newLine is better, recursively update the left or right half.
if (leftBetter != midBetter) {
addLine(newLine, 2 * index + 1, left, mid);
} else {
addLine(newLine, 2 * index + 2, mid + 1, right);
}
}
// Query the minimum y-value among all lines at a given x-coordinate.
public long query(int x) {
return query(x, 0, minX, maxX);
}
// Recursive query method in the segment [left, right] at tree index
private long query(int x, int index, int left, int right) {
if (index >= tree.length || tree[index] == null) {
return Long.MAX_VALUE;
}
int mid = (left + right) / 2;
long current = tree[index].evaluate(x);
if (left == right)
return current;
if (x <= mid) {
return Math.min(current, query(x, 2 * index + 1, left, mid));
} else {
return Math.min(current, query(x, 2 * index + 2, mid + 1, right));
}
}
}[-1e9, 1e9]), but you want to avoid allocating a huge array.public class LiChaoTree {
// Node class represents a segment of the x-axis [l, r] and stores the best line for that segment.
private static class Node {
long l, r; // x-coordinate range
Line line; // line currently stored at this node
Node left, right; // children nodes
public Node(long l, long r, Line line) {
this.l = l;
this.r = r;
this.line = line;
}
}
private Node root;
// Initialize LiChao Tree with a given x-range [minX, maxX]
public LiChaoTree(long minX, long maxX) {
root = new Node(minX, maxX, null);
}
// Public method to add a new line to the tree
public void addLine(Line newLine) {
addLine(root, newLine);
}
// Recursive method to add a line
private void addLine(Node node, Line newLine) {
if (node.line == null) {
// If no line exists at this node, insert the new line here.
node.line = newLine;
return;
}
long l = node.l;
long r = node.r;
long mid = (l + r) / 2;
// Early termination:
// If newLine is not better than the stored line at both endpoints,
// then it will never be the minimum in [left, right].
if (newLine.evaluate(l) >= node.line.evaluate(l) &&
newLine.evaluate(r) >= node.line.evaluate(r)) {
return;
}
boolean leftBetter = newLine.evaluate(l) < node.line.evaluate(l);
boolean midBetter = newLine.evaluate(mid) < node.line.evaluate(mid);
// For minimum queries, compare the new line and current line at the midpoint.
if (midBetter) {
// Swap: keep the better (lower) line at the node.
Line temp = node.line;
node.line = newLine;
newLine = temp;
}
// If we are at a leaf node, nothing more to do.
if (l == r)
return;
// Depending on where newLine is better, recursively update the left or right half.
if (leftBetter != midBetter) {
if (node.left == null) {
node.left = new Node(l, mid, null);
}
addLine(node.left, newLine);
}
// Else if the new line is better on the right segment:
else {
if (node.right == null) {
node.right = new Node(mid + 1, r, null);
}
addLine(node.right, newLine);
}
}
// Query the minimum y-value among all lines at a given x-coordinate.
public long query(long x) {
return query(root, x);
}
// Recursive query method
private long query(Node node, long x) {
if (node == null)
return Long.MAX_VALUE;
long curr = (node.line == null ? Long.MAX_VALUE : node.line.evaluate(x));
long mid = (node.r + node.l) / 2;
if (x <= mid) {
return Math.min(curr, query(node.left, x));
} else {
return Math.min(curr, query(node.right, x));
}
}
}This reduces the complexity of the solution to or , depending on the structure of the input.
References:
https://cp-algorithms.com/geometry/convex_hull_trick.html
https://codeforces.com/blog/entry/95494
https://codeforces.com/blog/entry/63823
Few youtube videos (don't have the urls collected)