A13 · Part 3 · Advanced
Sweep line, offline queries and geometry
Sort the moments when something changes, keep a summary of what is active, and skylines, offline queries and most geometry questions become one pass.
15On this page
- The sweep-line recipe: events, a tie-break and an active set
- The skyline problem: a heap with lazy deletion
- Offline queries: answer them in the order that suits you
- Coordinate compression, exactly
- Rectangle union: the simple way and the segment tree
- Geometry in integers: cross product and orientation
- Polygon area by the shoelace formula
- Point in polygon by ray casting
- Rectangles: overlap, area and the perfect rectangle
- Maximum points on a line: hash the direction, not the slope
- Valid square without a single square root
- Convex hull by monotone chain (erect the fence)
- Closest pair of points
- Floating point: why geometry stays in integers
- Recognizing it in an unseen problem
The sweep-line recipe: events, a tie-break and an active set
Picture a vertical line that starts at the far left of the plane and moves right. Almost everywhere nothing happens as it moves: the same shapes are under it a moment later as a moment ago. It only matters at a handful of places, where a shape begins or ends. A sweep line is the habit of looking only at those places. You stop at each one, update what the line is touching, and answer the question from that. The line can just as well be a timeline, a pointer over sorted queries, or a row scanning a grid; the idea is to sort the moments that matter and keep a summary of "what is active right now".
The intervals chapter already uses this with the simplest possible summary, a counter: meeting rooms, car pooling and difference arrays are all sweeps where the active set is just a number. This chapter starts where a number stops being enough. The recipe is the same four steps every time:
- Make events. Turn each object into the coordinates where it starts or stops mattering, carrying whatever the update needs (a height, a weight, an index).
- Sort the events, with an explicit rule for what happens when two events share a coordinate.
- Walk the events in order and update the active set.
- Read the answer from the active set at the right moments: after every event, after every group of events at one coordinate, or between events when the answer accumulates over a stretch.
What changes from problem to problem is the structure that holds the active set. Choose it by the question you will ask of the set:
| The question asked of the active set | Structure | Where it appears |
|---|---|---|
| How many are active? | a counter | meeting rooms, car pooling (intervals chapter) |
| What is the largest or smallest value among the active ones? | a heap, with lazy deletion | skyline, minimum interval for each query (below) |
| What is the nearest active value above or below this one, or remove one specific entry? | an ordered set | the ordered structures chapter |
| How much total length is covered? | a segment tree over compressed coordinates | rectangle union (below) |
The tie-break is part of the algorithm
When a start and an end fall on the same coordinate, which one is processed
first changes the answer, and the right choice comes from the question and
not from a trick. Ask whether a thing that ends at x is still
there at x. If the interval is closed, [1, 2] still
contains 2, so a start at 2 should see it: process starts first. If it is
half-open, [1, 2) no longer contains 2, so process ends first,
which is what the intervals chapter does. Writing the order as named numbers
keeps the choice visible in the code:
const START = 0, END = 1; // the order of these two numbers IS the tie-break
function maxOverlapClosed(intervals) {
const events = [];
for (const [from, to] of intervals) {
events.push([from, START]);
events.push([to, END]);
}
events.sort((a, b) => a[0] - b[0] || a[1] - b[1]); // same time: starts first, so [1, 2] and [2, 3] touch
let active = 0, best = 0;
for (const [, kind] of events) {
active += kind === START ? 1 : -1;
best = Math.max(best, active);
}
return best;
}
For [[1, 2], [2, 3]] the events sort to (1, START),
(2, START), (2, END), (3, END), so the
count reaches 2: the two closed intervals share the point 2. Swapping the two
constants gives 1, the answer for half-open intervals. The sort is
O(n log n) and the walk is linear.
There are two ways to avoid tie-breaking altogether, and both appear below. One is to process all events at a coordinate before reading the answer, as the skyline does. The other is to read the answer only between coordinates, as the area sweep does: between two events at the same coordinate no width passes, so their order cannot change the total.
⚠ Sort numbers with a comparator that returns a number
[10, 9, 1, 100].sort() returns [1, 10, 100, 9],
because the default sort compares text. A comparator that returns a boolean,
such as (a, b) => a > b, never returns a negative number and
leaves [3, 1, 2] unsorted. Write (a, b) => a - b,
and chain tie-breaks with || as above.
Say it like this → "I'll turn each object into two
events, sort them with an explicit rule for ties, and sweep while keeping the
active ones in a structure that can answer the one question I need, like the
largest height right now."
The skyline problem: a heap with lazy deletion
Each building is [left, right, height]: it stands on the ground
from left up to, but not including, right. Draw them
all and trace the outline against the sky. Report that outline as the list of
key points [x, height], where each key point is the left
end of a flat stretch, and the last one has height 0. The rule that makes the
answer unique: when two neighbouring stretches have the same height, they are
one stretch, so no key point appears between them.
Why the obvious way fails. You could compute the height at every integer
x by scanning all buildings, then compress runs. That costs the
width of the whole range times n, and the range can be 231 wide.
The idea. The height at x is the maximum height over the
buildings with left ≤ x < right. That maximum can only change
at a coordinate where some building starts or ends, so the outline is decided
by at most 2n coordinates. Sweep over them in order, keep the buildings
covering the line in a structure that can say "the tallest", and whenever the
tallest height differs from the height just before, record [x, height].
The active set needs three operations: insert a building when its left edge is reached, read the maximum height, and remove a building when its right edge is passed. A heap does the first two in O(log n) and O(1), but it cannot remove an arbitrary entry cheaply. The fix is lazy deletion: do not remove it. Leave the dead building where it is, and discard it only when it reaches the top, where it is about to be read. This is correct for a simple reason: you only ever read the top. A dead entry underneath a live top cannot change the maximum, and a dead entry on top is thrown away before you look. It also requires that death is permanent, and it is: the sweep only moves right, so a building that has ended never comes back.
class Heap {
constructor(before) {
this.before = before; // before(a, b) is true when a must leave the heap earlier than b
this.items = [];
}
get size() {
return this.items.length;
}
peek() {
return this.items[0];
}
push(value) {
const a = this.items;
a.push(value);
let i = a.length - 1;
while (i > 0) {
const parent = (i - 1) >> 1;
if (!this.before(a[i], a[parent])) break;
[a[parent], a[i]] = [a[i], a[parent]];
i = parent;
}
}
pop() {
const a = this.items;
const top = a[0];
const last = a.pop();
if (a.length > 0) {
a[0] = last;
let i = 0;
for (;;) {
const left = 2 * i + 1, right = left + 1;
let first = i;
if (left < a.length && this.before(a[left], a[first])) first = left;
if (right < a.length && this.before(a[right], a[first])) first = right;
if (first === i) break;
[a[first], a[i]] = [a[i], a[first]];
i = first;
}
}
return top;
}
}
function getSkyline(buildings) {
const xs = [...new Set(buildings.flatMap(([left, right]) => [left, right]))].sort((a, b) => a - b);
const byLeft = [...buildings].sort((a, b) => a[0] - b[0]);
const alive = new Heap((a, b) => a[0] > b[0]); // entries [height, right]; the tallest leaves first
const skyline = [];
let next = 0, previous = 0;
for (const x of xs) {
while (next < byLeft.length && byLeft[next][0] <= x) {
const [, right, height] = byLeft[next++];
alive.push([height, right]);
}
while (alive.size > 0 && alive.peek()[1] <= x) alive.pop(); // lazy deletion: only a dead TOP has to go
const height = alive.size > 0 ? alive.peek()[0] : 0;
if (height !== previous) {
skyline.push([x, height]);
previous = height;
}
}
return skyline;
}
The heap holds [height, right] pairs, so a building carries its
own end with it, and "dead" simply means right <= x. The
comparison is <= because the building is half-open: at
x === right it no longer covers anything. All buildings that start
at or before x are pushed first and all dead tops are popped next,
and only then is the height read. That order is what makes ties harmless: a
building ending at x and another starting at x are both
dealt with before the single read at x.
Dry run: [[2, 9, 10], [3, 7, 15], [5, 12, 12], [15, 20, 10], [19, 24, 8]]
| x | pushed | popped | heap after, tallest first | height | key point |
|---|---|---|---|---|---|
| 2 | [10, 9] | none | [10, 9] | 10 | [2, 10] |
| 3 | [15, 7] | none | [15, 7] [10, 9] | 15 | [3, 15] |
| 5 | [12, 12] | none | [15, 7] [12, 12] [10, 9] | 15 | none, same height |
| 7 | none | [15, 7] | [12, 12] [10, 9] | 12 | [7, 12] |
| 9 | none | none | [12, 12] [10, 9] | 12 | none; [10, 9] is dead but buried |
| 12 | none | [12, 12] and [10, 9] | empty | 0 | [12, 0] |
| 15 | [10, 20] | none | [10, 20] | 10 | [15, 10] |
| 19 | [8, 24] | none | [10, 20] [8, 24] | 10 | none |
| 20 | none | [10, 20] | [8, 24] | 8 | [20, 8] |
| 24 | none | [8, 24] | empty | 0 | [24, 0] |
The answer is [[2, 10], [3, 15], [7, 12], [12, 0], [15, 10], [20, 8], [24, 0]].
Watch x = 9: the building [10, 9] has ended, but it
sits under [12, 12], so nobody notices and nobody pays for it. It is
only removed at x = 12, when it surfaces together with the building
on top of it. That is lazy deletion working as intended.
Cost. Sorting the coordinates is O(n log n). Each building is pushed once and popped at most once, each in O(log n), so the sweep is O(n log n) too. The heap never holds more than n entries, so the space is O(n).
The ordered-set alternative. Instead of a heap you can keep the heights of the active buildings in a sorted multiset: insert at the left edge, remove that exact height at the right edge, read the largest. Nothing is left dead, so there is no lazy deletion, and the same O(n log n) holds. JavaScript has no built-in ordered set, which is why the heap version is the one to write in an interview; the ordered structures chapter builds the structure if you want the other version.
⚠ Skyline pitfalls
Emit a key point only when the height differs from the previous emitted
height; otherwise two equal-height buildings that touch produce a bogus point.
Compare with
right <= x and not <, or a building
that has just ended still holds the outline up for one more key point. Keep the
final drop to 0: the heap becoming empty gives height 0, which differs from the
last height, so the closing point appears by itself. And sort the coordinates
with a numeric comparator.
Offline queries: answer them in the order that suits you
A static pile of intervals and a list of questions about it: how many cover this time, which one is the smallest that contains this point. Answering each question by scanning the intervals costs O(n) per question and O(n · q) in all. The way out is the word offline. If you get all the questions before you must answer any, you are free to answer them in any order you like, as long as each answer is written back to its original slot. The order worth choosing is usually sorted by the thing being asked, because then the sweep above applies: the "line" is the query value moving upward.
To answer in a different order, sort indices rather than values:
order = queries.map((_, i) => i).sort((a, b) => queries[a] - queries[b]).
Then answer[order[k]] is where the k-th smallest query's answer goes.
Sorting the query values themselves loses the positions and you cannot undo it.
Number of flowers in full bloom: a count is a difference of two counts
Each flower is in bloom on the closed range [start, end]. For each
visitor arriving at time t, report how many flowers are in bloom.
A flower is in bloom at t when start ≤ t and
t ≤ end. Count the flowers that have opened by t
(start ≤ t) and subtract the ones that have closed before
t (end < t). A flower that closed before
t opened before t too, so every subtracted flower was
counted, and what remains is exactly the flowers in bloom. Each of the two counts
is "how many values in a sorted array are at most (or below) t", which is a binary
search.
function countAtMost(sorted, t) {
let lo = 0, hi = sorted.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (sorted[mid] <= t) lo = mid + 1;
else hi = mid;
}
return lo; // how many values are <= t
}
function countBelow(sorted, t) {
let lo = 0, hi = sorted.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (sorted[mid] < t) lo = mid + 1;
else hi = mid;
}
return lo; // how many values are < t
}
function fullBloomFlowers(flowers, people) {
const starts = flowers.map(([start]) => start).sort((a, b) => a - b);
const ends = flowers.map(([, end]) => end).sort((a, b) => a - b);
return people.map((t) => countAtMost(starts, t) - countBelow(ends, t)); // opened by t, minus closed before t
}
Dry run: flowers [[1, 6], [3, 7], [9, 12], [4, 13]], visitors [2, 3, 7, 11]
starts = [1, 3, 4, 9], ends = [6, 7, 12, 13]
| t | starts ≤ t | ends < t | in bloom |
|---|---|---|---|
| 2 | 1 (just 1) | 0 | 1 |
| 3 | 2 (1 and 3) | 0 | 2 |
| 7 | 3 (1, 3, 4) | 1 (just 6) | 2 |
| 11 | 4 | 2 (6 and 7) | 2 |
The answer is [1, 2, 2, 2]. Look at t = 7: the flower
[3, 7] ends at 7 and is still in bloom, which is why the second
count uses end < t and not end <= t. Using the
same comparison in both helpers is the standard off-by-one here. Sorting is
O(n log n) and each visitor costs two binary searches, so the total is
O((n + q) log n), with O(n) extra space. This version does not even need the
queries sorted, because binary search answers any order.
Minimum interval to include each query: the sweep with a heap
Now the question changes to: for each query point q, what is the size
(right - left + 1) of the smallest interval that contains
it, or -1 if there is none? A count could be split into two counts; a minimum
cannot. So keep the intervals that currently contain q in a
min-heap keyed by size, and sort the queries so that q only grows.
Sorted this way, two things happen as q increases. Intervals with
left ≤ q become candidates, and they stay candidates until they end.
Intervals with right < q are useless for this query and also for
every later one, since later queries are larger. That permanent death is exactly
what lazy deletion needs. So: push every interval whose left end is at or before
q, pop dead tops, and the top is the answer. The code reuses the
Heap class from the skyline, here ordered so that the smallest size
comes out first.
function minInterval(intervals, queries) {
const byLeft = [...intervals].sort((a, b) => a[0] - b[0]);
const order = queries.map((_, i) => i).sort((a, b) => queries[a] - queries[b]); // query indices, smallest query first
const open = new Heap((a, b) => a[0] < b[0]); // entries [size, right]; the smallest size leaves first
const answer = new Array(queries.length).fill(-1);
let next = 0;
for (const qi of order) {
const q = queries[qi];
while (next < byLeft.length && byLeft[next][0] <= q) {
const [left, right] = byLeft[next++];
open.push([right - left + 1, right]);
}
while (open.size > 0 && open.peek()[1] < q) open.pop(); // ended before q, so it also ends before every later query
if (open.size > 0) answer[qi] = open.peek()[0];
}
return answer;
}
Dry run: intervals [[1, 4], [2, 4], [3, 6], [4, 4]], queries [2, 3, 4, 5]
Heap entries are [size, right]; the queries are already in sorted order here.
| q | pushed | popped | heap after, smallest first | answer |
|---|---|---|---|---|
| 2 | [4, 4] and [3, 4] | none | [3, 4] [4, 4] | 3 |
| 3 | [4, 6] | none | [3, 4] [4, 4] [4, 6] | 3 |
| 4 | [1, 4] | none | [1, 4] [3, 4] [4, 4] [4, 6] | 1 |
| 5 | none | [1, 4] and [3, 4] | [4, 6] on top, with the dead [4, 4] buried under it | 4 |
The result is [3, 3, 1, 4]. At q = 5 the entries
[1, 4] and [3, 4] sit on top and both ended at 4, so
both are popped. Two entries of size 4 remain: [4, 6], which is
still alive, and [4, 4], which is dead. This run leaves the live one
on top, so the loop stops and the answer is 4; had the dead one been on top, it
would have been popped next and the answer would be the same. A dead entry that
stays buried is harmless, which is the whole point of deleting lazily.
The cost is one sort of the intervals, one sort of the query indices, and a push
and a pop per interval, so O((n + q) log(n + q)) time and O(n + q) space. The
pitfalls are the ones to rehearse. Write the answer at answer[qi],
not at the sorted position. Pop on right < q, not
<=, because the range is closed. And do not forget the -1 default
for a query that no interval reaches.
When offline is not available
Offline needs every query up front, and no query may depend on an earlier answer. If the queries arrive one at a time and each must be answered before the next appears, there is no sorted order to exploit, and you need a structure that answers any query on demand, such as the trees in the segment and Fenwick tree chapter. Ask the interviewer which case you are in. "Are all queries given up front?" is often the most valuable single question in these problems.
Say it like this → "Since all the queries are known up
front, I'll sort them and sweep. As the query value only increases, an interval
that has ended never matters again, so I can delete lazily from the heap, and
I'll keep each query's original index to put the answers back in order."
Coordinate compression, exactly
Many of these problems give coordinates up to 109 but only a few hundred or thousand distinct ones. The algorithm cannot index an array by such a coordinate, but it does not need to: a sweep, a sort or a segment tree only asks whether one coordinate is smaller than another. Coordinate compression replaces each value by its rank among the distinct values, which keeps every comparison and shrinks the range to at most the number of distinct values.
function compress(values) {
const sorted = [...new Set(values)].sort((a, b) => a - b); // the comparator matters: the default sort is textual
const rank = new Map(sorted.map((value, i) => [value, i]));
return { sorted, rank };
}
function rankTransform(array) {
const { rank } = compress(array);
return array.map((value) => rank.get(value) + 1);
}
For [100, 7, 1000000000, 7, 42] the distinct sorted values are
[7, 42, 100, 1000000000], so the ranks are 0, 1, 2 and 3, and
rankTransform returns [3, 1, 4, 1, 2] with ranks
starting at 1. Equal values get equal ranks, and the order is preserved. The cost
is O(n log n) for the sort, and each later lookup in the Map is O(1)
on average. If you need the rank of a value that may not be in the set, such as a
query coordinate, binary search the sorted array instead: the number of distinct
values below it, countBelow(sorted, v) from above, is its rank.
What compression keeps and what it destroys. Order survives; distances do
not. Two coordinates one apart and two coordinates a billion apart can be
neighbours after compression. Anything that depends on a distance must go back
to the original values, and the sorted array is how: the length of the gap
between rank i and rank i + 1 is
sorted[i + 1] - sorted[i]. The rectangle union below uses exactly
that.
⚠ Compression pitfalls
Include both endpoints of every interval or rectangle, not only the
starts. Remove duplicates before ranking, or equal values get different ranks.
Sort with
(a, b) => a - b. And mind closed against half-open:
if closed integer intervals [l, r] must keep the unit gaps between
them, add r + 1 to the values and treat them as
[l, r + 1).
Rectangle union: the simple way and the segment tree
Given n axis-aligned rectangles [x1, y1, x2, y2], find the area of
their union, with overlaps counted once. Adding the areas counts overlaps
several times, and correcting for them by inclusion and exclusion is
exponential. Counting grid cells fails when the coordinates reach 109.
The idea. Sweep along x. Between two consecutive distinct x coordinates
(a slab) the set of rectangles that cover the slab does not change, so
the covered height is one number for the whole slab, and the slab contributes
width × covered height. The covered height is the total length of
the union of the y-ranges of those rectangles, which is the interval merge from
the intervals chapter. There are at most 2n - 1 slabs.
function unionAreaSimple(rects) {
const xs = [...new Set(rects.flatMap(([x1, , x2]) => [x1, x2]))].sort((a, b) => a - b);
const byBottom = [...rects].sort((a, b) => a[1] - b[1]); // sorted by y once, so each slab needs no sort
let total = 0n;
for (let i = 0; i + 1 < xs.length; i++) {
const left = xs[i], right = xs[i + 1];
let covered = 0, reach = -Infinity;
for (const [x1, y1, x2, y2] of byBottom) {
if (x1 > left || x2 < right || y2 <= reach) continue; // not covering this slab, or already inside what is covered
covered += y2 - Math.max(y1, reach); // count only the part above what is already covered
reach = y2;
}
total += BigInt(right - left) * BigInt(covered); // width times height can pass 2 ** 53
}
return total;
}
Sorting the rectangles by bottom edge once means every slab can merge the
y-ranges in one pass, with reach as the highest point covered so far:
a range lying entirely below reach adds nothing, and any other adds
only the part above reach. There are up to 2n - 1 slabs, and each
scans n rectangles, so this is O(n²) time and O(n) space. For rectangles
[[0, 0, 2, 2], [1, 0, 2, 3], [1, 0, 3, 1]] the slabs are
[0, 1], [1, 2] and [2, 3] with covered
heights 2, 3 and 1, so the area is 2 + 3 + 1 = 6.
Faster: let a segment tree keep the covered length. The quadratic version
recomputes the covered length from scratch for every slab, though only one or two
rectangles enter or leave between slabs. What is needed is a structure that
supports "add 1 to the cover of the y-range [a, b)", "subtract 1 from
it", and "total covered length right now". Compress the y coordinates first.
The elementary segments are the gaps between neighbouring distinct y
values, numbered 0 to m - 1, and a rectangle covers the segments from the rank of
its bottom to the rank of its top, exclusive.
Each tree node stands for a range of elementary segments and stores two numbers.
cover is how many rectangles have this node as one of the pieces
their y-range was split into, so each such rectangle covers the node's whole
range. length is the covered length inside the node's range. If
cover is positive, the whole range is covered and the length is
ys[hi] - ys[lo]. Otherwise the length is whatever the two children
report, or 0 at a leaf. Nothing has to be pushed down to children, because every
removal subtracts from exactly the nodes where the matching insertion added, and
after every update the root's length is the covered height.
function unionArea(rects) {
const ys = [...new Set(rects.flatMap(([, y1, , y2]) => [y1, y2]))].sort((a, b) => a - b);
const yIndex = new Map(ys.map((y, i) => [y, i]));
const segments = ys.length - 1; // the gaps between neighbouring y values
if (segments < 1) return 0n;
const cover = new Array(4 * segments).fill(0); // how many rectangles cover this node's whole range
const length = new Array(4 * segments).fill(0); // covered length inside this node's range
function update(node, lo, hi, from, to, delta) { // node stands for gaps lo .. hi - 1
if (to <= lo || hi <= from) return;
if (from <= lo && hi <= to) {
cover[node] += delta;
} else {
const mid = (lo + hi) >> 1;
update(2 * node, lo, mid, from, to, delta);
update(2 * node + 1, mid, hi, from, to, delta);
}
if (cover[node] > 0) length[node] = ys[hi] - ys[lo];
else if (hi - lo === 1) length[node] = 0;
else length[node] = length[2 * node] + length[2 * node + 1];
}
const events = [];
for (const [x1, y1, x2, y2] of rects) {
if (x1 === x2 || y1 === y2) continue;
events.push([x1, 1, yIndex.get(y1), yIndex.get(y2)]);
events.push([x2, -1, yIndex.get(y1), yIndex.get(y2)]);
}
events.sort((a, b) => a[0] - b[0]); // ties do not matter: no width passes between them
let total = 0n, previousX = events.length > 0 ? events[0][0] : 0;
for (const [x, delta, from, to] of events) {
total += BigInt(x - previousX) * BigInt(length[1]); // the covered length held since the last event
previousX = x;
update(1, 0, segments, from, to, delta);
}
return total;
}
Dry run: [[0, 0, 2, 2], [1, 0, 2, 3], [1, 0, 3, 1]], with y values 0, 1, 2, 3
| Event at x | Gaps changed | Area added before it | Total | Covered height after |
|---|---|---|---|---|
| 0: add rectangle 1 | [0, 2) | 0 | 0 | 2 |
| 1: add rectangle 2 | [0, 3) | 1 × 2 = 2 | 2 | 3 |
| 1: add rectangle 3 | [0, 1) | 0 × 3 = 0 | 2 | 3 |
| 2: remove rectangle 1 | [0, 2) | 1 × 3 = 3 | 5 | 3 |
| 2: remove rectangle 2 | [0, 3) | 0 × 3 = 0 | 5 | 1 |
| 3: remove rectangle 3 | [0, 1) | 1 × 1 = 1 | 6 | 0 |
The total is 6, matching the slab version. Two events at x = 1 and
two at x = 2 are processed in whatever order the sort leaves them,
and the second of each pair adds area 0, so the order does not matter. Each
update touches O(log m) nodes, and there are 2n updates, so the whole sweep is
O(n log n) after sorting, with O(n) space.
⚠ Union-area pitfalls
Areas overflow. A single rectangle with coordinates up to 109 has area
1018, far beyond 253, which is why the code multiplies in
BigInt. A problem that asks for the area modulo 109 + 7
wants Number(area % 1000000007n) at the very end. Use the same
[from, to) pair for a rectangle's removal as for its insertion, or
the counts do not cancel. And skip rectangles with zero width or height: they
add nothing, and a zero-height one would build an empty range.
Geometry in integers: cross product and orientation
The rest of the chapter is plane geometry, and nearly all of it rests on one
operation. For vectors u = (ux, uy) and v = (vx, vy),
the cross product is the number ux · vy - uy · vx. To see what
it means, write the vectors by length and angle,
u = |u| (cos α, sin α) and v = |v| (cos β, sin β). Then
the cross product is |u| |v| (cos α sin β - sin α cos β), which is
|u| |v| sin(β - α). Its sign is the sign of the sine of the angle
you turn through to get from u to v: positive for a
counter-clockwise turn of less than a half turn, negative for clockwise, and
zero when the vectors are parallel or one is zero. Its size is the area of the
parallelogram the two vectors span.
For three points, take the vectors from o to a and from
o to b. Walking o → a → b turns left if the
cross product is positive, right if negative, and goes straight (or back on
itself) if zero.
function cross(o, a, b) {
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
}
function orientation(o, a, b) {
const c = cross(o, a, b);
return c > 0 ? 1 : c < 0 ? -1 : 0; // 1: left turn, -1: right turn, 0: collinear
}
orientation reduces the number to -1, 0 or 1. Keep to that sign
whenever you only need the direction: the size of cross is easy to
overflow, while its sign is all the algorithms below use. One caution about
pictures: on a screen, y grows downward, which mirrors the plane, so a "left
turn" in the formula looks clockwise there. Decide by the sign and the formula,
not by how the points look on screen.
Exactness. With integer coordinates every operation here is an integer
operation, so the result is exact, and zero really means collinear. A
Number holds integers exactly up to 253, about
9 · 1015. If every coordinate has absolute value at most M, each
difference is at most 2M, each product at most 4M², and the cross product at most
8M². That stays below 253 while M is up to about 3 · 107.
Beyond that, use BigInt, shown in the floating-point section.
Collinear points and the point-on-segment test
Three points are collinear when cross(a, b, c) === 0. That says
c is on the line through a and b,
not on the segment between them. To restrict it to the segment, add the bounding
box check: c must lie between the endpoints in x and in y.
function onSegment(a, b, p) {
return (
cross(a, b, p) === 0 &&
Math.min(a[0], b[0]) <= p[0] && p[0] <= Math.max(a[0], b[0]) &&
Math.min(a[1], b[1]) <= p[1] && p[1] <= Math.max(a[1], b[1])
);
}
Why the box is enough once the point is on the line: along a line, x and y both
change steadily, so a point between the endpoints in the coordinate that varies
is between them on the line. For a vertical segment the x test is trivially true
and the y test does the work; for a horizontal one it is the other way round.
A degenerate "segment" with a === b has cross product 0 for every
p, and the box shrinks to a single point, so only p === a
passes, which is correct. This is O(1).
Do two segments intersect?
Segment ab and segment cd cross properly when
c and d are on opposite sides of the line through
a and b, and a and b
are on opposite sides of the line through c and d.
Opposite sides means the two orientations differ. Both conditions are needed: the
first says that segment cd crosses the line through ab,
but not that it crosses the segment ab itself, and the second closes
that gap from the other side.
Write the comparison as "the two orientations differ", not as "the product of two cross products is negative". A zero is then its own side, and the same test also accepts an endpoint that lies on the other segment (a T) and two segments that share an endpoint, which is what interviewers like to probe. What it cannot see is the case where both endpoints of one segment lie on the other's line, so two orientations are 0 and equal: the segments are collinear and may overlap or leave a gap. A segment that has shrunk to a single point lands there too. For those, check whether an endpoint of one segment lies inside the other: orientation 0 and inside its bounding box.
function segmentsIntersect(a, b, c, d) {
const o1 = orientation(a, b, c), o2 = orientation(a, b, d);
const o3 = orientation(c, d, a), o4 = orientation(c, d, b);
if (o1 !== o2 && o3 !== o4) return true; // each segment straddles the other's line
if (o1 === 0 && onSegment(a, b, c)) return true; // an endpoint lies on the other segment
if (o2 === 0 && onSegment(a, b, d)) return true;
if (o3 === 0 && onSegment(c, d, a)) return true;
if (o4 === 0 && onSegment(c, d, b)) return true;
return false;
}
Dry run on seven cases
| Case | a, b, c, d | o1, o2, o3, o4 | What decides | Result |
|---|---|---|---|---|
| X crossing | (0,0) (4,4) (0,4) (4,0) | 1, -1, -1, 1 | o1 ≠ o2 and o3 ≠ o4 | true |
| T: an end on the other | (0,0) (4,0) (2,0) (2,3) | 0, 1, 1, -1 | o1 ≠ o2 and o3 ≠ o4, although o1 is 0 | true |
| shared endpoint | (0,0) (2,2) (2,2) (4,0) | 0, -1, -1, 0 | o1 ≠ o2 and o3 ≠ o4 | true |
| collinear, overlapping | (0,0) (3,3) (2,2) (5,5) | 0, 0, 0, 0 | the first test cannot fire; (2,2) is on ab | true |
| collinear, a gap | (0,0) (1,1) (2,2) (3,3) | 0, 0, 0, 0 | no endpoint lies inside the other segment | false |
| parallel | (0,0) (4,0) (0,1) (4,1) | 1, 1, -1, -1 | o1 = o2, and nothing is 0 | false |
| near miss: lines cross, segments do not | (0,0) (4,0) (5,-1) (5,1) | -1, 1, 1, 1 | o3 = o4: a and b are on the same side of cd | false |
In the T case c is on the line of ab while d
is off it, and a and b fall on opposite sides of the line
through cd, so the first test reports the touch even though
o1 is 0. In the two all-zero rows the first test cannot fire, since
o1 === o2, and only the endpoint checks can say true. Two collinear
segments that overlap always have an endpoint of one inside the other, so those
four checks are enough. Across 200,000 random pairs of segments on a 5 × 5 grid,
including collinear, touching and single-point segments, the function matched an
independent exact calculation every time, and it gave the same answer with the two
segments swapped or their endpoints reversed.
⚠ Segment pitfalls
Do not test
cross1 * cross2 < 0: the product of two large cross
products can overflow even when each is exact, and it also loses the zero case.
Compare the orientations, which are only -1, 0 and 1. Do not stop after the
first test; the collinear case needs the four endpoint checks. And do not assume
the endpoints come in a convenient order: swapping a with
b, or one segment with the other, must not change the answer.
Say it like this → "I'll use integer cross products so the
result is exact. Segments cross if each one's endpoints are on opposite sides of
the other's line. Then I'll handle the zero cases: an endpoint on the other
segment, and the collinear overlap, with a bounding-box check."
Polygon area by the shoelace formula
The cross product gives the area of a triangle: for the triangle
o, a, b it is half of cross(o, a, b), with a sign. A
polygon with vertices p0, p1, ..., p(n-1) given in order around its
boundary can be cut into the fan of triangles
(p0, p1, p2), (p0, p2, p3), .... Adding their signed areas gives the
polygon's area, and this holds for concave polygons too. A fan triangle can
stick out of a concave polygon, but each triangle carries the sign of its own
turn, so a piece that sticks out is counted once forwards and once backwards by
the triangles of the fan, and cancels. It is called the shoelace formula
because, written out in coordinates, the products cross like laces.
function doubledArea(polygon) {
let sum = 0;
for (let i = 1; i + 1 < polygon.length; i++) {
sum += cross(polygon[0], polygon[i], polygon[i + 1]); // the triangle fan from vertex 0
}
return sum; // positive for counter-clockwise, negative for clockwise
}
The function returns twice the area, as an integer, which avoids the
halves and keeps the arithmetic exact; divide by 2 at the end if you need the
area itself. The sign tells the orientation: positive when the vertices run
counter-clockwise, negative for clockwise, so Math.abs gives the
area and the sign is a free orientation test. For the rectangle
[[0, 0], [4, 0], [4, 3], [0, 3]] the fan has two triangles with
doubled areas 12 and 12, so the result is 24 and the area is 12. For the concave
polygon [[0, 0], [6, 0], [6, 6], [3, 2], [0, 6]] it returns 48, an
area of 24, and reversing the vertex order gives -48. Time is O(n), space O(1).
A cross-check you can trust. For a polygon whose vertices all have integer
coordinates, Pick's theorem says that area = I + B / 2 - 1, where I is the number
of lattice points strictly inside and B the number on the boundary. B is the sum
of gcd(|dx|, |dy|) over the edges, using the gcd from the
math chapter: an edge from one lattice point to
another contributes exactly that many lattice points, counting one of its ends. In the chapter's checks, the shoelace
result equalled 2I + B - 2 on three thousand random polygons.
⚠ Shoelace pitfalls
The vertices must be in boundary order, not sorted. A polygon whose edges cross
itself does not have an "area" this formula can report; the signed pieces
cancel in ways that mean nothing. Repeating the first vertex at the end adds a
degenerate triangle of area 0, which is harmless, and fewer than 3 vertices give
0. For large coordinates the cross products can leave the safe range, so the
BigInt version applies.
Point in polygon by ray casting
Is a point inside a polygon, possibly a concave one? Shoot a ray from the point to the right, to infinity, and count how many edges it crosses. Each crossing switches between outside and inside, and the far end of the ray is outside, so the point is inside exactly when the count is odd. That is the whole idea. The difficulty is in the edge cases, and an exact integer test removes all of them.
Three things go wrong if you code it carelessly:
- The ray passes through a vertex. Two edges meet there, and a naive test counts the crossing twice, or not at all.
- An edge lies along the ray. A horizontal edge on the ray's row has no single crossing point.
- The point is on the boundary. Neither "inside" nor "outside" is obviously right.
The first two are solved by one half-open rule. Count an edge only when
(a.y > p.y) !== (b.y > p.y): exactly one endpoint is strictly
above the ray's row. This treats a vertex exactly on the row as if it were
slightly below the ray, so the ray never actually touches a vertex. An
edge ending on the row is counted only if its other end is above, once, and a
horizontal edge, which has both ends on the same side, is never counted. For an
edge that passes the test, decide whether the crossing is to the right of the
point with the cross product sign: for an edge going up, the point must be on its
left (cross > 0); for an edge going down, on its right
(cross < 0). No division and no rounding are involved. The
boundary is handled first, with onSegment.
function pointInPolygon(p, polygon) {
let crossings = 0;
for (let i = 0; i < polygon.length; i++) {
const a = polygon[i], b = polygon[(i + 1) % polygon.length];
if (onSegment(a, b, p)) return "boundary";
if ((a[1] > p[1]) !== (b[1] > p[1])) { // the edge straddles the row of p; a flat edge never does
const side = cross(a, b, p);
if (b[1] > a[1] ? side > 0 : side < 0) crossings++; // the crossing lies to the right of p
}
}
return crossings % 2 === 1 ? "inside" : "outside";
}
Dry run: the diamond [[2, 0], [4, 2], [2, 4], [0, 2]] and the point (1, 2)
The ray from (1, 2) runs right along y = 2, straight through the vertex (4, 2).
| Edge | One end strictly above y = 2? | cross(a, b, p) | Counted? |
|---|---|---|---|
| (2,0) to (4,2) | no: 0 and 2 are both not above 2 | not computed | no |
| (4,2) to (2,4) | yes: only (2,4) is above | 6, edge goes up, positive | yes |
| (2,4) to (0,2) | yes: only (2,4) is above | 2, edge goes down, needs negative | no |
| (0,2) to (2,0) | no | not computed | no |
One crossing, an odd count, so the point is inside, which is right. The vertex
(4, 2) was used by two edges but counted once. A naive rule that counts an edge
whenever the row lies between its ends inclusively counts both edges at
(4, 2) and gets 2, an even number, so it calls the point outside. The same
function reports (5, 2) as outside, (4, 2) as the
boundary and (2, 2) as inside. Time is O(n) per point and space is
O(1).
⚠ Point-in-polygon pitfalls
Decide with the interviewer whether a boundary point counts as inside; the code
returns three answers precisely so the caller can choose. Keep the
onSegment test before the ray count, or a point on an edge is
classified inconsistently: the half-open rule calls it inside on one side of the
polygon and outside on the other. Use the identical comparison, > p.y,
on both ends of every edge: mixing > and >= from
one edge to the next makes a shared vertex count differently for its two edges.
For a convex polygon with counter-clockwise vertices there is a simpler test:
the point is inside or on the boundary exactly when
cross(a, b, p) >= 0 for every edge.
Rectangles: overlap, area and the perfect rectangle
An axis-aligned rectangle is [x1, y1, x2, y2] with
x1 < x2 and y1 < y2: bottom-left and top-right
corners. Two rectangles overlap in a region of positive area exactly when their
x-ranges overlap and their y-ranges overlap. Two ranges
(a1, a2) and (b1, b2) overlap when
a1 < b2 and b1 < a2, the same test as the My
Calendar check in the intervals chapter.
function rectanglesOverlap(a, b) {
return a[0] < b[2] && b[0] < a[2] && a[1] < b[3] && b[1] < a[3]; // strict: sharing an edge is not overlap
}
function overlapArea(a, b) {
const width = Math.min(a[2], b[2]) - Math.max(a[0], b[0]);
const height = Math.min(a[3], b[3]) - Math.max(a[1], b[1]);
return Math.max(0, width) * Math.max(0, height); // clamp each side separately, then multiply
}
function totalArea(a, b) {
const area = (r) => (r[2] - r[0]) * (r[3] - r[1]);
return area(a) + area(b) - overlapArea(a, b);
}
The comparisons are strict, so two rectangles that share only an edge or a
corner do not overlap. If a problem counts touching as overlap, use
<=, and say so. For the area, each side of the overlap is the
smaller of the two top values minus the larger of the two bottom values, and
that can be negative when the rectangles are apart. Clamp each side at 0
before multiplying. Clamping only the product is the classic bug: two negative
sides multiply to a positive number, and rectangles that are apart in both
directions would report a phantom overlap. The union of two rectangles has area
A + B - overlap. All three functions are O(1), and they agreed with a
cell-by-cell count on twenty thousand random pairs.
The perfect rectangle: two checks, each catching what the other misses
Given n rectangles, do they fit together to cover one larger rectangle exactly, with no gaps and no overlaps? Two tests, both linear:
- The areas add up. The sum of the rectangle areas must equal the area of the bounding box, the smallest rectangle containing all of them.
- The corners cancel. Look at every corner of every rectangle. In a perfect tiling, a point where tiles meet is a corner of an even number of them: a point inside a straight edge of one tile is the corner of two neighbours, and a point where four tiles meet is the corner of all four. Only the four corners of the whole bounding box belong to exactly one tile. So toggle each corner in a set, adding it if absent and removing it if present. At the end, exactly the four bounding-box corners must be left.
function isRectangleCover(rects) {
let left = Infinity, bottom = Infinity, right = -Infinity, top = -Infinity, area = 0;
const corners = new Set();
const toggle = (x, y) => {
const key = x + "," + y;
if (corners.has(key)) corners.delete(key);
else corners.add(key);
};
for (const [x1, y1, x2, y2] of rects) {
left = Math.min(left, x1);
bottom = Math.min(bottom, y1);
right = Math.max(right, x2);
top = Math.max(top, y2);
area += (x2 - x1) * (y2 - y1);
toggle(x1, y1);
toggle(x1, y2);
toggle(x2, y1);
toggle(x2, y2);
}
if (area !== (right - left) * (top - bottom)) return false;
return (
corners.size === 4 &&
corners.has(left + "," + bottom) && corners.has(left + "," + top) &&
corners.has(right + "," + bottom) && corners.has(right + "," + top)
);
}
Neither test is enough alone, and it is worth seeing why. The two rectangles
[0, 0, 1, 2] and [0, 0, 2, 1] overlap in a unit square
and leave a unit gap at the top right, so the area is 2 + 2 = 4, which equals the
2 × 2 bounding box and passes the first check; but the corner (0, 0) is toggled
twice and disappears, so the second check fails. Three copies of
[0, 0, 1, 1] toggle every corner three times, so exactly the four
box corners remain and the second check passes, but the area 3 is not 1, so the
first check fails. Together they catch both.
A note on how much to trust the pair. The necessity of both tests is the argument above. That the two together are also sufficient is true, but the proof is longer than the code. What this chapter relies on is a brute-force comparison: every set of up to 5 rectangles on a 3 × 3 grid and up to 4 on a 4 × 4 grid, about 5.3 million sets in all, plus twenty thousand tilings with one rectangle removed, duplicated, stretched, shrunk or shifted, checked cell by cell. It never disagreed. Time is O(n) and space is O(n) for the set.
Dry run: [[1, 1, 3, 3], [3, 1, 4, 2], [3, 2, 4, 4], [1, 3, 2, 4], [2, 3, 3, 4]]
| Rectangle | Area | Corners toggled |
|---|---|---|
| [1, 1, 3, 3] | 4 | (1,1) (1,3) (3,1) (3,3) |
| [3, 1, 4, 2] | 1 | (3,1) removed; (3,2) (4,1) (4,2) added |
| [3, 2, 4, 4] | 2 | (3,2) removed; (3,4) added; (4,2) removed; (4,4) added |
| [1, 3, 2, 4] | 1 | (1,3) removed; (1,4) (2,3) (2,4) added |
| [2, 3, 3, 4] | 1 | (2,3) removed; (2,4) removed; (3,3) removed; (3,4) removed |
The areas sum to 9, the bounding box is 3 × 3 = 9, and the corners left are
(1,1), (1,4), (4,1) and (4,4), which are exactly the four box corners. The answer
is true. The input [[1, 1, 2, 3], [1, 3, 2, 4], [3, 1, 4, 2], [3, 2, 4, 4]]
leaves the middle column empty: its areas sum to 6 against a box of 9, so the
first check already fails.
⚠ Perfect-rectangle pitfalls
The tests assume every rectangle has positive width and height. A zero-area
rectangle would still toggle corners. Do not forget to compare the box corners by
value and not only the count of 4: four leftover points that are not the box
corners fail. Build set keys as strings, such as
x + "," + y, rather
than packing two numbers into one, which can collide. And remember an empty list
has no bounding box: the code above returns false because the
infinite bounds never match an area of 0.
Maximum points on a line: hash the direction, not the slope
Given n points, find the largest number that lie on one straight line. Testing
every triple with the cross product is O(n³). The better question is: fix one
point as an anchor. Every other point gives a direction from the anchor, and
the points that line up with the anchor are exactly the ones with the same
direction. So count points per direction with a Map, and the best
line through the anchor holds the largest count plus the anchor. Repeat for
every anchor: O(n²) lookups.
The direction is the whole problem. The slope dy / dx looks natural,
but it fails twice. A vertical line has dx = 0 and needs a special
case, and a division of two integers can give the same floating-point number for
two different slopes. The exact key is the pair (dy, dx) after two
normalisations. Reduce it by dividing both by their gcd,
taken from the math chapter, so that (2, 4), (1, 2)
and (3, 6) all become (1, 2): two integer vectors are parallel exactly when they
are the same vector after dividing out the gcd and fixing the sign. Fix the
sign, so that (1, 2) and (-1, -2), which point opposite ways along the same
line, get one key: make dx positive, and when dx is 0,
make dy positive.
function maxPoints(points) {
const n = points.length;
if (n <= 2) return n;
let best = 1;
for (let i = 0; i < n; i++) {
const slopes = new Map();
let duplicates = 0, onBestLine = 0;
for (let j = i + 1; j < n; j++) {
let dx = points[j][0] - points[i][0];
let dy = points[j][1] - points[i][1];
if (dx === 0 && dy === 0) {
duplicates++; // the same point again belongs to every line through i
continue;
}
const g = gcd(dx, dy);
dx /= g;
dy /= g;
if (dx < 0 || (dx === 0 && dy < 0)) { // one direction, not two opposite ones
dx = -dx;
dy = -dy;
}
const key = dy + "/" + dx;
const count = (slopes.get(key) ?? 0) + 1;
slopes.set(key, count);
onBestLine = Math.max(onBestLine, count);
}
best = Math.max(best, onBestLine + duplicates + 1);
}
return best;
}
Two details deserve a sentence each. The loop looks only at points after the
anchor (j > i): the best line's lowest-indexed point, used as the
anchor, sees every other point on that line later in the array, so nothing is
missed. And a point identical to the anchor lies on every line through the
anchor, so it is counted separately in duplicates and added to
whichever direction wins. If every point is the same, onBestLine stays
0 and the answer is the number of duplicates plus the anchor, which is n.
Dry run: anchor (1, 4) among (1,4) (1,1) (3,2) (5,3) (4,1) (2,3)
| Point | (dx, dy) | gcd | Key after reducing and fixing the sign | Count so far |
|---|---|---|---|---|
| (1,1) | (0, -3) | 3 | 1/0 | 1 |
| (3,2) | (2, -2) | 2 | -1/1 | 1 |
| (5,3) | (4, -1) | 1 | -1/4 | 1 |
| (4,1) | (3, -3) | 3 | -1/1 | 2 |
| (2,3) | (1, -1) | 1 | -1/1 | 3 |
The key is written dy/dx. Three points share -1/1, so
the line through the anchor holds 3 + 1 = 4 points, the answer for this input. The
row for (1,1) shows the sign rule: the vertical direction (0, -3) becomes
1/0, not -1/0. Time is O(n² log C), where C bounds the
coordinate differences, because each gcd takes a logarithmic number of steps, and
the space is O(n) for one map.
⚠ Slope-hashing pitfalls
The single most tested trap: the points (0, 0), (94911150, 94911151) and
(94911149, 94911150) are not on one line, but the slopes
94911151 / 94911150 and 94911150 / 94911149 are the
same Number, so a floating-point key reports 3 points where the
answer is 2. The reduced integer key gives 2. Also: skip the gcd when both
differences are zero, because gcd(0, 0) is 0 and dividing by it gives
NaN; normalise the sign after the division; and let the
vertical case produce the ordinary key "1/0", a string, with no
Infinity and no special branch.
Valid square without a single square root
Four points are given in any order. Do they form a square? Trying the orderings
and checking right angles works but is clumsy. A neater test uses distances. A
square with side s has six pairwise distances: four equal sides, and
two equal diagonals. In squared form, which stays in integers, the sides are
s² and the diagonals are 2s² by Pythagoras. So compute
the six squared distances, sort them, and require four equal smallest values
that are not zero, then two equal larger values, each twice the smaller.
function squaredDistance(a, b) {
return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2;
}
function validSquare(p1, p2, p3, p4) {
const points = [p1, p2, p3, p4];
const d = [];
for (let i = 0; i < 4; i++) {
for (let j = i + 1; j < 4; j++) d.push(squaredDistance(points[i], points[j]));
}
d.sort((a, b) => a - b);
return d[0] > 0 && d[0] === d[3] && d[4] === d[5] && d[4] === 2 * d[0]; // four equal sides, two diagonals of twice the square
}
For (0,0) (1,1) (1,0) (0,1) the six squared distances sorted are
[1, 1, 1, 1, 2, 2]: four of 1, two of 2, and 2 is twice 1, so the
answer is true. A tilted square such as
(0,0) (2,1) (1,3) (-1,2) gives [5, 5, 5, 5, 10, 10] and
is accepted. The parallelogram (0,0) (2,0) (3,1) (1,1), whose sides
have two different lengths, gives [2, 2, 2, 4, 4, 10] and is
rejected. The cost is 6 distances and a sort of 6 numbers: O(1).
Why the pattern is sufficient. That a square produces it is clear. The
converse needs an argument, because the test never looks at angles. Suppose four
of the six squared distances equal d > 0 and the other two equal
2d. The two long pairs either share a point or are disjoint.
- Disjoint long pairs, say
ACandBD: then the four other pairsAB, BC, CD, DAhave squared lengthd. In triangleABC,AB² + BC² = 2d = AC², so the angle at B is a right angle, and it is an isosceles right triangle on the hypotenuseAC. The same holds for triangleADC. There are only two points that make an isosceles right triangle on a given hypotenuse, one on each side of it; D cannot equal B becauseBD² = 2d > 0, so D is the other one, and ABCD is a square. - Long pairs sharing a point, say
PQandPR: thenPS, QR, QS, RSall have squared lengthd, so Q, R and S form an equilateral triangle of side √d, and P is at distance √d from S and √(2d) from both Q and R. P is then on the perpendicular bisector of QR, which passes through S, at distance √d from S: either on the far side of S from QR or on the same side as QR. Measuring from the midpoint M of QR, S is (√3/2)√d from M, so those two positions are (√3/2 + 1)√d and (1 - √3/2)√d from M, whilePQ² = 2ddemandsPM² = 2d - d/4, that is (√7/2)√d. Neither matches, so this case cannot occur.
I also checked the test against a permutation-and-right-angle definition on all 65,536 ordered quadruples of points from a 4 × 4 grid, and on 100,000 random ones, with no disagreement.
⚠ Valid-square pitfalls
Forgetting the
d[0] > 0 check accepts four identical points, whose
six distances are all 0 and satisfy every equality. A rhombus has four equal
sides but unequal diagonals, so it fails the equal-diagonals check, and a
rectangle has two different side lengths, so it has no four equal smallest
distances. Skip square
roots: Math.sqrt rounds, and the squared comparison is exact.
Convex hull by monotone chain (erect the fence)
The convex hull of a set of points is the smallest convex polygon that contains all of them: stretch a rubber band around the points and let go. "Erect the fence" asks for every point that lies on that band, including points in the middle of a straight stretch. The monotone chain method builds the hull in two halves and uses nothing but the cross product.
Sort the points by x, breaking ties by y. Build the lower hull going left
to right with a stack: for each new point p, while the stack has two
or more points and the last two points a, b together with
p make a right turn (cross(a, b, p) < 0), pop
b; then push p. Then do the same over the points in
reverse order for the upper hull. The two chains joined give the
boundary in counter-clockwise order.
Why the cross-product test is the right one. The lower hull is the part of
the boundary you see from below: going left to right it only ever turns left (or
goes straight), because a right turn would make a dent pointing into the hull.
Look at what a right turn means for the three points a, b, p, whose
x values do not decrease. Then b lies strictly above the segment
from a to p (when x values tie, the same holds with
"above" read as the vertical order). The point on that segment directly below
b is a blend of a and p, both in the set,
so the hull reaches strictly below b there, and b cannot
be on the lower boundary. Popping it loses nothing. After the pops stop, the last
two stack points and p make a left turn or go straight, and the
invariant, "every consecutive triple on the stack turns left or goes straight",
holds again with p on top. p itself belongs on the stack
because nothing to its right has been seen yet and it is the newest rightmost
point of the prefix.
A straight triple, cross === 0, is the one decision that
differs between versions. The middle point is on the hull boundary but is not a
corner. "Erect the fence" wants it, so pop only when the cross product is
strictly negative. A problem that wants only the corners pops on
<= 0 as well.
function convexHull(points, keepCollinear) {
const sorted = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const pts = sorted.filter((p, i) => i === 0 || p[0] !== sorted[i - 1][0] || p[1] !== sorted[i - 1][1]);
if (pts.length <= 2) return pts;
const mustPop = keepCollinear ? (turn) => turn < 0 : (turn) => turn <= 0;
function chain(list) {
const stack = [];
for (const p of list) {
while (stack.length >= 2 && mustPop(cross(stack[stack.length - 2], stack[stack.length - 1], p))) stack.pop();
stack.push(p);
}
return stack;
}
const lower = chain(pts);
const upper = chain([...pts].reverse());
const seen = new Set();
const hull = [];
for (const p of [...lower, ...upper]) {
const key = p[0] + "," + p[1];
if (!seen.has(key)) {
seen.add(key);
hull.push(p);
}
}
return hull;
}
Dry run: [[1,1],[2,0],[2,2],[2,4],[3,3],[4,2]], keeping collinear points
Sorted by x then y, the order is (1,1) (2,0) (2,2) (2,4) (3,3) (4,2).
| Chain | New point | Pops (cross < 0) | Stack afterwards |
|---|---|---|---|
| lower | (1,1), (2,0) | none | (1,1) (2,0) |
| lower | (2,2) | none: cross 2 | (1,1) (2,0) (2,2) |
| lower | (2,4) | none: cross 0, straight, kept | (1,1) (2,0) (2,2) (2,4) |
| lower | (3,3) | (2,4) with -2, (2,2) with -2 | (1,1) (2,0) (3,3) |
| lower | (4,2) | (3,3) with -4 | (1,1) (2,0) (4,2) |
| upper | (4,2), (3,3), (2,4), (2,2), (2,0) | none, with a straight triple kept | (4,2) (3,3) (2,4) (2,2) (2,0) |
| upper | (1,1) | (2,0) with -2, (2,2) with -2 | (4,2) (3,3) (2,4) (1,1) |
Joining the lower chain (1,1) (2,0) (4,2) with the upper chain
(4,2) (3,3) (2,4) (1,1) and dropping repeats gives
(1,1) (2,0) (4,2) (3,3) (2,4). The point (2,2) is correctly absent:
it is inside the hull. If every point is on one line, both chains hold all of
them, and removing repeats leaves each once.
Cost. The sort is O(n log n). In each chain every point is pushed once and
popped at most once, so building both chains is O(n), and the total is
O(n log n) time and O(n) space. The same code with keepCollinear set
to false returns only the corners: for the bottom-edge example
(0,0) (1,0) (2,0) (2,2) (0,2) it returns four points, and with
true it returns five, including (1,0).
⚠ Convex-hull pitfalls
Remove duplicate points first; otherwise a repeated point makes a zero turn that
the "strictly negative" rule keeps. Handle fewer than three distinct points
before building chains. Choose
< or <= on purpose
and say which you chose: corners only against every boundary point is the whole
difference between two problems. The output order is counter-clockwise starting
at the leftmost-lowest point, and some checkers accept any order while others do
not, so ask.
Closest pair of points
Given n points, find the smallest distance between any two. Comparing all pairs
is O(n²). Two ways reach O(n log n). The divide and conquer version splits
the points by x, solves both halves, and repairs the pair that straddles the
split; it belongs with the other recursive splits in the
divide and conquer chapter. The
sweep version fits this chapter: process the points in x order and keep the
already-seen points whose x is within the best distance d found so
far, ordered by y. For a new point, only the active points whose y is within
d of it can improve the answer, and a packing argument says there are
a small constant number of them (the argument below gives at most eight): all pairs among the processed points are at least
d apart, a box d wide and 2d tall splits
into eight squares of side d / 2, and two points in one such square
would be closer than d. Finding those neighbours in the y-ordered
set needs an ordered structure, which the
ordered structures chapter covers.
A short version needs no special structure and is usually fast. Sort by x, and for each point scan backwards through the earlier points, stopping as soon as the gap in x alone is no smaller than the best distance found so far, because every earlier point is at least that far away in x.
function closestPairSquared(points) {
const pts = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
let best = Infinity;
for (let i = 1; i < pts.length; i++) {
for (let j = i - 1; j >= 0; j--) {
const dx = pts[i][0] - pts[j][0];
if (dx * dx >= best) break; // everything further left is at least this far away in x
const dy = pts[i][1] - pts[j][1];
best = Math.min(best, dx * dx + dy * dy);
}
}
return best;
}
It returns the squared distance, which is exact in integers and avoids
the square root; take Math.sqrt only for display. It returns
Infinity for fewer than two points. The honest cost is O(n²) in the
worst case: if all points share one x, the gap in x is 0, the early exit never
fires, and every pair is compared. On well-spread input it does far less, but
when an interviewer asks for the guaranteed bound, use the sweep with an ordered
set or the divide and conquer.
Floating point: why geometry stays in integers
JavaScript numbers are 64-bit floating point. Every integer up to 253
is stored exactly, and so is every sum, difference and product of integers whose
result stays within that range. Geometry with integer input stays exact as
long as no intermediate value leaves that range. Once it does, the damage is silent: no
error, just a wrong sign. Here is a concrete case. The points
o = (0, 0), a = (94911150, 94911151) and
b = (94911149, 94911150) are not collinear, since the exact cross
product is 94911150 · 94911150 - 94911151 · 94911149 = 1. In floating point the two
products are both 9008126394322500, and the cross product comes out
as 0, "collinear". The slopes 94911151 / 94911150 and
94911150 / 94911149 are also the same double. When coordinates can be
this large, BigInt restores exactness:
function crossBig(o, a, b) {
const ax = BigInt(a[0]) - BigInt(o[0]), ay = BigInt(a[1]) - BigInt(o[1]);
const bx = BigInt(b[0]) - BigInt(o[0]), by = BigInt(b[1]) - BigInt(o[1]);
return ax * by - ay * bx; // exact whatever the size; compare it with 0n
}
crossBig(o, a, b) for the points above returns 1n. A
BigInt is slower and cannot be mixed with a Number, so
convert the coordinates up front, and compare the result with 0n,
> 0n and < 0n. Use it only when the coordinates
can exceed about 3 · 107; below that, Number is exact and
much faster.
The rules that follow:
- Compare, do not divide. Ask whether two slopes are equal by cross-multiplying,
dy1 * dx2 === dy2 * dx1, while the products stay in the safe range, or by reducing with the gcd, not by comparing quotients. - Compare squared distances. Skip
Math.sqrtandMath.hypot:a² + b² < c²is exact for integers and the root is rounded. - Do not use an epsilon on integer input. A tolerance such as
Math.abs(x - y) < 1e-9is not transitive: with 0, 0.8e-9 and 1.6e-9, the first two are "equal", the last two are "equal", and the first and last are not. Its right size also depends on the scale of the data. Epsilon belongs to genuinely real-valued input, where you should state the tolerance you chose. - Sort by angle with the cross product, not with
Math.atan2, when you can: split the plane into two half-planes first, then compare within a half-plane by the sign of the cross product. Two different directions can have the sameatan2: the vectors (94911151, 94911150) and (94911150, 94911149) do. - Leave a note on the range. "Coordinates up to 104, so the cross product is at most about 8 · 108 and fits" is a sentence that earns trust in an interview.
Two small -0 facts, since negative zero surfaces whenever a zero is
negated, as in the sign fix of a direction key. 0 * -1 is -0, which is
=== to 0 and prints as "0" in a string
key, and a Map treats -0 and 0 as one key. So it
is harmless in comparisons and hash keys, but Object.is(0 * -1, 0)
is false, which matters only to equality checks built on
Object.is.
Say it like this → "The coordinates are integers up to
a bound I'll state, so I'll keep everything in integers: cross products for
orientation, squared distances for comparisons, and a gcd-reduced pair instead
of a slope. That avoids every floating-point question, and if the bound were
larger I would switch the cross product to
BigInt."
Recognizing it in an unseen problem
- Buildings, intervals, rectangles or events with start and end coordinates, and a question about "the tallest", "the smallest" or "the total covered" at each moment → a sweep line, with a heap, an ordered set or a segment tree as the active set
- The answer changes only at the endpoints → look at the 2n endpoints only, not the whole range
- Many queries against a fixed set, all known up front → offline: sort the queries (keeping their indices) and sweep; or turn a count into a difference of two prefix counts with binary search
- Coordinates up to 109 with few distinct values, and only comparisons matter → coordinate compression, going back to the sorted values when a length is needed
- "Area of the union of rectangles" → a sweep over x with a segment tree over compressed y, or the quadratic slab version when n is small
- "Do these segments touch or cross", "which side of the line", "is this a left turn" → the cross product and orientation, handling the zero cases explicitly
- "Is the point inside this polygon" → ray casting with the half-open rule; "area of this polygon" → the shoelace sum
- "Most points on a line" or "group points by direction" → a gcd-reduced, sign-normalised pair as the key, not a floating-point slope
- "Do these four points form a square" → six squared distances; "which points enclose all the rest" → monotone chain
- Any of the above with huge coordinates → check whether a cross product can pass 253, and switch to
BigIntif it can
Before you move on
- List the four steps of the sweep-line recipe, and say which structure you would pick for "how many", "the largest", "the nearest value" and "the total covered length".
- Explain how the order of a start and an end at the same coordinate follows from closed against half-open, and name two ways to avoid tie-breaking altogether.
- Write the skyline with a heap of
[height, right]pairs, and say why lazy deletion is correct and why the sweep must read the height only after all events at one x. - Answer "flowers in full bloom" as a difference of two binary searches, and say why one bound is
<=and the other<. - Solve the minimum interval for each query offline, and say why sorting the queries makes deleting an ended interval permanent.
- Write coordinate compression from memory and say what it preserves and what it destroys.
- Explain the slab idea behind rectangle union, then how the segment tree's
coverandlengthfields give the covered height without pushing anything down. - Write
crossandorientation, explain why the sign is the turn direction, and state the coordinate range in whichNumberis exact. - Write the segment intersection test and trace the T, shared-endpoint, collinear-overlap and collinear-gap cases.
- Compute a polygon's doubled area with the fan of triangles, and read the orientation from its sign.
- Cast a ray with the half-open rule, and show how a vertex on the ray is counted once.
- Compute rectangle overlap area with each side clamped at 0, and explain why the perfect-rectangle test needs both the area check and the corner check.
- Key a direction by a gcd-reduced pair with a fixed sign, and give the example where a floating-point slope fails.
- Test a square by six squared distances, and say why four equal distances plus two double ones is enough.
- Build a convex hull with the monotone chain, explain the pop condition with the cross product, and say which comparison keeps collinear boundary points.
- Say why the closest-pair sweep checks only a constant number of neighbours, and what the short scan does in the worst case.
Opens in the editor: write it, run it, and check it against real tests.
All 159 for this level →5 questions, pass at 4. The check runs in your browser and the result stays on this device, so it is a self-check and not a certificate.
Finished reading?
0 of 48 chapters read in this series