From 818c834b9336164bd97992c1ea335b2d558d4006 Mon Sep 17 00:00:00 2001 From: Haydn Ewers <27211+haydn@users.noreply.github.com> Date: Sun, 27 Jun 2021 19:08:43 +1000 Subject: [PATCH] Add undirected option for all relevant functions --- index.ts | 154 +++++++++++++++++------ test.ts | 376 ++++++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 423 insertions(+), 107 deletions(-) diff --git a/index.ts b/index.ts index 869737d..262d910 100644 --- a/index.ts +++ b/index.ts @@ -16,13 +16,17 @@ type Graph = { }; }; -const addEdge = (graph: Graph, [u, v]: Edge): Graph => { +const addEdge = (graph: Graph, [u, v]: Edge, options?: { undirected?: boolean }): Graph => { const result = clone(graph); if (result[u][v] === 0) { result[u][v] = 1; } + if (options?.undirected && result[v][u] === 0) { + result[v][u] = 1; + } + return result; }; @@ -90,12 +94,27 @@ const create = (size: number = 0, id: (i: number) => string = (i) => i.toString( return result; }; -const degree = (graph: Graph, vertex: string, weighted = false): number => { - return indegree(graph, vertex, weighted) + outdegree(graph, vertex, weighted); +const degree = ( + graph: Graph, + vertex: string, + options?: { weighted?: boolean; undirected?: boolean }, +): number => { + if (options?.undirected && !isUndirected(graph)) { + throw Error( + "Unable to calculate degree. Expected an undirected graph, but got a directed graph.", + ); + } + const resolvedGraph = options?.undirected ? toDirected(graph) : graph; + return ( + indegree(resolvedGraph, vertex, { weighted: options?.weighted }) + + outdegree(resolvedGraph, vertex, { weighted: options?.weighted }) + ); }; const descendants = (graph: Graph, vertex: string): Set => { - if (isCyclic(graph)) throw "Cannot retrieve descendants in a graph that contains cycles."; + if (isCyclic(graph)) { + throw Error("Cannot retrieve descendants in a graph that contains cycles."); + } let result: Set = new Set(); @@ -106,12 +125,17 @@ const descendants = (graph: Graph, vertex: string): Set => { return result; }; -const edges = (graph: Graph): Set => { +const edges = (graph: Graph, options?: { undirected?: boolean }): Set => { + if (options?.undirected && !isUndirected(graph)) { + throw Error("Expected undirected graph, but got a directed graph."); + } + + const resolvedGraph = options?.undirected ? toDirected(graph) : graph; const result: Set = new Set([]); - for (let u in graph) { - for (let v in graph[u]) { - if (graph[u][v] !== 0) { + for (let u in resolvedGraph) { + for (let v in resolvedGraph[u]) { + if (resolvedGraph[u][v] !== 0) { result.add([u, v]); } } @@ -120,7 +144,7 @@ const edges = (graph: Graph): Set => { return result; }; -const fromD3 = (d3Graph: D3Graph): Graph => { +const fromD3 = (d3Graph: D3Graph, options?: { undirected?: boolean }): Graph => { const result: Graph = {}; for (let u of d3Graph.nodes) { @@ -132,6 +156,7 @@ const fromD3 = (d3Graph: D3Graph): Graph => { for (let { source: u, target: v } of d3Graph.links) { result[u][v] = result[u][v] === 0 ? 1 : result[u][v] + 1; + if (options?.undirected && u != v) result[v][u] = result[v][u] === 0 ? 1 : result[v][u] + 1; } return result; @@ -139,32 +164,38 @@ const fromD3 = (d3Graph: D3Graph): Graph => { const getEdge = (graph: Graph, [u, v]: Edge): number => graph[u][v]; -const indegree = (graph: Graph, vertex: string, weighted = false): number => { +const indegree = (graph: Graph, vertex: string, options?: { weighted?: boolean }): number => { let result = 0; for (let u in graph) { if (graph[u][vertex] !== 0) { - result += weighted ? graph[u][vertex] : 1; + result += options?.weighted ? graph[u][vertex] : 1; } } return result; }; -const isCyclic = (graph: Graph): boolean => { +const isCyclic = (graph: Graph, options?: { undirected?: boolean }): boolean => { + if (options?.undirected && !isUndirected(graph)) { + throw Error("Expected undirected graph, but got a directed graph."); + } + const visited: Set = new Set(); - const path: Set = new Set(); for (let i in graph) { - if (visited.has(i)) continue; - const cycleFound = _isCyclic(graph, visited, path, i); + const cycleFound = visited.has(i) + ? false + : options?.undirected + ? _isCyclicUndirected(graph, visited, undefined, i) + : _isCyclicDirected(graph, visited, new Set(), i); if (cycleFound) return true; } return false; }; -const _isCyclic = ( +const _isCyclicDirected = ( graph: Graph, visited: Set, path: Set, @@ -175,11 +206,7 @@ const _isCyclic = ( for (let i in graph[vertex]) { if (graph[vertex][i] !== 0) { - if (visited.has(i)) { - if (path.has(i)) return true; - continue; - } - const cycleFound = _isCyclic(graph, visited, path, i); + const cycleFound = path.has(i) ? true : _isCyclicDirected(graph, visited, path, i); if (cycleFound) return true; } } @@ -189,6 +216,26 @@ const _isCyclic = ( return false; }; +const _isCyclicUndirected = ( + graph: Graph, + visited: Set, + parent: string, + vertex: string, +): boolean => { + visited.add(vertex); + + for (let i in graph[vertex]) { + if (graph[vertex][i] !== 0) { + const cycleFound = visited.has(i) + ? i !== parent + : _isCyclicUndirected(graph, visited, vertex, i); + if (cycleFound) return true; + } + } + + return false; +}; + const isUndirected = (graph: Graph): boolean => { for (let [u, v] of vertexPairs(graph)) { if (graph[u][v] !== graph[v][u]) return false; @@ -220,12 +267,12 @@ const order = (graph: Graph): number => { return result; }; -const outdegree = (graph: Graph, vertex: string, weighted = false): number => { +const outdegree = (graph: Graph, vertex: string, options?: { weighted?: boolean }): number => { let result = 0; for (let v in graph[vertex]) { if (graph[vertex][v] !== 0) { - result += weighted ? graph[vertex][v] : 1; + result += options?.weighted ? graph[vertex][v] : 1; } } @@ -244,10 +291,11 @@ const parents = (graph: Graph, vertex: string): Set => { return result; }; -const removeEdge = (graph: Graph, [u, v]: Edge): Graph => { +const removeEdge = (graph: Graph, [u, v]: Edge, options?: { undirected?: boolean }): Graph => { const result = clone(graph); result[u][v] = 0; + if (options?.undirected) result[v][u] = 0; return result; }; @@ -267,20 +315,31 @@ const removeVertex = (graph: Graph, vertex: string): Graph => { return result; }; -const setEdge = (graph: Graph, [u, v]: Edge, weight: number): Graph => { +const setEdge = ( + graph: Graph, + [u, v]: Edge, + weight: number, + options?: { undirected?: boolean }, +): Graph => { const result = clone(graph); result[u][v] = weight; + if (options?.undirected) result[v][u] = weight; return result; }; -const size = (graph: Graph): number => { +const size = (graph: Graph, options?: { undirected?: boolean }): number => { + if (options?.undirected && !isUndirected(graph)) { + throw Error("Expected undirected graph, but got a directed graph."); + } + + const resolvedGraph = options?.undirected ? toDirected(graph) : graph; let result = 0; - for (let u in graph) { - for (let v in graph[u]) { - if (graph[u][v] !== 0) { + for (let u in resolvedGraph) { + for (let v in resolvedGraph[u]) { + if (resolvedGraph[u][v] !== 0) { result += 1; } } @@ -289,15 +348,20 @@ const size = (graph: Graph): number => { return result; }; -const toD3 = (graph: Graph): D3Graph => { +const toD3 = (graph: Graph, options?: { undirected?: boolean }): D3Graph => { + if (options?.undirected && !isUndirected(graph)) { + throw Error("Expected undirected graph, but got a directed graph."); + } + + const resolvedGraph = options?.undirected ? toDirected(graph) : graph; const nodes = []; const links = []; - for (let u in graph) { + for (let u in resolvedGraph) { nodes[nodes.length] = { id: u }; - for (let v in graph[u]) { - if (graph[u][v] !== 0) { - let i = graph[u][v]; + for (let v in resolvedGraph[u]) { + if (resolvedGraph[u][v] !== 0) { + let i = resolvedGraph[u][v]; do { links.push({ source: u, target: v }); i -= 1; @@ -309,8 +373,26 @@ const toD3 = (graph: Graph): D3Graph => { return { nodes, links }; }; +const toDirected = (graph: Graph): Graph => { + if (!isUndirected(graph)) { + return graph; + } + + const result: Graph = {}; + const vertices = Object.keys(graph); + + for (let u = 0; u < vertices.length; u++) { + result[vertices[u]] = {}; + for (let v = 0; v < vertices.length; v++) { + result[vertices[u]][vertices[v]] = v >= u ? graph[vertices[u]][vertices[v]] : 0; + } + } + + return result; +}; + const topologicalSort = (graph: Graph): Array => { - if (isCyclic(graph)) throw "Cannot sort a graph that contains cycles."; + if (isCyclic(graph)) throw Error("Cannot sort a graph that contains cycles."); const result: Array = []; const visited: Set = new Set(); @@ -399,6 +481,8 @@ export { setEdge, size, toD3, + // toDirected, + // makeUndirected as toUndirected, topologicalSort, transpose, vertices, diff --git a/test.ts b/test.ts index 7431ba1..de04b7c 100644 --- a/test.ts +++ b/test.ts @@ -31,7 +31,7 @@ import { import test from "tape"; test("addEdge", (t) => { - t.plan(2); + t.plan(3); t.deepEqual( addEdge( @@ -62,6 +62,22 @@ test("addEdge", (t) => { }, "Adding an edge that already exists should be a no-op", ); + + t.deepEqual( + addEdge( + { + a: { a: 0, b: 0 }, + b: { a: 0, b: 0 }, + }, + ["a", "b"], + { undirected: true }, + ), + { + a: { a: 0, b: 1 }, + b: { a: 1, b: 0 }, + }, + "Add undirected edge", + ); }); test("addVertex", (t) => { @@ -204,7 +220,7 @@ test("create", (t) => { }); test("degree", (t) => { - t.plan(5); + t.plan(7); t.equal(degree({ a: { a: 0 } }, "a"), 0, "Base case"); @@ -229,7 +245,7 @@ test("degree", (t) => { c: { a: -0.5, b: 0, c: 0 }, }, "a", - true, + { weighted: true }, ), 2.5, "The weighted degree should be the sum of edge weights", @@ -252,11 +268,36 @@ test("degree", (t) => { a: { a: 1.5 }, }, "a", - true, + { weighted: true }, ), 3, - "Loops should count twice towards the degree", + "Loops should count twice towards the weighted degree", ); + + t.equal( + degree( + { + a: { a: 0, b: 1, c: 1 }, + b: { a: 1, b: 0, c: 0 }, + c: { a: 1, b: 0, c: 0 }, + }, + "a", + { undirected: true }, + ), + 2, + "Reciprocal edges should only be counted once in undirected mode", + ); + + t.throws(() => { + degree( + { + a: { a: 0, b: 1 }, + b: { a: 0, b: 0 }, + }, + "a", + { undirected: true }, + ); + }, "Using the undirected option on a directed graph should throw an error"); }); test("descendants", (t) => { @@ -295,11 +336,11 @@ test("descendants", (t) => { }, "a", ); - }); + }, "A graph with cycles should throw an error"); }); test("edges", (t) => { - t.plan(2); + t.plan(4); t.deepEqual( edges({ @@ -325,10 +366,37 @@ test("edges", (t) => { ["c", "a"], ]), ); + + t.deepEqual( + edges( + { + a: { a: 1, b: 1, c: 0 }, + b: { a: 1, b: 0, c: 1 }, + c: { a: 0, b: 1, c: 0 }, + }, + { undirected: true }, + ), + new Set([ + ["a", "a"], + ["a", "b"], + ["b", "c"], + ]), + "Undirected mode", + ); + + t.throws(() => { + edges( + { + a: { a: 0, b: 1 }, + b: { a: 0, b: 0 }, + }, + { undirected: true }, + ); + }, "Using the undirected option on a directed graph should throw an error"); }); test("fromD3", (t) => { - t.plan(1); + t.plan(2); t.deepEqual( fromD3({ @@ -345,6 +413,25 @@ test("fromD3", (t) => { c: { a: 0, b: 0, c: 0 }, }, ); + + t.deepEqual( + fromD3( + { + nodes: [{ id: "a" }, { id: "b" }, { id: "c" }], + links: [ + { source: "a", target: "a" }, + { source: "a", target: "b" }, + { source: "a", target: "c" }, + ], + }, + { undirected: true }, + ), + { + a: { a: 1, b: 1, c: 1 }, + b: { a: 1, b: 0, c: 0 }, + c: { a: 1, b: 0, c: 0 }, + }, + ); }); test("getEdge", (t) => { @@ -385,80 +472,136 @@ test("indegree", (t) => { c: { a: 0.5, b: 0, c: 0 }, }, "a", - true, + { weighted: true }, ), 2.5, ); }); test("isCyclic", (t) => { - t.plan(9); + t.test("directed graph", (t) => { + t.plan(9); - t.equal(isCyclic({}), false); + t.equal(isCyclic({}), false); - t.equal( - isCyclic({ - a: { a: 0 }, - }), - false, - ); + t.equal( + isCyclic({ + a: { a: 0 }, + }), + false, + ); - t.equal( - isCyclic({ - a: { a: 0, b: 0 }, - b: { a: 0, b: 0 }, - }), - false, - ); + t.equal( + isCyclic({ + a: { a: 0, b: 0 }, + b: { a: 0, b: 0 }, + }), + false, + ); - t.equal( - isCyclic({ - a: { a: 0, b: 1 }, - b: { a: 0, b: 0 }, - }), - false, - ); + t.equal( + isCyclic({ + a: { a: 0, b: 1 }, + b: { a: 0, b: 0 }, + }), + false, + ); - t.equal( - isCyclic({ - a: { a: 0, b: 1, c: 1 }, - b: { a: 0, b: 0, c: 0 }, - c: { a: 0, b: 0, c: 0 }, - }), - false, - ); + t.equal( + isCyclic({ + a: { a: 0, b: 1, c: 1 }, + b: { a: 0, b: 0, c: 0 }, + c: { a: 0, b: 0, c: 0 }, + }), + false, + ); - t.equal( - isCyclic({ - a: { a: 1 }, - }), - true, - ); + t.equal( + isCyclic({ + a: { a: 1 }, + }), + true, + ); - t.equal( - isCyclic({ - a: { a: 0, b: 1 }, - b: { a: 1, b: 0 }, - }), - true, - ); + t.equal( + isCyclic({ + a: { a: 0, b: 1 }, + b: { a: 1, b: 0 }, + }), + true, + ); - t.equal( - isCyclic({ - a: { a: 0, b: 1, c: 0 }, - b: { a: 0, b: 0, c: 1 }, - c: { a: 1, b: 0, c: 0 }, - }), - true, - ); + t.equal( + isCyclic({ + a: { a: 0, b: 1, c: 0 }, + b: { a: 0, b: 0, c: 1 }, + c: { a: 1, b: 0, c: 0 }, + }), + true, + ); - t.equal( - isCyclic({ - a: { a: 1, b: 0 }, - b: { a: 0, b: 1 }, - }), - true, - ); + t.equal( + isCyclic({ + a: { a: 1, b: 0 }, + b: { a: 0, b: 1 }, + }), + true, + ); + }); + + t.test("undirected graph", (t) => { + t.plan(5); + + t.equal(isCyclic({}, { undirected: true }), false, "Base case"); + + t.equal( + isCyclic( + { + a: { a: 0, b: 1 }, + b: { a: 1, b: 0 }, + }, + { undirected: true }, + ), + false, + "Single edge", + ); + + t.equal( + isCyclic( + { + a: { a: 0, b: 1, c: 0 }, + b: { a: 1, b: 0, c: 1 }, + c: { a: 0, b: 1, c: 0 }, + }, + { undirected: true }, + ), + false, + "2 edge graph", + ); + + t.equal( + isCyclic( + { + a: { a: 0, b: 1, c: 1 }, + b: { a: 1, b: 0, c: 1 }, + c: { a: 1, b: 1, c: 0 }, + }, + { undirected: true }, + ), + true, + "Complete graph", + ); + + t.throws(() => { + isCyclic( + { + a: { a: 0, b: 1 }, + b: { a: 0, b: 0 }, + }, + { undirected: true }, + ); + }, "Using the undirected option on a directed graph should throw an error"); + }); }); test("isUndirected", (t) => { @@ -579,7 +722,7 @@ test("outdegree", (t) => { c: { a: 0, b: 0, c: 0 }, }, "a", - true, + { weighted: true }, ), 2.5, ); @@ -612,7 +755,7 @@ test("parents", (t) => { }); test("removeEdge", (t) => { - t.plan(2); + t.plan(3); t.deepEqual( removeEdge( @@ -643,6 +786,22 @@ test("removeEdge", (t) => { b: { a: 0, b: 0 }, }, ); + + t.deepEqual( + removeEdge( + { + a: { a: 0, b: 1 }, + b: { a: 1, b: 0 }, + }, + ["a", "b"], + { undirected: true }, + ), + { + a: { a: 0, b: 0 }, + b: { a: 0, b: 0 }, + }, + "Undirected", + ); }); test("removeVertex", (t) => { @@ -665,7 +824,7 @@ test("removeVertex", (t) => { }); test("setEdge", (t) => { - t.plan(3); + t.plan(4); t.deepEqual( setEdge( @@ -711,10 +870,27 @@ test("setEdge", (t) => { b: { a: 0, b: 0 }, }, ); + + t.deepEqual( + setEdge( + { + a: { a: 0, b: 1 }, + b: { a: 0, b: 0 }, + }, + ["a", "b"], + 1.5, + { undirected: true }, + ), + { + a: { a: 0, b: 1.5 }, + b: { a: 1.5, b: 0 }, + }, + "Undirected", + ); }); test("size", (t) => { - t.plan(3); + t.plan(5); t.equal(size({}), 0); @@ -734,10 +910,33 @@ test("size", (t) => { }), 2, ); + + t.equal( + size( + { + a: { a: 0, b: 1, c: 0 }, + b: { a: 1, b: 0, c: 1 }, + c: { a: 0, b: 1, c: 0 }, + }, + { undirected: true }, + ), + 2, + "Undirected", + ); + + t.throws(() => { + size( + { + a: { a: 0, b: 1 }, + b: { a: 0, b: 0 }, + }, + { undirected: true }, + ); + }, "Using the undirected option on a directed graph should throw an error"); }); test("toD3", (t) => { - t.plan(1); + t.plan(3); t.deepEqual( toD3({ @@ -755,6 +954,39 @@ test("toD3", (t) => { ], }, ); + + t.deepEqual( + toD3( + { + a: { a: 0, b: 1, c: 1 }, + b: { a: 1, b: 0, c: 0 }, + c: { a: 1, b: 0, c: 0 }, + }, + { undirected: true }, + ), + { + nodes: [{ id: "a" }, { id: "b" }, { id: "c" }], + links: [ + { source: "a", target: "b" }, + { source: "a", target: "c" }, + ], + }, + ); + + t.throws(() => { + toD3( + { + a: { a: 0, b: 1 }, + b: { a: 0, b: 0 }, + }, + { undirected: true }, + ); + }, "Using the undirected option on a directed graph should throw an error"); +}); + +test("toDirected", (t) => { + t.plan(1); + t.skip("TODO"); }); test("topologicalSort", (t) => { -- 2.51.2