The convex hull of a set of points is the smallest convex polygon that contains them all: picture a rubber band stretched around nails in a board and let go. It is one of the first structures computed in geometry code, because once you have it, questions such as the farthest pair of points, the minimum enclosing rectangle and fast point-in-region tests become simple.

Graham scan, published by Ronald Graham in 1972, computes the hull in O(n log n) time with one sort and one pass over a stack. The algorithm fits in twenty lines, yet implementations fail regularly on duplicate points, collinear points, floating-point angles and integer overflow. This article builds it from the one geometric primitive it needs, traces it by hand, and covers each of those traps.

Advertisement

The only primitive: orientation

Everything in Graham scan rests on one question: going from point o to a and then to b, do you turn left, turn right, or go straight? The answer is the sign of the 2D cross product:

def cross(o, a, b):
    """> 0: o -> a -> b turns left (counter-clockwise); < 0: right; 0: collinear."""
    return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])

The value is twice the signed area of triangle o, a, b. Only its sign is used, which is why the algorithm can be exact: with integer coordinates the computation involves only subtraction and multiplication, so there is no rounding at all.

Exactness has a cost in headroom. If coordinates fit in a signed 32-bit range of about plus or minus 109, differences reach 2 x 109, each product reaches 4 x 1018, and the difference of two products reaches 8 x 1018, just under the 64-bit limit of about 9.22 x 1018. So 64-bit arithmetic is enough for coordinates up to 109 but not for the full 32-bit range; beyond it use 128-bit integers or arbitrary precision. In Python integers never overflow; in Java and C++ write the products in long explicitly, because int * int overflows before it is widened.

With floating-point coordinates the sign of a nearly zero cross product can come out wrong, and a hull algorithm fed inconsistent answers can produce a polygon that is not convex or skip a corner. Robust code uses adaptive-precision predicates such as Shewchuk's, snaps inputs to an integer grid, or accepts a tolerance and documents what it does with near-collinear points.

The algorithm and why it works

Graham scan has three steps.

  1. Pick a pivot that is certainly on the hull: the point with the lowest y, breaking ties by lowest x. Every other point lies above it, or level with it and to its right, so their polar angles about the pivot fall in the range from 0 up to (but not including) 180 degrees.
  2. Sort the other points by polar angle around the pivot. Points on the same ray from the pivot are ordered by distance, nearest first.
  3. Scan the sorted points with a stack that starts with the pivot. For each point, while the last two points on the stack and the new point do not make a strict left turn, pop the top. Then push the new point. At the end the stack holds the hull, counter-clockwise.

The sort does not need angles. Because all points lie in a half-plane above the pivot, point a comes before point b exactly when cross(p0, a, b) > 0. This comparison is exact with integers and avoids atan2, whose floating-point results can make two collinear points look slightly different, or two different directions look equal.

Why does the scan work? The invariant is that the stack always holds the convex hull of the points processed so far, as a chain that turns left at every vertex. A new point is outside that chain's last edge exactly when it makes a right turn with the top two. In that case the top point is inside the triangle formed by the pivot, the point below it and the new point, so it cannot be a hull corner and is popped. Popping repeats until the chain is convex again. Each point is pushed once and popped at most once, so the scan is O(n) amortised, and the O(n log n) sort dominates.

Advertisement

Tested implementation

This implementation returns the strict hull, corners only, in counter-clockwise order from the pivot. It was tested against a brute-force hull on 3,000 random inputs including duplicates, all-collinear sets and inputs with fewer than three points.

from functools import cmp_to_key

def dist2(a, b):
    return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2

def graham_scan(points):
    pts = list(set(points))                         # duplicates break the angle sort
    if len(pts) < 3:
        return sorted(pts, key=lambda p: (p[1], p[0]))
    p0 = min(pts, key=lambda p: (p[1], p[0]))       # lowest y, then lowest x
    rest = [p for p in pts if p != p0]

    def by_angle(a, b):
        turn = cross(p0, a, b)
        if turn > 0:
            return -1                               # a has the smaller polar angle
        if turn < 0:
            return 1
        return -1 if dist2(p0, a) < dist2(p0, b) else 1   # same ray: nearer first

    rest.sort(key=cmp_to_key(by_angle))
    stack = [p0]
    for p in rest:
        while len(stack) >= 2 and cross(stack[-2], stack[-1], p) <= 0:
            stack.pop()                             # right turn or straight: not a corner
        stack.append(p)
    return stack

