Skip to content

Commit 7b93ee6

Browse files
committed
feat: add core graph algorithms
Implement traversal, analysis, and clustering algorithms: - Traversal: BFS, DFS, bidirectional BFS, degree-prioritised expansion - Analysis: connected components, SCC, topological sort, cycle detection - Clustering: Louvain, Leiden, Infomap, label propagation - Pathfinding: Dijkstra, mutual information, path ranking - Decomposition: biconnected, k-core, core-periphery - Extraction: ego networks, motifs, trusses, subgraphs - Graph data structure with adapter pattern
1 parent 0ee31a9 commit 7b93ee6

57 files changed

Lines changed: 15194 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { type Graph } from "../graph/graph";
2+
import { type Component } from "../types/algorithm-results";
3+
import { type InvalidInputError } from "../types/errors";
4+
import { type Edge,type Node } from "../types/graph";
5+
import { Err as Error_,Ok, type Result } from "../types/result";
6+
7+
/**
8+
* Find all connected components in an undirected graph (or weakly connected components in directed graph).
9+
*
10+
* Uses DFS to traverse each component. For directed graphs, treats edges as undirected.
11+
*
12+
* Time Complexity: O(V + E)
13+
* Space Complexity: O(V)
14+
* @param graph - The graph to analyze
15+
* @returns Result containing array of components
16+
*/
17+
export const connectedComponents = <N extends Node, E extends Edge = Edge>(graph: Graph<N, E>): Result<Component<N>[], InvalidInputError> => {
18+
if (!graph) {
19+
return Error_({
20+
type: "invalid-input",
21+
message: "Graph cannot be null or undefined",
22+
});
23+
}
24+
25+
const nodes = graph.getAllNodes();
26+
const visited = new Set<string>();
27+
const components: Component<N>[] = [];
28+
let componentId = 0;
29+
30+
const dfs = (nodeId: string, componentNodes: N[]): void => {
31+
visited.add(nodeId);
32+
33+
const node = graph.getNode(nodeId);
34+
if (node.some) {
35+
componentNodes.push(node.value);
36+
}
37+
38+
// Get neighbors (treats directed graph as undirected for connectivity)
39+
const neighborsResult = graph.getNeighbors(nodeId);
40+
if (neighborsResult.ok) {
41+
for (const neighborId of neighborsResult.value) {
42+
if (!visited.has(neighborId)) {
43+
dfs(neighborId, componentNodes);
44+
}
45+
}
46+
}
47+
48+
// For directed graphs, also check reverse edges (incoming edges)
49+
if (graph.isDirected()) {
50+
const allEdges = graph.getAllEdges();
51+
for (const edge of allEdges) {
52+
if (edge.target === nodeId && !visited.has(edge.source)) {
53+
dfs(edge.source, componentNodes);
54+
}
55+
}
56+
}
57+
};
58+
59+
// Find all components
60+
for (const node of nodes) {
61+
if (!visited.has(node.id)) {
62+
const componentNodes: N[] = [];
63+
dfs(node.id, componentNodes);
64+
65+
components.push({
66+
id: componentId++,
67+
nodes: componentNodes,
68+
size: componentNodes.length,
69+
});
70+
}
71+
}
72+
73+
return Ok(components);
74+
};
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { type Graph } from "../graph/graph";
2+
import { type CycleInfo } from "../types/algorithm-results";
3+
import { type InvalidInputError } from "../types/errors";
4+
import { type Edge,type Node } from "../types/graph";
5+
import { None,type Option, Some } from "../types/option";
6+
import { Err as Error_,Ok, type Result } from "../types/result";
7+
8+
/**
9+
* Detect cycles in a graph using DFS.
10+
*
11+
* For directed graphs: detects back edges (edges to ancestors in DFS tree)
12+
* For undirected graphs: detects any edge except parent edge
13+
*
14+
* Time Complexity: O(V + E)
15+
* Space Complexity: O(V)
16+
* @param graph - The graph to check for cycles
17+
* @returns Result containing Option with cycle info if found
18+
*/
19+
export const detectCycle = <N extends Node, E extends Edge>(graph: Graph<N, E>): Result<Option<CycleInfo<N, E>>, InvalidInputError> => {
20+
if (!graph) {
21+
return Error_({
22+
type: "invalid-input",
23+
message: "Graph cannot be null or undefined",
24+
});
25+
}
26+
27+
const nodes = graph.getAllNodes();
28+
const visited = new Set<string>();
29+
const inStack = new Set<string>();
30+
const parent = new Map<string, string | null>();
31+
32+
const dfsDirected = (nodeId: string): Option<CycleInfo<N, E>> => {
33+
visited.add(nodeId);
34+
inStack.add(nodeId);
35+
36+
const neighborsResult = graph.getNeighbors(nodeId);
37+
if (neighborsResult.ok) {
38+
for (const neighborId of neighborsResult.value) {
39+
if (inStack.has(neighborId)) {
40+
// Back edge - cycle found!
41+
return reconstructCycle(nodeId, neighborId);
42+
}
43+
44+
if (!visited.has(neighborId)) {
45+
parent.set(neighborId, nodeId);
46+
const cycleResult = dfsDirected(neighborId);
47+
if (cycleResult.some) {
48+
return cycleResult;
49+
}
50+
}
51+
}
52+
}
53+
54+
inStack.delete(nodeId);
55+
return None();
56+
};
57+
58+
const dfsUndirected = (nodeId: string, parentId: string | null): Option<CycleInfo<N, E>> => {
59+
visited.add(nodeId);
60+
61+
const neighborsResult = graph.getNeighbors(nodeId);
62+
if (neighborsResult.ok) {
63+
for (const neighborId of neighborsResult.value) {
64+
if (!visited.has(neighborId)) {
65+
parent.set(neighborId, nodeId);
66+
const cycleResult = dfsUndirected(neighborId, nodeId);
67+
if (cycleResult.some) {
68+
return cycleResult;
69+
}
70+
} else if (neighborId !== parentId) {
71+
// Back edge (not to parent) - cycle found!
72+
return reconstructCycle(nodeId, neighborId);
73+
}
74+
}
75+
}
76+
77+
return None();
78+
};
79+
80+
const reconstructCycle = (fromId: string, toId: string): Option<CycleInfo<N, E>> => {
81+
const cycleNodes: N[] = [];
82+
const cycleEdges: E[] = [];
83+
84+
// Build path from 'from' back to 'to'
85+
let current = fromId;
86+
const path: string[] = [current];
87+
88+
// Trace back through parents until we find 'to'
89+
while (current !== toId && parent.has(current)) {
90+
const parentId = parent.get(current);
91+
if (parentId) {
92+
path.unshift(parentId);
93+
current = parentId;
94+
} else {
95+
break;
96+
}
97+
}
98+
99+
// Add closing edge
100+
path.push(toId);
101+
102+
// Convert to nodes and edges
103+
for (let index = 0; index < path.length; index++) {
104+
const node = graph.getNode(path[index]);
105+
if (node.some) {
106+
cycleNodes.push(node.value);
107+
}
108+
109+
if (index < path.length - 1) {
110+
const edges = graph.getOutgoingEdges(path[index]);
111+
if (edges.ok) {
112+
const edge = edges.value.find(e => e.target === path[index + 1]);
113+
if (edge) {
114+
cycleEdges.push(edge);
115+
}
116+
}
117+
}
118+
}
119+
120+
return Some({ nodes: cycleNodes, edges: cycleEdges });
121+
};
122+
123+
// Run DFS from all unvisited nodes
124+
for (const node of nodes) {
125+
if (!visited.has(node.id)) {
126+
parent.set(node.id, null);
127+
128+
const cycleResult = graph.isDirected()
129+
? dfsDirected(node.id)
130+
: dfsUndirected(node.id, null);
131+
132+
if (cycleResult.some) {
133+
return Ok(cycleResult);
134+
}
135+
}
136+
}
137+
138+
return Ok(None());
139+
};

