Trees & graphs · Lesson 35 of 62 · about 60 min
Graph structure: cycles, SCCs, bridges & cloning
Detect cycles in directed graphs with three colors, check bipartiteness, find strongly connected components and bridges, and deep-copy graphs and lists with random pointers.
On this page · 8 sections
Cycles in a directed graph: three colors#
In an undirected graph, reaching an already-visited node (other than the one you came from) means a cycle. In a directed graph that is not enough: in A→B, A→C, B→C, DFS reaches C twice, but there is no cycle. What matters is whether the node is on the current path. Give each node one of three colors:
| Color | Meaning |
|---|---|
| white | not visited yet |
| gray | visiting: on the current DFS path, not finished |
| black | finished: everything reachable from it has been explored |
Reaching a gray node means you followed edges back to a node on your own path: a cycle. Reaching a black node is harmless.
package main
import "fmt"
const (
white = iota
gray
black
)
// findCycle returns one directed cycle, or nil if the graph is acyclic.
func findCycle(adj [][]int) []int {
color := make([]int, len(adj))
parent := make([]int, len(adj))
var cycle []int
var dfs func(u int) bool
dfs = func(u int) bool {
color[u] = gray
for _, v := range adj[u] {
switch color[v] {
case gray: // back edge: walk parents from u back to v
cycle = []int{v}
for x := u; x != v; x = parent[x] {
cycle = append([]int{x}, cycle...)
}
cycle = append([]int{v}, cycle...)
return true
case white:
parent[v] = u
if dfs(v) {
return true
}
}
}
color[u] = black
return false
}
for u := range adj {
if color[u] == white dfs(u) {
return cycle
}
}
return nil
}
func main() {
dag := [][]int{{1, 2}, {2}, {}} // A->B, A->C, B->C: C is reached twice, no cycle
fmt.Println("dag:", findCycle(dag))
looped := [][]int{{1}, {2}, {3}, {1}, {}} // 1 -> 2 -> 3 -> 1
fmt.Println("cycle:", findCycle(looped))
}dag: []
cycle: [1 2 3 1]Kahn's algorithm from the topological sort lesson detects the same thing differently: if some nodes never reach in-degree 0, they are stuck on a cycle. Use Kahn when you also want the order; use colors when you want to report the cycle or find which nodes are "safe" (nodes that finish black without ever touching a cycle).
Bipartite graphs: two-coloring#
A graph is bipartite if its nodes split into two groups with every edge going between the groups: students and courses, job applicants and jobs, or "can these people be split into two teams with no rivals on the same team?". Color the start node 0, its neighbors 1, their neighbors 0, and so on with BFS. If an edge ever connects two nodes of the same color, the graph has an odd cycle and is not bipartite.
package main
import "fmt"
func twoColor(adj [][]int) ([]int, bool) {
color := make([]int, len(adj))
for i := range color {
color[i] = -1
}
for s := range adj { // the graph may have several components
if color[s] != -1 {
continue
}
color[s] = 0
queue := []int{s}
for len(queue) > 0 {
u := queue[0]
queue = queue[1:]
for _, v := range adj[u] {
if color[v] == -1 {
color[v] = 1 - color[u]
queue = append(queue, v)
} else if color[v] == color[u] {
return color, false // same color on both ends of an edge
}
}
}
}
return color, true
}
func undirected(n int, edges [][2]int) [][]int {
adj := make([][]int, n)
for _, e := range edges {
adj[e[0]] = append(adj[e[0]], e[1])
adj[e[1]] = append(adj[e[1]], e[0])
}
return adj
}
func main() {
square := undirected(4, [][2]int{{0, 1}, {1, 2}, {2, 3}, {3, 0}}) // even cycle
fmt.Println(twoColor(square))
triangle := undirected(3, [][2]int{{0, 1}, {1, 2}, {2, 0}}) // odd cycle
_, ok := twoColor(triangle)
fmt.Println("triangle bipartite:", ok)
}[0 1 0 1] true
triangle bipartite: falseStrongly connected components#
In a directed graph, a strongly connected component (SCC) is a maximal group of nodes where every node can reach every other. Collapsing each SCC to a single node always leaves a DAG, which is how compilers find mutually recursive functions, how 2-SAT solvers work, and how you find "circular dependency" groups in a package graph.
Kosaraju's algorithm takes two DFS passes:
- DFS the graph and record nodes in order of finish time.
- Reverse every edge. Take nodes in decreasing finish order; each DFS from an unvisited node on the reversed graph collects exactly one SCC.
Why it works: the node that finishes last belongs to a "source" component. In the reversed graph, edges out of that component point backward, so the second DFS cannot leak into other components.
package main
import (
"fmt"
"slices"
)
func kosaraju(adj [][]int) [][]int {
n := len(adj)
seen := make([]bool, n)
var order []int
var dfs1 func(u int)
dfs1 = func(u int) {
seen[u] = true
for _, v := range adj[u] {
ifseen[v] {
dfs1(v)
}
}
order = append(order, u) // record on finish
}
for u := range n {
ifseen[u] {
dfs1(u)
}
}
rev := make([][]int, n)
for u, vs := range adj {
for _, v := range vs {
rev[v] = append(rev[v], u)
}
}
comp := make([]int, n)
for i := range comp {
comp[i] = -1
}
var sccs [][]int
var dfs2 func(u, id int)
dfs2 = func(u, id int) {
comp[u] = id
sccs[id] = append(sccs[id], u)
for _, v := range rev[u] {
if comp[v] == -1 {
dfs2(v, id)
}
}
}
for i := n - 1; i >= 0; i-- { // decreasing finish time
if u := order[i]; comp[u] == -1 {
sccs = append(sccs, nil)
dfs2(u, len(sccs)-1)
}
}
for _, c := range sccs {
slices.Sort(c)
}
return sccs
}
func main() {
// 0 -> 1 -> 2 -> 0 form a loop; 2 -> 3; 3 -> 4 -> 5 -> 3 form another; 5 -> 6
adj := [][]int{{1}, {2}, {0, 3}, {4}, {5}, {3, 6}, {}}
fmt.Println(kosaraju(adj))
}[[0 1 2] [3 4 5] [6]]Three components: the two loops and node 6 alone. (Tarjan's algorithm finds the same components in a single DFS using the low values described next.)
Bridges and articulation points#
A bridge is an edge whose removal disconnects the graph; an articulation point is such a node. In a network, they are the single points of failure. Tarjan's method records two numbers per node during one DFS:
disc[u]: when u was first visited;low[u]: the earliestdiscreachable from u's subtree using at most one back edge.
For a tree edge u → v: if low[v] > disc[u], nothing under v can get back to u or above without this edge, so u-v is a bridge. If low[v] >= disc[u] (and u is not the root), u is an articulation point.
package main
import "fmt"
func bridges(n int, edges [][2]int) [][2]int {
adj := make([][]int, n)
for _, e := range edges {
adj[e[0]] = append(adj[e[0]], e[1])
adj[e[1]] = append(adj[e[1]], e[0])
}
disc := make([]int, n)
low := make([]int, n)
timer := 0
var out [][2]int
var dfs func(u, parent int)
dfs = func(u, parent int) {
timer++
disc[u], low[u] = timer, timer
for _, v := range adj[u] {
switch {
case v == parent:
continue
case disc[v] != 0: // back edge to an ancestor
low[u] = min(low[u], disc[v])
default:
dfs(v, u)
low[u] = min(low[u], low[v])
if low[v] > disc[u] {
out = append(out, [2]int{u, v})
}
}
}
}
for u := range n {
if disc[u] == 0 {
dfs(u, -1)
}
}
return out
}
func main() {
// A triangle 0-1-2, a bridge 1-3, then another triangle 3-4-5, and a tail 5-6.
edges := [][2]int{{0, 1}, {1, 2}, {2, 0}, {1, 3}, {3, 4}, {4, 5}, {5, 3}, {5, 6}}
fmt.Println("bridges:", bridges(7, edges))
}bridges: [[5 6] [1 3]]Edges inside a triangle are never bridges, because each one has a second route around the triangle.
Cloning graphs and lists with random pointers#
"Deep copy this structure" sounds simple until nodes are shared or form cycles. Copying naively either loops forever or produces duplicate copies of the same node. The fix is always the same: a map from each original node to its copy. When you reach a node again, return the existing copy.
package main
import "fmt"
// --- Clone Graph ---
type GNode struct {
Val int
Neighbors []*GNode
}
func cloneGraph(n *GNode) *GNode {
copies := map[*GNode]*GNode{}
var dfs func(*GNode) *GNode
dfs = func(o *GNode) *GNode {
if o == nil {
return nil
}
if c, ok := copies[o]; ok {
return c // already copied: reuse it (this is what stops cycles)
}
c := &GNode{Val: o.Val}
copies[o] = c // register BEFORE recursing into neighbors
for _, nb := range o.Neighbors {
c.Neighbors = append(c.Neighbors, dfs(nb))
}
return c
}
return dfs(n)
}
// --- Copy List with Random Pointer ---
type LNode struct {
Val int
Next, Random *LNode
}
func copyRandomList(head *LNode) *LNode {
copies := map[*LNode]*LNode{nil: nil}
for n := head; n != nil; n = n.Next { // pass 1: make every copy
copies[n] = &LNode{Val: n.Val}
}
for n := head; n != nil; n = n.Next { // pass 2: wire Next and Random through the map
copies[n].Next = copies[n.Next]
copies[n].Random = copies[n.Random]
}
return copies[head]
}
func main() {
// A square graph 1-2-3-4-1: every node is reachable two ways, and there is a cycle.
n1, n2, n3, n4 := &GNode{Val: 1}, &GNode{Val: 2}, &GNode{Val: 3}, &GNode{Val: 4}
n1.Neighbors = []*GNode{n2, n4}
n2.Neighbors = []*GNode{n1, n3}
n3.Neighbors = []*GNode{n2, n4}
n4.Neighbors = []*GNode{n3, n1}
c := cloneGraph(n1)
fmt.Println("clone is a new node:", c != n1, " values:", c.Val, c.Neighbors[0].Val, c.Neighbors[1].Val)
fmt.Println("cycle preserved:", c.Neighbors[0].Neighbors[0] == c) // 1 -> 2 -> back to the SAME clone of 1
// List 7 -> 13 -> 11, randoms: 7->nil, 13->7, 11->7
a, b, d := &LNode{Val: 7}, &LNode{Val: 13}, &LNode{Val: 11}
a.Next, b.Next = b, d
b.Random, d.Random = a, a
cp := copyRandomList(a)
for n := cp; n != nil; n = n.Next {
r := "nil"
if n.Random != nil {
r = fmt.Sprint(n.Random.Val)
}
fmt.Printf("%d(random %s) ", n.Val, r)
}
fmt.Println("\nshares no nodes with the original:", cp != a cp.Next != b cp.Next.Random == cp)
}clone is a new node: true values: 1 2 4
cycle preserved: true
7(random nil) 13(random 7) 11(random 7)
shares no nodes with the original: trueRegistering the copy in the map before visiting neighbors is the crucial line in cloneGraph. Move it after the loop and the cycle 1 → 2 → 1 recurses forever.
Summary#
- Directed cycles: three colors; a gray node reached again closes a cycle.
- Bipartite means two-colorable, which means no odd cycle; check with BFS.
- Kosaraju finds strongly connected components with two DFS passes, the second on the reversed graph.
- Tarjan's
disc/lowvalues find bridges (low[v] > disc[u]) and articulation points in one DFS. - Deep copies of shared or cyclic structures need a map from original to copy, filled before recursing.
Check your understanding
Answer before scrolling back up. If you miss one, reread the matching section, then try again.
Q1.In three-color DFS cycle detection on a directed graph, a cycle exists when you reach a node that is:
Q2.A graph is bipartite exactly when:
Q3.Kosaraju's algorithm runs DFS twice. The second pass runs on:
Q4.An edge u-v (v a DFS child of u) is a bridge when:
Q5.When cloning a graph, why keep a map from original node to copy?
Practice in Go
Write a table test first, then the solution. On LeetCode, choose Go as the language. Open a hint only after 15 minutes stuck.
- Course Schedule ↗medium
Show hint
Detect a cycle in a directed graph: three colors, or Kahn's algorithm.
- Course Schedule II ↗medium
Show hint
Return the topological order, or empty if a cycle exists.
- Is Graph Bipartite? ↗medium
Show hint
BFS two-coloring from every uncolored node (the graph may be disconnected).
- Clone Graph ↗medium
Show hint
DFS or BFS with a map from original to clone.
Show hint
Map from old node to new node; two passes.
Show hint
Tarjan's bridges: disc and low arrays.
Show hint
A node is safe if it is not on and cannot reach a cycle: it ends black in three-color DFS.
Finished this lesson?
Mark it done to track your progress. It is saved in this browser only.