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.json
Source

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;
  }
}