Cairn
algorithms · computational geometry · O((n + k) log n)

back to Geometry

Bentley–Ottmann Algorithm

Line Segment Intersection, this site's other O(1) entry, decides whether one pair of segments crosses. Given n segments and asked to find every crossing pair, the obvious move is to run that same O(1) test on all n(n − 1) / 2 pairs — O(n²) total, and that page's own Complexity section names this exact algorithm as the way to do better without changing what "better" costs per pair. Bentley–Ottmann finds every intersection in O((n + k) log n), where k is the number of intersections actually found — faster than checking all pairs whenever k is small relative to , which is the common case for segments that mostly don't cross.

The idea is a sweep line: an imaginary vertical line moving left to right across the plane. At any fixed sweep position, the segments it currently crosses have a well-defined top-to-bottom order — call this the status structure. That order only ever changes at one of three moments: a segment starts (enters the sweep), a segment ends (leaves the sweep), or two active segments cross (swap places in the order). Between those moments nothing changes, so nothing needs re-checking — the algorithm only does work at these events, processed left to right from an event queue, instead of continuously scanning the whole plane.

Try it

Five segments, six real crossings among them — a deliberately dense example, picked to make the mechanism visible (every kind of event fires at least once) rather than to show off a pair-test count lower than brute force's fixed C(5,2) = 10. It doesn't come out ahead here — watch the pair-test counter below climb past 10 before the sweep finishes — because with 6 of the 10 possible pairs genuinely crossing, this is close to the algorithm's own worst case (see Complexity). The real savings show up at larger n with proportionally fewer crossings; the Pitfalls section below has a measured 80-segment case where the difference is stark. Step through and watch the status structure strip below the canvas: it always lists exactly the segments the dashed sweep line currently crosses, top to bottom. Every event — start, end, or intersection — is computed live from the five segments' real coordinates by the same sweep() function described below, not scripted per step. Watch B×D at x=150 closely: D is a short horizontal segment sitting between B and A when the sweep starts, and it's only once D ends and drops out of the status structure that B and A — now newly adjacent — get checked against each other at all.

status structure (top→bottom): —
event 0/0 · pair-tests so far: 0 (brute force: 10, fixed)
Press Step or Run.

Why it works

The event queue starts with just 2n events — every segment's left endpoint (a "start") and right endpoint (an "end") — sorted by x. Intersection events aren't known up front; they get discovered and scheduled during the sweep, which is the mechanism that makes this efficient rather than exhaustive. Whenever two segments become adjacent in the status structure — freshly inserted next to each other, or left newly next to each other after something between them is removed — the algorithm checks that one pair, and only that pair, for a future crossing. If they do cross further right than the current sweep position, that crossing is pushed onto the event queue as a future event, to be handled exactly when the sweep line reaches it.

This is sound because of a standard exchange argument: two segments cannot cross without, at the moment they cross, being adjacent in the status structure. If they weren't adjacent, some third active segment would sit between them — and a continuous curve can't swap sides of another continuous curve without crossing it first, so that in-between segment would have to cross one of the two first, which is itself a crossing the algorithm would have already scheduled and processed as an event before this one. So working strictly through the event queue in x order, testing only pairs that are freshly adjacent right now, never misses a crossing — it just defers discovering it until the two segments in question are actually next to each other.

Each event type needs exactly one adjacency re-check, no more: a start checks the new segment against its one new neighbor above and one below; an end checks whatever two segments the removed one used to separate, now possibly neighbors themselves; an intersection swaps the two crossing segments' order and checks each one against its new outer neighbor. Every one of these is a fixed, small number of pair-tests per event — the whole reason the total stays proportional to n + k instead of .

Reference implementation

Simplified for non-vertical segments in general position (see Pitfalls for what that leaves out). The status structure below is a plain sorted array for clarity — see Complexity for what that costs against the algorithm's real bound:

