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