Algorithms
Range set
A set of integers held as ranges, so a selection over a million rows is a handful of numbers.
Behaviour
After any additions and removals, it holds exactly the integers a plain set would
fc.assert(
fc.property(operations, (steps) => {
let set = EMPTY_RANGE_SET;
const model = new Set<number>();
for (const [add, [start, end]] of steps) {
set = add ? rangeSetAdd(set, start, end) : rangeSetRemove(set, start, end);
for (let value = start; value < end; value += 1) {
if (add) model.add(value);
else model.delete(value);
}
}
for (let value = -1; value <= LIMIT + 1; value += 1) {
if (rangeSetHas(set, value) !== model.has(value)) return false;
}
return canonical(set) && rangeSetSize(set) === model.size;
}),
);Ranges that overlap or touch merge into one
expect(rangeSetAdd(rangeSetAdd(EMPTY_RANGE_SET, 0, 5), 5, 10)).toEqual([0, 10]);
expect(rangeSetAdd([0, 5, 10, 15, 20, 25], 3, 22)).toEqual([0, 25]);Removing from the middle of a range leaves both sides
expect(rangeSetRemove([0, 1_000_000], 10, 13)).toEqual([0, 10, 13, 1_000_000]);A change that changes nothing returns the same set, so nothing re-renders
const set: RangeSet = [0, 10, 20, 30];
expect(rangeSetAdd(set, 2, 8)).toBe(set);
expect(rangeSetAdd(set, 5, 5)).toBe(set);
expect(rangeSetRemove(set, 10, 20)).toBe(set);
expect(rangeSetRemove(set, 40, 50)).toBe(set);Its argument is never modified
const set: RangeSet = Object.freeze([0, 10]);
rangeSetAdd(set, 20, 30);
rangeSetRemove(set, 2, 4);
expect(set).toEqual([0, 10]);Inverting within bounds gives what the set does not hold there
expect(rangeSetInvert([2, 4, 8, 10], 0, 12)).toEqual([0, 2, 4, 8, 10, 12]);
expect(rangeSetInvert(EMPTY_RANGE_SET, 0, 5)).toEqual([0, 5]);
expect(rangeSetInvert([0, 5], 0, 5)).toEqual([]);Inverting within any bounds agrees with checking every integer there
fc.assert(
fc.property(operations, span, (steps, [start, end]) => {
let set = EMPTY_RANGE_SET;
for (const [add, [from, to]] of steps) {
set = add ? rangeSetAdd(set, from, to) : rangeSetRemove(set, from, to);
}
const inverted = rangeSetInvert(set, start, end);
for (let value = -1; value <= LIMIT + 1; value += 1) {
const outside = value >= start && value < end && !rangeSetHas(set, value);
if (rangeSetHas(inverted, value) !== outside) return false;
}
return canonical(inverted);
}),
);A range that is empty, backwards or not a number changes nothing
const set: RangeSet = [0, 10];
for (const [start, end] of [
[5, 5],
[8, 2],
[Number.NaN, 20],
[20, Number.NaN],
] as const) {
expect(rangeSetAdd(set, start, end)).toBe(set);
expect(rangeSetRemove(set, start, end)).toBe(set);
expect(rangeSetInvert(set, start, end)).toEqual([]);
}Its integers are listed in order
expect([...rangeSetValues([1, 3, 7, 9])]).toEqual([1, 2, 7, 8]);Membership among a hundred thousand ranges reads at most eighteen numbers
const ranges = Array.from({ length: 200_000 }, (_, i) => (i % 2 === 0 ? i * 5 : (i - 1) * 5 + 3));
const set = counted(ranges);
for (const value of [0, 4, 499_998, 999_993, 2_000_000]) {
set.reset();
rangeSetHas(set.array, value);
expect(set.accesses()).toBeLessThanOrEqual(18);
}Inverting a hundred thousand ranges reads each of their numbers once, after one search
const ranges = Array.from({ length: 200_000 }, (_, i) => (i % 2 === 0 ? i * 5 : (i - 1) * 5 + 3));
const set = counted(ranges);
const inverted = rangeSetInvert(set.array, 0, 1_000_000);
expect(inverted).toHaveLength(200_000);
expect(inverted.slice(0, 4)).toEqual([3, 10, 13, 20]);
expect(set.accesses()).toBeLessThanOrEqual(200_000 + 18);Inverting a short span of a large set reads only the ranges in that span
const ranges = Array.from({ length: 200_000 }, (_, i) => (i % 2 === 0 ? i * 5 : (i - 1) * 5 + 3));
const set = counted(ranges);
expect(rangeSetInvert(set.array, 500_000, 500_030)).toEqual([
500_003, 500_010, 500_013, 500_020, 500_023, 500_030,
]);
expect(set.accesses()).toBeLessThanOrEqual(18 + 8);React
Install
npx shadcn@latest add https://ui.publictheta.com/r/range-set.jsonSource
src/algorithms/range-set.ts
/**
* A set of integers held as ranges: `[0, 1_000_000)` is two numbers, and
* "everything but these three rows" is four ranges. It is what a selection over
* a million rows needs, where a set of row numbers would be a million entries.
*
* A range set is a flat array of half-open ranges โ `[start0, end0, start1, โฆ]`
* โ kept sorted, disjoint and merged, so two equal sets are equal arrays. Every
* operation returns a new array and leaves its argument alone, which is what
* React state wants; membership is O(log r) in the number of ranges, a change or
* an inversion O(r).
*
* Bounds are safe integers, to ยฑ2^53; nothing here is narrowed to 32 bits, so
* the number of ranges is limited only by the length of an array.
*/
export type RangeSet = readonly number[];
export const EMPTY_RANGE_SET: RangeSet = Object.freeze([]);
/** The index of the first range that ends after `value`, by binary search. */
function firstEndingAfter(set: RangeSet, value: number): number {
let low = 0;
let high = set.length / 2;
while (low < high) {
const middle = (low + high) >>> 1;
if (set[middle * 2 + 1]! > value) high = middle;
else low = middle + 1;
}
return low;
}
export function rangeSetHas(set: RangeSet, value: number): boolean {
const index = firstEndingAfter(set, value) * 2;
return index < set.length && set[index]! <= value;
}
/** The set with every integer in `[start, end)` added. */
export function rangeSetAdd(set: RangeSet, start: number, end: number): RangeSet {
// Written this way round so that a bound that is not a number is no range either.
if (!(start < end)) return set;
// Ranges that touch the new one merge with it: the first ending at or after
// `start`, through the last starting at or before `end`.
const first = firstEndingAfter(set, start - 1);
let last = first;
while (last * 2 < set.length && set[last * 2]! <= end) last += 1;
if (last === first) return [...set.slice(0, first * 2), start, end, ...set.slice(first * 2)];
const mergedStart = Math.min(start, set[first * 2]!);
const mergedEnd = Math.max(end, set[last * 2 - 1]!);
if (last - first === 1 && mergedStart === set[first * 2] && mergedEnd === set[first * 2 + 1]) return set;
return [...set.slice(0, first * 2), mergedStart, mergedEnd, ...set.slice(last * 2)];
}
/** The set with every integer in `[start, end)` removed. */
export function rangeSetRemove(set: RangeSet, start: number, end: number): RangeSet {
if (!(start < end)) return set;
const first = firstEndingAfter(set, start);
let last = first;
while (last * 2 < set.length && set[last * 2]! < end) last += 1;
if (last === first) return set;
const kept: number[] = [];
// What the first and last overlapped ranges have outside the removed span.
if (set[first * 2]! < start) kept.push(set[first * 2]!, start);
if (set[last * 2 - 1]! > end) kept.push(end, set[last * 2 - 1]!);
return [...set.slice(0, first * 2), ...kept, ...set.slice(last * 2)];
}
/** How many integers the set holds. */
export function rangeSetSize(set: RangeSet): number {
let size = 0;
for (let i = 0; i < set.length; i += 2) size += set[i + 1]! - set[i]!;
return size;
}
/**
* Every integer of `[start, end)` that the set does not hold: one search for
* where `start` falls, then one pass over the ranges up to `end`.
*/
export function rangeSetInvert(set: RangeSet, start: number, end: number): RangeSet {
if (!(start < end)) return EMPTY_RANGE_SET;
const inverted: number[] = [];
// Where the gap being measured began.
let gap = start;
for (let i = firstEndingAfter(set, start) * 2; i < set.length && gap < end; i += 2) {
const rangeStart = set[i]!;
if (rangeStart >= end) break;
if (rangeStart > gap) inverted.push(gap, rangeStart);
gap = set[i + 1]!;
}
if (gap < end) inverted.push(gap, end);
return inverted;
}
/** The integers of the set, in order. */
export function* rangeSetValues(set: RangeSet): Generator<number, void, void> {
for (let i = 0; i < set.length; i += 2) {
for (let value = set[i]!; value < set[i + 1]!; value += 1) yield value;
}
}