Lesson 11.24,006 words

Convex Hull

The convex hull is the smallest convex polygon enclosing a point set — the rubber band snapped around the nails. We build it with Andrew's monotone chain, sorting by (x,y)(x,y) and sweeping a lower and upper hull while popping any non-left turn via the orientation primitive, in O(nlogn)O(n\log n).

╌╌╌╌

The previous lesson gave us one operation: the orientation of an ordered triple of points , read off the sign of the cross product

A positive value means turns counterclockwise (a left turn), a negative value means clockwise (a right turn), and zero means the three points are collinear. That one branch-free, multiplication-only test, with no divisions, no square roots, and no angles, is the only geometric primitive this lesson needs. We now use it to solve the foundational problem of computational geometry: given points in the plane, find their convex hull.

The problem

The mental picture is exact and worth keeping: hammer a nail into the plane at each point, stretch a rubber band wide enough to enclose them all, and let go. The band snaps taut around the outermost points and traces the hull; the points it touches are the hull vertices, and everything else lands strictly inside.

The convex hull is the smallest convex polygon enclosing the points

A hull algorithm must report the vertices in boundary order (say counterclockwise). The naive approaches are slow: testing each ordered pair to see whether all other points lie on one side of the line through it identifies hull edges in ; gift-wrapping (Jarvis march) pivots from one hull vertex to the next in time, where is the number of hull vertices, good when the hull is tiny but when every point is a vertex. We can do better, and provably best, in .

Gift-wrapping: from pick so every other point lies left of ray

Gift-wrapping is worth a concrete step, because it is the most literal statement of what on the hull means. Start at the guaranteed hull vertex the lowest point, and pick the next vertex as the one that makes every other point fall to the left of the ray — equivalently, is the most clockwise point seen from . Take and candidates , , . To choose between and , test : is a right turn from , i.e. more clockwise, so beats . Testing against gives , so is a left turn and still wins. After one full pass is the most clockwise, so is the next hull vertex, and we wrap again from . Each vertex costs a full pass, which is why the total is — cheap when the hull is small, quadratic when every point is a corner.

jarvis_march.pypython
from typing import Iterable

from hull_geometry import Point, cross, squared_distance

def jarvis_march(points: Iterable[Point]) -> list[Point]:
  """
    Convex hull of `points`, in counterclockwise boundary order, keeping\n
    only true corners. Fewer than three distinct points are returned\n
    sorted, as-is.\n
  """
  unique: list[Point] = sorted(set(points))
  if len(unique) < 3:
    return unique

  # leftmost, then lowest — a guaranteed hull vertex.
  start: Point = min(unique)
  hull: list[Point] = []
  current: Point = start

  while True:
    hull.append(current)

    # seed with any point other than the current one.
    candidate: Point = unique[0] if unique[0] != current else unique[1]

    # pick the most clockwise contender, ties broken by distance, so the
    # boundary wraps counterclockwise.
    for contender in unique:
      if contender == current:
        continue
      orientation: int = cross(current, candidate, contender)
      if orientation < 0 or (
        orientation == 0
        and squared_distance(current, contender)
        > squared_distance(current, candidate)
      ):
        candidate = contender

    # stop once the wrap returns to the start.
    current = candidate
    if current == start:
      break

  return hull

Andrew's monotone chain

The cleanest algorithm sorts the points once and sweeps them with a stack, building the boundary in two passes.

The pop condition is pure orientation. Suppose the stack ends in and we are about to add . If is a left turn (cross product ), is a genuine corner of the hull-so-far and we keep it. If it is a right turn or collinear (cross product ), then is inside the corner that opens up (the rubber band would not touch ), so we pop and re-test with the new top. This is the rejection drawn below.

Pop while the last three points don't turn counterclockwise

Here bends to the right, so the chain dips below the convex boundary at . The algorithm pops and the corrected edge runs straight from to , restoring the all-left-turns invariant.

Inside the left-to-right sweep this plays out as a stack that grows and occasionally collapses. Below, the stack holds when arrives; the turn at is a right turn, so pops, and now the turn at is still a right turn, so pops too, leaving the taut chain .

