Range Module
Ordered map of disjoint half-open intervals
Ordered map interval tracking. Relevant to order book modeling.
Problem
Design a data structure tracking a set of half-open integer intervals with three operations:
addRange(left, right)— add[left, right)to the tracked set (merging with anything it touches).queryRange(left, right)— returntrueiff every number in[left, right)is currently tracked.removeRange(left, right)— remove[left, right)from the tracked set (splitting any interval it cuts through).
All three operations should run in O(log n) amortized where n is the current number of stored disjoint intervals.
Intuition
The structure we want is a set of disjoint, non-adjacent, half-open intervals sorted by start. addRange merges anything that touches or overlaps into a single interval. queryRange answers by finding the closest-starting interval at or before left and checking that it extends to right. removeRange splits straddling intervals.
An ordered map keyed by start with value end gives us O(log n) predecessor/successor queries — exactly what we need.
Half-open [l, r) is not a cosmetic choice: it makes adjacency trivial. [0, 3) and [3, 5) are adjacent iff the end of one equals the start of the other; no +1 tricks.
Approach
Boolean Array / Naive Simulation
O(R) per opO(R)Maintain a Vec<bool> of size 10^9 + 1. Each op flips or reads a range. Correct but space-prohibitive at LC's scale (R = 10^9). Useful for testing correctness on small inputs.
struct RangeModule {
covered: std::collections::BTreeSet<i32>,
}
impl RangeModule {
fn new() -> Self { Self { covered: std::collections::BTreeSet::new() } }
fn add_range(&mut self, left: i32, right: i32) {
for x in left..right { self.covered.insert(x); }
}
fn query_range(&self, left: i32, right: i32) -> bool {
(left..right).all(|x| self.covered.contains(&x))
}
fn remove_range(&mut self, left: i32, right: i32) {
for x in left..right { self.covered.remove(&x); }
}
}
class RangeModule {
private covered = new Set<number>();
addRange(l: number, r: number): void { for (let x = l; x < r; x++) this.covered.add(x); }
queryRange(l: number, r: number): boolean {
for (let x= l; x < r; x++) if (!this.covered.has(x)) return false;
return true;
}
removeRange(l: number, r: number): void { for (let x= l; x < r; x++) this.covered.delete(x); }
}Ordered Map of Intervals
O(log n) amortizedO(n)Store BTreeMap<start, end> (Rust) / sorted entries in a plain Map<number, number> (TS). Invariant: intervals are disjoint and non-adjacent, sorted by start.
addRange(L, R). Collect every stored interval [s, e) with s ≤ R and e ≥ L (overlap or touch). Delete them; insert [min(L, s_min), max(R, e_max)).
queryRange(L, R). Look up the predecessor interval (largest s ≤ L). Return true iff e ≥ R.
removeRange(L, R). Collect every stored interval [s, e) with s < R and e > L (strict — adjacency doesn't count for removal). Delete each; re-insert [s, L) if s < L and [R, e) if e > R.
Each op touches k intervals and does O(k log n) work; k is amortized O(1) because every op either adds ≤ 1 interval and destroys k, or removes ≤ 2 per input interval.
use std::collections::BTreeMap;
struct RangeModule {
intervals: BTreeMap<i32, i32>,
}
impl RangeModule {
fn new() -> Self { Self { intervals: BTreeMap::new() } }
fn add_range(&mut self, left: i32, right: i32) {
let (mut lo, mut hi) = (left, right);
let touching: Vec<i32> = self
.intervals
.range(..=hi)
.filter(|(_, &e)| e >= lo)
.map(|(&s, _)| s)
.collect();
for s in touching {
let e = self.intervals.remove(&s).unwrap();
if s < lo { lo= s; }
if e > hi { hi = e; }
}
self.intervals.insert(lo, hi);
}
fn query_range(&self, left: i32, right: i32) -> bool {
self.intervals
.range(..=left)
.next_back()
.map_or(false, |(_, &e)| e >= right)
}
fn remove_range(&mut self, left: i32, right: i32) {
let crossing: Vec<i32> = self
.intervals
.range(..right)
.filter(|(_, &e)| e > left)
.map(|(&s, _)| s)
.collect();
let mut to_insert: Vec<(i32, i32)> = Vec::new();
for s in crossing {
let e = self.intervals.remove(&s).unwrap();
if s < left { to_insert.push((s, left)); }
if e > right { to_insert.push((right, e)); }
}
for (s, e) in to_insert { self.intervals.insert(s, e); }
}
}
// Simple sorted-array implementation. O(log n) via binary search for query;
// mutations walk a contiguous slice, so O(k) with amortized O(1) touched.
class RangeModule {
private starts: number[] = [];
private ends: number[] = [];
private bisectRight(arr: number[], x: number): number {
let lo = 0, hi = arr.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (arr[mid] <= x) lo = mid + 1;
else hi = mid;
}
return lo;
}
addRange(left: number, right: number): void {
let lo = left, hi = right;
// Find first interval with start > hi; everything before may touch.
const end = this.bisectRight(this.starts, hi);
// Walk backwards from end - 1 while the interval touches.
let removeLo = end, removeHi = end;
for (let i = end - 1; i >= 0 && this.ends[i] >= lo; i--) {
if (this.starts[i] < lo) lo = this.starts[i];
if (this.ends[i] > hi) hi = this.ends[i];
removeLo = i;
}
this.starts.splice(removeLo, removeHi - removeLo, lo);
this.ends.splice(removeLo, removeHi - removeLo, hi);
}
queryRange(left: number, right: number): boolean {
const i = this.bisectRight(this.starts, left) - 1;
return i >= 0 && this.ends[i] >= right;
}
removeRange(left: number, right: number): void {
const end = this.bisectRight(this.starts, right - 1);
let start = end;
while (start - 1 >= 0 && this.ends[start - 1] > left) start--;
if (start === end) return;
const chunkStarts = this.starts.splice(start, end - start);
const chunkEnds = this.ends.splice(start, end - start);
const replaceStarts: number[] = [];
const replaceEnds: number[] = [];
for (let i = 0; i < chunkStarts.length; i++) {
if (chunkStarts[i] < left) {
replaceStarts.push(chunkStarts[i]);
replaceEnds.push(left);
}
if (chunkEnds[i] > right) {
replaceStarts.push(right);
replaceEnds.push(chunkEnds[i]);
}
}
this.starts.splice(start, 0, ...replaceStarts);
this.ends.splice(start, 0, ...replaceEnds);
}
}Trace
Starting empty, running add(10,20); remove(14,16); query(10,14); query(13,15); query(16,17):
| op | intervals after | return |
|---|---|---|
| add(10, 20) | {[10, 20)} | — |
| remove(14, 16) | {[10, 14), [16, 20)} | — |
| query(10, 14) | unchanged | true (predecessor [10, 14) covers) |
| query(13, 15) | unchanged | false (predecessor [10, 14) ends at 14 less than 15) |
| query(16, 17) | unchanged | true (predecessor [16, 20) covers) |
Edge cases
- Adjacent adds (
addRange(0, 3); addRange(3, 5)) — merged thanks to the≥(not strict) overlap check with half-open intervals. - Remove adjacent, not overlapping (stored
[0, 3),removeRange(3, 5)) — strict>on the end check prevents a spurious split; nothing changes. - Query on untouched region — predecessor lookup returns
None/negative index →falseimmediately. - Degenerate intervals (
addRange(x, x)) — the caller is expected not to send these, but our code treats them as a no-op:lo == hi, nothing to merge into.
Mixing strict vs. non-strict comparisons between addRange (touch counts) and removeRange (only strict overlap) is the single most common bug. Half-open intervals make the right answer crisp: ≥ for add, > for remove.
Takeaway
Ordered-map interval management is the workhorse of any "track ranges over time" problem. Order books, time-series presence, kernel page allocators, rate-limit windows — same structure. In the interview, reach for it the moment you see "half-open" or "interval add / query / remove".
If the language lacks a native ordered map (TypeScript), a sorted starts/ends pair with binary search is clean enough to write in 10 minutes. Resist the urge to implement a balanced BST from scratch.
More in Timed Random Hards
Sweep line + ordered multiset. Compose techniques under pressure.
Bitmask DP (TSP variant). Can you identify it within 3 minutes?
Set cover via bitmask DP. n ≤ 16 is the signal.
2D sweep line + coordinate compression. Multi-technique composition.