Computational Geometry
By the end of this lesson you will be able to decide, using nothing but integer multiplication, whether a point turns left or right of a line (the cross product), whether two segments touch (segmentsIntersect), whether ANY pair among n segments touches (a sweep line with a status set), how to wrap a fence around a cloud of points two different ways (grahamScan and jarvisMarch), and how to find the two closest points in O(n lg n) with divide and conquer. Every procedure runs on a picture you can step through, and you can feed each one your own points.
0. Points, segments and why we avoid division
Every picture in this lesson puts x to the right and y upward on the page. Every coordinate is a whole number. That is deliberate: the tests below use only addition, subtraction and multiplication of integers, so answers are exact. The obvious school method — compute the slope y₂−y₁ over x₂−x₁ — needs division, which turns into rounded decimals in a computer, breaks when a segment is vertical (division by zero), and can give the wrong answer when two points are only barely apart. Careful geometry code avoids it, and so do we.
anySegmentsIntersect assumes no segment is vertical — the custom-input box rejects vertical segments — and the picture also runs a brute-force check at the end so you can see the sweep agree with it.In Dart we store a point as a tiny class Pt with integer fields x and y. It has value equality (two Pt(3,4) are equal), which makes sets and maps of points easy. Squared distance dist2 is used everywhere instead of the real distance, because the square root is the only non-integer step and comparing squares gives the same order.
Why integers and not slopes? Here is the failure in code: a vertical segment has no slope, and two different slopes can round to the same double. The cross-product test (introduced next) uses no division and tells them apart.
1a. Cross product and direction(pi, pj, pk)
Make pi the origin: the arrow to pk is (pk−pi) and the arrow to pj is (pj−pi). Their cross product is a single number, the (signed) area of the parallelogram the two arrows span: (xk−xi)(yj−yi) − (xj−xi)(yk−yi). We call this direction. Its sign is the whole answer:
< 0 → counterclockwise: the walk turns left.
= 0 → the three points are collinear (on one straight line; two of them may even coincide).
Swapping pj and pk flips the sign — the Dart file checks this on thousands of random triples.
The pictures walk through the pseudocode: first the two arrows from pi, then the crosswise multiplication, then the verdict taken from the sign:
The next three pictures show the sign as a map. Fix the directed line from pi to pj, then test extra points one at a time: every point on the left side gives a negative direction, on the right side a positive one, exactly on the line zero. The last one accepts your own pi, pj and up to six test points.
direction computes it with the vectors in the order (pk−pi) then (pj−pi), so a positive value means sin θ > 0 for the angle from pk to pj — pk lies clockwise of pj. Overflow: with |coordinates| ≤ 109 the result still fits a 64-bit Dart int (checked against BigInt in the verify file); on the web, Dart int is a JavaScript number, so keep coordinates below about 3·107 there.Dart implementation (points are Pt objects; direction uses no arrays, so there are no indices to worry about):
The maths as code. The cross product of two arrows, direction written through it, the meaning of its sign, the "area of the parallelogram" and "|a||b| sin θ" facts, and the swap-flips-the-sign rule are all computed and compared here:
Overflow bound as code. With |coordinates| ≤ C every difference is ≤ 2C, every product ≤ 4C² and the result ≤ 8C²; the code finds the largest C for which that fits a 64-bit int and checks direction against BigInt:
Input size → what's feasible. one direction is Θ(1) (about 9 operations): Q = 107 calls ≈ 108 steps → fine in one second; coordinates up to 109 are exact in a native 64-bit int, but up to only about 3·107 on the web (JavaScript numbers).
1b. onSegment(pi, pj, pk)
Notice the precondition: onSegment only makes sense when pk is collinear with pi and pj (direction = 0). It never checks that itself — segmentsIntersect calls it only after direction returned 0. Only the bounding box is tested, which is a shortcut that is correct because the point is already on the line.
onSegment never checks collinearity. Take pi = (1,1), pj = (7,4) and pk = (5,1): pk is inside the bounding box (1 ≤ 5 ≤ 7 and 1 ≤ 1 ≤ 4) so onSegment says TRUE, yet direction is not 0 and the point is nowhere near the string. Always ask direction = 0 first.direction = 0) and inside its bounding box (onSegment). Neither test alone is enough.Dart implementation (min and max come from dart:math):
The pitfall above in code: the bounding-box test alone accepts a point that is not on the string; combine it with direction = 0. A lattice-point enumeration (pi + t·(dx/g, dy/g)) serves as the independent reference:
Input size → what's feasible. Θ(1) per call (4 comparisons): 107 calls ≈ 4·107 steps → fine.
1c. segmentsIntersect(p1, p2, p3, p4)
onSegment.The four direction values d1..d4 say which side each endpoint is on (or 0 for "exactly on the line"). Line 5 is the clean crossing test; lines 7–13 handle each endpoint lying on the other segment.
onSegment checks decide — all four fail, so line 15 returns FALSE.onSegment (lines 7–14). Otherwise they are disjoint (line 15). The Dart file compares this procedure with an independent exact parametric solver on 40,000 random segment pairs (small coordinates on purpose, so touching and collinear cases are frequent).Dart implementation:
Which return row fires for a given input (6, 8, 10, 12, 14 or 15) — the same case analysis as the pseudocode, as a function:
Input size → what's feasible. Θ(1) per pair: 106 pairs ≈ 6·107 steps → fine. Testing all pairs of n = 104 segments is n²/2 = 5·107 calls (fine), but n = 105 is 5·109 calls → use the sweep of section 2.
2. anySegmentsIntersect(S): the sweep line
The sweep stops at event points: the 2n segment endpoints sorted left to right (ties: left endpoints before right endpoints, then lower y first). The status is a sorted set that supports insert, delete, find-the-neighbour-above and find-the-neighbour-below; a red-black tree (or any balanced search tree) makes each of them cost O(lg n). In the pictures the letters give segment names, the green segments are those currently in T, grey ones are finished, the highlighted one is the segment of the current event, and yellow marks the neighbour being tested.
segmentsIntersect calls: O(n lg n) in total. Correctness idea: look at the leftmost intersection point q. Just before the sweep line reaches q, the two segments that meet there have become neighbours in T (nothing can lie between them or the intersection would not be the leftmost), and the event that made them neighbours (an insert or a delete) tested them — so the procedure returns TRUE at or before q. If no intersection exists, every test returns FALSE, and it correctly reaches the final return false (row 15). The procedure only says whether an intersection exists; reporting all k of them needs Bentley–Ottmann (Expert question).Dart implementation. The status T is a SplayTreeMap (a balanced search tree from dart:collection) keyed by segment index, whose order is "height at the current sweep position": compareAt compares two segments' heights exactly by cross-multiplication instead of division, ties are broken by slope just before (DELETE) or just after (INSERT) the sweep line, then by index. firstKeyAfter and lastKeyBefore give the neighbour above and the neighbour below, so every insert, delete and neighbour lookup costs O(lg n) and the whole run is O(n lg n):
The cost claim as code: an instrumented copy of the sweep counts events and segmentsIntersect tests (2n events, at most 2 tests per insert and 1 per delete, so ≤ 3n tests against n(n−1)/2 pairs):
Input size → what's feasible. n = 105 segments → 2n = 2·105 events × lg n ≈ 17 = 3.4·106 tree steps → fine (all pairs would be 5·109). Keep |coordinates| ≤ 105: the exact height comparison multiplies values up to 6·1010 by 2·105, which would overflow 64 bits near 109.
3a. grahamScan(Q)
grahamScan builds that band by walking round the nails in angle order, keeping a stack (a pile where you only touch the top) of "nails on the band so far", and undoing a step (popping) whenever the newest nail would make the band bend the wrong way.Step by step: (1) pick p0, the lowest point (leftmost if tied) — it is certainly on the hull; (2) sort all other points by the angle they make around p0, counterclockwise, using direction as the comparator (no trigonometry!); if several points have the same angle keep only the farthest — the nearer ones lie on the segment to it, never at a corner; (3) push p0 and the first two sorted points r[0], r[1]; (4) for each next sorted point r[i], while the top two stack points and the new point do not make a left turn (direction ≥ 0), pop the top; then push the new point. What remains is the hull in counterclockwise order.
direction comparator, not with atan2: angles are floating point, so nearly-equal angles can tie or swap and the scan silently produces a wrong hull. Points with exactly the same angle from p0 (direction = 0) must be ordered by distance, and only the farthest is kept (row 3 of the pseudocode).| Rows | Work | Cost |
|---|---|---|
| 1 | one pass to find p0 | Θ(n) |
| 2 | sort by polar angle (comparator = one direction) | Θ(n lg n) |
| 3 | one pass dropping the nearer of equal-angle points | Θ(n) |
| 7 | the initial stack of three points | Θ(1) |
| 8–11 | every point is pushed once (≤ m pushes) and popped at most once (≤ m − 2 pops), so the while loop's total work over the whole for loop is linear (amortized) | Θ(n) |
| Total | O(n lg n), dominated by the sort | |
r[0..i−1], in counterclockwise order. Popping removes any top vertex that the new point would swallow (a right turn or straight line), so the invariant is restored with r[i] on top. Degenerate case: if after the same-angle filter fewer than two sorted points remain (all points collinear with p0), the procedure returns an empty list — the hull is only a segment, not a polygon.Dart implementation. The pseudocode and the Dart code use the same names: the pivot is p0, the sorted points are r[0], r[1], …, so the loop is for (var i = 2; i < r.length; i++) and the three initial pushes are [p0, r[0], r[1]]. The Dart code also merges duplicate points first (toSet) and returns [] for fewer than three distinct points.
Rows 2–3 as code: the direction comparator, the tempting atan2 comparator that fails on nearly equal angles, a comparison counter (≈ n lg n), and the "keep the farthest on a ray" filter:
The loop invariant and the amortized cost as code: scanSorted is the scan loop (rows 7–11 of the pseudocode) with a hook that checks, at the start of every iteration, that the stack is exactly the hull of the points seen so far, and counters that confirm pushes ≤ m + 1 and pops ≤ pushes − 3:
Input size → what's feasible. n = 2·105 → n lg n ≈ 3.5·106 comparisons, whatever the hull looks like; n = 106 still ≈ 2·107 → fine. The scan uses an explicit stack, so no recursion-depth limit.
3b. jarvisMarch(Q)
grahamScan.How one corner is found (rows 5–9): guess that next is any other point; then look at every point q — if q is on the right of the arrow cur→next (direction > 0), the guess was not extreme enough, so q becomes the new guess; if q is exactly on that line, keep the farther one (so points in the middle of a hull edge are skipped, like grahamScan does). After the loop, every point is on the left of (or on) cur→next, which is the definition of a hull edge.
| Points n | Hull corners h | jarvisMarch ≈ n·h | grahamScan ≈ n·lg n | Faster |
|---|---|---|---|---|
| 1000 | 4 | 4,000 | ≈ 10,000 | Jarvis |
| 1000 | 10 | 10,000 | ≈ 10,000 | tie |
| 1000 | 1000 (all on a circle) | 1,000,000 | ≈ 10,000 | Graham |
jarvisMarch does one full scan of all n points per hull corner, so its cost depends on the answer size h: fast when the hull is small, O(n²) when every point is a corner. grahamScan's cost depends only on n.Dart implementation (points stay in a normal 0-indexed List):
The O(nh) claim as code: an instrumented copy counts the direction tests (exactly h·(n−1)) and compares them with grahamScan's n lg n:
Why n lg n is optimal for large hulls (the lower bound in the cheat sheet), as code: sort numbers by hulling the points (a, a²):
Input size → what's feasible. n = 106 points with h ≤ 20 → n·h = 2·107 → fine; h = n = 104 → 108 (borderline); h = n = 105 → 1010 (too slow) → use grahamScan.
4. Finding the closest pair of points
x (the points sorted by x) and y (sorted by y) are built once, and the recursion splits both arrays in linear time, never re-sorting.| Level | Calls | Size of each | Work at this level |
|---|---|---|---|
| 0 | 1 | 16 | ≈ 16 |
| 1 | 2 | 8 | ≈ 16 |
| 2 | 4 | 4 | ≈ 16 |
| 3 | 8 | 2–3 (base case ≤ 3) | ≈ 16 |
| lg n levels × n per level | Θ(n lg n) (plus O(n lg n) for the two initial sorts) | ||
Dart implementation. Two recursion-helpers give the classic "wrapper + recursive function" shape. closestPair builds X and Y once; closestRec is the recursive step. Splitting Y into YL and YR needs to know which points went left; because Pt has value equality (duplicates are indistinguishable) we count how many copies of each point are in XL. The result is a Dart record (squaredDistance, pointA, pointB):
The recurrence T(n) = 2T(n/2) + O(n) as code: the recursive cost function, its closed form n⌈lg n⌉ − 2⌈lg n⌉ + n (exactly n lg n for powers of 2), and the slower re-sorting variant that costs a factor lg n more:
The "only 7" bound as code: an exhaustive search shows a δ×δ square holds at most 4 points that are pairwise ≥ δ apart, so the δ×2δ window holds at most 8:
Input size → what's feasible. n = 2·105 → n lg n ≈ 3.5·106 distance tests → fine; all n²/2 = 2·1010 pairs is too slow; for n ≤ 3000 even the brute force (4.5·106 pairs) is fine.
Quiz
Interview questions
Cheat sheet
| Procedure | Time | Space | Handles collinear / touching? | When to use |
|---|---|---|---|---|
| direction (cross product) | Θ(1) | O(1) | Yes: 0 means collinear | Every orientation / turn / side-of-line test; exact with integers |
| onSegment | Θ(1) | O(1) | Only valid for a point already collinear | Second half of the touching test |
segmentsIntersect | Θ(1) | O(1) | Yes, including endpoint touching and overlap | Is this pair of segments crossing? |
anySegmentsIntersect | O(n lg n) with a balanced tree | O(n) | Assumes no vertical segments; ties handled by the event order | Do ANY two of n segments meet? (not all k) |
grahamScan | O(n lg n) | O(n) | Pops collinear points; all-collinear input gives an empty list | General hull; predictable time |
jarvisMarch | O(nh) | O(h) | Takes the farthest of collinear points | Hull expected to have very few corners |
| Closest pair (divide and conquer) | O(n lg n) | O(n) | Coincident points give distance 0 | Nearest neighbours, collision detection |
| Convex hull lower bound | Ω(n lg n) in the comparison model (sorting reduces to hull), so grahamScan is optimal when h is large | |||