@graphty/algorithms
Version:
Graph algorithms library for browser environments implemented in TypeScript
322 lines (267 loc) • 10.6 kB
text/typescript
import type {Graph} from "../../core/graph.js";
import type {NodeId} from "../../types/index.js";
/**
* Betweenness centrality implementation using Brandes' algorithm
*
* Measures the extent to which a node lies on paths between other nodes.
* Uses the fast O(VE) algorithm by Ulrik Brandes.
*/
/**
* Betweenness centrality options
*/
export interface BetweennessCentralityOptions {
/**
* Whether to normalize the centrality values (default: false)
*/
normalized?: boolean;
/**
* Whether to use endpoints in path counting (default: false)
*/
endpoints?: boolean;
}
/**
* Calculate betweenness centrality for all nodes using Brandes' algorithm
*/
export function betweennessCentrality(
graph: Graph,
options: BetweennessCentralityOptions = {},
): Record<string, number> {
const nodes = Array.from(graph.nodes()).map((node) => node.id);
const centrality: Record<string, number> = {};
// Initialize centrality scores
for (const nodeId of nodes) {
centrality[String(nodeId)] = 0;
}
// Brandes' algorithm
for (const source of nodes) {
const stack: NodeId[] = [];
const predecessors = new Map<NodeId, NodeId[]>();
const sigma = new Map<NodeId, number>(); // Number of shortest paths
const distance = new Map<NodeId, number>();
const delta = new Map<NodeId, number>();
// Initialize
for (const nodeId of nodes) {
predecessors.set(nodeId, []);
sigma.set(nodeId, 0);
distance.set(nodeId, -1);
delta.set(nodeId, 0);
}
sigma.set(source, 1);
distance.set(source, 0);
const queue: NodeId[] = [source];
// BFS to find shortest paths
while (queue.length > 0) {
const current = queue.shift();
if (!current) {
break;
}
stack.push(current);
for (const neighbor of Array.from(graph.neighbors(current))) {
const currentDistance = distance.get(current);
let neighborDistance = distance.get(neighbor);
if (currentDistance === undefined || neighborDistance === undefined) {
continue;
}
// First time we reach this neighbor
if (neighborDistance < 0) {
queue.push(neighbor);
distance.set(neighbor, currentDistance + 1);
neighborDistance = currentDistance + 1; // Update local variable
}
// Shortest path to neighbor via current
if (neighborDistance === currentDistance + 1) {
const neighborSigma = sigma.get(neighbor) ?? 0;
const currentSigma = sigma.get(current) ?? 0;
sigma.set(neighbor, neighborSigma + currentSigma);
const preds = predecessors.get(neighbor);
if (preds) {
preds.push(current);
}
}
}
}
// Accumulation - back-propagation of dependencies
while (stack.length > 0) {
const w = stack.pop();
if (!w) {
break;
}
const wPreds = predecessors.get(w) ?? [];
const wSigma = sigma.get(w) ?? 0;
const wDelta = delta.get(w) ?? 0;
for (const v of wPreds) {
const vSigma = sigma.get(v) ?? 0;
const vDelta = delta.get(v) ?? 0;
if (vSigma > 0 && wSigma > 0) {
let contribution = (vSigma / wSigma) * (1 + wDelta);
// Apply endpoints option: when false, exclude endpoint contributions
if (!options.endpoints) {
// For standard betweenness, don't count paths that only involve endpoints
const isTargetEndpoint = predecessors.get(w)?.length === 0 && w !== source;
if (isTargetEndpoint) {
contribution = 0; // Exclude endpoint contributions
}
}
delta.set(v, vDelta + contribution);
}
}
if (w !== source) {
const currentCentrality = centrality[String(w)] ?? 0;
centrality[String(w)] = currentCentrality + wDelta;
}
}
}
// For undirected graphs, divide by 2 (each shortest path is counted twice)
if (!graph.isDirected) {
for (const nodeId of nodes) {
const key = String(nodeId);
const currentValue = centrality[key];
if (currentValue !== undefined) {
centrality[key] = currentValue / 2;
}
}
}
// Normalization
if (options.normalized) {
const n = nodes.length;
let normalizationFactor: number;
if (graph.isDirected) {
normalizationFactor = (n - 1) * (n - 2);
} else {
normalizationFactor = ((n - 1) * (n - 2)) / 2;
}
if (normalizationFactor > 0) {
for (const nodeId of nodes) {
const key = String(nodeId);
const currentValue = centrality[key];
if (currentValue !== undefined) {
centrality[key] = currentValue / normalizationFactor;
}
}
}
}
return centrality;
}
/**
* Calculate betweenness centrality for a specific node
*/
export function nodeBetweennessCentrality(
graph: Graph,
targetNode: NodeId,
options: BetweennessCentralityOptions = {},
): number {
if (!graph.hasNode(targetNode)) {
throw new Error(`Node ${String(targetNode)} not found in graph`);
}
const allCentralities = betweennessCentrality(graph, options);
return allCentralities[String(targetNode)] ?? 0;
}
/**
* Calculate edge betweenness centrality
*/
export function edgeBetweennessCentrality(
graph: Graph,
options: BetweennessCentralityOptions = {},
): Map<string, number> {
const nodes = Array.from(graph.nodes()).map((node) => node.id);
const edgeCentrality = new Map<string, number>();
// Initialize edge centrality scores
for (const edge of Array.from(graph.edges())) {
const edgeKey = `${String(edge.source)}-${String(edge.target)}`;
edgeCentrality.set(edgeKey, 0);
}
// Modified Brandes' algorithm for edge betweenness
for (const source of nodes) {
const stack: NodeId[] = [];
const predecessors = new Map<NodeId, NodeId[]>();
const sigma = new Map<NodeId, number>();
const distance = new Map<NodeId, number>();
const delta = new Map<NodeId, number>();
// Initialize
for (const nodeId of nodes) {
predecessors.set(nodeId, []);
sigma.set(nodeId, 0);
distance.set(nodeId, -1);
delta.set(nodeId, 0);
}
sigma.set(source, 1);
distance.set(source, 0);
const queue: NodeId[] = [source];
// BFS to find shortest paths
while (queue.length > 0) {
const current = queue.shift();
if (!current) {
break;
}
stack.push(current);
for (const neighbor of Array.from(graph.neighbors(current))) {
const currentDistance = distance.get(current);
let neighborDistance = distance.get(neighbor);
if (currentDistance === undefined || neighborDistance === undefined) {
continue;
}
if (neighborDistance < 0) {
queue.push(neighbor);
distance.set(neighbor, currentDistance + 1);
neighborDistance = currentDistance + 1; // Update local variable
}
if (neighborDistance === currentDistance + 1) {
const neighborSigma = sigma.get(neighbor) ?? 0;
const currentSigma = sigma.get(current) ?? 0;
sigma.set(neighbor, neighborSigma + currentSigma);
const preds = predecessors.get(neighbor);
if (preds) {
preds.push(current);
}
}
}
}
// Accumulation for edges
while (stack.length > 0) {
const w = stack.pop();
if (!w) {
break;
}
const wPreds = predecessors.get(w) ?? [];
const wSigma = sigma.get(w) ?? 0;
const wDelta = delta.get(w) ?? 0;
for (const v of wPreds) {
const vSigma = sigma.get(v) ?? 0;
if (vSigma > 0 && wSigma > 0) {
let edgeContribution = vSigma / wSigma * (1 + wDelta);
// Apply endpoints option for edge betweenness
if (!options.endpoints) {
const isTargetEndpoint = predecessors.get(w)?.length === 0 && w !== source;
if (isTargetEndpoint) {
edgeContribution = 0; // Exclude endpoint contributions
}
}
// Update edge centrality
const edgeKey = `${String(v)}-${String(w)}`;
const currentEdgeCentrality = edgeCentrality.get(edgeKey) ?? 0;
edgeCentrality.set(edgeKey, currentEdgeCentrality + edgeContribution);
// Update node delta
const vDelta = delta.get(v) ?? 0;
delta.set(v, vDelta + edgeContribution);
}
}
}
}
// For undirected graphs, divide by 2 (each shortest path is counted twice)
if (!graph.isDirected) {
for (const [edgeKey, centrality] of Array.from(edgeCentrality)) {
edgeCentrality.set(edgeKey, centrality / 2);
}
}
// Normalization for edges
if (options.normalized) {
const n = nodes.length;
const normalizationFactor = graph.isDirected ? (n - 1) * (n - 2) : ((n - 1) * (n - 2)) / 2;
if (normalizationFactor > 0) {
for (const [edgeKey, centrality] of Array.from(edgeCentrality)) {
edgeCentrality.set(edgeKey, centrality / normalizationFactor);
}
}
}
return edgeCentrality;
}