src/algorithms/analysis/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* @file Automatically generated by barrelsby.
3+
*/
4+
5+
export * from "./connected-components";
6+
export * from "./cycle-detection";
7+
export * from "./scc";
8+
export * from "./topological-sort";

src/algorithms/analysis/scc.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { type Graph } from "../graph/graph";
2+
import { type Component } from "../types/algorithm-results";
3+
import { type InvalidInputError } from "../types/errors";
4+
import { type Edge,type Node } from "../types/graph";
5+
import { Err as Error_,Ok, type Result } from "../types/result";
6+
7+
/**
8+
* Find all strongly connected components using Tarjan's algorithm.
9+
*
10+
* A strongly connected component (SCC) is a maximal set of vertices where
11+
* every vertex is reachable from every other vertex in the set.
12+
*
13+
* Time Complexity: O(V + E)
14+
* Space Complexity: O(V)
15+
* @param graph - The directed graph to analyze
16+
* @returns Result containing array of SCCs
17+
*/
18+
export const stronglyConnectedComponents = <N extends Node, E extends Edge = Edge>(graph: Graph<N, E>): Result<Component<N>[], InvalidInputError> => {
19+
if (!graph) {
20+
return Error_({
21+
type: "invalid-input",
22+
message: "Graph cannot be null or undefined",
23+
});
24+
}
25+
26+
const nodes = graph.getAllNodes();
27+
const index = new Map<string, number>();
28+
const lowlink = new Map<string, number>();
29+
const onStack = new Set<string>();
30+
const stack: string[] = [];
31+
const components: Component<N>[] = [];
32+
let currentIndex = 0;
33+
let componentId = 0;
34+
35+
const strongConnect = (nodeId: string): void => {
36+
// Set depth index for node
37+
index.set(nodeId, currentIndex);
38+
lowlink.set(nodeId, currentIndex);
39+
currentIndex++;
40+
stack.push(nodeId);
41+
onStack.add(nodeId);
42+
43+
// Consider successors
44+
const neighborsResult = graph.getNeighbors(nodeId);
45+
if (neighborsResult.ok) {
46+
for (const neighborId of neighborsResult.value) {
47+
if (!index.has(neighborId)) {
48+
// Successor not yet visited; recurse
49+
strongConnect(neighborId);
50+
const nodeLowlink = lowlink.get(nodeId);
51+
const neighborLowlink = lowlink.get(neighborId);
52+
if (nodeLowlink !== undefined && neighborLowlink !== undefined) {
53+
lowlink.set(nodeId, Math.min(nodeLowlink, neighborLowlink));
54+
}
55+
} else if (onStack.has(neighborId)) {
56+
// Successor is on stack and hence in current SCC
57+
const nodeLowlink = lowlink.get(nodeId);
58+
const neighborIndex = index.get(neighborId);
59+
if (nodeLowlink !== undefined && neighborIndex !== undefined) {
60+
lowlink.set(nodeId, Math.min(nodeLowlink, neighborIndex));
61+
}
62+
}
63+
}
64+
}
65+
66+
// If nodeId is a root node, pop the stack and create SCC
67+
if (lowlink.get(nodeId) === index.get(nodeId)) {
68+
const sccNodes: N[] = [];
69+
let w: string;
70+
71+
do {
72+
const popped = stack.pop();
73+
if (popped === undefined) break;
74+
w = popped;
75+
onStack.delete(w);
76+
77+
const node = graph.getNode(w);
78+
if (node.some) {
79+
sccNodes.push(node.value);
80+
}
81+
} while (w !== nodeId);
82+
83+
components.push({
84+
id: componentId++,
85+
nodes: sccNodes,
86+
size: sccNodes.length,
87+
});
88+
}
89+
};
90+
91+
// Run Tarjan's algorithm from all unvisited nodes
92+
for (const node of nodes) {
93+
if (!index.has(node.id)) {
94+
strongConnect(node.id);
95+
}
96+
}
97+
98+
return Ok(components);
99+
};

0 commit comments

Comments
 (0)