Cairn
algorithms · computational geometry · O(n) expected

↩ back to Geometry

Smallest Enclosing Circle

The twelfth Geometry entry, and the first whose answer is a shape sized to fit the whole input rather than a fact about one point, one pair, or one polygon's interior. Given a set of points, what's the smallest circle that contains every one of them? Every support point of that circle — every point actually touching its boundary — is provably a vertex of the point set's convex hull (confirmed directly: 0 violations across 3,000 random trials of 5–19 points), so this is a genuinely different question from the ones the rest of this category answers, but a related one to Closest Pair of Points's "bare point set, no boundary" starting shape. The obvious-looking shortcut — find the two farthest-apart points and draw a circle with them as the diameter — is wrong far more often than it's right, which is most of what this page is actually about.

Try it

Eight points, three of them (A, B, C) placed in a near-equilateral triangle, the rest scattered safely inside it. Press Step or Run to watch the algorithm process points one at a time in the order chosen below, growing a working circle only when the next point falls outside it. The order selector processes the identical eight points and produces the identical final circle either way — only the amount of internal work changes. The checkbox overlays the "just use the two farthest points" shortcut for comparison.

inside-checks so far: 0
Press Step or Run.

Why it works

This is Welzl's randomized incremental construction, in the common "move-to-front" iterative form: process the points one at a time in a fixed order, keeping a working circle that's always the smallest circle enclosing every point seen so far. A new point that's already inside the working circle changes nothing. A point outside it can't be — the working circle was already minimal for the smaller set, and any circle enclosing the new set has to enclose this point too, so the boundary must move to reach it, and the new point must be on the boundary of whatever circle replaces it. That's the whole recursive structure: rebuild from scratch with this point pinned to the boundary, re-scanning every earlier point against the new candidate exactly the same way, one dimension down each time it happens again (a two-point diameter circle, tested against even-earlier points; a three-point circumcircle, which needs no further test at all — three points not all on one line pin a circle uniquely, so there is nothing left to violate). Three points is the recursion's floor because a circle in the plane has exactly three degrees of freedom (center x, center y, radius) — the same reason Delaunay Triangulation's circumcircles are determined by exactly three points and no fewer.

The expected linear running time comes from a backward analysis — the same proof technique Trapezoidal Map's own Why It Works section uses for its expected O(n log n) preprocessing bound, applied here to a flat sum instead of a harmonic one. Fix any prefix of i points already processed in random order, and ask: how likely is it that the i-th point was actually essential — one of the (at most three) points supporting the circle for that prefix? Since the order is uniformly random, any of the i points is equally likely to have landed last, and at most three of them are ever support points, so that probability is at most 3/i. When the i-th point is essential, the rebuild it triggers costs at most O(i) work (rescanning every earlier point). Multiply and sum: Σ O(i) · (3/i) = Σ O(3) = O(n) over all n points — a flat sum, not log n, precisely because the "cost when it happens" and the "chance it happens" cancel each other's dependence on i exactly, instead of just shrinking together. The bound is on the expectation, not any single run — see Pitfalls for what a non-random order actually does to it.

Reference implementation

function circleFrom1(a) { return { x: a.x, y: a.y, r: 0 }; }

function circleFrom2(a, b) {
  return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, r: Math.hypot(a.x - b.x, a.y - b.y) / 2 };
}

function circleFrom3(a, b, c) {
  const d = 2 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y));
  if (Math.abs(d) < 1e-9) {              // collinear: no unique circumcircle, fall back
    return [circleFrom2(a, b), circleFrom2(a, c), circleFrom2(b, c)]
      .reduce((best, cc) => (cc.r > best.r ? cc : best));
  }
  const a2 = a.x * a.x + a.y * a.y, b2 = b.x * b.x + b.y * b.y, c2 = c.x * c.x + c.y * c.y;
  const x = (a2 * (b.y - c.y) + b2 * (c.y - a.y) + c2 * (a.y - b.y)) / d;
  const y = (a2 * (c.x - b.x) + b2 * (a.x - c.x) + c2 * (b.x - a.x)) / d;
  return { x, y, r: Math.hypot(x - a.x, y - a.y) };
}

