From a28f826f183da656272af37ee6ab6d4982a47d2d Mon Sep 17 00:00:00 2001 From: Jordan Singer Date: Mon, 9 Oct 2023 10:49:56 -0500 Subject: [PATCH 1/5] implement bellman ford algorithm to find shortest path with negative weights --- paths.go | 62 ++++++++++++++++ paths_test.go | 197 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 257 insertions(+), 2 deletions(-) diff --git a/paths.go b/paths.go index e6f16e3f..3d4b75b3 100644 --- a/paths.go +++ b/paths.go @@ -75,6 +75,11 @@ func CreatesCycle[K comparable, T any](g Graph[K, T], source, target K) (bool, e // from the source vertex, ErrTargetNotReachable will be returned. If there are multiple shortest // paths, an arbitrary one will be returned. func ShortestPath[K comparable, T any](g Graph[K, T], source, target K) ([]K, error) { + + if directedGraph, ok := g.(*directed[K, T]); ok && directedGraph.Traits().IsDirected { + return bellmanFord(g, source, target) + } + weights := make(map[K]float64) visited := make(map[K]bool) @@ -142,6 +147,63 @@ func ShortestPath[K comparable, T any](g Graph[K, T], source, target K) ([]K, er return path, nil } +// bellmanFord is a helper function for ShortestPath that uses the Bellman-Ford algorithm to +// compute the shortest path between a source and a target vertex using the edge weights and returns +// the hash values of the vertices forming that path. This search runs in O(|V|*|E|) time. +// +// The returned path includes the source and target vertices. If the target cannot be reached +// from the source vertex, ErrTargetNotReachable will be returned. If there are multiple shortest +func bellmanFord[K comparable, T any](g Graph[K, T], source, target K) ([]K, error) { + + if !g.Traits().IsDirected { + return nil, errors.New("Bellman-Ford algorithm can only be used on directed graphs") + } + + dist := make(map[K]int) + prev := make(map[K]K) + + adjacencyMap, err := g.AdjacencyMap() + if err != nil { + return nil, fmt.Errorf("could not get adjacency map: %w", err) + } + for key := range adjacencyMap { + dist[key] = math.MaxInt32 + } + dist[source] = 0 + + for i := 0; i < len(adjacencyMap)-1; i++ { + for key, edges := range adjacencyMap { + for _, edge := range edges { + if newDist := dist[key] + edge.Properties.Weight; newDist < dist[edge.Target] { + dist[edge.Target] = newDist + prev[edge.Target] = key + } + } + } + } + + for _, edges := range adjacencyMap { + for _, edge := range edges { + if newDist := dist[edge.Source] + edge.Properties.Weight; newDist < dist[edge.Target] { + fmt.Println(edge.Source, edge.Target, newDist, dist[edge.Target]) + return nil, errors.New("graph contains a negative-weight cycle") + } + } + } + + path := []K{} + u := target + for u != source { + if _, ok := prev[u]; !ok { + return nil, ErrTargetNotReachable + } + path = append([]K{u}, path...) + u = prev[u] + } + path = append([]K{source}, path...) + return path, nil +} + type sccState[K comparable] struct { adjacencyMap map[K]map[K]Edge[K] components [][]K diff --git a/paths_test.go b/paths_test.go index a7771c36..814f3be9 100644 --- a/paths_test.go +++ b/paths_test.go @@ -304,6 +304,7 @@ func TestUndirectedShortestPath(t *testing.T) { sourceHash string targetHash string isWeighted bool + isDirected bool expectedShortestPath []string shouldFail bool }{ @@ -367,6 +368,22 @@ func TestUndirectedShortestPath(t *testing.T) { targetHash: "B", expectedShortestPath: []string{"B"}, }, + "can process negative weights": { + vertices: []string{"A", "B", "C", "D", "E"}, + edges: []Edge[string]{ + {Source: "A", Target: "B", Properties: EdgeProperties{Weight: 1}}, + {Source: "A", Target: "C", Properties: EdgeProperties{Weight: 2}}, + {Source: "B", Target: "C", Properties: EdgeProperties{Weight: 2}}, + {Source: "B", Target: "D", Properties: EdgeProperties{Weight: 2}}, + {Source: "C", Target: "E", Properties: EdgeProperties{Weight: 2}}, + {Source: "D", Target: "E", Properties: EdgeProperties{Weight: -1}}, + }, + isWeighted: true, + isDirected: true, + sourceHash: "A", + targetHash: "E", + expectedShortestPath: []string{"A", "B", "D", "E"}, + }, "target not reachable in a disconnected graph": { vertices: []string{"A", "B", "C", "D"}, edges: []Edge[string]{ @@ -382,8 +399,14 @@ func TestUndirectedShortestPath(t *testing.T) { } for name, test := range tests { - graph := New(StringHash) - graph.(*undirected[string, string]).traits.IsWeighted = test.isWeighted + var graph Graph[string, string] + if test.isDirected { + graph = New(StringHash, Directed()) + graph.(*directed[string, string]).traits.IsWeighted = test.isWeighted + } else { + graph = New(StringHash) + graph.(*undirected[string, string]).traits.IsWeighted = test.isWeighted + } for _, vertex := range test.vertices { _ = graph.AddVertex(vertex) @@ -413,6 +436,176 @@ func TestUndirectedShortestPath(t *testing.T) { } } +func Test_BellmanFord(t *testing.T) { + tests := map[string]struct { + vertices []string + edges []Edge[string] + sourceHash string + targetHash string + isWeighted bool + IsDirected bool + expectedShortestPath []string + shouldFail bool + }{ + "graph as on img/dijkstra.svg": { + vertices: []string{"A", "B", "C", "D", "E", "F", "G"}, + edges: []Edge[string]{ + + {Source: "A", Target: "C", Properties: EdgeProperties{Weight: 3}}, + {Source: "A", Target: "F", Properties: EdgeProperties{Weight: 2}}, + {Source: "C", Target: "D", Properties: EdgeProperties{Weight: 4}}, + {Source: "C", Target: "E", Properties: EdgeProperties{Weight: 1}}, + {Source: "C", Target: "F", Properties: EdgeProperties{Weight: 2}}, + {Source: "D", Target: "B", Properties: EdgeProperties{Weight: 1}}, + {Source: "E", Target: "B", Properties: EdgeProperties{Weight: 2}}, + {Source: "E", Target: "F", Properties: EdgeProperties{Weight: 3}}, + {Source: "F", Target: "G", Properties: EdgeProperties{Weight: 5}}, + {Source: "G", Target: "B", Properties: EdgeProperties{Weight: 2}}, + }, + isWeighted: true, + IsDirected: true, + sourceHash: "A", + targetHash: "B", + expectedShortestPath: []string{"A", "C", "E", "B"}, + }, + "diamond-shaped graph": { + vertices: []string{"A", "B", "C", "D"}, + edges: []Edge[string]{ + {Source: "A", Target: "B", Properties: EdgeProperties{Weight: 2}}, + {Source: "A", Target: "C", Properties: EdgeProperties{Weight: 4}}, + {Source: "B", Target: "D", Properties: EdgeProperties{Weight: 2}}, + {Source: "C", Target: "D", Properties: EdgeProperties{Weight: 2}}, + }, + isWeighted: true, + IsDirected: true, + sourceHash: "A", + targetHash: "D", + expectedShortestPath: []string{"A", "B", "D"}, + }, + "unweighted graph": { + vertices: []string{"A", "B", "C", "D", "E", "F", "G"}, + edges: []Edge[string]{ + {Source: "A", Target: "B", Properties: EdgeProperties{}}, + {Source: "A", Target: "C", Properties: EdgeProperties{}}, + {Source: "B", Target: "D", Properties: EdgeProperties{}}, + {Source: "C", Target: "F", Properties: EdgeProperties{}}, + {Source: "D", Target: "G", Properties: EdgeProperties{}}, + {Source: "E", Target: "G", Properties: EdgeProperties{}}, + {Source: "F", Target: "E", Properties: EdgeProperties{}}, + }, + IsDirected: true, + sourceHash: "A", + targetHash: "G", + expectedShortestPath: []string{"A", "B", "D", "G"}, + }, + "source equal to target": { + vertices: []string{"A", "B", "C", "D"}, + edges: []Edge[string]{ + {Source: "A", Target: "B", Properties: EdgeProperties{Weight: 2}}, + {Source: "A", Target: "C", Properties: EdgeProperties{Weight: 4}}, + {Source: "B", Target: "D", Properties: EdgeProperties{Weight: 2}}, + {Source: "C", Target: "D", Properties: EdgeProperties{Weight: 2}}, + }, + isWeighted: true, + IsDirected: true, + sourceHash: "B", + targetHash: "B", + expectedShortestPath: []string{"B"}, + }, + "target not reachable in a disconnected graph": { + vertices: []string{"A", "B", "C", "D"}, + edges: []Edge[string]{ + {Source: "A", Target: "B", Properties: EdgeProperties{Weight: 2}}, + {Source: "A", Target: "C", Properties: EdgeProperties{Weight: 4}}, + }, + isWeighted: true, + IsDirected: true, + sourceHash: "A", + targetHash: "D", + expectedShortestPath: []string{}, + shouldFail: true, + }, + "negative weights graph": { + vertices: []string{"A", "B", "C", "D", "E"}, + edges: []Edge[string]{ + {Source: "A", Target: "B", Properties: EdgeProperties{Weight: 1}}, + {Source: "A", Target: "C", Properties: EdgeProperties{Weight: 2}}, + {Source: "B", Target: "C", Properties: EdgeProperties{Weight: 2}}, + {Source: "B", Target: "D", Properties: EdgeProperties{Weight: 2}}, + {Source: "C", Target: "E", Properties: EdgeProperties{Weight: 2}}, + {Source: "D", Target: "E", Properties: EdgeProperties{Weight: -1}}, + }, + isWeighted: true, + IsDirected: true, + sourceHash: "A", + targetHash: "E", + expectedShortestPath: []string{"A", "B", "D", "E"}, + }, + "fails on negative cycles": { + vertices: []string{"A", "B", "C", "D", "E"}, + edges: []Edge[string]{ + {Source: "A", Target: "B", Properties: EdgeProperties{Weight: 1}}, + {Source: "A", Target: "C", Properties: EdgeProperties{Weight: 4}}, + {Source: "B", Target: "C", Properties: EdgeProperties{Weight: 2}}, + {Source: "B", Target: "D", Properties: EdgeProperties{Weight: 6}}, + {Source: "C", Target: "D", Properties: EdgeProperties{Weight: 3}}, + {Source: "C", Target: "E", Properties: EdgeProperties{Weight: 2}}, + {Source: "D", Target: "E", Properties: EdgeProperties{Weight: -3}}, + {Source: "E", Target: "C", Properties: EdgeProperties{Weight: -3}}, + }, + isWeighted: true, + IsDirected: true, + sourceHash: "A", + targetHash: "E", + expectedShortestPath: []string{}, + shouldFail: true, + }, + "fails if not directed": { + vertices: []string{"A", "B", "C", "D"}, + edges: []Edge[string]{ + {Source: "A", Target: "B", Properties: EdgeProperties{Weight: 2}}, + {Source: "A", Target: "C", Properties: EdgeProperties{Weight: 4}}, + }, + isWeighted: true, + sourceHash: "A", + targetHash: "D", + expectedShortestPath: []string{}, + shouldFail: true, + }, + } + for name, test := range tests { + graph := New(StringHash, Directed()) + graph.(*directed[string, string]).traits.IsWeighted = test.isWeighted + graph.(*directed[string, string]).traits.IsDirected = true + + for _, vertex := range test.vertices { + _ = graph.AddVertex(vertex) + } + + for _, edge := range test.edges { + if err := graph.AddEdge(edge.Source, edge.Target, EdgeWeight(edge.Properties.Weight)); err != nil { + t.Fatalf("%s: failed to add edge: %s", name, err.Error()) + } + } + + shortestPath, err := bellmanFord(graph, test.sourceHash, test.targetHash) + + if test.shouldFail != (err != nil) { + t.Fatalf("%s: error expectancy doesn't match: expected %v, got %v (error: %v)", name, test.shouldFail, (err != nil), err) + } + + if len(shortestPath) != len(test.expectedShortestPath) { + t.Fatalf("%s: path length expectancy doesn't match: expected %v, got %v", name, len(test.expectedShortestPath), len(shortestPath)) + } + + for i, expectedVertex := range test.expectedShortestPath { + if shortestPath[i] != expectedVertex { + t.Errorf("%s: path vertex expectancy doesn't match: expected %v at index %d, got %v", name, expectedVertex, i, shortestPath[i]) + } + } + } +} + func TestDirectedStronglyConnectedComponents(t *testing.T) { tests := map[string]struct { vertices []int From ffb43c98c1d576d0b51deed0c53ebd7108016b14 Mon Sep 17 00:00:00 2001 From: Jordan Singer Date: Mon, 9 Oct 2023 12:29:10 -0500 Subject: [PATCH 2/5] remove print statement --- paths.go | 1 - 1 file changed, 1 deletion(-) diff --git a/paths.go b/paths.go index 3d4b75b3..975d07dc 100644 --- a/paths.go +++ b/paths.go @@ -185,7 +185,6 @@ func bellmanFord[K comparable, T any](g Graph[K, T], source, target K) ([]K, err for _, edges := range adjacencyMap { for _, edge := range edges { if newDist := dist[edge.Source] + edge.Properties.Weight; newDist < dist[edge.Target] { - fmt.Println(edge.Source, edge.Target, newDist, dist[edge.Target]) return nil, errors.New("graph contains a negative-weight cycle") } } From d04edaac22ead4f743e2b9642b65e87218e5a14c Mon Sep 17 00:00:00 2001 From: Jordan Singer Date: Mon, 9 Oct 2023 12:34:03 -0500 Subject: [PATCH 3/5] remove type check --- paths.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paths.go b/paths.go index 975d07dc..472ba373 100644 --- a/paths.go +++ b/paths.go @@ -76,7 +76,7 @@ func CreatesCycle[K comparable, T any](g Graph[K, T], source, target K) (bool, e // paths, an arbitrary one will be returned. func ShortestPath[K comparable, T any](g Graph[K, T], source, target K) ([]K, error) { - if directedGraph, ok := g.(*directed[K, T]); ok && directedGraph.Traits().IsDirected { + if g.Traits().IsDirected { return bellmanFord(g, source, target) } From 7d16954aeb3c0ae99d3b777bb93cc1c971ff087b Mon Sep 17 00:00:00 2001 From: Jordan Singer Date: Mon, 9 Oct 2023 12:37:15 -0500 Subject: [PATCH 4/5] clean up unit test --- paths_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/paths_test.go b/paths_test.go index 814f3be9..5c8188e4 100644 --- a/paths_test.go +++ b/paths_test.go @@ -576,7 +576,6 @@ func Test_BellmanFord(t *testing.T) { for name, test := range tests { graph := New(StringHash, Directed()) graph.(*directed[string, string]).traits.IsWeighted = test.isWeighted - graph.(*directed[string, string]).traits.IsDirected = true for _, vertex := range test.vertices { _ = graph.AddVertex(vertex) From 781326827a18658097bc7509e86f4f8032a19c06 Mon Sep 17 00:00:00 2001 From: Jordan Singer Date: Mon, 9 Oct 2023 12:38:13 -0500 Subject: [PATCH 5/5] use weighted func --- paths_test.go | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/paths_test.go b/paths_test.go index 5c8188e4..2ae88873 100644 --- a/paths_test.go +++ b/paths_test.go @@ -401,11 +401,9 @@ func TestUndirectedShortestPath(t *testing.T) { for name, test := range tests { var graph Graph[string, string] if test.isDirected { - graph = New(StringHash, Directed()) - graph.(*directed[string, string]).traits.IsWeighted = test.isWeighted + graph = New(StringHash, Directed(), Weighted()) } else { - graph = New(StringHash) - graph.(*undirected[string, string]).traits.IsWeighted = test.isWeighted + graph = New(StringHash, Weighted()) } for _, vertex := range test.vertices { @@ -574,8 +572,7 @@ func Test_BellmanFord(t *testing.T) { }, } for name, test := range tests { - graph := New(StringHash, Directed()) - graph.(*directed[string, string]).traits.IsWeighted = test.isWeighted + graph := New(StringHash, Directed(), Weighted()) for _, vertex := range test.vertices { _ = graph.AddVertex(vertex)