added Directed graphs and DAG
Go / build (push) Successful in 31s

This commit is contained in:
Acid
2026-07-24 20:35:34 -04:00
parent 35592e1bb8
commit 5bc2be9e03
6 changed files with 578 additions and 7 deletions
+17 -7
View File
@@ -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
+117
View File
@@ -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.
+97
View File
@@ -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
}
+66
View File
@@ -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
}
+156
View File
@@ -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())
}
}
+125
View File
@@ -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())
}
}