The plane-sweep paradigm turns a static 2-D geometry problem into a dynamic
1-D ordered-set problem: a vertical line sweeps left to right, stopping at an
x-sorted event queue while a balanced-BST status structure tracks the
objects it currently crosses, ordered by y. We derive Bentley–Ottmann segment
intersection in O((n+k)logn), recover closest-pair in O(nlogn), and
reduce skyline, rectangle-area, and overlap problems to ±1 event sweeps.
╌╌╌╌
A great many geometric problems share an awkward shape: they ask a question about
n objects scattered in the plane whose answer seems to depend on every pair of
objects at once. Do any two of these n segments cross? What is the area covered
by the union of these n rectangles? The brute-force answer compares all
(2n) pairs and costs Θ(n2). The plane-sweep paradigm
avoids the quadratic cost by never considering the whole plane at once: a
vertical line sweeps from left to right, and the algorithm tracks only
what is locally true at the line's current position.1
The payoff is a recurring reduction: a hard 2-D problem becomes a sequence of
cheap updates to a one-dimensional ordered set. At any instant, the only objects
that matter are the ones the sweep line currently crosses, and among those, only
the ones adjacent in y can interact. We have already built every tool this
needs: balanced BSTs and ordered sets from the Balanced Trees lesson,
Fenwick and segment trees from the structures that follow, and the sweep is the
paradigm that puts them to geometric work.
The plane-sweep paradigm
Every sweep-line algorithm is assembled from two data structures and one loop.
The loop is then uniform. Pop the leftmost event; it marks a combinatorial
change: an object enters the active set, an object leaves it, or two active
objects swap y-order. Update the status structure accordingly, inspect the few
neighbors the change could affect, and emit any answers. Between events nothing
in the active set changes order, so we never need to look there.2
The art in any specific problem is choosing what counts as an event and what
the status orders by. The rest is bookkeeping.
The two structures work in tandem: the event queue hands out the next
x-coordinate to stop at, and at each stop the status structure is edited and
its new neighbors inspected. The snapshot below freezes one instant — the queue
holding the events still to the right of the sweep, and the status holding the
three segments the line currently crosses, top to bottom in y.
One instant of a sweep: the event queue (sorted upcoming x-stops) and the status structure (active segments, ordered by y at the sweep line).
Segment intersection: Bentley–Ottmann
Given n line segments, report all k pairs that cross. The naive test of every
pair is Θ(n2), wasteful when k is small. The Bentley–Ottmann sweep does
it in O((n+k)logn) by exploiting a single geometric fact.1
Sweep a vertical line left to right. The status structure holds the segments
currently straddling the line, ordered by the y-coordinate at which each segment
meets the line. As the line advances this order is stable except at three kinds
of event:
Left endpoint of a segment: insert it into the status at its y-position.
Right endpoint: delete the segment from the status.
Intersection point of two segments: the two segments swap their relative
order in the status (the one that was above is now below).
The following invariant collapses the problem from all pairs
to a constant check per event.
So at each event we test only the handful of newly-adjacent pairs for a future
crossing, and any crossing we find is pushed into the event queue as a new event:
On insert, the new segment acquires an upper and a lower neighbor; test
each of those two pairs.
On delete, the departing segment's old neighbors become adjacent; test
that one new pair.
On a swap, the two swapping segments acquire new outer neighbors; test
those two new pairs.
Maintain the y-ordered active set; at each event check only neighbors
The accented segments form the active set; the sweep line meets them at
three y-values, and the status stores them in that vertical order. Because the
total number of events is n endpoints plus k intersections, and each costs
O(logn) for the ordered-set operations, the total is O((n+k)logn) — a
decisive win over Θ(n2) whenever k=o(n2/logn).
The swap event is the subtle one. Two segments s and t are adjacent in the
status, s above t, until the sweep reaches their crossing c; at that x they
meet at equal y, and just past it their order in the status flips — t is now
above s. The swap exposes two new adjacencies (the segment u formerly outside
s now neighbors t, and the segment below t now neighbors s), and those are
the only pairs worth testing next.
At crossing c the two segments swap status order, exposing two new adjacencies to test
We keep the implementation at this level: the careful part is degeneracy
(vertical segments, three segments through a point) and reliable orientation tests,
which the references treat in full.
segment_intersection.pypython
from typing import NamedTuple, Optional
class Point(NamedTuple):
"""
A point in the plane.\n
"""
x: float
y: float
class Segment(NamedTuple):
"""
A line segment between two endpoints.\n
"""
start: Point
end: Point
def _orientation(first: Point, second: Point, third: Point) -> int:
"""
Sign of the cross product of (second - first) and (third - first):\n
+1 counter-clockwise, -1 clockwise, 0 collinear.\n
"""
# signed area of the triangle; its sign is the turn direction.
cross: float = (
(second.x - first.x) * (third.y - first.y)
- (second.y - first.y) * (third.x - first.x)
)
if cross > 0:
return 1
if cross < 0:
return -1
return 0
def _on_segment(point: Point, segment: Segment) -> bool:
"""
Whether `point`, known collinear with `segment`, lies within its box.\n
"""
return (
min(segment.start.x, segment.end.x) <= point.x
<= max(segment.start.x, segment.end.x)
and min(segment.start.y, segment.end.y) <= point.y
<= max(segment.start.y, segment.end.y)
)
def segments_cross(first: Segment, second: Segment) -> bool:
"""
Whether two segments intersect, including collinear overlap and shared\n
endpoints. Uses the standard four-orientation straddle test.\n
"""
orient_a: int = _orientation(first.start, first.end, second.start)
orient_b: int = _orientation(first.start, first.end, second.end)
orient_c: int = _orientation(second.start, second.end, first.start)
orient_d: int = _orientation(second.start, second.end, first.end)
# general case: each segment straddles the line through the other.
if orient_a != orient_b and orient_c != orient_d:
return True
# collinear-and-overlapping special cases.
if orient_a == 0 and _on_segment(second.start, first):
return True
if orient_b == 0 and _on_segment(second.end, first):
return True
if orient_c == 0 and _on_segment(first.start, second):
return True
if orient_d == 0 and _on_segment(first.end, second):
return True
return False
def _y_at(segment: Segment, x: float) -> float:
"""
The y-coordinate where `segment` meets the vertical line at `x`.\n
For a vertical segment, returns its lower endpoint's y.\n
"""
start, end = segment.start, segment.end
if start.x == end.x:
return min(start.y, end.y)
# interpolate along the segment's slope to the sweep line.
slope: float = (end.y - start.y) / (end.x - start.x)
return start.y + slope * (x - start.x)
def _crossing_point(first: Segment, second: Segment) -> Optional[Point]:
"""
The intersection point of two non-parallel segments, if they meet at a\n
single point; otherwise None (parallel, collinear, or non-crossing).\n
"""
# endpoint differences along each axis, per Cramer's rule.
x_diff: tuple[float, float] = (
first.start.x - first.end.x,
second.start.x - second.end.x,
)
y_diff: tuple[float, float] = (
first.start.y - first.end.y,
second.start.y - second.end.y,
)
# a zero determinant means the lines are parallel (no single crossing).
denominator: float = x_diff[0] * y_diff[1] - x_diff[1] * y_diff[0]
if denominator == 0:
return None
# 2x2 cross terms feed the line-intersection formula.
cross_first: float = (
first.start.x * first.end.y - first.start.y * first.end.x
)
cross_second: float = (
second.start.x * second.end.y - second.start.y * second.end.x
)
# solve for the line crossing, then keep it only if it lies on both segments.
point_x: float = (
cross_first * x_diff[1] - x_diff[0] * cross_second
) / denominator
point_y: float = (
cross_first * y_diff[1] - y_diff[0] * cross_second
) / denominator
point = Point(point_x, point_y)
if _on_segment(point, first) and _on_segment(point, second):
return point
return None
def report_intersections(
segments: list[Segment],
) -> list[tuple[int, int]]:
"""
Every pair of indices `(i, j)` with `i < j` whose segments intersect.\n
Sweeps left to right over endpoint and computed-crossing events, keeping\n
the active segments ordered by their y at the sweep line and testing only\n
pairs made newly adjacent. Returns pairs sorted ascending.\n
Assumes general position (no vertical or collinear segments); the\n
references handle those degeneracies in full.\n
"""
found: set[tuple[int, int]] = set()
active: list[int] = []
def order_key(index: int, x: float) -> float:
return _y_at(segments[index], x)
def record(first_index: int, second_index: int) -> None:
"""
Test a pair, recording it on a true crossing.\n
"""
low, high = sorted((first_index, second_index))
if segments_cross(segments[low], segments[high]):
found.add((low, high))
# the sweep stops at every x where the y-order can change: endpoints and
# pairwise crossings. Stops are kept sorted so that between consecutive stops
# the order of the active set is fixed; a crossing pair becomes adjacent in
# the stop immediately before their crossing, so testing neighbours at the
# midpoint of every gap catches it.
# record each segment's x-span and seed the stops with its endpoints.
start_x: dict[int, float] = {}
end_x: dict[int, float] = {}
stops: set[float] = set()
for index, segment in enumerate(segments):
left, right = sorted((segment.start, segment.end))
start_x[index] = left.x
end_x[index] = right.x
stops.add(left.x)
stops.add(right.x)
# add every pairwise crossing x so the order can change at it.
for first in range(len(segments)):
for second in range(first + 1, len(segments)):
point = _crossing_point(segments[first], segments[second])
if point is not None:
stops.add(point.x)
ordered_stops: list[float] = sorted(stops)
for position, stop in enumerate(ordered_stops):
# refresh membership: a segment is active across [start_x, end_x].
active = [
index
for index in range(len(segments))
if start_x[index] <= stop <= end_x[index]
]
# sample the order at the stop and just to either side of it: the order
# just-left exposes pairs about to cross, the stop catches concurrent
# crossings, and just-right catches pairs that have just swapped. Testing
# adjacent pairs in each sampled order covers every crossing among
# non-vertical segments in general position.
samples: list[float] = [stop]
if position > 0:
samples.append((ordered_stops[position - 1] + stop) / 2)
if position + 1 < len(ordered_stops):
samples.append((stop + ordered_stops[position + 1]) / 2)
# at each sample, order the active set by y and test adjacent pairs.
for sample_x in samples:
active.sort(key=lambda index: order_key(index, sample_x))
for adjacent in range(len(active) - 1):
record(active[adjacent], active[adjacent + 1])
return sorted(found)
def report_intersections_naive(
segments: list[Segment],
) -> list[tuple[int, int]]:
"""
Brute-force O(n^2) reference: test every pair directly.\n
"""
# test every i < j pair directly.
found: list[tuple[int, int]] = []
for first in range(len(segments)):
for second in range(first + 1, len(segments)):
if segments_cross(segments[first], segments[second]):
found.append((first, second))
return found
Closest pair, swept
The Selection lesson found the closest pair of n points by divide-and-conquer
in O(nlogn). A sweep gives the same bound with a different, often simpler,
mechanism, and it generalizes the narrow strip trick of that proof into a
running invariant.3
Sort the points by x and sweep. Let δ be the smallest distance found so
far. Keep the points whose x lies within δ of the sweep line in a balanced
set ordered by y. When the sweep reaches a new point p:
Evict from the set every point more than δ to the left of p in x.
Among the survivors, only those within δ of p in y can beat δ,
so query the set for the y-window [py−δ,py+δ].
Each point triggers one insertion, O(1) deletions amortized, and an O(logn)
range query returning O(1) points to test — so O(nlogn) overall, dominated by
the initial sort. The sweep makes the strip dynamic: rather than recomputing a
fresh strip at each recursion, it slides one along, inserting and evicting at the
boundary.
Trace the eviction on the five points A(0,1), B(1,4), C(2,2), D(6,3),
E(7,1), swept in x-order. After processing A,B,C the best distance is
δ=dist(A,C)=4+1=5≈2.24 (closer
than A–B or B–C). Now D arrives at x=6. Every active point with
x<6−δ≈3.76 is evicted — that is A, B, and C, all three —
because none can be within δ of D horizontally. The y-window query
around D returns nothing, so δ is unchanged. When E arrives at x=7,
only D survives the x=7−δ cutoff, and dist(D,E)=1+4=5
ties but does not beat δ. The closest pair is A–C, found without ever
comparing the left cluster against the right one — the eviction did that pruning
for free.
Closest pair: only the δ×2δ box left of p (width δ, height 2δ) can beat δ, and it holds O(1) points.
closest_pair_sweep.pypython
import math
from bisect import insort
from typing import NamedTuple, Optional, Sequence
class Point(NamedTuple):
"""
A point in the plane.\n
"""
x: float
y: float
def _distance(first: Point, second: Point) -> float:
"""
Euclidean distance between two points.\n
"""
return math.hypot(first.x - second.x, first.y - second.y)
def closest_pair(points: Sequence[Point]) -> Optional[tuple[Point, Point]]:
"""
The two distinct points at minimum Euclidean distance, or None if fewer\n
than two points are given. Ties resolve to the first pair encountered.\n
"""
if len(points) < 2:
return None
# sweep order is left-to-right, ties by y.
by_x: list[Point] = sorted(points, key=lambda point: (point.x, point.y))
best_distance: float = math.inf
best_pair: Optional[tuple[Point, Point]] = None
# the active strip, holding each survivor as a (y, x) key so it stays sorted
# by y and a y-window is a contiguous slice; insort keeps it ordered.
strip: list[tuple[float, float]] = []
left_edge: int = 0
for current in by_x:
# evict every point more than `best_distance` to the left in x; once the
# best shrinks these can never participate again.
while (
left_edge < len(strip)
and current.x - by_x[left_edge].x > best_distance
):
strip.remove((by_x[left_edge].y, by_x[left_edge].x))
left_edge += 1
# only survivors within `best_distance` in y can beat the current best.
start: int = _lower_bound(strip, current.y - best_distance)
stop: int = _lower_bound(strip, current.y + best_distance)
for candidate_y, candidate_x in strip[start:stop + 1]:
candidate = Point(candidate_x, candidate_y)
separation: float = _distance(current, candidate)
if separation < best_distance:
best_distance = separation
best_pair = (candidate, current)
# admit the current point to the strip for later points to query.
insort(strip, (current.y, current.x))
return best_pair
def _lower_bound(strip: list[tuple[float, float]], target_y: float) -> int:
"""
First index in the y-sorted strip whose y is not less than `target_y`.\n
"""
low: int = 0
high: int = len(strip)
# binary search on the y-key of each strip entry.
while low < high:
middle: int = (low + high) // 2
if strip[middle][0] < target_y:
low = middle + 1
else:
high = middle
return low
def closest_pair_naive(
points: Sequence[Point],
) -> Optional[tuple[Point, Point]]:
"""
Brute-force O(n^2) reference: the closest of every pair.\n
"""
if len(points) < 2:
return None
best_distance: float = math.inf
best_pair: Optional[tuple[Point, Point]] = None
# keep the closest of every distinct pair.
for first in range(len(points)):
for second in range(first + 1, len(points)):
separation: float = _distance(points[first], points[second])
if separation < best_distance:
best_distance = separation
best_pair = (points[first], points[second])
return best_pair
Interval and rectangle sweeps
The practice problems are sweeps in disguise, and they reveal a simpler status
structure than a full BST: when objects are axis-aligned, events are +1/−1deltas and the status is a count or a segment tree.
Maximum overlap (My Calendar III, Describe the Painting). Given intervals
[ℓi,ri), find the maximum number covering any point — or the coverage
profile. Emit a +1 event at each ℓi and a −1 event at each ri, sort
the events by coordinate, and sweep a running sum. The running sum is the number
of intervals covering the current coordinate; its maximum is the answer.
sort E by coordinate; break ties with −1 before +1
6
cur←0;best←0
7
for each event (x,δ) in Edo
8
cur←cur+δ
9
best←max(best,cur)
10
returnbest
The tie-break matters: at a shared coordinate, ending an interval before starting
the next reflects half-open [ℓ,r) intervals and avoids spuriously counting an
endpoint touch as an overlap. To describe the painting rather than only its peak,
emit a coverage segment between consecutive distinct event coordinates whenever
cur>0, merging equal-cur neighbors. The sweep costs O(nlogn) for the
sort and O(n) for the pass.
Trace it on the three intervals A=[1,5), B=[2,7), C=[3,5). The six events,
sorted (with −1 before +1 at ties), and the running sum are:
coord
δ
interval
cur after
best
1
+1
A begins
1
1
2
+1
B begins
2
2
3
+1
C begins
3
3
5
−1
A ends
2
3
5
−1
C ends
1
3
7
−1
B ends
0
3
The peak best=3 occurs on [3,5), where all three intervals overlap — exactly
the segment the figure below highlights. Note the two −1 events at coordinate 5:
because A and C both end there and no interval begins, the running sum drops
cleanly from 3 to 1 without a phantom bump.
Sweep ±1 events to track coverage; the peak is the max overlap
interval_sweep.pypython
from typing import NamedTuple, Sequence
class Interval(NamedTuple):
"""
A half-open interval [low, high): covers low up to but not including high.\n
"""
low: float
high: float
class CoverageSegment(NamedTuple):
"""
A maximal half-open run [low, high) over which exactly `multiplicity`\n
intervals overlap.\n
"""
low: float
high: float
multiplicity: int
def max_overlap(intervals: Sequence[Interval]) -> int:
"""
The largest number of intervals covering any single coordinate.\n
Ties at a shared coordinate break with -1 (ends) before +1 (starts) so a\n
half-open endpoint touch is not counted as an overlap.\n
"""
# +1 at each start, -1 at each end of a non-empty interval.
events: list[tuple[float, int]] = []
for interval in intervals:
if interval.low < interval.high:
events.append((interval.low, +1))
events.append((interval.high, -1))
# at equal coordinate, -1 sorts before +1 because -1 < +1.
events.sort()
# the running sum is the live coverage; track its peak.
current: int = 0
best: int = 0
for _, delta in events:
current += delta
best = max(best, current)
return best
def coverage_profile(
intervals: Sequence[Interval],
) -> list[CoverageSegment]:
"""
The coverage count over every maximal run of coordinates, in order.\n
Only covered runs (count > 0) are emitted, and adjacent runs with equal\n
count are merged into one segment.\n
"""
# +1 at each start, -1 at each end of a non-empty interval.
events: list[tuple[float, int]] = []
for interval in intervals:
if interval.low < interval.high:
events.append((interval.low, +1))
events.append((interval.high, -1))
if not events:
return []
events.sort()
segments: list[CoverageSegment] = []
current: int = 0
index: int = 0
while index < len(events):
coordinate: float = events[index][0]
# apply every event at this exact coordinate before reading the count.
while index < len(events) and events[index][0] == coordinate:
current += events[index][1]
index += 1
# the count `current` now holds from `coordinate` up to the next one.
if current > 0 and index < len(events):
next_coordinate: float = events[index][0]
# extend the previous run if it abuts at the same count; else open one.
mergeable: bool = bool(segments) and (
segments[-1].multiplicity == current
and segments[-1].high == coordinate
)
if mergeable:
segments[-1] = CoverageSegment(
segments[-1].low, next_coordinate, current
)
else:
segments.append(
CoverageSegment(coordinate, next_coordinate, current)
)
return segments
def max_overlap_naive(intervals: Sequence[Interval]) -> int:
"""
Brute-force reference: at each distinct start coordinate, count the\n
intervals covering it.\n
"""
# every distinct start is a candidate for the peak overlap.
coordinates: set[float] = {
interval.low for interval in intervals if interval.low < interval.high
}
# count the intervals covering each candidate and keep the largest.
best: int = 0
for coordinate in coordinates:
covering: int = sum(
1
for interval in intervals
if interval.low <= coordinate < interval.high
)
best = max(best, covering)
return best
Skyline. Given n buildings as (ℓ,r,h), output the silhouette of their
union. Sweep x-events at building edges; the status is a multiset of active
heights. At a left edge insert h; at a right edge remove it. After each event
the current skyline height is the multiset's maximum, and a key point is emitted
whenever that maximum changes. A balanced multiset (or a heap with lazy deletion)
gives O(nlogn).
skyline.pypython
import heapq
from collections import Counter
from typing import NamedTuple, Sequence
class Building(NamedTuple):
"""
An axis-aligned building from x=left to x=right with the given height,\n
its base on the ground line y=0.\n
"""
left: float
right: float
height: float
class KeyPoint(NamedTuple):
"""
A skyline key point: the silhouette height becomes `height` at x.\n
"""
x: float
height: float
class _ActiveHeights:
"""
A multiset of active heights supporting max and lazy removal.\n
The heap stores negated heights for a max-view; a Counter records pending\n
removals so a popped-but-deleted height can be skipped on the next peek.\n
"""
def __init__(self) -> None:
self._heap: list[float] = [0.0]
self._pending: Counter[float] = Counter()
def add(self, height: float) -> None:
"""
Insert one copy of `height`.\n
"""
heapq.heappush(self._heap, -height)
def remove(self, height: float) -> None:
"""
Mark one copy of `height` for lazy deletion.\n
"""
self._pending[height] += 1
def maximum(self) -> float:
"""
The largest active height, discarding any lazily-deleted tops first.\n
"""
# drop tops that were marked for deletion before reading the max.
while self._pending[-self._heap[0]] > 0:
self._pending[-self._heap[0]] -= 1
heapq.heappop(self._heap)
return -self._heap[0]
def skyline(buildings: Sequence[Building]) -> list[KeyPoint]:
"""
The silhouette of the union of `buildings` as left-to-right key points,\n
each marking where the outline height changes. The final point returns\n
the outline to the ground (height 0).\n
"""
events: list[tuple[float, int, float]] = []
for building in buildings:
if building.left < building.right and building.height > 0:
# at equal x, taller starts sort before shorter starts (kind, then the
# negated height); a start (kind 0) sorts before an end (kind 1) so a
# touching neighbour does not dip the outline to ground.
events.append((building.left, 0, -building.height))
events.append((building.right, 1, building.height))
events.sort()
active = _ActiveHeights()
result: list[KeyPoint] = []
previous_height: float = 0.0
index: int = 0
while index < len(events):
coordinate: float = events[index][0]
# process every event sharing this x before reading the outline height.
while index < len(events) and events[index][0] == coordinate:
_, kind, signed_height = events[index]
if kind == 0:
active.add(-signed_height)
else:
active.remove(signed_height)
index += 1
# emit a key point wherever the running max changes the outline height.
current_height: float = active.maximum()
if current_height != previous_height:
result.append(KeyPoint(coordinate, current_height))
previous_height = current_height
return result
def skyline_naive(buildings: Sequence[Building]) -> list[KeyPoint]:
"""
Brute-force reference: sample the outline height between consecutive\n
distinct x-coordinates and emit a key point wherever it changes.\n
"""
# every building edge is a candidate x for an outline change.
coordinates: list[float] = sorted(
{building.left for building in buildings if building.height > 0}
| {building.right for building in buildings if building.height > 0}
)
result: list[KeyPoint] = []
previous_height: float = 0.0
for coordinate in coordinates:
# the outline height here is the tallest building covering this x.
current_height: float = 0.0
for building in buildings:
if building.left <= coordinate < building.right:
current_height = max(current_height, building.height)
# record a key point only where that height changes.
if current_height != previous_height:
result.append(KeyPoint(coordinate, current_height))
previous_height = current_height
return result
Union of rectangle areas (Rectangle Area II). Sweep a vertical line across
x-events at rectangle left and right edges. The status tracks, for the current
x-slab, the total length of y covered by at least one active rectangle. Each
rectangle contributes a +1/−1 on its y-interval at its left/right edge; a
segment tree over compressed y-coordinates maintains the covered length under
these interval updates in O(logn) each — the same coordinate-compressed
segment tree from the Fenwick & Segment Trees lesson. The area is the sum over
slabs of (covered y-length) × (slab width):
area=slabs∑coveredy(slab)⋅(xi+1−xi).
With n rectangles there are 2nx-events and O(n) distinct y-values, so
the sweep runs in O(nlogn).
Rectangle-union area: a vertical sweep accumulates covered y-length times slab width
rectangle_area.pypython
from bisect import bisect_left
from typing import NamedTuple, Sequence
class Rectangle(NamedTuple):
"""
An axis-aligned rectangle spanning [x1, x2] by [y1, y2].\n
"""
x1: float
y1: float
x2: float
y2: float
class _CoverageTree:
"""
A segment tree over the elementary y-intervals between consecutive\n
compressed coordinates. Each leaf is one elementary interval; a node\n
tracks how many active rectangles cover it (`count`) and the total length\n
of its span that is covered by at least one (`covered`). Interval updates\n
add a delta to a span; the root's `covered` is the length under the sweep.\n
"""
def __init__(self, coordinates: Sequence[float]) -> None:
self._coordinates: Sequence[float] = coordinates
cells: int = max(len(coordinates) - 1, 0)
self._count: list[int] = [0 for _ in range(4 * max(cells, 1))]
self._covered: list[float] = [0.0 for _ in range(4 * max(cells, 1))]
self._cells: int = cells
def update(self, low_index: int, high_index: int, delta: int) -> None:
"""
Add `delta` to every elementary interval in the coordinate range\n
[low_index, high_index).\n
"""
if self._cells > 0:
self._update(1, 0, self._cells - 1, low_index, high_index - 1, delta)
def _update(
self,
node: int,
node_low: int,
node_high: int,
target_low: int,
target_high: int,
delta: int,
) -> None:
# node's span lies entirely outside the target range.
if target_low > node_high or target_high < node_low:
return
# node's span lies entirely inside: stamp the delta on its count.
if target_low <= node_low and node_high <= target_high:
self._count[node] += delta
# partial overlap: split at the midpoint and recurse into both halves.
else:
middle: int = (node_low + node_high) // 2
self._update(2 * node, node_low, middle, target_low, target_high, delta)
self._update(
2 * node + 1, middle + 1, node_high, target_low, target_high, delta
)
self._refresh(node, node_low, node_high)
def _refresh(self, node: int, node_low: int, node_high: int) -> None:
"""
Recompute a node's covered length from its own count and children.\n
"""
if self._count[node] > 0:
self._covered[node] = (
self._coordinates[node_high + 1] - self._coordinates[node_low]
)
elif node_low == node_high:
self._covered[node] = 0.0
else:
self._covered[node] = (
self._covered[2 * node] + self._covered[2 * node + 1]
)
@property
def covered(self) -> float:
"""
Total y-length covered by at least one active rectangle.\n
"""
return self._covered[1] if self._cells > 0 else 0.0
class _Edge(NamedTuple):
"""
A vertical edge of a rectangle: the x at which it occurs, the y-span it\n
affects, and the +1 (left edge) or -1 (right edge) it contributes.\n
"""
x: float
y_low: float
y_high: float
delta: int
def union_area(rectangles: Sequence[Rectangle]) -> float:
"""
The total area covered by the union of `rectangles` (overlaps counted\n
once). Returns 0 for an empty input.\n
"""
# split each non-degenerate rectangle into a +1 left edge and -1 right edge.
edges: list[_Edge] = []
y_values: list[float] = []
for rectangle in rectangles:
if rectangle.x1 < rectangle.x2 and rectangle.y1 < rectangle.y2:
edges.append(_Edge(rectangle.x1, rectangle.y1, rectangle.y2, +1))
edges.append(_Edge(rectangle.x2, rectangle.y1, rectangle.y2, -1))
y_values.append(rectangle.y1)
y_values.append(rectangle.y2)
if not edges:
return 0.0
# compress the y-axis and sweep the edges left to right.
compressed: list[float] = sorted(set(y_values))
tree = _CoverageTree(compressed)
edges.sort(key=lambda edge: edge.x)
area: float = 0.0
previous_x: float = edges[0].x
for edge in edges:
# accumulate the slab [previous_x, edge.x): covered y-length times width.
area += tree.covered * (edge.x - previous_x)
previous_x = edge.x
# apply this edge's +1/-1 over its compressed y-range.
low_index: int = bisect_left(compressed, edge.y_low)
high_index: int = bisect_left(compressed, edge.y_high)
tree.update(low_index, high_index, edge.delta)
return area
def union_area_naive(
rectangles: Sequence[Rectangle],
precision: int = 0,
) -> float:
"""
Brute-force reference: compress both axes into a grid of cells and sum the\n
area of every cell covered by at least one rectangle.\n
"""
# compress both axes into the grid of cell boundaries.
x_values: list[float] = sorted(
{rectangle.x1 for rectangle in rectangles}
| {rectangle.x2 for rectangle in rectangles}
)
y_values: list[float] = sorted(
{rectangle.y1 for rectangle in rectangles}
| {rectangle.y2 for rectangle in rectangles}
)
area: float = 0.0
for column in range(len(x_values) - 1):
cell_x1: float = x_values[column]
cell_x2: float = x_values[column + 1]
for row in range(len(y_values) - 1):
cell_y1: float = y_values[row]
cell_y2: float = y_values[row + 1]
# add a cell's area if its center sits inside any rectangle.
mid_x: float = (cell_x1 + cell_x2) / 2
mid_y: float = (cell_y1 + cell_y2) / 2
for rectangle in rectangles:
if (
rectangle.x1 <= mid_x <= rectangle.x2
and rectangle.y1 <= mid_y <= rectangle.y2
):
area += (cell_x2 - cell_x1) * (cell_y2 - cell_y1)
break
return round(area, precision) if precision else area
Optimal intersection and the sweep's reach
Bentley and Ottmann introduced the segment-intersection sweep in 1979, and its
O((n+k)logn) bound is the one the textbooks teach.4 But that bound is not
optimal: the logn multiplies the outputk as well as the input, so on a set
with k=Θ(n2) crossings the sweep costs Θ(n2logn), a full log
factor above the Θ(n2) needed just to list the answer. The natural target
is O(nlogn+k) — the sort, plus constant work per reported crossing.
That optimum was reached in stages. Chazelle and Edelsbrunner (1992) gave the first
O(nlogn+k) algorithm, though it needed O(n) working space and an
intricate construction.5 Balaban (1995) then produced a cleaner deterministic
O(nlogn+k)-time, O(n)-space algorithm, and simple randomized incremental
methods hit the same expected bound.6 The lesson's Bentley–Ottmann sweep
remains the one to reach for in practice: it is simple, its logn overhead is
negligible unless crossings are dense, and its status-structure idea is the
reusable part.
The segment-intersection bounds: naive all-pairs, the Bentley--Ottmann sweep, and the optimal output-sensitive result.
The plane-sweep paradigm reaches far past intersection. The same left-to-right line
with an ordered status structure builds the Voronoi diagram of n sites in
O(nlogn) via Fortune's algorithm (1987), where the status is the parabolic
beach line and events are site arrivals and arc disappearances.7 It also
drives Delaunay triangulation, trapezoidal decomposition, and map overlay — the
core tools behind the proximity structures
in the next lesson. Once a problem's answer changes only at a discrete set of
x-coordinates and depends only on locally adjacent objects, the sweep is almost
always the right approach.
Takeaways
The plane-sweep paradigm reduces a 2-D geometry problem to a 1-D ordered-set
problem by advancing a vertical line through an x-sorted event queue while a
status structure holds the objects crossing the line, ordered by y.
The geometry is paid only at events and only against adjacent objects in
the status, turning Θ(n2) all-pairs work into near-O(nlogn) sweeps.
Bentley–Ottmann reports all k segment crossings in O((n+k)logn): events
are endpoints plus discovered intersections, and the adjacency invariant means
only neighboring segments can next cross.
Closest pair sweeps a dynamic δ-strip — a balanced y-set queried in a
δ×2δ window holding O(1) points — for O(nlogn).
Interval and rectangle sweeps use +1/−1 events: a running sum gives maximum
overlap and coverage, a multiset gives the skyline, and a segment tree
over compressed y gives the union of rectangle areas.
Footnotes
CLRS, Ch. 33 — Computational Geometry (§33.2): the sweep with an event queue and a y-ordered status, and segment-intersection in O(nlogn). ↩↩2
Skiena, § — Sweepline / Geometry: plane-sweep as a general technique; events advance a status structure tracking active objects. ↩
Erickson, Ch. — (geometry): closest-pair and the packing argument bounding a δ-rectangle to O(1) points. ↩
Jon L. Bentley and Thomas A. Ottmann, Algorithms for Reporting and Counting Geometric Intersections,IEEE Transactions on Computers C-28(9), 1979 — the original O((n+k)logn) segment-intersection sweep. ↩
Bernard Chazelle and Herbert Edelsbrunner, An Optimal Algorithm for Intersecting Line Segments in the Plane,Journal of the ACM 39(1), 1992 — the first O(nlogn+k)-time output-sensitive algorithm. ↩
Ivan J. Balaban, An Optimal Algorithm for Finding Segments Intersections,Proc. 11th Symposium on Computational Geometry, 1995 — a deterministic O(nlogn+k)-time, O(n)-space algorithm. ↩
Steven Fortune, A Sweepline Algorithm for Voronoi Diagrams,Algorithmica 2, 1987 — the beach-line sweep computing the Voronoi diagram of n sites in O(nlogn). ↩