diff --git a/ROADMAP.md b/ROADMAP.md index 56ae73b..78cb668 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -93,7 +93,7 @@ - [x] Marching cubes isosurface generation **Graph algorithms** - [x] Minimum spanning tree (Kruskal) - - [ ] Strongly connected components (Tarjan/Kosaraju) + - [x] Strongly connected components (Tarjan/Kosaraju) - [ ] Maximum flow (Dinic preferred; Edmonds–Karp fallback) **Spatial & collision expansion** - [ ] Octree partitioning for 3D space diff --git a/docs/index.d.ts b/docs/index.d.ts index ff0e160..1b8f2b0 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -143,6 +143,7 @@ export const examples: { readonly topologicalSort: 'examples/graph.ts'; readonly computeMinimumSpanningTree: 'examples/kruskal.ts'; readonly computeStronglyConnectedComponents: 'examples/scc.ts'; + readonly computeMaximumFlowDinic: 'examples/maxflow.ts'; }; readonly geometry: { readonly convexHull: 'examples/geometry.ts'; @@ -2919,6 +2920,42 @@ export function computeStronglyConnectedComponents(graph: Graph): SCCResult; */ export function buildCondensationGraph(graph: Graph, components: string[][]): Graph; +/** + * Edge description for capacity graphs. + * Import: graph/maxflow.ts + */ +export interface FlowEdge { + source: string; + target: string; + capacity: number; +} + +/** + * Dinic maximum flow configuration. + * Import: graph/maxflow.ts + */ +export interface MaxFlowOptions { + nodes: ReadonlyArray; + edges: ReadonlyArray; + source: string; + sink: string; +} + +/** + * Maximum flow result with per-edge flows. + * Import: graph/maxflow.ts + */ +export interface MaxFlowResult { + maxFlow: number; + flows: Array<{ source: string; target: string; flow: number }>; +} + +/** + * Computes maximum flow using Dinic's algorithm with residual network. + * Import: graph/maxflow.ts + */ +export function computeMaximumFlowDinic(options: MaxFlowOptions): MaxFlowResult; + // ============================================================================ // 📐 GEOMETRY & VISUALS // ============================================================================ diff --git a/examples/maxflow.ts b/examples/maxflow.ts new file mode 100644 index 0000000..ff1ff5b --- /dev/null +++ b/examples/maxflow.ts @@ -0,0 +1,15 @@ +import { computeMaximumFlowDinic } from '../src/index.js'; + +const nodes = ['S', 'A', 'B', 'T']; +const edges = [ + { source: 'S', target: 'A', capacity: 10 }, + { source: 'S', target: 'B', capacity: 5 }, + { source: 'A', target: 'B', capacity: 15 }, + { source: 'A', target: 'T', capacity: 10 }, + { source: 'B', target: 'T', capacity: 10 }, +]; + +const result = computeMaximumFlowDinic({ nodes, edges, source: 'S', sink: 'T' }); +console.log('MaxFlow:', result.maxFlow); +console.log('Flows:', result.flows); + diff --git a/src/graph/maxflow.ts b/src/graph/maxflow.ts new file mode 100644 index 0000000..d267d0b --- /dev/null +++ b/src/graph/maxflow.ts @@ -0,0 +1,129 @@ +export interface FlowEdge { + source: string; + target: string; + capacity: number; +} + +export interface MaxFlowOptions { + nodes: ReadonlyArray; + edges: ReadonlyArray; + source: string; + sink: string; +} + +export interface MaxFlowResult { + maxFlow: number; + flows: Array<{ source: string; target: string; flow: number }>; +} + +interface EdgeInternal { + to: number; + rev: number; + capacity: number; +} + +export function computeMaximumFlowDinic(options: MaxFlowOptions): MaxFlowResult { + validateOptions(options); + const indexByNode = new Map(); + options.nodes.forEach((id, idx) => indexByNode.set(id, idx)); + const n = options.nodes.length; + const graph: EdgeInternal[][] = Array.from({ length: n }, () => []); + + function addEdge(uIdx: number, vIdx: number, cap: number): void { + const a: EdgeInternal = { to: vIdx, rev: graph[vIdx].length, capacity: cap }; + const b: EdgeInternal = { to: uIdx, rev: graph[uIdx].length, capacity: 0 }; + graph[uIdx].push(a); + graph[vIdx].push(b); + } + + for (const e of options.edges) { + const u = indexByNode.get(e.source)!; + const v = indexByNode.get(e.target)!; + addEdge(u, v, e.capacity); + } + + const s = indexByNode.get(options.source)!; + const t = indexByNode.get(options.sink)!; + + let maxFlow = 0; + const level = new Array(n).fill(-1); + const it = new Array(n).fill(0); + + function bfs(): boolean { + level.fill(-1); + const queue: number[] = [s]; + level[s] = 0; + for (let qi = 0; qi < queue.length; qi += 1) { + const v = queue[qi]; + for (const e of graph[v]) { + if (e.capacity > 0 && level[e.to] < 0) { + level[e.to] = level[v] + 1; + queue.push(e.to); + } + } + } + return level[t] >= 0; + } + + function dfs(v: number, f: number): number { + if (v === t) return f; + for (let i = it[v]; i < graph[v].length; i += 1) { + it[v] = i; + const e = graph[v][i]; + if (e.capacity <= 0 || level[v] + 1 !== level[e.to]) continue; + const d = dfs(e.to, Math.min(f, e.capacity)); + if (d > 0) { + e.capacity -= d; + graph[e.to][e.rev].capacity += d; + return d; + } + } + return 0; + } + + while (bfs()) { + it.fill(0); + let flow; + // eslint-disable-next-line no-cond-assign + while ((flow = dfs(s, Number.POSITIVE_INFINITY)) > 0) { + maxFlow += flow; + } + } + + // Extract flows on original directed edges + const flows: Array<{ source: string; target: string; flow: number }> = []; + for (const e of options.edges) { + const u = indexByNode.get(e.source)!; + const v = indexByNode.get(e.target)!; + // Residual capacity back edge holds the flow pushed + let pushed = 0; + for (const edge of graph[v]) { + if (edge.to === u) { + pushed += edge.capacity; // this is the accumulated back capacity + } + } + const flowValue = Math.max(0, pushed); + flows.push({ source: e.source, target: e.target, flow: flowValue }); + } + + return { maxFlow, flows }; +} + +function validateOptions(options: MaxFlowOptions): void { + if (!Array.isArray(options.nodes) || options.nodes.length === 0) { + throw new Error('nodes must contain at least one node.'); + } + const seen = new Set(options.nodes); + if (!seen.has(options.source) || !seen.has(options.sink)) { + throw new Error('source and sink must be present in nodes.'); + } + for (const { source, target, capacity } of options.edges) { + if (!seen.has(source) || !seen.has(target)) { + throw new Error(`Edge references unknown node: ${source}-${target}`); + } + if (capacity < 0 || !Number.isFinite(capacity)) { + throw new Error('capacity must be a finite non-negative number.'); + } + } +} + diff --git a/src/index.ts b/src/index.ts index 3f8d06e..f4fe9d2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -142,6 +142,7 @@ export const examples = { topologicalSort: 'examples/graph.ts', computeMinimumSpanningTree: 'examples/kruskal.ts', computeStronglyConnectedComponents: 'examples/scc.ts', + computeMaximumFlowDinic: 'examples/maxflow.ts', }, geometry: { convexHull: 'examples/geometry.ts', @@ -1011,6 +1012,13 @@ export { computeStronglyConnectedComponents, buildCondensationGraph } from './gr export type { SCCResult } from './graph/scc.js'; +/** + * Maximum flow via Dinic with residual network and edge flows. + */ +export { computeMaximumFlowDinic } from './graph/maxflow.js'; + +export type { MaxFlowOptions, MaxFlowResult, FlowEdge } from './graph/maxflow.js'; + // ============================================================================ // 📐 GEOMETRY UTILITIES // ============================================================================ diff --git a/tests/maxflow.test.ts b/tests/maxflow.test.ts new file mode 100644 index 0000000..d712e0b --- /dev/null +++ b/tests/maxflow.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { computeMaximumFlowDinic } from '../src/index.js'; + +describe('computeMaximumFlowDinic', () => { + it('computes max flow on a small graph', () => { + const nodes = ['S', 'A', 'B', 'T']; + const edges = [ + { source: 'S', target: 'A', capacity: 10 }, + { source: 'S', target: 'B', capacity: 5 }, + { source: 'A', target: 'B', capacity: 15 }, + { source: 'A', target: 'T', capacity: 10 }, + { source: 'B', target: 'T', capacity: 10 }, + ]; + + const result = computeMaximumFlowDinic({ nodes, edges, source: 'S', sink: 'T' }); + expect(result.maxFlow).toBe(15); + const outOfS = result.flows + .filter((e) => e.source === 'S') + .reduce((sum, e) => sum + e.flow, 0); + expect(outOfS).toBe(result.maxFlow); + }); + + it('validates inputs', () => { + expect(() => + computeMaximumFlowDinic({ nodes: [], edges: [], source: 'S', sink: 'T' }) + ).toThrow('nodes must contain at least one node.'); + expect(() => + computeMaximumFlowDinic({ nodes: ['S'], edges: [], source: 'S', sink: 'T' }) + ).toThrow('source and sink must be present in nodes.'); + }); +}); +