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
+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.