Go Guide

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
  1. 01Cycles in a directed graph: three colors
  2. 02Bipartite graphs: two-coloring
  3. 03Strongly connected components
  4. 04Bridges and articulation points
  5. 05Cloning graphs and lists with random pointers
  6. 06Summary
  7. 07Check your understanding
  8. 08Practice in Go

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:

ColorMeaning
whitenot visited yet
grayvisiting: on the current DFS path, not finished
blackfinished: 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.

cycle.go
Go
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))
}
Output
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.

bipartite.go
Go
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)
}
Output
[0 1 0 1] true
triangle bipartite: false

Strongly 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:

  1. DFS the graph and record nodes in order of finish time.
  2. 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.

scc.go
Go
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] {
			if !seen[v] {
				dfs1(v)
			}
		}
		order = append(order, u) // record on finish
	}
	for u := range n {
		if !seen[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))
}
Output
[[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 earliest disc reachable 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.

bridges.go
Go
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))
}
Output
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.

clone.go
Go
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)
}
Output
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: true

Registering 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/low values 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.

  1. Q1.In three-color DFS cycle detection on a directed graph, a cycle exists when you reach a node that is:

  2. Q2.A graph is bipartite exactly when:

  3. Q3.Kosaraju's algorithm runs DFS twice. The second pass runs on:

  4. Q4.An edge u-v (v a DFS child of u) is a bridge when:

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

Finished this lesson?

Mark it done to track your progress. It is saved in this browser only.