function sweep(segments) {
  let events = [];
  segments.forEach(s => {
    events.push({ x: s.p1.x, type: 'start', seg: s.label });
    events.push({ x: s.p2.x, type: 'end', seg: s.label });
  });
  events.sort((a, b) => a.x - b.x);

  let T = [];               // status structure: labels, top-to-bottom, at the current sweep x
  const scheduled = new Set();
  const found = [];

  function testPair(a, b, sweepX) {
    if (!a || !b) return;
    const key = [a, b].sort().join('|');
    if (scheduled.has(key)) return;
    const pt = segIntersection(byLabel[a], byLabel[b]);
    if (pt && pt.x > sweepX) {
      events.push({ x: pt.x, type: 'intersection', seg: [a, b] });
      events.sort((e1, e2) => e1.x - e2.x);
      scheduled.add(key);
    }
  }

  while (events.length) {
    const ev = events.shift();
    if (ev.type === 'start') {
      const idx = insertionIndex(T, ev.seg, ev.x);   // by y at ev.x
      T.splice(idx, 0, ev.seg);
      testPair(T[idx - 1], T[idx], ev.x);
      testPair(T[idx], T[idx + 1], ev.x);
    } else if (ev.type === 'end') {
      const idx = T.indexOf(ev.seg);
      const above = T[idx - 1], below = T[idx + 1];
      T.splice(idx, 1);
      testPair(above, below, ev.x);                  // newly adjacent, if both exist
    } else {
      const [a, b] = ev.seg;
      const lo = Math.min(T.indexOf(a), T.indexOf(b));
      const hi = lo + 1;
      [T[lo], T[hi]] = [T[hi], T[lo]];                // crossing flips their order
      found.push({ pair: [a, b], x: ev.x });
      testPair(T[lo - 1], T[lo], ev.x);
      testPair(T[hi], T[hi + 1], ev.x);
    }
  }
  return found;
}

Pitfalls

Skipping the end-event re-check silently drops real intersections. A version that only re-checks adjacency on start and intersection events — reasoning "removal only shrinks the structure, what's there to check" — misses crossings that only become discoverable once something between them is gone. Verified directly with three segments: A from (40,280) to (260,200), B from (240,180) to (380,240), C from (200,220) to (460,180). The correct sweep finds both real crossings, A×C at x≈208.7 and B×C at x≈298.1 — matching an independent brute-force check of all three pairs. Drop the end-event check and B×C disappears: at x=240 when B starts, the status structure is [B, A, C]B and C aren't adjacent, A sits between them. Only at x=260, when A ends and the structure becomes [B, C], do they become adjacent for the first time — and a version that never re-checks on removal never schedules the test that would have found the crossing sitting at x≈298.1, well past where the two segments were ever directly compared.

Checking a new or removed segment against every currently active segment stays correct but throws away the entire efficiency argument. It's tempting to over-check "to be safe" — but every active segment is exactly what neighbors-only checking is built to avoid touching. Measured directly on n long, near-parallel segments (mostly co-active across the whole sweep, so only a handful genuinely cross): at n = 80 with k = 20 real intersections, the neighbors-only version ran 136 pair-tests total, while a check-against-everything-active variant ran 6,338 — worse than the 3,160 a flat one-time brute-force pass over all C(80,2) pairs would cost, because the over-checking variant re-tests long-lived segments against the whole structure repeatedly, once per event, not once per pair. Same output, same correctness — but only one of these is the algorithm this page is actually about.

Complexity

Time: O((n + k) log n) when the status structure is backed by a balanced tree (or skip list) supporting O(log n) insert, delete, and neighbor lookup: 2n start/end events plus k intersection events, each event doing a constant number of these operations, plus O(log n) to push/pop the event queue itself (a priority queue, ordered by x). This page's own JavaScript uses a plain sorted array for the status structure instead — same demo convention as Dijkstra's Algorithm's array-backed priority queue — so insertion and removal are O(n) via splice/indexOf rather than O(log n); the event *count* and *which pairs get tested* are exactly what a tree-backed version would do, only the per-event cost differs. Space: O(n + k) for the event queue, O(n) for the status structure. One honest caveat about the output-sensitive bound itself: if k is close to its O(n²) ceiling — nearly every pair genuinely crosses — then O((n + k) log n) becomes O(n² log n), asymptotically worse than the brute-force O(n²) it's meant to improve on. The algorithm only wins when k is small relative to , which is the case it's built for, not every case.

This site's guide, Choosing a Geometry Algorithm, places this entry as the answer once the question moves from one pair to which pairs among n segments cross, built on top of Line Segment Intersection's own O(1) test as its core primitive.