In Java the comparator must be written with long arithmetic and must be consistent, or List.sort may throw IllegalArgumentException: Comparison method violates its general contract! on large inputs:

record Pt(long x, long y) {}

static long dist2(Pt a, Pt b) {
    long dx = a.x() - b.x(), dy = a.y() - b.y();
    return dx * dx + dy * dy;
}

static long cross(Pt o, Pt a, Pt b) {
    return (a.x() - o.x()) * (b.y() - o.y()) - (a.y() - o.y()) * (b.x() - o.x());
}

Comparator<Pt> byAngle(Pt p0) {
    return (a, b) -> {
        long t = cross(p0, a, b);
        if (t != 0) return t > 0 ? -1 : 1;
        return Long.compare(dist2(p0, a), dist2(p0, b));   // ties: nearer first
    };
}

Deduplicating first matters: two copies of one point give a zero cross product and zero distance, and copies of the pivot itself have no defined angle.

Worked example, step by step

Take the eight points in the figure. The pivot is (0,0), the lowest point. Sorted by angle the rest are (4,0), (2,1), (3,2), (4,3), (1,1), (2,3), (0,3). Note that (4,3) precedes (1,1) even though both look diagonal: its angle is about 37 degrees against 45.

p0 (0, 0)1 (4, 0)2 (2, 1)3 (3, 2)4 (4, 3)5 (1, 1)6 (2, 3)7 (0, 3)Stack after each point1 (4,0): push2 (2,1): left turn, push3 (3,2): pop 2, push4 (4,3): pop 3, push5 (1,1): left turn, push6 (2,3): pop 5, push7 (0,3): collinear, pop 6, pushhull: (0,0) (4,0) (4,3) (0,3)Dashed rays from the pivot show the angular order; blue points are hull corners.
Graham scan on eight points. Points are numbered in polar-angle order around the pivot (0,0). The scan keeps a stack and pops any point that would make a right turn or a straight line, leaving the four corners of the hull in counter-clockwise order.

PointTestActionStack after
(4,0)fewer than two on stackpush(0,0) (4,0)
(2,1)cross((0,0),(4,0),(2,1)) = 4push(0,0) (4,0) (2,1)
(3,2)cross((4,0),(2,1),(3,2)) = -3pop (2,1), then push(0,0) (4,0) (3,2)
(4,3)cross((4,0),(3,2),(4,3)) = -3pop (3,2), then push(0,0) (4,0) (4,3)
(1,1)cross((4,0),(4,3),(1,1)) = 9push(0,0) (4,0) (4,3) (1,1)
(2,3)cross((4,3),(1,1),(2,3)) = -4pop (1,1), then push(0,0) (4,0) (4,3) (2,3)
(0,3)cross((4,3),(2,3),(0,3)) = 0pop (2,3), then push(0,0) (4,0) (4,3) (0,3)

The last row shows the collinear rule at work. (2,3) lies on the top edge between (4,3) and (0,3). With <= 0 it is popped and the hull has four corners; with < 0 it would be kept as a point on an edge.

Degenerate inputs

Real inputs are full of special cases, and each has a definite answer.

  • Fewer than three distinct points: the hull is the points themselves. Return them rather than running the scan.
  • All points collinear: with strict popping the result is the two endpoints, which is the correct degenerate hull, a segment.
  • Keeping points on edges: some uses, such as fence-post problems, want every boundary point. Change the pop test to < 0, and fix the sort: points on the last ray must come farthest first, because the scan walks back toward the pivot along that ray. Reverse the tail of the sorted list that is collinear with the pivot and the last point. Forgetting this drops the inner points of the final edge.
  • Pivot ties: choosing the lowest y without the x tie-break can pick a pivot that is not a corner, which breaks the half-plane assumption behind the comparator.
  • Output orientation: callers often assume counter-clockwise. Document it, and reverse for clockwise rather than changing the comparator.

