@graphty/algorithms
Version:
Graph algorithms library for browser environments implemented in TypeScript
456 lines (378 loc) • 14.2 kB
text/typescript
import type {NodeId} from "../types/index.js";
/**
* Interface for read-only graph operations
*/
export interface ReadonlyGraph<TNodeId = NodeId> {
nodeCount(): number;
edgeCount(): number;
hasNode(nodeId: TNodeId): boolean;
hasEdge(source: TNodeId, target: TNodeId): boolean;
neighbors(nodeId: TNodeId): IterableIterator<TNodeId>;
outDegree(nodeId: TNodeId): number;
nodes(): IterableIterator<TNodeId>;
}
/**
* Internal data structure for CSR representation
*/
interface CSRGraphData<TNodeId> {
// Row pointers: indices where each node's edges start
rowPointers: Uint32Array;
// Column indices: destination nodes for each edge
columnIndices: Uint32Array;
// Edge weights (optional)
edgeWeights?: Float32Array;
// Reverse edges for bottom-up BFS (optional)
reverseRowPointers?: Uint32Array;
reverseColumnIndices?: Uint32Array;
// Node ID mapping
nodeIdToIndex: Map<TNodeId, number>;
indexToNodeId: TNodeId[];
}
/**
* Compressed Sparse Row (CSR) graph representation
*
* Provides cache-efficient graph storage with sequential memory access patterns.
* Optimized for traversal operations and sparse graphs.
*/
export class CSRGraph<TNodeId = NodeId> implements ReadonlyGraph<TNodeId> {
private data: CSRGraphData<TNodeId>;
constructor(adjacencyList: Map<TNodeId, TNodeId[]>, weights?: Map<string, number>, buildReverse = true) {
this.data = this.buildCSR(adjacencyList, weights, buildReverse);
}
/**
* Build CSR structure from adjacency list
*/
private buildCSR(
adjacencyList: Map<TNodeId, TNodeId[]>,
weights?: Map<string, number>,
buildReverse = true,
): CSRGraphData<TNodeId> {
// Collect all unique nodes (both sources and targets)
const allNodes = new Set<TNodeId>();
for (const [source, neighbors] of adjacencyList) {
allNodes.add(source);
for (const neighbor of neighbors) {
allNodes.add(neighbor);
}
}
// Sort nodes for consistent ordering. Numeric IDs sort numerically,
// string IDs sort lexicographically for predictable iteration order.
const nodes = Array.from(allNodes).sort((a, b) => {
if (typeof a === "number" && typeof b === "number") {
return a - b;
}
return String(a).localeCompare(String(b));
});
const nodeCount = nodes.length;
// Build node mappings
const nodeIdToIndex = new Map<TNodeId, number>();
const indexToNodeId: TNodeId[] = [];
nodes.forEach((nodeId, index) => {
nodeIdToIndex.set(nodeId, index);
indexToNodeId[index] = nodeId;
});
// Count total edges
let edgeCount = 0;
for (const neighbors of adjacencyList.values()) {
edgeCount += neighbors.length;
}
// Allocate arrays
const rowPointers = new Uint32Array(nodeCount + 1);
const columnIndices = new Uint32Array(edgeCount);
const edgeWeights = weights ? new Float32Array(edgeCount) : undefined;
// Build CSR structure
let currentEdge = 0;
for (let i = 0; i < nodeCount; i++) {
rowPointers[i] = currentEdge;
const nodeId = indexToNodeId[i];
if (nodeId === undefined) {
throw new Error(`Invalid node index ${String(i)}`);
}
const neighbors = adjacencyList.get(nodeId) ?? [];
// Sort neighbors for better cache locality and binary search
const sortedNeighbors = neighbors
.map((n) => {
const index = nodeIdToIndex.get(n);
if (index === undefined) {
throw new Error(`Node ${String(n)} not found in nodeIdToIndex map`);
}
return {id: n, index};
})
.sort((a, b) => a.index - b.index);
for (const neighbor of sortedNeighbors) {
columnIndices[currentEdge] = neighbor.index;
if (edgeWeights && weights) {
const edgeKey = `${String(nodeId)}-${String(neighbor.id)}`;
const weight = weights.get(edgeKey);
if (weight !== undefined) {
edgeWeights[currentEdge] = weight;
} else {
edgeWeights[currentEdge] = 1;
}
}
currentEdge++;
}
}
rowPointers[nodeCount] = currentEdge;
const result: CSRGraphData<TNodeId> = {
rowPointers,
columnIndices,
nodeIdToIndex,
indexToNodeId,
};
if (edgeWeights) {
result.edgeWeights = edgeWeights;
}
// Build reverse edges for bottom-up BFS
if (buildReverse) {
const reverseAdjacency = new Map<number, number[]>();
for (let i = 0; i < nodeCount; i++) {
reverseAdjacency.set(i, []);
}
// Build reverse adjacency from forward edges
for (let i = 0; i < nodeCount; i++) {
const start = rowPointers[i];
const end = rowPointers[i + 1];
if (start !== undefined && end !== undefined) {
for (let j = start; j < end; j++) {
const target = columnIndices[j];
if (target !== undefined) {
reverseAdjacency.get(target)?.push(i);
}
}
}
}
// Build reverse CSR
const reverseRowPointers = new Uint32Array(nodeCount + 1);
const reverseColumnIndices = new Uint32Array(edgeCount);
let reverseEdge = 0;
for (let i = 0; i < nodeCount; i++) {
reverseRowPointers[i] = reverseEdge;
const incoming = reverseAdjacency.get(i) ?? [];
incoming.sort((a, b) => a - b);
for (const source of incoming) {
reverseColumnIndices[reverseEdge++] = source;
}
}
reverseRowPointers[nodeCount] = reverseEdge;
result.reverseRowPointers = reverseRowPointers;
result.reverseColumnIndices = reverseColumnIndices;
}
return result;
}
// Core API methods
nodeCount(): number {
return this.data.indexToNodeId.length;
}
edgeCount(): number {
return this.data.columnIndices.length;
}
hasNode(nodeId: TNodeId): boolean {
return this.data.nodeIdToIndex.has(nodeId);
}
hasEdge(source: TNodeId, target: TNodeId): boolean {
const sourceIndex = this.data.nodeIdToIndex.get(source);
const targetIndex = this.data.nodeIdToIndex.get(target);
if (sourceIndex === undefined || targetIndex === undefined) {
return false;
}
const start = this.data.rowPointers[sourceIndex];
const end = this.data.rowPointers[sourceIndex + 1];
// Binary search for target in sorted neighbors
if (start === undefined || end === undefined) {
return false;
}
return this.binarySearch(this.data.columnIndices, targetIndex, start, end) !== -1;
}
/**
* Get neighbors as node IDs
*/
neighbors(nodeId: TNodeId): IterableIterator<TNodeId> {
const nodeIndex = this.data.nodeIdToIndex.get(nodeId);
if (nodeIndex === undefined) {
return new Set<TNodeId>().values();
}
const start = this.data.rowPointers[nodeIndex];
const end = this.data.rowPointers[nodeIndex + 1];
const {columnIndices, indexToNodeId} = this.data;
function* generateNeighbors(): Generator<TNodeId> {
if (start !== undefined && end !== undefined) {
for (let i = start; i < end; i++) {
const idx = columnIndices[i];
if (idx !== undefined) {
const nodeId = indexToNodeId[idx];
if (nodeId !== undefined) {
yield nodeId;
}
}
}
}
}
return generateNeighbors();
}
/**
* Get all nodes
*/
nodes(): IterableIterator<TNodeId> {
return this.data.indexToNodeId.values();
}
/**
* Get neighbors as indices (internal use)
*/
getNeighborIndices(nodeIndex: number): number[] {
if (nodeIndex < 0 || nodeIndex >= this.data.indexToNodeId.length) {
return [];
}
const start = this.data.rowPointers[nodeIndex];
const end = this.data.rowPointers[nodeIndex + 1];
if (start === undefined || end === undefined) {
return [];
}
return Array.from(this.data.columnIndices.subarray(start, end));
}
outDegree(nodeId: TNodeId): number {
const nodeIndex = this.data.nodeIdToIndex.get(nodeId);
if (nodeIndex === undefined) {
return 0;
}
return this.outDegreeByIndex(nodeIndex);
}
/**
* Get out-degree by index (internal use)
*/
outDegreeByIndex(nodeIndex: number): number {
const start = this.data.rowPointers[nodeIndex];
const end = this.data.rowPointers[nodeIndex + 1];
if (start !== undefined && end !== undefined) {
return end - start;
}
return 0;
}
/**
* Iterator support for neighbor indices
*/
*iterateNeighborIndices(nodeIndex: number): Generator<number> {
const start = this.data.rowPointers[nodeIndex];
const end = this.data.rowPointers[nodeIndex + 1];
if (start !== undefined && end !== undefined) {
for (let i = start; i < end; i++) {
const value = this.data.columnIndices[i];
if (value !== undefined) {
yield value;
}
}
}
}
/**
* Iterator support for incoming neighbor indices (for bottom-up BFS)
*/
*iterateIncomingNeighborIndices(nodeIndex: number): Generator<number> {
if (!this.data.reverseRowPointers || !this.data.reverseColumnIndices) {
return;
}
const start = this.data.reverseRowPointers[nodeIndex];
const end = this.data.reverseRowPointers[nodeIndex + 1];
if (start !== undefined && end !== undefined) {
for (let i = start; i < end; i++) {
const value = this.data.reverseColumnIndices[i];
if (value !== undefined) {
yield value;
}
}
}
}
/**
* Convert node ID to index
*/
nodeToIndex(nodeId: TNodeId): number {
const index = this.data.nodeIdToIndex.get(nodeId);
if (index === undefined) {
throw new Error(`Node ${String(nodeId)} not found in graph`);
}
return index;
}
/**
* Convert index to node ID
*/
indexToNodeId(index: number): TNodeId {
const nodeId = this.data.indexToNodeId[index];
if (nodeId === undefined) {
throw new Error(`Index ${String(index)} out of bounds`);
}
return nodeId;
}
/**
* Get edge weight
*/
getEdgeWeight(source: TNodeId, target: TNodeId): number | undefined {
if (!this.data.edgeWeights) {
return undefined;
}
const sourceIndex = this.data.nodeIdToIndex.get(source);
const targetIndex = this.data.nodeIdToIndex.get(target);
if (sourceIndex === undefined || targetIndex === undefined) {
return undefined;
}
const start = this.data.rowPointers[sourceIndex];
const end = this.data.rowPointers[sourceIndex + 1];
if (start === undefined || end === undefined) {
return undefined;
}
const edgeIndex = this.binarySearch(this.data.columnIndices, targetIndex, start, end);
if (edgeIndex === -1) {
return undefined;
}
return this.data.edgeWeights[edgeIndex];
}
/**
* Binary search for target in sorted array
*/
private binarySearch(arr: Uint32Array, target: number, start: number, end: number): number {
let left = start;
let right = end - 1;
while (left <= right) {
const mid = (left + right) >>> 1;
const value = arr[mid];
if (value === undefined) {
return -1;
}
if (value === target) {
return mid;
}
if (value < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}
/**
* Create CSR graph from standard Graph
*/
static fromGraph<TNodeId = NodeId>(graph: {
nodes(): IterableIterator<{id: TNodeId}>;
neighbors(nodeId: TNodeId): IterableIterator<TNodeId>;
hasNode(nodeId: TNodeId): boolean;
getEdge?(source: TNodeId, target: TNodeId): {weight?: number} | undefined;
}): CSRGraph<TNodeId> {
const adjacencyList = new Map<TNodeId, TNodeId[]>();
const weights = new Map<string, number>();
// Build adjacency list
for (const node of graph.nodes()) {
const neighbors: TNodeId[] = [];
for (const neighbor of graph.neighbors(node.id)) {
neighbors.push(neighbor);
// Get edge weight if available
if (graph.getEdge) {
const edge = graph.getEdge(node.id, neighbor);
if (edge?.weight !== undefined) {
weights.set(`${String(node.id)}-${String(neighbor)}`, edge.weight);
}
}
}
adjacencyList.set(node.id, neighbors);
}
return new CSRGraph(adjacencyList, weights.size > 0 ? weights : undefined);
}
}