One sweep step: arrives and cascades two pops, then , leaving the lower chain taut
Algorithm:Monotone-Chain(P)\textsc{Monotone-Chain}(P) — convex hull of points PP in O(nlogn)O(n\log n)
  1. 1
    sort PP ascending by (x,y)(x, y), removing duplicates
  2. 2
    L[]L \gets [\,]
    lower hull
  3. 3
    for each point pp in PP (left to right) do
  4. 4
    while L2|L| \ge 2 and cross(L2,L1,p)0\text{cross}(L_{-2}, L_{-1}, p) \le 0 do
  5. 5
    pop LL
  6. 6
    push pp onto LL
  7. 7
    U[]U \gets [\,]
    upper hull
  8. 8
    for each point pp in PP (right to left) do
  9. 9
    while U2|U| \ge 2 and cross(U2,U1,p)0\text{cross}(U_{-2}, U_{-1}, p) \le 0 do
  10. 10
    pop UU
  11. 11
    push pp onto UU
  12. 12
    drop the last element of LL and of UU
    shared endpoints
  13. 13
    return LL concatenated with UU
    counterclockwise

Here denote the top two stack elements and is the orientation value above. The last point of each chain is the first point of the other (the global min and max under the sort order), so we drop one copy of each to avoid duplicating the two extreme vertices.

A worked lower-hull sweep. Take the six points , , , , , , already sorted by . The left-to-right sweep builds the lower chain as a stack; each row is the stack after processing one point, with the cross test that fired.

pointtest at the top twoactionstack
push
push
pop , push
push
, then pop , pop , push
push

The lower hull is : the sweep discarded (a right turn at ), kept tentatively, then evicted both when arrived and the chain straightened. The upper-hull pass runs the mirror sweep right to left and closes the polygon.

Complexity. The sort costs . Each sweep is linear despite the inner while: a point is pushed once and popped at most once, so the total number of pop operations across a sweep is at most . The work after sorting is therefore , and the sort dominates: overall.

Monotone chain builds the lower chain ( increasing) then the upper chain back
monotone_chain.pypython
from typing import Iterable

from hull_geometry import Point, cross

def monotone_chain(points: Iterable[Point], keep_collinear: bool = False) -> list[Point]:
  """
    Convex hull of `points`, returned in counterclockwise boundary order.\n
    With `keep_collinear` False the hull holds only true corners; True keeps\n
    every point lying on a hull edge. For fewer than three distinct points\n
    the deduplicated, sorted points are returned as-is.\n
  """
  ordered: list[Point] = sorted(set(points))
  if len(ordered) < 3:
    return ordered

  def turns_inward(below: Point, pivot: Point, candidate: Point) -> bool:
    """
      Whether `pivot` should pop: the triple does not turn left.\n
      When collinear points are kept, only a strict right turn pops.\n
    """
    orientation: int = cross(below, pivot, candidate)
    return orientation < 0 if keep_collinear else orientation <= 0

  def build_chain(sweep: list[Point]) -> list[Point]:
    """
      One monotone sweep: push each point, popping the top while the last\n
      three points fail to turn counterclockwise.\n
    """
    chain: list[Point] = []
    for candidate in sweep:
      while len(chain) >= 2 and turns_inward(chain[-2], chain[-1], candidate):
        chain.pop()
      chain.append(candidate)
    return chain

  lower: list[Point] = build_chain(ordered)
  upper: list[Point] = build_chain(list(reversed(ordered)))

  # the last point of each chain is the first of the other (global min/max),
  # so drop one copy of each shared endpoint before concatenating.
  return lower[:-1] + upper[:-1]
hull_geometry.pypython
from typing import NamedTuple

class Point(NamedTuple):
  """
    A planar point with integer x and y components.\n
    Integer coordinates keep the cross product exact, so orientation never\n
    misfires from floating-point rounding.\n
  """
  x: int
  y: int

