Cairn
algorithms · computational geometry · O(log n) query, O(n²) worst-case preprocessing

back to Geometry

Slab Decomposition

Point in Polygon's own Complexity section names the gap this page fills: ray casting answers one query in O(n), and if the polygon is fixed but queried over and over, redoing that full edge scan every time is wasted work. Slab decomposition trades a one-time preprocessing pass for much cheaper repeated queries: sort the polygon's vertices by x, and slice the plane into vertical slabs between each pair of consecutive x-coordinates. Inside one open slab, no polygon vertex exists — which means no edge can start, end, or cross another edge there, since a simple polygon's edges only ever meet at vertices. So the set of edges spanning a given slab, and their top-to-bottom order, is exactly the same everywhere inside it. Locating a query point becomes two binary searches instead of one linear scan: which slab is the point's x in, then where does its y fall among that slab's edges. Same crossing-parity idea ray casting uses — an odd number of edges below the point means inside — just computed in O(log n) instead of O(n) once the slabs are built.

Try it

The same seven-vertex arrow polygon and the same six query points as Point in Polygon, so the two pages can be compared directly. This polygon's five distinct vertex x-coordinates (100, 300, and 450 — the other two vertices repeat 100 and 300) produce just two slabs: [100, 300), holding the body's top and bottom edges, and [300, 450), holding the head's two slanted edges. The dashed vertical lines mark the slab boundaries; the shaded band marks which slab the binary search landed in; the two edges immediately bracketing the query point highlight — the lower one decisive (solid accent), the upper one shown for context (solid dark).

slab:
Pick a point above.

Why it works

Ray casting's crossing-parity rule doesn't change — it's still true that a point is inside exactly when an odd number of edges lie below it on a ray to infinity. What changes is how fast that count can be found. A slab's edge list is built once, sorted top to bottom, and stays valid for every query that ever lands in that slab, because (as above) nothing inside an open slab can reorder the edges crossing it. So the query doesn't need to test every edge in the slab one at a time — it can binary search the sorted list directly for how many lie below the point's y, the same way it binary searches the slab list for the point's x in the first place. Two sorted lists, two binary searches, O(log n) total.

One case that looks like it needs special handling turns out not to: a vertical polygon edge (this demo's arrow has three) spans zero width in x, so it can never satisfy "spans this slab's full width" for a slab that has any width at all — the membership test that builds each slab's edge list excludes vertical edges automatically, with no separate check required. The excluded edge isn't lost information; it just never participates in a slab-interior query, exactly like a horizontal edge never registering a ray-casting crossing on Point in Polygon.

Reference implementation

function buildSlabs(poly) {
  const edges = polygonEdges(poly); // n edges, each { a, b }
  const xs = [...new Set(poly.map(p => p.x))].sort((a, b) => a - b);
  const slabs = [];
  for (let i = 0; i < xs.length - 1; i++) {
    const xLeft = xs[i], xRight = xs[i + 1];
    const mid = (xLeft + xRight) / 2;
    const spanning = edges.filter(e => {
      if (e.a.x === e.b.x) return false; // vertical edge, zero width, never spans
      const lo = Math.min(e.a.x, e.b.x), hi = Math.max(e.a.x, e.b.x);
      return lo <= xLeft && hi >= xRight;
    });
    spanning.sort((e1, e2) => yAt(e1, mid) - yAt(e2, mid));
    slabs.push({ xLeft, xRight, edges: spanning });
  }
  return slabs;
}

function locate(slabs, point) {
  // binary search for the slab: xLeft <= point.x < xRight
  let lo = 0, hi = slabs.length - 1, slab = null;
  while (lo <= hi) {
    const mid = (lo + hi) >> 1, s = slabs[mid];
    if (point.x < s.xLeft) hi = mid - 1;
    else if (point.x >= s.xRight) lo = mid + 1;
    else { slab = s; break; }
  }
  if (!slab) return false; // outside every slab -> outside the polygon
  // binary search within the slab for how many edges lie below point.y
  let a = 0, b = slab.edges.length;
  while (a < b) {
    const mid = (a + b) >> 1;
    if (yAt(slab.edges[mid], point.x) < point.y) a = mid + 1;
    else b = mid;
  }
  return a % 2 === 1; // odd count of edges below -> inside
}

Pitfalls

The slab boundary convention has to be consistently right-open, or points sitting exactly on a slab's own left edge silently vanish. This page's own exactly on an edge preset, (100, 190), sits on x = 100 — both the polygon's leftmost vertex and this demo's own leftmost slab boundary. The shipped rule above treats each slab as [xLeft, xRight): a point with x exactly equal to a slab's own xLeft belongs to that slab. Switch the convention to left-open, (xLeft, xRight] — checking point.x <= s.xLeft to move left instead of point.x < s.xLeft — and the binary search for x = 100 immediately steps past the first slab looking for one further left, finds none, and reports the point outside every slab. That misclassifies every point on the polygon's entire left edge as outside, not just this one preset — verified by running both conventions against 1,500 points held at x = 100, 300, and 450 (this polygon's three distinct vertex x-coordinates) with random y: the shipped right-open rule agrees with Point in Polygon's ray casting on all 1,500, and the left-open rule disagrees on 373 of them, all systematically on whichever boundary landed on the wrong side.

A slab can hold every one of the polygon's edges, and there can be almost as many slabs as vertices — so the straightforward preprocessing above is O(n²) in the worst case, not O(n log n). This page's own arrow keeps that cost invisible (two slabs, two edges each) because its vertices barely stagger in x. A polygon built to stagger them — a rectangle with n/4 thin horizontal teeth notched in from alternating sides, each teeth's tip staggered slightly further across so no two vertices share an x-coordinate, but every tooth still spans nearly the rectangle's full width — forces almost every one of its ~n/4 slabs to hold almost every one of its ~n edges. Built and measured directly: at n = 132 vertices (32 teeth) the slabs hold 1,122 total (slab, edge) pairs; at n = 260 (64 teeth), 4,290 pairs — the ratio of pairs to n nearly doubles alongside n itself, the signature of genuine quadratic growth, not a constant-factor artifact. Confirmed simple (no self-intersecting edges) by brute-force segment check before trusting the measurement, and confirmed both instances still agree with ray casting on every point of 50,000 random trials — the blowup is in preprocessing cost, not correctness.

Complexity

Query: O(log n) always — one binary search over at most n − 1 slabs, one binary search over at most n edges within whichever slab is found, and neither search's cost depends on where the point actually lands. Preprocessing: O(n²) worst case for the reference implementation above, both in time (each of up to n slabs is compared against all n edges to build its list) and in space (an adversarial polygon, per Pitfalls, can put nearly every edge in nearly every slab). A further preprocessing structure — the randomized incremental trapezoidal map, which decomposes the plane the same way but merges neighboring regions instead of treating every slab as one uniform column — brings that down to O(n) expected space and O(n log n) expected construction time while keeping the same O(log n) query.

This site's guide, Choosing a Geometry Algorithm, places this entry alongside Trapezoidal Map as the two ways to answer many point-in-polygon queries against the same fixed polygon in O(log n) — reach for this one when the polygon is small or trusted not to be adversarial; reach for Trapezoidal Map once it could be large or hostile.