Algorithms
Dependency order
The order to recompute in, the cycle when there is none, and what a change affects — over typed arrays.
Behaviour
Every node appears once, after everything it depends on
fc.assert(
fc.property(acyclic, ({ count, edges }) => {
const order = topologicalOrder(graphFrom(count, edges));
if (order === null || new Set(order).size !== count) return false;
const position = new Map([...order].map((node, index) => [node, index]));
for (let i = 0; i < edges.length; i += 2) {
if (position.get(edges[i]!)! >= position.get(edges[i + 1]!)!) return false;
}
return true;
}),
);A graph with a cycle has no order
expect(topologicalOrder(graphFrom(3, [0, 1, 1, 2, 2, 0]))).toBeNull();
expect(topologicalOrder(graphFrom(2, [0, 1, 1, 1]))).toBeNull();A cycle is reported as the nodes that form it, each leading to the next
fc.assert(
fc.property(acyclic, fc.nat(), ({ count, edges }, pick) => {
fc.pre(edges.length > 0);
// Closing any edge's path back on itself makes a cycle.
const at = (pick % (edges.length / 2)) * 2;
const cyclic = [...edges, edges[at + 1]!, edges[at]!];
const cycle = findCycle(graphFrom(count, cyclic));
if (cycle === null || cycle.length < 2) return false;
const has = (from: number, to: number) => {
for (let i = 0; i < cyclic.length; i += 2)
if (cyclic[i] === from && cyclic[i + 1] === to) return true;
return false;
};
return [...cycle].every((node, index) => has(node, cycle[(index + 1) % cycle.length]!));
}),
);A graph without a cycle reports none
fc.assert(fc.property(acyclic, ({ count, edges }) => findCycle(graphFrom(count, edges)) === null));What a change affects is the changed nodes and all that depend on them, in dependency order
fc.assert(
fc.property(acyclic, fc.array(fc.nat(), { minLength: 1, maxLength: 4 }), ({ count, edges }, picks) => {
const graph = graphFrom(count, edges);
const order = topologicalOrder(graph)!;
const changed = picks.map((pick) => pick % count);
const affected = [...affectedBy(graph, order, changed)];
const expected = reachable(edges, changed);
const position = new Map([...order].map((node, index) => [node, index]));
return (
affected.length === expected.size &&
affected.every((node) => expected.has(node)) &&
affected.every(
(node, index) => index === 0 || position.get(affected[index - 1]!)! < position.get(node)!,
)
);
}),
);A node named twice among the changed is recomputed once
const graph = graphFrom(4, [0, 1, 1, 2, 0, 2]);
const order = topologicalOrder(graph)!;
expect([...affectedBy(graph, order, [1, 0, 1, 0])]).toEqual([0, 1, 2]);
expect([...affectedBy(graph, order, [])]).toEqual([]);Asking again gives the same answer: nothing of one question is left for the next
fc.assert(
fc.property(
acyclic,
fc.array(fc.array(fc.nat(), { maxLength: 4 }), { minLength: 2, maxLength: 6 }),
({ count, edges }, questions) => {
const graph = graphFrom(count, edges);
const order = topologicalOrder(graph)!;
return questions.every((picks) => {
const changed = picks.map((pick) => pick % count);
const expected = reachable(edges, changed);
const affected = affectedBy(graph, order, changed);
return affected.length === expected.size && affected.every((node) => expected.has(node));
});
},
),
);A change to a node that is not in the graph is an error, and the next question is answered as usual
const graph = graphFrom(4, [0, 1, 1, 2]);
const order = topologicalOrder(graph)!;
for (const node of [4, -1, 0.5, Number.NaN]) {
expect(() => affectedBy(graph, order, [0, 1, node])).toThrow(RangeError);
expect([...affectedBy(graph, order, [1])]).toEqual([1, 2]);
}An order of another number of nodes than the graph's is an error
const graph = graphFrom(4, [0, 1, 1, 2]);
const order = topologicalOrder(graphFrom(3, [0, 1]))!;
expect(() => affectedBy(graph, order, [0])).toThrow(RangeError);An edge to or from a node that is not in the graph is an error
for (const edge of [
[0, 3],
[3, 0],
[-1, 0],
[0, -1],
[0.5, 1],
[1, Number.NaN],
]) {
expect(() => graphFrom(3, [0, 1, ...edge, 1, 2])).toThrow(RangeError);
}
expect(() => graphFrom(0, [0, 0])).toThrow(RangeError);Edges are two nodes each; a list that ends partway through one is an error
expect(() => graphFrom(3, [0, 1, 2])).toThrow("two nodes each");A graph holds up to 2^31 − 2 nodes and 2^31 − 1 edges; a larger one, or a number of nodes that is not whole, is an error
// Refused on the numbers alone, before anything is read or made.
const unread = (length: number) =>
new Proxy<ArrayLike<number>>(
{ length },
{
get(target, key) {
if (key === "length") return target.length;
throw new Error("read");
},
},
);
expect(() => graphFrom(2 ** 31 - 1, unread(2))).toThrow("at most 2^31 − 2");
expect(() => graphFrom(-1, unread(2))).toThrow("whole number of nodes");
expect(() => graphFrom(2.5, unread(2))).toThrow("whole number of nodes");
expect(() => graphFrom(3, unread(2 ** 32))).toThrow("at most 2^31 − 1 edges");Ordering a graph reads each node and edge a fixed number of times
const nodes = 20_000;
const edges: number[] = [];
for (let node = 0; node < nodes; node += 1) {
for (const ahead of [1, 7, 31]) if (node + ahead < nodes) edges.push(node, node + ahead);
}
const graph = graphFrom(nodes, edges);
const offsets = counted(graph.offsets);
const targets = counted(graph.targets);
expect(topologicalOrder({ offsets: offsets.array, targets: targets.array })).not.toBeNull();
expect(offsets.accesses() + targets.accesses()).toBeLessThanOrEqual(4 * (nodes + edges.length / 2));Recomputing after a change costs what depends on it, not the size of the graph
// A million nodes in chains of ten: a change reaches at most nine others.
const nodes = 1_000_000;
const edges: number[] = [];
for (let node = 0; node < nodes; node += 1) if (node % 10 !== 9) edges.push(node, node + 1);
const graph = graphFrom(nodes, edges);
const order = topologicalOrder(graph)!;
affectedBy(graph, order, [0]);
const offsets = counted(graph.offsets);
const targets = counted(graph.targets);
const affected = affectedBy({ offsets: offsets.array, targets: targets.array }, order, [500_003]);
expect([...affected]).toEqual([500_003, 500_004, 500_005, 500_006, 500_007, 500_008, 500_009]);
expect(offsets.accesses() + targets.accesses()).toBeLessThanOrEqual(40);React
Install
npx shadcn@latest add https://ui.publictheta.com/r/topological.jsonSource
src/algorithms/topological.ts
/**
* Dependency order over a directed graph: the order to recompute things in so
* that each is computed after everything it depends on — the formulas of a
* grid, the nodes of a flow, the constraints of a layout.
*
* A graph is held in compressed sparse rows, two typed arrays, so a traversal
* touches memory in order and allocates nothing per node. An edge `from → to`
* means `to` depends on `from`: `from` comes first.
*
* Nodes and positions among the edges are 32-bit integers, so a graph holds up
* to 2^31 − 2 nodes and 2^31 − 1 edges. A node that is not in the graph — in an
* edge, or among those said to have changed — is a mistake in the caller, and
* throws a `RangeError`.
*/
export interface Graph {
/** For node n, its targets are `targets[offsets[n] … offsets[n + 1])`. */
readonly offsets: Int32Array;
readonly targets: Int32Array;
}
const MAX_NODES = 2 ** 31 - 2;
const MAX_EDGES = 2 ** 31 - 1;
/** A graph of `nodeCount` nodes from a flat list of edges, `[from0, to0, from1, …]`, in O(V + E). */
export function graphFrom(nodeCount: number, edges: ArrayLike<number>): Graph {
if (!Number.isInteger(nodeCount) || nodeCount < 0 || nodeCount > MAX_NODES) {
throw new RangeError(`A graph holds a whole number of nodes, at most 2^31 − 2, not ${nodeCount}.`);
}
const edgeCount = edges.length / 2;
if (!Number.isInteger(edgeCount)) {
throw new RangeError(`Edges are two nodes each; ${edges.length} is not a multiple of two.`);
}
if (edgeCount > MAX_EDGES) throw new RangeError(`A graph holds at most 2^31 − 1 edges, not ${edgeCount}.`);
const offsets = new Int32Array(nodeCount + 1);
for (let i = 0; i < edges.length; i += 2) {
const from = edges[i]!;
const to = edges[i + 1]!;
// One comparison each refuses negatives, fractions and what is not a number.
if (from >>> 0 !== from || from >= nodeCount || to >>> 0 !== to || to >= nodeCount) {
throw new RangeError(`Edge ${i / 2}, ${from} → ${to}, names a node that is not among ${nodeCount}.`);
}
offsets[from + 1]! += 1;
}
for (let node = 0; node < nodeCount; node += 1) offsets[node + 1]! += offsets[node]!;
const targets = new Int32Array(edgeCount);
const next = offsets.slice(0, nodeCount);
for (let i = 0; i < edges.length; i += 2) {
const from = edges[i]!;
targets[next[from]!] = edges[i + 1]!;
next[from]! += 1;
}
return { offsets, targets };
}
/**
* Every node, each after all the nodes it depends on — or `null` when the graph
* has a cycle and no such order exists. O(V + E).
*/
export function topologicalOrder({ offsets, targets }: Graph): Int32Array | null {
const count = offsets.length - 1;
const pending = new Int32Array(count);
for (let i = 0; i < targets.length; i += 1) pending[targets[i]!]! += 1;
// The order doubles as the queue: nodes are appended as they become ready.
const order = new Int32Array(count);
let ready = 0;
for (let node = 0; node < count; node += 1) {
if (pending[node] === 0) order[ready++] = node;
}
for (let done = 0; done < ready; done += 1) {
const node = order[done]!;
for (let i = offsets[node]!; i < offsets[node + 1]!; i += 1) {
const target = targets[i]!;
pending[target]! -= 1;
if (pending[target] === 0) order[ready++] = target;
}
}
return ready === count ? order : null;
}
/**
* The nodes of one cycle, each depending on the one before and the first on the
* last — what to show people when their formulas refer to each other — or
* `null` when there is none. O(V + E).
*/
export function findCycle({ offsets, targets }: Graph): Int32Array | null {
const count = offsets.length - 1;
// 0 unvisited, 1 on the current path, 2 finished.
const state = new Uint8Array(count);
const path = new Int32Array(count);
const cursor = new Int32Array(count);
for (let root = 0; root < count; root += 1) {
if (state[root] !== 0) continue;
let depth = 0;
path[0] = root;
cursor[0] = offsets[root]!;
state[root] = 1;
while (depth >= 0) {
const node = path[depth]!;
const next = cursor[depth]!;
if (next === offsets[node + 1]) {
state[node] = 2;
depth -= 1;
continue;
}
const target = targets[next]!;
cursor[depth] = next + 1;
if (state[target] === 1) return path.slice(path.indexOf(target), depth + 1);
if (state[target] === 0) {
depth += 1;
path[depth] = target;
cursor[depth] = offsets[target]!;
state[target] = 1;
}
}
}
return null;
}
interface Scratch {
/** Each node's position in the order. */
readonly rank: Int32Array;
/** 1 for a node already found in the call under way; all 0 between calls. */
readonly seen: Uint8Array;
/** The nodes found so far, each waiting its turn to have its edges followed. */
readonly queue: Int32Array;
}
const scratches = new WeakMap<Int32Array, Scratch>();
function scratchFor(order: Int32Array): Scratch {
let scratch = scratches.get(order);
if (scratch === undefined) {
const rank = new Int32Array(order.length);
for (let position = 0; position < order.length; position += 1) rank[order[position]!] = position;
scratch = { rank, seen: new Uint8Array(order.length), queue: new Int32Array(order.length) };
scratches.set(order, scratch);
}
return scratch;
}
/**
* What must be recomputed when `changed` nodes change: those nodes and
* everything that depends on them, in the order `order` has them.
*
* The first call for an order makes scratch space the size of the graph. After
* it, a call allocates only what it returns, and costs the k nodes it returns,
* their edges, and the O(k log k) of sorting them into the order — not the size
* of the graph.
*/
export function affectedBy(graph: Graph, order: Int32Array, changed: ArrayLike<number>): Int32Array {
const { offsets, targets } = graph;
const count = offsets.length - 1;
if (order.length !== count) {
throw new RangeError(`An order of ${order.length} nodes is not an order of this graph's ${count}.`);
}
const { rank, seen, queue } = scratchFor(order);
let found = 0;
for (let i = 0; i < changed.length; i += 1) {
const node = changed[i]!;
if (node >>> 0 !== node || node >= count) {
// Leave no marks behind for the next call to trip on.
for (let j = 0; j < found; j += 1) seen[queue[j]!] = 0;
throw new RangeError(`Changed node ${node} is not among ${count}.`);
}
if (seen[node] === 0) {
seen[node] = 1;
queue[found++] = node;
}
}
for (let head = 0; head < found; head += 1) {
const node = queue[head]!;
for (let i = offsets[node]!; i < offsets[node + 1]!; i += 1) {
const target = targets[i]!;
if (seen[target] === 0) {
seen[target] = 1;
queue[found++] = target;
}
}
}
// Unmarking as they are read out costs only the result, where a count of
// calls would one day pass what its marks could hold.
const affected = new Int32Array(found);
for (let i = 0; i < found; i += 1) {
const node = queue[i]!;
seen[node] = 0;
affected[i] = rank[node]!;
}
// Sorting positions in the order, then reading the order back, sorts the nodes.
// In place: the array was made just above, for this.
// oxlint-disable-next-line unicorn/no-array-sort
affected.sort();
for (let i = 0; i < found; i += 1) affected[i] = order[affected[i]!]!;
return affected;
}