def cross(origin: Point, first: Point, second: Point) -> int:
  """
    Orientation of the ordered triple origin -> first -> second.\n
    Returns (first - origin) x (second - origin): > 0 left turn,\n
    < 0 right turn, 0 collinear.\n
  """
  return (
    (first.x - origin.x) * (second.y - origin.y)
    - (first.y - origin.y) * (second.x - origin.x)
  )

def squared_distance(first: Point, second: Point) -> int:
  """
    Squared Euclidean distance between two points.\n
    Squared so the comparison stays exact integer arithmetic — taking the\n
    root is never needed to decide which of two pairs is farther apart.\n
  """
  delta_x: int = first.x - second.x
  delta_y: int = first.y - second.y
  return delta_x * delta_x + delta_y * delta_y

Graham scan: the classic alternative

The original hull algorithm, Graham's scan, has the same skeleton but a different ordering. Pick the point with the lowest -coordinate (ties broken by ) as a pivot ; it is certainly a hull vertex. Sort the remaining points by polar angle around , then scan them in that angular order, maintaining a stack and popping whenever the last three points fail to turn left, the identical orientation test. Because the points are visited in angular order, one pass suffices to trace the whole boundary, again in .1

Graham scan sorts the other points by polar angle around the lowest point ; the scan then walks them counterclockwise, popping right turns.

Monotone chain is usually preferred in practice precisely because it avoids the polar-angle sort: comparing lexicographically uses only the coordinates, whereas sorting by angle requires either (floating point, slow, imprecise) or cross-product comparisons with careful handling of the pivot, which means more code and more numerical fragility for the same asymptotics.

graham_scan.pypython
from functools import cmp_to_key
from typing import Iterable

from hull_geometry import Point, cross, squared_distance

def graham_scan(points: Iterable[Point], keep_collinear: bool = False) -> list[Point]:
  """
    Convex hull of `points`, in counterclockwise boundary order.\n
    With `keep_collinear` False only true corners are kept; True keeps every\n
    point lying on a hull edge. Fewer than three distinct points are\n
    returned sorted, as-is.\n
  """
  unique: list[Point] = sorted(set(points))
  if len(unique) < 3:
    return unique

  # lowest y, then lowest x — guaranteed to be a hull vertex.
  pivot: Point = min(unique, key=lambda point: (point.y, point.x))
  rest: list[Point] = [point for point in unique if point != pivot]

  def by_angle(first: Point, second: Point) -> int:
    """
      Order two points by polar angle around the pivot; nearer first when\n
      the angle ties, so collinear runs are radially sorted.\n
    """
    # a left turn means `first` comes earlier in angular order.
    orientation: int = cross(pivot, first, second)
    if orientation > 0:
      return -1
    if orientation < 0:
      return 1

    # collinear: the nearer point sorts first.
    near: int = squared_distance(pivot, first)
    far: int = squared_distance(pivot, second)
    return -1 if near < far else (1 if near > far else 0)

  rest.sort(key=cmp_to_key(by_angle))

  # to keep collinear points on the final edge (pivot back to the last
  # corner), the trailing collinear run must be reversed so it reads outward.
  if keep_collinear:
    tail: int = len(rest) - 1
    while tail > 0 and cross(pivot, rest[tail], rest[tail - 1]) == 0:
      tail -= 1
    rest[tail:] = reversed(rest[tail:])

  def turns_inward(below: Point, pivot_point: Point, candidate: Point) -> bool:
    """
      Whether `pivot_point` should pop: the triple does not turn left.\n
    """
    orientation: int = cross(below, pivot_point, candidate)
    return orientation < 0 if keep_collinear else orientation <= 0

  # scan in angular order, popping any vertex that fails to turn left.
  hull: list[Point] = [pivot]
  for candidate in rest:
    while len(hull) >= 2 and turns_inward(hull[-2], hull[-1], candidate):
      hull.pop()
    hull.append(candidate)

  return hull

Degeneracies

