From 5bc2be9e035cacc2393cf3dd63cce7cf39bdd2cf Mon Sep 17 00:00:00 2001 From: Acid Date: Fri, 24 Jul 2026 20:35:34 -0400 Subject: [PATCH] added Directed graphs and DAG --- README.md | 24 ++-- explanations/directedAcyclicGraph.md | 117 +++++++++++++++++++ graphs/dag.go | 97 +++++++++++++++ graphs/directedGraph.go | 66 +++++++++++ tests/graphTests/dag_test.go | 156 +++++++++++++++++++++++++ tests/graphTests/directedGraph_test.go | 125 ++++++++++++++++++++ 6 files changed, 578 insertions(+), 7 deletions(-) create mode 100644 explanations/directedAcyclicGraph.md create mode 100644 graphs/dag.go create mode 100644 graphs/directedGraph.go create mode 100644 tests/graphTests/dag_test.go create mode 100644 tests/graphTests/directedGraph_test.go diff --git a/README.md b/README.md index 65a62d2..23fa670 100644 --- a/README.md +++ b/README.md @@ -13,14 +13,14 @@ - [x] Binary Search Tree - [x] AVL Tree - [x] Heap (min/max) - - - [x] Priority Queue - [ ] Trie ## Graph - [x] Unweighted - [x] Weighted -- [ ] Directed +- [x] Directed +- [x] DAG ## Sets @@ -33,25 +33,35 @@ # Algorithms ωψγ +## Etc + - [x] Kadane's Algorithm - [x] Euclidean GCD - [x] Fibonacci -- [x] Heap Sort +- [x] Miller Rabin prime test - [ ] Modular Arithmetic - [ ] Sieve of Eratosthenes + +## Heaps & Trees + +- [x] Priority Queue +- [x] Heap Sort - [x] BFS Breadth-First Search - [ ] DFS Depth-First Search -- [x] Miller Rabin prime test + +## Graphs + +- [ ] Topological Sort with Kahn's algorithm # Documentation -> linear Structures +> Linear Structures ```bash go doc -all ./linear | bat -l go ``` -> algorithms +> Algorithms ```bash go doc -all ./algo | bat -l go @@ -63,7 +73,7 @@ go doc -all ./algo | bat -l go go doc -all ./sets/ | bat -l go ``` -> trees +> Trees ```bash for p in ./trees/ ./trees/avl ./trees/heap; do go doc -all "$p"; done | bat -l go diff --git a/explanations/directedAcyclicGraph.md b/explanations/directedAcyclicGraph.md new file mode 100644 index 0000000..681f96c --- /dev/null +++ b/explanations/directedAcyclicGraph.md @@ -0,0 +1,117 @@ +# Directed Acyclic Graph (DAG) — How the Cycle Check Works + +A **directed graph** stores one-way edges: `a -> b` means you can go from +`a` to `b`, but not necessarily back. In the adjacency list each edge is +stored **once**, on the source vertex's out-list: + +``` +a -> b +b -> c + +adjList: + a: [b] + b: [c] + c: [] +``` + +(Contrast with the undirected `Graph`, where `a -> b` is stored twice — in +both `a`'s and `b`'s lists — which is why its `Size()` divides by two and the +directed `Size()` does not.) + +A **DAG** adds one rule: **no cycles**. You can never follow the arrows and +end up back where you started. `a -> b -> c -> a` is forbidden. + +--- + +## 1. Where the invariant is enforced + +There is exactly one guard, inside `AddVertex(from, to)`: + +```go +// from -> to closes a cycle iff "to" can already reach "from". +if gp.hasPath(to, from) { + return false +} +``` + +The reasoning is short but important: + +> The graph is acyclic **before** every `AddVertex` call — it starts empty, +> and this guard has held every previous time. Given that, the *only* way the +> new edge `from -> to` can create a cycle is if a path `to -> ... -> from` +> already exists. The new edge would close that path into a loop. + +So the question "would this edge create a cycle?" reduces to "can `to` already +reach `from`?" — and that is a reachability query, answered by DFS. + +Note the argument order: `hasPath(to, from)`, **not** `hasPath(from, to)`. You +search *forward from `to`* trying to arrive back at `from`. + +--- + +## 2. What DFS actually does + +`hasPath` just seeds a fresh `visited` set and calls `dfs`: + +```go +func (gp *DAG[T]) dfs(current, dst T, visited map[T]bool) bool { + if current == dst { // (1) standing on the target -> found it + return true + } + visited[current] = true // (2) never revisit this vertex + + for _, next := range gp.adjList[current] { // (3) each out-neighbor + if !visited[next] && gp.dfs(next, dst, visited) { // (4) ask the same question of it + return true + } + } + return false // (5) exhausted every path -> unreachable +} +``` + +The mental model: `dfs` does **not** know the answer itself. It asks *"can I, +`current`, reach `dst`?"* by delegating the *same* question to each of its +out-neighbors. If any neighbor can reach `dst`, then so can `current`, and the +`true` bubbles straight up the call stack. + +- **(1)** is the only place `true` is born. +- **(2)** the `visited` set guarantees termination. If the data ever did + contain a cycle, without it the recursion would loop forever. +- **(4)** short-circuits: the first neighbor that succeeds ends the search. +- **(5)** if every branch dead-ends, there is no path. + +--- + +## 3. Trace: rejecting a cycle + +Graph is `a -> b`, `b -> c`. Someone calls `AddVertex("c", "a")`, which fires +`hasPath("a", "c")` → `dfs("a", "c", {})`: + +| Call | `current` | `current == dst`? | neighbors | action | +| ---- | --------- | ----------------- | --------- | ------------------------------ | +| 1 | `a` | `a == c`? no | `[b]` | mark `a`, recurse into `b` | +| 2 | `b` | `b == c`? no | `[c]` | mark `b`, recurse into `c` | +| 3 | `c` | `c == c`? **yes** | — | **return `true`** | + +The `true` returns up through calls 2 and 1. `hasPath` is `true`, so `c -> a` +is **rejected** — `a` could already reach `c`, so the edge would close a loop. + +## 4. Trace: allowing an edge + +Graph is just `a -> b`. Someone calls `AddVertex("a", "c")` → `hasPath("c", "a")` +→ `dfs("c", "a", {})`: + +| Call | `current` | `current == dst`? | neighbors | action | +| ---- | --------- | ----------------- | --------- | ------------------------------ | +| 1 | `c` | `c == a`? no | `[]` | mark `c`, loop body never runs | +| — | | | | fall through → **return `false`** | + +No path → the edge `a -> c` is safe and gets appended. + +--- + +## 5. Cost + +Each `AddVertex` runs one DFS: **O(V + E)** time, O(V) space for `visited`. +The self-loop case (`from == to`) is handled by a cheap equality check up front, +before any DFS runs. diff --git a/graphs/dag.go b/graphs/dag.go new file mode 100644 index 0000000..f392d58 --- /dev/null +++ b/graphs/dag.go @@ -0,0 +1,97 @@ +package graphs + +import "slices" + +// DAG -> Directed Acyclic Graph. Any edge that would introduce a cycle is +// rejected, so the acyclic invariant always holds. +type DAG[T comparable] struct { + adjList map[T][]T +} + +// NewDAG() -> Creates a Directed Acyclic Graph +func NewDAG[T comparable]() *DAG[T] { + gp := &DAG[T]{ + adjList: make(map[T][]T), + } + + return gp +} + +// AddIsolatedVertex() -> Creates an isolated Vertex returns True if ok , +// false if vertex already exist +func (gp *DAG[T]) AddIsolatedVertex(newVertex T) bool { + _, ok := gp.adjList[newVertex] + if !ok { + gp.adjList[newVertex] = make([]T, 0) + return true + } + return false +} + +// AddVertex() -> adds a directed edge from -> to, creating either vertex if it +// is missing. Returns false (and adds nothing) when the edge is a self loop, +// already exists, or would create a cycle. +func (gp *DAG[T]) AddVertex(from T, to T) bool { + // self loops are cycles of length 1 + if from == to { + return false + } + + gp.AddIsolatedVertex(from) + gp.AddIsolatedVertex(to) + + if slices.Contains(gp.adjList[from], to) { + return false + } + + // from -> to closes a cycle iff "to" can already reach "from". + if gp.hasPath(to, from) { + return false + } + + gp.adjList[from] = append(gp.adjList[from], to) + return true +} + +// hasPath() -> reports whether a directed path exists from src to dst. +func (gp *DAG[T]) hasPath(src T, dst T) bool { + visited := make(map[T]bool) + return gp.dfs(src, dst, visited) +} + +func (gp *DAG[T]) dfs(current T, dst T, visited map[T]bool) bool { + if current == dst { + return true + } + + visited[current] = true + + for _, next := range gp.adjList[current] { + if !visited[next] && gp.dfs(next, dst, visited) { + return true + } + } + + return false +} + +// Order() -> returns the amount of Vertices +func (gp *DAG[T]) Order() int { + return len(gp.adjList) +} + +// Size() -> returns the amount of Edges +func (gp *DAG[T]) Size() int { + var size int + + // directed: each edge is stored once, so no halving. + for _, val := range gp.adjList { + size += len(val) + } + return size +} + +// Display() -> returns adjList[map]T []T +func (gp *DAG[T]) Display() map[T][]T { + return gp.adjList +} diff --git a/graphs/directedGraph.go b/graphs/directedGraph.go new file mode 100644 index 0000000..379ac23 --- /dev/null +++ b/graphs/directedGraph.go @@ -0,0 +1,66 @@ +package graphs + +import "slices" + +// DirectedGraph +type DirectedGraph[T comparable] struct { + adjList map[T][]T +} + +// NewDirectedGraph() -> Creates a Directed Graph +func NewDirectedGraph[T comparable]() *DirectedGraph[T] { + gp := &DirectedGraph[T]{ + adjList: make(map[T][]T), + } + + return gp +} + +// AddIsolatedVertex() -> Creates an isolated Vertex returns True if ok , +// false if vertex already exist +func (gp *DirectedGraph[T]) AddIsolatedVertex(newVertex T) bool { + _, ok := gp.adjList[newVertex] + if !ok { + gp.adjList[newVertex] = make([]T, 0) + return true + } + return false +} + +// AddVertex() -> Creates a directed edge newVertex -> toVertex. Creates both +// vertices if they dont exist. +func (gp *DirectedGraph[T]) AddVertex(newVertex T, toVertex T) { + // self loops are not allowed in this graph + if newVertex == toVertex { + return + } + + gp.AddIsolatedVertex(newVertex) + gp.AddIsolatedVertex(toVertex) + + // directed: only store the edge on the source's out-list, once. + if !slices.Contains(gp.adjList[newVertex], toVertex) { + gp.adjList[newVertex] = append(gp.adjList[newVertex], toVertex) + } +} + +// Order() -> returns the amount of Vertices +func (gp *DirectedGraph[T]) Order() int { + return len(gp.adjList) +} + +// Size() -> returns the amount of Edges +func (gp *DirectedGraph[T]) Size() int { + var size int + + // directed: each edge is stored once, so no halving. + for _, val := range gp.adjList { + size += len(val) + } + return size +} + +// Display() -> returns adjList[map]T []T +func (gp *DirectedGraph[T]) Display() map[T][]T { + return gp.adjList +} diff --git a/tests/graphTests/dag_test.go b/tests/graphTests/dag_test.go new file mode 100644 index 0000000..05db322 --- /dev/null +++ b/tests/graphTests/dag_test.go @@ -0,0 +1,156 @@ +package tests + +import ( + "slices" + "testing" + + "datastructures/graphs" +) + +func TestNewDAGIsEmpty(t *testing.T) { + dag := graphs.NewDAG[int]() + + if dag.Order() != 0 { + t.Errorf("new dag Order = %d, want 0", dag.Order()) + } + if dag.Size() != 0 { + t.Errorf("new dag Size = %d, want 0", dag.Size()) + } +} + +func TestDAGAddVertexReturnsTrueOnSuccess(t *testing.T) { + dag := graphs.NewDAG[string]() + + if !dag.AddVertex("a", "b") { + t.Error("AddVertex(a, b) = false, want true") + } + if dag.Order() != 2 { + t.Errorf("Order = %d, want 2", dag.Order()) + } + if dag.Size() != 1 { + t.Errorf("Size = %d, want 1", dag.Size()) + } + if !slices.Contains(dag.Display()["a"], "b") { + t.Errorf("expected a->b, got a: %v", dag.Display()["a"]) + } +} + +// The core invariant: an edge that would close a cycle must be rejected. +func TestDAGRejectsCycle(t *testing.T) { + dag := graphs.NewDAG[string]() + dag.AddVertex("a", "b") + dag.AddVertex("b", "c") + + // c -> a would close a -> b -> c -> a + if dag.AddVertex("c", "a") { + t.Error("AddVertex(c, a) = true, want false (would create a cycle)") + } + if dag.Size() != 2 { + t.Errorf("Size = %d, want 2 (cycle edge must not be added)", dag.Size()) + } + if slices.Contains(dag.Display()["c"], "a") { + t.Errorf("c->a should not have been added, got c: %v", dag.Display()["c"]) + } +} + +// A back-edge across a longer chain must also be caught. +func TestDAGRejectsLongCycle(t *testing.T) { + dag := graphs.NewDAG[int]() + dag.AddVertex(1, 2) + dag.AddVertex(2, 3) + dag.AddVertex(3, 4) + dag.AddVertex(4, 5) + + // 5 -> 1 would close the whole chain into a loop + if dag.AddVertex(5, 1) { + t.Error("AddVertex(5, 1) = true, want false (long cycle)") + } + if dag.Size() != 4 { + t.Errorf("Size = %d, want 4", dag.Size()) + } +} + +func TestDAGSelfLoopRejected(t *testing.T) { + dag := graphs.NewDAG[int]() + + if dag.AddVertex(1, 1) { + t.Error("AddVertex(1, 1) = true, want false (self loop)") + } + if dag.Size() != 0 { + t.Errorf("self-loop should add no edge, Size = %d", dag.Size()) + } +} + +// A diamond has multiple paths between two vertices but no cycle: all edges +// should be accepted. +func TestDAGAllowsDiamond(t *testing.T) { + dag := graphs.NewDAG[string]() + + if !dag.AddVertex("a", "b") { + t.Error("a->b rejected") + } + if !dag.AddVertex("a", "c") { + t.Error("a->c rejected") + } + if !dag.AddVertex("b", "d") { + t.Error("b->d rejected") + } + if !dag.AddVertex("c", "d") { + t.Error("c->d rejected (diamond, not a cycle)") + } + + if dag.Order() != 4 { + t.Errorf("Order = %d, want 4", dag.Order()) + } + if dag.Size() != 4 { + t.Errorf("Size = %d, want 4", dag.Size()) + } +} + +func TestDAGDuplicateEdgeRejected(t *testing.T) { + dag := graphs.NewDAG[string]() + dag.AddVertex("a", "b") + + if dag.AddVertex("a", "b") { + t.Error("duplicate AddVertex(a, b) = true, want false") + } + if len(dag.Display()["a"]) != 1 { + t.Errorf("expected no duplicate edge, got a: %v", dag.Display()["a"]) + } + if dag.Size() != 1 { + t.Errorf("Size = %d, want 1", dag.Size()) + } +} + +// Rejecting a cycle edge must not corrupt the graph: valid edges added +// afterward should still work. +func TestDAGStillUsableAfterRejection(t *testing.T) { + dag := graphs.NewDAG[string]() + dag.AddVertex("a", "b") + dag.AddVertex("b", "c") + dag.AddVertex("c", "a") // rejected + + if !dag.AddVertex("c", "d") { + t.Error("AddVertex(c, d) = false after a rejection, want true") + } + if dag.Size() != 3 { + t.Errorf("Size = %d, want 3", dag.Size()) + } +} + +func TestDAGAddIsolatedVertex(t *testing.T) { + dag := graphs.NewDAG[string]() + + if !dag.AddIsolatedVertex("solo") { + t.Error("AddIsolatedVertex on new vertex = false, want true") + } + if dag.AddIsolatedVertex("solo") { + t.Error("AddIsolatedVertex on existing vertex = true, want false") + } + if dag.Order() != 1 { + t.Errorf("Order = %d, want 1", dag.Order()) + } + if dag.Size() != 0 { + t.Errorf("isolated vertex should add no edges, Size = %d", dag.Size()) + } +} diff --git a/tests/graphTests/directedGraph_test.go b/tests/graphTests/directedGraph_test.go new file mode 100644 index 0000000..5a39a43 --- /dev/null +++ b/tests/graphTests/directedGraph_test.go @@ -0,0 +1,125 @@ +package tests + +import ( + "slices" + "testing" + + "datastructures/graphs" +) + +func TestNewDirectedGraphIsEmpty(t *testing.T) { + graph := graphs.NewDirectedGraph[int]() + + if graph.Order() != 0 { + t.Errorf("new graph Order = %d, want 0", graph.Order()) + } + if graph.Size() != 0 { + t.Errorf("new graph Size = %d, want 0", graph.Size()) + } +} + +func TestDirectedAddVertexIsOneWay(t *testing.T) { + graph := graphs.NewDirectedGraph[string]() + graph.AddVertex("a", "b") + + if graph.Order() != 2 { + t.Errorf("Order = %d, want 2", graph.Order()) + } + if graph.Size() != 1 { + t.Errorf("Size = %d, want 1", graph.Size()) + } + + adjacency := graph.Display() + if !slices.Contains(adjacency["a"], "b") { + t.Errorf("expected b in a's out-list, got %v", adjacency["a"]) + } + // directed: the edge must NOT appear in reverse. + if slices.Contains(adjacency["b"], "a") { + t.Errorf("directed edge should not point back, got b: %v", adjacency["b"]) + } +} + +func TestDirectedSizeCountsEachEdgeOnce(t *testing.T) { + graph := graphs.NewDirectedGraph[string]() + graph.AddVertex("a", "b") + graph.AddVertex("b", "a") + + // two distinct directed edges: a->b and b->a + if graph.Size() != 2 { + t.Errorf("Size = %d, want 2 (a->b and b->a are distinct)", graph.Size()) + } +} + +func TestDirectedAddVertexIsIdempotent(t *testing.T) { + graph := graphs.NewDirectedGraph[string]() + graph.AddVertex("a", "b") + graph.AddVertex("a", "b") + + if len(graph.Display()["a"]) != 1 { + t.Errorf("expected no duplicate edge, got %v", graph.Display()["a"]) + } + if graph.Size() != 1 { + t.Errorf("Size = %d, want 1", graph.Size()) + } +} + +// Regression: a vertex that is already the target of an edge must still be +// able to have its own outgoing edges (the old check2 loop broke this). +func TestDirectedTargetCanHaveOutgoingEdges(t *testing.T) { + graph := graphs.NewDirectedGraph[string]() + graph.AddVertex("c", "a") // a is now a target + graph.AddVertex("a", "b") // a must still be allowed an out-edge + + if !slices.Contains(graph.Display()["a"], "b") { + t.Errorf("expected a->b even though a is a target, got a: %v", graph.Display()["a"]) + } + if graph.Size() != 2 { + t.Errorf("Size = %d, want 2", graph.Size()) + } +} + +func TestDirectedMultipleOutEdges(t *testing.T) { + graph := graphs.NewDirectedGraph[int]() + graph.AddVertex(1, 2) + graph.AddVertex(1, 3) + graph.AddVertex(1, 4) + + if graph.Order() != 4 { + t.Errorf("Order = %d, want 4", graph.Order()) + } + if graph.Size() != 3 { + t.Errorf("Size = %d, want 3", graph.Size()) + } + if len(graph.Display()[1]) != 3 { + t.Errorf("expected vertex 1 out-degree 3, got %v", graph.Display()[1]) + } +} + +func TestDirectedSelfLoopRejected(t *testing.T) { + graph := graphs.NewDirectedGraph[int]() + graph.AddVertex(1, 1) + + if graph.Size() != 0 { + t.Errorf("self-loop should add no edge, Size = %d", graph.Size()) + } + if slices.Contains(graph.Display()[1], 1) { + t.Errorf("vertex 1 should not point to itself, got %v", graph.Display()[1]) + } +} + +func TestDirectedAddIsolatedVertex(t *testing.T) { + graph := graphs.NewDirectedGraph[string]() + + if !graph.AddIsolatedVertex("solo") { + t.Error("AddIsolatedVertex on new vertex = false, want true") + } + if graph.AddIsolatedVertex("solo") { + t.Error("AddIsolatedVertex on existing vertex = true, want false") + } + if graph.Order() != 1 { + t.Errorf("Order = %d, want 1", graph.Order()) + } + if graph.Size() != 0 { + t.Errorf("isolated vertex should add no edges, Size = %d", graph.Size()) + } +}