diff --git a/README.md b/README.md index 48c481f..c15774e 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ CDN usage: | Gameplay systems | `createDeltaTimeManager`, `createFixedTimestepLoop`, `createCamera2D`, `createParticleSystem`, `createSpriteAnimation`, `createTweenSystem`, `createPlatformerController`, `createTopDownController`, `createTileMapController`, `computeFieldOfView`, `createInventory`, `calculateDamage`, `createCooldownController`, `updateStatusEffects`, `createQuestMachine`, `createWaveSpawner`, `createSoundManager`, `createInputManager`, `createSaveManager`, `createScreenTransition` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts`, `gameplay/spriteAnimation.ts`, `gameplay/tween.ts`, `gameplay/platformerPhysics.ts`, `gameplay/topDownMovement.ts`, `gameplay/tileMap.ts`, `gameplay/shadowcasting.ts`, `gameplay/inventory.ts`, `gameplay/combat.ts`, `gameplay/questMachine.ts`, `gameplay/waveSpawner.ts`, `gameplay/soundManager.ts`, `gameplay/inputManager.ts`, `gameplay/saveManager.ts`, `gameplay/screenTransitions.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/platformerPhysics.ts`, `examples/topDownMovement.ts`, `examples/tileMap.ts`, `examples/shadowcasting.ts`, `examples/inventory.ts`, `examples/combat.ts`, `examples/quest.ts`, `examples/waveSpawner.ts`, `examples/soundManager.ts`, `examples/inputManager.ts`, `examples/saveManager.ts`, `examples/screenTransitions.ts` | | Search & text | `fuzzySearch`, `fuzzyScore`, `Trie`, `binarySearch`, `levenshteinDistance`, `kmpSearch`, `rabinKarp`, `boyerMooreSearch`, `buildSuffixArray`, `longestCommonSubsequence`, `diffStrings` | `search/*.ts` | `examples/search.ts` | | Data & diff pipelines | `diff`, `deepClone`, `groupBy`, `diffJson`, `diffJsonAdvanced`, `applyJsonDiff`, `applyJsonDiffSelective`, `flatten`, `unflatten`, `diffTree`, `applyTreeDiff` | `data/*.ts` | `examples/jsonDiff.ts`, `examples/treeDiff.ts` | -| Graph algorithms | `graphBFS`, `graphDFS`, `topologicalSort` | `graph/traversal.ts` | `examples/graph.ts` | +| Graph algorithms | `graphBFS`, `graphDFS`, `topologicalSort`, `computeMinimumSpanningTree` | `graph/traversal.ts`, `graph/kruskal.ts` | `examples/graph.ts`, `examples/kruskal.ts` | | Visual & geometry | `convexHull`, `lineIntersection`, `pointInPolygon`, `bresenhamLine`, `easing`, `quadraticBezier`, `cubicBezier`, `hexToRgb`, `rgbToHex`, `rgbToHsl`, `hslToRgb`, `mixRgbColors`, `computeForceDirectedLayout`, `computeMarchingSquares`, `computeMarchingCubes` | `geometry/*.ts`, `visual/*.ts` | `examples/geometry.ts`, `examples/bresenham.ts`, `examples/visual.ts`, `examples/color.ts`, `examples/forceDirected.ts`, `examples/marchingSquares.ts`, `examples/marchingCubes.ts` | ## Scripts diff --git a/ROADMAP.md b/ROADMAP.md index 4624733..d5df7b3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -92,7 +92,7 @@ - [x] Marching squares contour extraction - [x] Marching cubes isosurface generation - **Graph algorithms** - - [ ] Minimum spanning tree (Kruskal) + - [x] Minimum spanning tree (Kruskal) - [ ] Strongly connected components (Tarjan/Kosaraju) - [ ] Maximum flow (Ford–Fulkerson / Edmonds–Karp) - **Spatial & collision expansion** diff --git a/docs/index.d.ts b/docs/index.d.ts index febbf63..39835fd 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -141,6 +141,7 @@ export const examples: { readonly graphBFS: 'examples/graph.ts'; readonly graphDFS: 'examples/graph.ts'; readonly topologicalSort: 'examples/graph.ts'; + readonly computeMinimumSpanningTree: 'examples/kruskal.ts'; }; readonly geometry: { readonly convexHull: 'examples/geometry.ts'; @@ -2863,6 +2864,40 @@ export function graphDFS( */ export function topologicalSort(graph: Graph): string[]; +/** + * Weighted edge representation for MST computation. + * Import: graph/kruskal.ts + */ +export interface WeightedEdge { + source: string; + target: string; + weight: number; +} + +/** + * Options for computing a minimum spanning tree via Kruskal's algorithm. + * Import: graph/kruskal.ts + */ +export interface KruskalOptions { + nodes: ReadonlyArray; + edges: ReadonlyArray; +} + +/** + * Result payload for a minimum spanning tree. + * Import: graph/kruskal.ts + */ +export interface MinimumSpanningTree { + edges: WeightedEdge[]; + totalWeight: number; +} + +/** + * Computes a minimum spanning tree using Kruskal's algorithm. + * Import: graph/kruskal.ts + */ +export function computeMinimumSpanningTree(options: KruskalOptions): MinimumSpanningTree; + // ============================================================================ // 📐 GEOMETRY & VISUALS // ============================================================================ diff --git a/examples/kruskal.ts b/examples/kruskal.ts new file mode 100644 index 0000000..6cf1425 --- /dev/null +++ b/examples/kruskal.ts @@ -0,0 +1,16 @@ +import { computeMinimumSpanningTree } from '../src/index.js'; + +const nodes = ['A', 'B', 'C', 'D', 'E']; +const edges = [ + { source: 'A', target: 'B', weight: 2 }, + { source: 'A', target: 'C', weight: 3 }, + { source: 'B', target: 'C', weight: 1 }, + { source: 'B', target: 'D', weight: 4 }, + { source: 'C', target: 'D', weight: 5 }, + { source: 'C', target: 'E', weight: 6 }, + { source: 'D', target: 'E', weight: 7 }, +]; + +const mst = computeMinimumSpanningTree({ nodes, edges }); +console.log('MST weight:', mst.totalWeight); +console.log('Edges:', mst.edges); diff --git a/src/graph/kruskal.ts b/src/graph/kruskal.ts new file mode 100644 index 0000000..753b2c2 --- /dev/null +++ b/src/graph/kruskal.ts @@ -0,0 +1,97 @@ +export interface WeightedEdge { + source: string; + target: string; + weight: number; +} + +export interface KruskalOptions { + nodes: ReadonlyArray; + edges: ReadonlyArray; +} + +export interface MinimumSpanningTree { + edges: WeightedEdge[]; + totalWeight: number; +} + +export function computeMinimumSpanningTree(options: KruskalOptions): MinimumSpanningTree { + validateOptions(options); + const disjointSet = new DisjointSet(options.nodes); + const edges = [...options.edges].sort((a, b) => a.weight - b.weight); + const result: WeightedEdge[] = []; + let totalWeight = 0; + + for (const edge of edges) { + if (disjointSet.union(edge.source, edge.target)) { + result.push(edge); + totalWeight += edge.weight; + } + } + + return { edges: result, totalWeight }; +} + +class DisjointSet { + private parent: Map = new Map(); + private rank: Map = new Map(); + + constructor(nodes: ReadonlyArray) { + for (const node of nodes) { + this.parent.set(node, node); + this.rank.set(node, 0); + } + } + + find(node: string): string { + const parent = this.parent.get(node); + if (parent === undefined) { + throw new Error(`Unknown node: ${node}`); + } + if (parent !== node) { + const root = this.find(parent); + this.parent.set(node, root); + return root; + } + return parent; + } + + union(a: string, b: string): boolean { + const rootA = this.find(a); + const rootB = this.find(b); + if (rootA === rootB) { + return false; + } + + const rankA = this.rank.get(rootA) ?? 0; + const rankB = this.rank.get(rootB) ?? 0; + if (rankA < rankB) { + this.parent.set(rootA, rootB); + } else if (rankA > rankB) { + this.parent.set(rootB, rootA); + } else { + this.parent.set(rootB, rootA); + this.rank.set(rootA, rankA + 1); + } + return true; + } +} + +function validateOptions(options: KruskalOptions): void { + if (!Array.isArray(options.nodes) || options.nodes.length === 0) { + throw new Error('nodes must contain at least one node.'); + } + if (!Array.isArray(options.edges)) { + throw new Error('edges must be an array.'); + } + const seenNodes = new Set(options.nodes); + const edges: ReadonlyArray = options.edges; + for (const edge of edges) { + const { source, target, weight } = edge; + if (!seenNodes.has(source) || !seenNodes.has(target)) { + throw new Error(`Edge references unknown node: ${source}-${target}`); + } + if (!Number.isFinite(weight)) { + throw new Error('Edge weights must be finite numbers.'); + } + } +} diff --git a/src/index.ts b/src/index.ts index debadcf..5dc480e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -140,6 +140,7 @@ export const examples = { graphBFS: 'examples/graph.ts', graphDFS: 'examples/graph.ts', topologicalSort: 'examples/graph.ts', + computeMinimumSpanningTree: 'examples/kruskal.ts', }, geometry: { convexHull: 'examples/geometry.ts', @@ -995,6 +996,13 @@ export type { */ export { graphBFS, graphDFS, topologicalSort } from './graph/traversal.js'; +/** + * Minimum spanning tree computation via Kruskal's algorithm. + */ +export { computeMinimumSpanningTree } from './graph/kruskal.js'; + +export type { KruskalOptions, MinimumSpanningTree, WeightedEdge } from './graph/kruskal.js'; + // ============================================================================ // 📐 GEOMETRY UTILITIES // ============================================================================ diff --git a/tests/kruskal.test.ts b/tests/kruskal.test.ts new file mode 100644 index 0000000..e187f61 --- /dev/null +++ b/tests/kruskal.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { computeMinimumSpanningTree } from '../src/index.js'; + +describe('computeMinimumSpanningTree', () => { + it('builds minimum spanning tree for connected graph', () => { + const nodes = ['A', 'B', 'C', 'D']; + const edges = [ + { source: 'A', target: 'B', weight: 1 }, + { source: 'A', target: 'C', weight: 5 }, + { source: 'B', target: 'C', weight: 2 }, + { source: 'B', target: 'D', weight: 4 }, + { source: 'C', target: 'D', weight: 3 }, + ]; + + const result = computeMinimumSpanningTree({ nodes, edges }); + expect(result.edges).toHaveLength(nodes.length - 1); + expect(result.totalWeight).toBe(1 + 2 + 3); + + const mstEdges = result.edges.map((edge) => [edge.source, edge.target].sort().join('-')); + expect(mstEdges).toEqual(['A-B', 'B-C', 'C-D']); + }); + + it('ignores heavier edges that form cycles', () => { + const nodes = ['X', 'Y', 'Z']; + const edges = [ + { source: 'X', target: 'Y', weight: 10 }, + { source: 'Y', target: 'Z', weight: 2 }, + { source: 'X', target: 'Z', weight: 1 }, + ]; + + const result = computeMinimumSpanningTree({ nodes, edges }); + expect(result.totalWeight).toBe(3); + const sorted = result.edges.map((edge) => [edge.source, edge.target].sort().join('-')).sort(); + expect(sorted).toEqual(['X-Z', 'Y-Z']); + }); + + it('validates inputs', () => { + expect(() => computeMinimumSpanningTree({ nodes: [], edges: [] })).toThrow('nodes must contain at least one node.'); + expect(() => + computeMinimumSpanningTree({ + nodes: ['A'], + edges: [{ source: 'A', target: 'B', weight: 1 }], + }) + ).toThrow('Edge references unknown node'); + }); +});