Real inputs are not in general position, and the hull is where edge cases bite.

  • Duplicate points must be removed first (the sort makes this a linear scan); a repeated point can otherwise wedge a zero-length edge into the chain and break the turn test.
  • Collinear points on a hull edge are a deliberate policy choice, and it lives entirely in the comparison operator. Using in the pop condition (as above) discards collinear points, keeping only true corners — the minimal vertex set. Using keeps collinear points on the boundary, so a flat edge with interior points reports all of them. Problems like Erect the Fence, which ask for every point lying on the fence, want the variant; most geometry that follows (diameter, area) wants the lean hull. Decide which one your caller needs and pick the inequality accordingly.

For example, suppose the bottom edge of the hull runs through , , — three points on one horizontal line, with strictly between the corners. When the sweep has on the stack and arrives, . Under the rule the zero triggers a pop, so is dropped and the edge is the single segment — the lean hull with corners only. Under the strict rule the zero does not pop, so stays and the boundary lists — every point on the fence. The entire difference between corners and on the boundary is that one comparison.

A lower bound:

The running time is not an artifact of these particular algorithms. No comparison-based hull algorithm can beat it, by the same lower-bound argument that pins comparison sorting.

Lifting puts every point on a convex parabola, so the hull sorts the

So monotone chain and Graham scan are asymptotically optimal: the sort they pay for is, in a precise sense, the same sort the problem itself requires.

parabola_sort_reduction.pypython
from typing import Iterable

from hull_geometry import Point
from monotone_chain import monotone_chain

def lift_to_parabola(values: Iterable[int]) -> list[Point]:
  """
    Map each real x to the lifted point (x, x^2) on the parabola y = x^2.\n
  """
  return [Point(value, value * value) for value in values]

def sort_via_hull(values: Iterable[int]) -> list[int]:
  """
    Sort `values` ascending by computing the convex hull of their parabola\n
    lifts and reading the x-coordinates in boundary order. Duplicates\n
    collapse onto the same point, so the result holds each distinct value\n
    once — the reduction's whole point is that the hull *is* the sorted set.\n
  """
  lifted: list[Point] = lift_to_parabola(values)
  hull: list[Point] = monotone_chain(lifted)

  # the hull comes back counterclockwise starting at the lowest-x vertex; on
  # a strictly convex parabola that order is exactly increasing x.
  return sorted(point.x for point in hull)

What the hull is good for

The hull is rarely the final answer. It is a preprocessing step that collapses messy points down to an ordered convex polygon of vertices, after which many extremal questions become easy. The key technique is rotating calipers: walk two pointers around the hull in tandem, exploiting the fact that as one supporting line rotates, the farthest or nearest vertex advances monotonically.2 This computes the polygon diameter (the farthest pair of points in the whole set, which must be two hull vertices) in time after the hull, rather than the of checking all pairs.

Rotating calipers: two parallel supporting lines turn around the hull; the diameter is the widest antipodal pair

The same caliper sweep yields the smallest enclosing rectangle (its optimal orientation always has a side flush with a hull edge), the width of the point set, and the convex layers (peel the hull, recurse on the interior). Whenever a problem cares only about the outermost shape of a point cloud (collision bounds, fitting, nearest-feature queries), computing the hull first is the standard first step.3

hull_rotating_calipers.pypython
from typing import Iterable, NamedTuple, Optional

from hull_geometry import Point, cross, squared_distance
from monotone_chain import monotone_chain

def _signed_polygon_area_doubled(hull: list[Point]) -> int:
  """
    Twice the signed area of a polygon by the shoelace formula.\n
    Positive when the vertices wind counterclockwise.\n
  """
  # sum the shoelace cross terms over each consecutive edge.
  total: int = 0
  count: int = len(hull)
  for index in range(count):
    current: Point = hull[index]
    following: Point = hull[(index + 1) % count]
    total += current.x * following.y - following.x * current.y

  return total

class Diameter(NamedTuple):
  """
    The farthest pair of points and their squared separation.\n
  """
  first: Point
  second: Point
  squared_length: int