The inclusive variant changes only two places, the tail of the sort and the pop test. This version was checked against a brute-force list of every input point lying on the strict hull's boundary, on 4,000 small random inputs dense with collinear points:

    rest.sort(key=cmp_to_key(by_angle))
    i = len(rest) - 1
    while i > 0 and cross(p0, rest[i - 1], rest[-1]) == 0:
        i -= 1                                  # find where the last ray starts
    if i > 0:                                   # skip when every point is collinear
        rest[i:] = reversed(rest[i:])           # last ray: farthest first
    stack = [p0]
    for p in rest:
        while len(stack) >= 2 and cross(stack[-2], stack[-1], p) < 0:
            stack.pop()                         # pop only on a strict right turn
        stack.append(p)
    return stack

The all-collinear guard matters: if every point lies on one ray, reversing it would send the scan out to the far end and back, and the stack would not describe a segment walked once. Strict hulls are the safer default; choose inclusive only when a caller needs every boundary point.

Complexity and alternatives

Graham scan runs in O(n log n) time and O(n) extra space. That is optimal in the comparison model: sorting reduces to hull finding (lift numbers x onto the parabola y = x2; the hull visits them in sorted order), so no comparison-based hull algorithm beats Omega(n log n) in general.

When the hull has few corners, h, output-sensitive algorithms can do better. Jarvis march (gift wrapping) costs O(nh), good when h is tiny; Chan's algorithm achieves O(n log h) by combining Graham scan on small groups with gift wrapping across them.

AlgorithmTimeStrengthWeakness
Graham scanO(n log n)simple, exact with integer predicatesangular sort needs care with ties
Andrew's monotone chainO(n log n)sorts by x then y; no pivot or anglestwo passes, upper and lower
QuickhullO(n log n) typical, O(n2) worstfast on random data, extends to 3Dquadratic on adversarially spaced points on a circle
Jarvis marchO(nh)trivial codequadratic when most points are corners
ChanO(n log h)optimal output-sensitivecomplex, rarely worth it in practice

In production code Andrew's monotone chain is often preferred because its lexicographic sort is easier to get right than an angular one; Graham scan remains the clearest way to learn the stack invariant both share.

Using the hull, and testing it

Once you have the hull as a counter-clockwise polygon, several queries become cheap. A point is inside a convex polygon if it is left of every edge, which binary search over the fan of triangles from vertex 0 answers in O(log n). The diameter, the farthest pair of input points, is between two hull vertices and is found in O(h) by rotating calipers, as are the minimum-width strip and the minimum-area enclosing rectangle. Collision engines use hulls as cheap bounding shapes before exact tests; GIS tools use them to outline point clusters; robotics planners grow obstacles by their hulls.

Operationally, treat hull code like any numeric kernel: property-test it against a brute-force reference on small random inputs, include adversarial sets (many duplicates, many collinear points, points on a circle, huge coordinates), and assert convexity and containment in debug builds. Bugs here are silent: a wrong hull still looks like a polygon.

What to do next

  1. Implement cross with exact integer arithmetic and check your coordinate range against the 64-bit headroom.
  2. Deduplicate inputs and handle fewer than three points before the scan.
  3. Sort with the cross-product comparator, ties by distance, never with atan2.
  4. Decide strict or inclusive hulls, and if inclusive, reverse the collinear tail of the sort.
  5. Property-test against a brute-force hull, including all-collinear and duplicate-heavy inputs.
  6. Next, implement Andrew's monotone chain and rotating calipers on top of the same predicate.

Keep learning: Quicksort and Merge sort for the sort that dominates the cost, Divide and conquer for the strategy behind Quickhull, and Greedy algorithms for the local-choice reasoning behind the stack scan.

Key takeaway: Graham scan picks the lowest point as pivot, sorts the rest by polar angle using exact cross products, and keeps a stack that pops any point that fails to make a strict left turn, giving the hull in O(n log n). Its correctness depends on exact orientation tests with enough integer headroom, deduplicated input, distance tie-breaks in the sort and a deliberate choice between strict and inclusive hulls. Property-test it against brute force, because a wrong hull still looks like a polygon.