const EPS = 1e-7;
function inside(c, p) {
  const dx = c.x - p.x, dy = c.y - p.y;
  return dx * dx + dy * dy <= (c.r + EPS) * (c.r + EPS);
}

function smallestEnclosingCircle(points) {
  shuffle(points);                        // load-bearing — see Pitfalls
  let circle = null;
  for (let i = 0; i < points.length; i++) {
    const p = points[i];
    if (circle && inside(circle, p)) continue;
    circle = circleFrom1(p);
    for (let j = 0; j < i; j++) {
      if (inside(circle, points[j])) continue;
      circle = circleFrom2(p, points[j]);
      for (let k = 0; k < j; k++) {
        if (inside(circle, points[k])) continue;
        circle = circleFrom3(p, points[j], points[k]);
      }
    }
  }
  return circle;
}

Pitfalls

Skipping the shuffle doesn't break correctness — the algorithm above still reaches the exact right circle no matter what order it's fed. It breaks the only reason to use this algorithm over a simpler one: the expected-linear time bound. The backward analysis above depends on every prefix being a uniformly random sample, which a fixed, structured order isn't. Feeding the identical eight demo points above in an order where the three true boundary points (A, B, C) arrive last instead of first costs 59 inside-checks to reach the same circle that arriving-first reaches in 12 — a 4.9× difference on the same 8 points, purely from order, reproducible directly in the demo above. The effect gets far more dramatic at scale: sweeping points already sorted by angle around a common circle (the worst realistic case — no shuffle ever gets applied) against the identical points in random order, both measured by counting real inside() calls:

nangle-sorted orderrandom order (avg of 20 shuffles)ratio
1001,742472.73.7×
2006,643750.38.9×
40024,8131,657.215.0×
80095,5883,091.630.9×
1,600361,3918,030.645.0×

The angle-sorted column roughly quadruples with every doubling of n — the real signature of O(n²) growth, the outcome the backward analysis explicitly doesn't cover once the order stops being random. The random column stays close to linear. The shuffle isn't a defensive nicety; it's the one line of code the entire complexity bound rests on.

"Find the two farthest-apart points and draw a circle with them as the diameter" looks like the answer, uses the same points this algorithm would call a support set, and is simply wrong whenever the true answer needs all three. On a clean equilateral triangle with side 10, the real smallest enclosing circle has radius 5.7735 (the circumcircle, all three vertices on the boundary); the farthest-pair shortcut reports 5.0000 (any two vertices are equally far apart, so it picks a pair arbitrarily) — a circle that misses the third vertex by 8.6603 − 5.0000 = 3.6603 units, over 70% of its own radius. This isn't a rare near-miss: across 20,000 random triangles, the farthest-pair shortcut fails to enclose all three points 27.7% of the time — any triangle with no obtuse angle needs all three vertices, and only an obtuse (or right) triangle lets the longest side's two endpoints suffice as a diameter. The same failure shows up on the demo's own 8-point set: the farthest pair is B–C (checkable directly with the comparison checkbox above), giving a circle of radius 150 that misses A by 80 units and both E and H by roughly 11 — three separate points outside a circle that's supposed to contain everything, not a boundary-hugging rounding error.

Complexity

Time: O(n) expected, via the backward analysis above — genuinely linear, not n log n, because a circle's support set is bounded by a constant (3) rather than growing with the input the way a convex hull's does. Worst case (adversarial or unshuffled order) degrades toward O(n²), exactly the growth pattern measured in Pitfalls. Space: O(1) beyond the input itself — only the current circle and its at-most-three support points are ever held at once, no matter how many points have already been processed. Verified against an O(n⁴) brute-force oracle (every candidate circle from all pairs and all triples of points, keeping the smallest one that encloses everything) across 5,000 random trials at 2–8 points: 0 mismatches, radii agreeing to within 7 × 10⁻¹⁵ of each other, plus degenerate cases checked directly — collinear points, duplicate points, all-identical points, a single point, and exactly two points all produce the correct circle.

This site's guide, Choosing a Geometry Algorithm, places this entry as a fourth, genuinely different question from the other eleven: not "is this point inside," not "do these cross," not "triangulate or partition this region," but "what's the smallest shape that fits around everything at once."