def hull_diameter(points: Iterable[Point]) -> Optional[Diameter]:
  """
    The farthest pair of points in the set, found by rotating calipers in\n
    O(n) after the hull (O(n log n) overall). Returns None for an empty set\n
    and a degenerate self-pair for a single distinct point.\n
  """
  hull: list[Point] = monotone_chain(points)
  if not hull:
    return None

  # degenerate hulls: a single point pairs with itself, two points pair.
  if len(hull) == 1:
    only: Point = hull[0]
    return Diameter(only, only, 0)
  if len(hull) == 2:
    return Diameter(hull[0], hull[1], squared_distance(hull[0], hull[1]))

  # seed the best with the first edge, then rotate calipers around the hull.
  count: int = len(hull)
  best: Diameter = Diameter(hull[0], hull[1], squared_distance(hull[0], hull[1]))
  antipodal: int = 1

  for index in range(count):
    following: int = (index + 1) % count

    # advance the far pointer while the next vertex is farther from the
    # current edge — the area test compares perpendicular distances.
    while abs(
      cross(hull[index], hull[following], hull[(antipodal + 1) % count])
    ) > abs(cross(hull[index], hull[following], hull[antipodal])):
      antipodal = (antipodal + 1) % count

    # both endpoints of the edge can pair with the antipodal vertex.
    for endpoint in (index, following):
      separation: int = squared_distance(hull[endpoint], hull[antipodal])
      if separation > best.squared_length:
        best = Diameter(hull[endpoint], hull[antipodal], separation)
  return best

class BoundingRectangle(NamedTuple):
  """
    A minimum-area enclosing rectangle: its four corners (counterclockwise)\n
    and its area.\n
  """
  corners: tuple[Point, Point, Point, Point]
  area: float

def _dot(vector: Point, point: Point) -> int:
  """
    Dot product of a direction vector with a point's position vector.\n
  """
  return vector.x * point.x + vector.y * point.y

def _cross_scalar(vector: Point, point: Point) -> int:
  """
    Scalar cross of a direction vector with a point — its signed extent\n
    along the perpendicular direction.\n
  """
  return vector.x * point.y - vector.y * point.x

def minimum_bounding_rectangle(points: Iterable[Point]) -> Optional[BoundingRectangle]:
  """
    The smallest-area rectangle enclosing all points, by trying every hull\n
    edge as a candidate orientation (rotating calipers). Returns None for an\n
    empty set. Degenerate (collinear) inputs yield a zero-area rectangle.\n
  """
  hull: list[Point] = monotone_chain(points)
  if not hull:
    return None
  if len(hull) < 3:
    # all points collinear (or fewer than three): a zero-area "rectangle".
    span: int = len(hull)
    corners: tuple[Point, Point, Point, Point] = (
      hull[0 % span],
      hull[1 % span],
      hull[2 % span],
      hull[3 % span],
    )
    return BoundingRectangle(corners, 0.0)

  # ensure counterclockwise so the edge normal points outward consistently.
  if _signed_polygon_area_doubled(hull) < 0:
    hull.reverse()

  count: int = len(hull)
  best: Optional[BoundingRectangle] = None
  for index in range(count):
    # take each hull edge as a candidate rectangle orientation.
    edge_start: Point = hull[index]
    edge_end: Point = hull[(index + 1) % count]
    direction: Point = Point(edge_end.x - edge_start.x, edge_end.y - edge_start.y)

    # skip a zero-length (duplicate-vertex) edge.
    length_squared: float = float(_dot(direction, direction))
    if length_squared == 0.0:
      continue

    # project every hull vertex onto the edge direction and its normal; the
    # rectangle spans the extreme projections in each axis.
    along_values: list[int] = [_dot(direction, vertex) for vertex in hull]
    across_values: list[int] = [_cross_scalar(direction, vertex) for vertex in hull]
    min_along, max_along = min(along_values), max(along_values)
    min_across, max_across = min(across_values), max(across_values)

    # the rectangle's side lengths are the projection spans, normalized.
    width: float = (max_along - min_along) / (length_squared ** 0.5)
    height: float = (max_across - min_across) / (length_squared ** 0.5)
    area: float = width * height
    if best is None or area < best.area:
      # unit vectors along the edge and its outward normal.
      length: float = length_squared ** 0.5
      unit_along: tuple[float, float] = (direction.x / length, direction.y / length)
      unit_across: tuple[float, float] = (-unit_along[1], unit_along[0])

      def corner(along: int, across: int) -> Point:
        position_x: float = (
          unit_along[0] * along / length + unit_across[0] * across / length
        )
        position_y: float = (
          unit_along[1] * along / length + unit_across[1] * across / length
        )
        return Point(round(position_x), round(position_y))

      # the four corners are the extreme projections, counterclockwise.
      corners = (
        corner(min_along, min_across),
        corner(max_along, min_across),
        corner(max_along, max_across),
        corner(min_along, max_across),
      )
      best = BoundingRectangle(corners, area)

  return best

Output-sensitive and higher-dimensional hulls

The standard references give for the hull, and the parabola lower bound says that is optimal when the hull is reported in sorted order. But that lower bound applies only because the output is a sorted sequence of points. When the hull has just vertices, a lower cost should be possible. Two lines of work address exactly that.

Chan's algorithm (1996) resolves this: it computes the hull in , output-sensitive in the true hull size , which matches the lower bound for every and beats whenever the hull is small.4 The trick combines the two algorithms already in this lesson. Guess a bound on ; partition the points into groups of each and build each group's hull with Graham scan in , for total. Then run a gift-wrap over the group hulls, using binary search on each group hull to find its tangent in , so each wrap step costs and steps cost . Choosing makes both parts . Since is unknown, Chan runs the whole thing with (squaring the guess) and stops the first time the wrap completes within steps; the doubling search adds only a constant factor.

Chan's algorithm: hull each of the groups (Graham), then gift-wrap over the group hulls with binary-search tangents, for .

QuickHull is the practical divide-and-conquer analogue of quicksort. Take the two extreme- points; they split the set into points above and below the line between them. For the upper part, find the point farthest from that line — it is a hull vertex — and recurse on the two sub-lines it forms, discarding every point inside the triangle. Average performance is and it is very fast on typical inputs, but like quicksort it degrades to on adversarial sets where each split peels off only one point.5 QuickHull is what the widely used Qhull library implements, and it generalizes cleanly to three and higher dimensions, where the hull is a polytope with faces and the plane-sweep intuition of this module no longer applies.

Takeaways

  • The convex hull is the smallest convex polygon enclosing a point set (the rubber band around the nails), and reporting it in boundary order is the foundational problem of planar computational geometry.
  • Andrew's monotone chain sorts by , then sweeps a lower and upper hull, popping any vertex where the last three points fail to turn counterclockwise (cross product ). It is , dominated by the sort.
  • The pop condition is the orientation primitive from the previous lesson: keep left turns, reject right turns and (by policy) collinear points.
  • Graham scan achieves the same bound by sorting on polar angle; monotone chain avoids angle computation and is more numerically stable.
  • Degeneracies, namely duplicates and collinear hull-edge points, are handled by deduplicating and by choosing (keep collinear) versus (drop them).
  • Any hull algorithm is by reduction from sorting (lift points onto a parabola), so these algorithms are optimal.
  • The hull is a preprocessing step: rotating calipers give the diameter, smallest enclosing rectangle, and width in once the hull is built.

Footnotes

  1. CLRS, Ch. 33 — Computational Geometry (§33.3): Graham's scan sorts by polar angle around the lowest point and maintains a stack of left turns, in .
  2. Skiena, § — Convex Hull: the hull as the most basic geometric structure, with rotating calipers for farthest-pair and enclosing-shape queries.
  3. CLRS, Ch. 33 — Computational Geometry (§33.3): the convex hull as a preprocessing primitive reducing points to an ordered convex polygon.
  4. Timothy M. Chan, Optimal output-sensitive convex hull algorithms in two and three dimensions, Discrete & Computational Geometry 16(4), 1996 — the hull combining group Graham scans with a gift-wrap over group hulls and a doubling search on .
  5. C. Bradford Barber, David P. Dobkin, and Hannu Huhdanpaa, The Quickhull Algorithm for Convex Hulls, ACM Transactions on Mathematical Software 22(4), 1996 — the divide-and-conquer QuickHull implemented in the Qhull library, extending to higher dimensions.
Practice

╌╌ END ╌╌