Graphs: BFS / DFS / Topological Sort
One-liner: model the problem as nodes and edges, then explore it with a queue (BFS) or recursion (DFS) while a visited set stops you from going in circles.
The analogy
Imagine dropping a stone in a pond versus walking through a maze. BFS is the ripple: it reaches everything 1 step away, then everything 2 steps away, and so on — so it finds the nearest things first. DFS is the maze walker with a ball of string: follow one corridor to its dead end, then back up and try the next. Both need a chalk mark on every room they have been in (the visited set), otherwise they walk the same loop forever.
Recognition signals
Think graph when you see:
- Relationships: friends, roads, dependencies, links, "connected to".
- A grid where you move up, down, left and right — every cell is a node.
- "How many groups / islands / components?" — one traversal per unvisited node.
- "Fewest steps / moves" with equal cost per step — BFS.
- "Can everything be ordered so that X comes before Y?" or "is there a cycle?" — topological sort.
- "Copy / clone" a structure with links that can loop back.
Step-by-step walkthrough
Take a diamond: edges 0-1, 0-2, 1-3, 2-3, starting at 0.
BFS
- Queue
[0], mark 0 visited. - Take 0. Its neighbours 1 and 2 are new, so enqueue both. Queue
[1, 2]. - Take 1. Neighbour 3 is new, enqueue it. Neighbour 0 is already visited, skip it.
- Take 2. Neighbour 3 is already visited (it was marked when enqueued). Skip.
- Take 3. Done. Order:
0 1 2 3.
DFS on the same graph dives instead: 0 → 1 → 3 → 2. Node 3 is reached through 1 before 2 gets a turn.
Code template
First turn the input into an adjacency list. Then the traversal template: a queue and a visited set.
// BuildGraph turns an edge list into an adjacency list (undirected: store both directions).
// For a directed graph, drop the second append.
func BuildGraph(n int, edges [][]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
}// BFS visits nodes in rings around start: a queue plus a visited set.
func BFS(adj [][]int, start int) []int {
if start < 0 || start >= len(adj) {
return nil
}
visited := make([]bool, len(adj))
order := []int{}
queue := []int{start}
visited[start] = true // mark when ENQUEUED, not when dequeued
for len(queue) > 0 {
node := queue[0]
queue = queue[1:]
order = append(order, node)
for _, next := range adj[node] {
if !visited[next] {
visited[next] = true
queue = append(queue, next)
}
}
}
return order
}Why each part exists:
adj[a] = append(adj[a], b); adj[b] = append(adj[b], a)An adjacency list answers "who are my neighbours?" in O(degree). For a directed graph (dependencies, one-way streets) add only the first direction.
visited[start] = true // before queueingMark nodes when you enqueue them, not when you dequeue. Otherwise two nodes can both enqueue the same neighbour before it is processed, and the work (or the answer) doubles.
node := queue[0]; queue = queue[1:]First in, first out. Because the oldest discovered node is processed first, nodes come out in order of distance from the start. That ordering is the entire reason BFS finds shortest paths.
if !visited[next]The cycle breaker. Every node is queued at most once, so the traversal does at most one unit of work per node and per edge: O(V + E).
See it run
Type your own edges and a start node as edges;start, for example 0-1,0-2,1-3,2-3;0. Watch the queue for BFS, then switch to the DFS version and watch the stack grow and shrink.
- queue
- [0]
- visited
- {0}
- order
- []
Put start node 0 in the queue and mark it visited right away (mark on enqueue, so it can never be queued twice).
// BFS visits nodes in rings around start: a queue plus a visited set.
func BFS(adj [][]int, start int) []int {
if start < 0 || start >= len(adj) {
return nil
}
visited := make([]bool, len(adj))
order := []int{}
queue := []int{start}
visited[start] = true // mark when ENQUEUED, not when dequeued
for len(queue) > 0 {
node := queue[0]
queue = queue[1:]
order = append(order, node)
for _, next := range adj[node] {
if !visited[next] {
visited[next] = true
queue = append(queue, next)
}
}
}
return order
}The depth-first version
// DFS dives as deep as possible before backing up: recursion is the stack.
func DFS(adj [][]int, start int) []int {
if start < 0 || start >= len(adj) {
return nil
}
visited := make([]bool, len(adj))
order := []int{}
var visit func(node int)
visit = func(node int) {
visited[node] = true
order = append(order, node)
for _, next := range adj[node] {
if !visited[next] {
visit(next)
}
}
}
visit(start)
return order
}- stack
- []
- visited
- {}
- order
- []
Call visit(0). Recursion is the stack: each call waits for the ones above it.
// DFS dives as deep as possible before backing up: recursion is the stack.
func DFS(adj [][]int, start int) []int {
if start < 0 || start >= len(adj) {
return nil
}
visited := make([]bool, len(adj))
order := []int{}
var visit func(node int)
visit = func(node int) {
visited[node] = true
order = append(order, node)
for _, next := range adj[node] {
if !visited[next] {
visit(next)
}
}
}
visit(start)
return order
}Recursion is the stack. The call stack remembers where to resume when a branch is exhausted. For a very deep graph (a million-node chain), write the stack by hand with a slice to avoid blowing the call stack.
The real solutions
Number of islands (grid DFS)
// NumIslands: every cell is a node, its 4 neighbours are the edges.
// Each unvisited land cell starts a new island; DFS sinks the whole island.
func NumIslands(grid [][]byte) int {
count := 0
var sink func(r, c int)
sink = func(r, c int) {
if r < 0 || c < 0 || r >= len(grid) || c >= len(grid[r]) || grid[r][c] != '1' {
return
}
grid[r][c] = '0' // mark visited by overwriting (the grid is our visited set)
sink(r+1, c)
sink(r-1, c)
sink(r, c+1)
sink(r, c-1)
}
for r := range grid {
for c := range grid[r] {
if grid[r][c] == '1' {
count++
sink(r, c)
}
}
}
return count
}A grid is an implicit graph: there is no adjacency list, the four neighbours are computed on the fly. Overwriting 1 with 0 is the visited set. Each time the outer loops find a 1, that is a new island, and sink erases all of it.
Connected components
// CountComponents: how many separate groups does an undirected graph have?
// One traversal per unvisited node; each traversal covers exactly one group.
func CountComponents(n int, edges [][]int) int {
adj := BuildGraph(n, edges)
visited := make([]bool, n)
var dfs func(node int)
dfs = func(node int) {
visited[node] = true
for _, next := range adj[node] {
if !visited[next] {
dfs(next)
}
}
}
count := 0
for i := 0; i < n; i++ {
if !visited[i] {
count++
dfs(i)
}
}
return count
}Same idea with explicit edges: start a traversal from every node not yet visited and count how many times you had to start.
Shortest path in an unweighted graph
// ShortestPath: fewest edges from src to dst in an unweighted graph, or -1 if unreachable.
// BFS reaches nodes in order of distance, so the first time we see dst is the shortest.
func ShortestPath(adj [][]int, src, dst int) int {
if src < 0 || dst < 0 || src >= len(adj) || dst >= len(adj) {
return -1
}
dist := make([]int, len(adj))
for i := range dist {
dist[i] = -1 // -1 doubles as "not visited"
}
dist[src] = 0
queue := []int{src}
for len(queue) > 0 {
node := queue[0]
queue = queue[1:]
if node == dst {
return dist[node]
}
for _, next := range adj[node] {
if dist[next] == -1 {
dist[next] = dist[node] + 1
queue = append(queue, next)
}
}
}
return -1
}Instead of a separate visited set, dist does both jobs: -1 means unseen, any other value is the distance. Returning the first time we pop dst is correct because BFS pops nodes in non-decreasing distance order.
Course schedule (Kahn topological sort)
// CanFinish: Kahn's topological sort. prerequisites[i] = {course, mustTakeFirst}.
// Repeatedly take courses with no unmet prerequisites; if some are never taken, there is a cycle.
func CanFinish(numCourses int, prerequisites [][]int) bool {
adj := make([][]int, numCourses)
indegree := make([]int, numCourses)
for _, p := range prerequisites {
adj[p[1]] = append(adj[p[1]], p[0]) // edge: prerequisite -> course
indegree[p[0]]++
}
queue := []int{}
for i, d := range indegree {
if d == 0 {
queue = append(queue, i)
}
}
taken := 0
for len(queue) > 0 {
node := queue[0]
queue = queue[1:]
taken++
for _, next := range adj[node] {
indegree[next]--
if indegree[next] == 0 {
queue = append(queue, next)
}
}
}
return taken == numCourses
}The indegree of a course is how many prerequisites are still unmet. Courses at 0 can be taken now. Taking one satisfies a prerequisite for each of its dependants. If the queue runs dry before every course is taken, the remaining ones wait on each other: a cycle.
Cycle detection in a directed graph
// HasCycle: does a DIRECTED graph contain a cycle? DFS with three colours.
// 0 = unvisited, 1 = on the current path, 2 = fully explored. Meeting a 1 means a back edge.
func HasCycle(adj [][]int) bool {
state := make([]int, len(adj))
var dfs func(node int) bool
dfs = func(node int) bool {
state[node] = 1
for _, next := range adj[node] {
if state[next] == 1 || (state[next] == 0 && dfs(next)) {
return true
}
}
state[node] = 2
return false
}
for i := range adj {
if state[i] == 0 && dfs(i) {
return true
}
}
return false
}Three states are needed. A node that is merely visited is not enough: in a directed graph, reaching a node that was finished earlier is harmless (two paths into the same node). Only reaching a node that is on the current path (state 1) proves a cycle.
Clone graph
// Node is the graph node used by clone-graph style problems.
type Node struct {
Val int
Neighbors []*Node
}
// CloneGraph: the map from old node to its copy is also the visited set.
func CloneGraph(node *Node) *Node {
if node == nil {
return nil
}
copies := map[*Node]*Node{}
var clone func(n *Node) *Node
clone = func(n *Node) *Node {
if c, ok := copies[n]; ok {
return c
}
c := &Node{Val: n.Val}
copies[n] = c // register BEFORE recursing, or cycles recurse forever
for _, nb := range n.Neighbors {
c.Neighbors = append(c.Neighbors, clone(nb))
}
return c
}
return clone(node)
}Word Ladder — the graph is hidden
There is no edge list. Words are the nodes, and two words are neighbours when they differ in one letter. Because every move costs one step, a plain BFS finds the shortest sequence. Watch the queue grow one layer at a time:
- current
- —
- queue
- hit
Words are the nodes of a graph; two words are neighbours when they differ in exactly one letter. The word list is a set so we can test a candidate in O(1).
// LadderLength: words are nodes, and two words are connected when they differ by one letter.
// BFS from beginWord counts the fewest words in a transformation sequence. Neighbours are found by trying all
// 26 letters in each position and keeping those in the word list; each word is removed once seen.
func LadderLength(beginWord, endWord string, wordList []string) int {
words := make(map[string]bool, len(wordList))
for _, w := range wordList {
words[w] = true
}
if !words[endWord] {
return 0
}
queue := []string{beginWord}
delete(words, beginWord)
for steps := 1; len(queue) > 0; steps++ {
for size := len(queue); size > 0; size-- {
cur := queue[0]
queue = queue[1:]
if cur == endWord {
return steps
}
b := []byte(cur)
for i := range b {
orig := b[i]
for ch := byte('a'); ch <= 'z'; ch++ {
b[i] = ch
if next := string(b); words[next] {
delete(words, next) // visited
queue = append(queue, next)
}
}
b[i] = orig
}
}
}
return 0
}// LadderLength: words are nodes, and two words are connected when they differ by one letter.
// BFS from beginWord counts the fewest words in a transformation sequence. Neighbours are found by trying all
// 26 letters in each position and keeping those in the word list; each word is removed once seen.
func LadderLength(beginWord, endWord string, wordList []string) int {
words := make(map[string]bool, len(wordList))
for _, w := range wordList {
words[w] = true
}
if !words[endWord] {
return 0
}
queue := []string{beginWord}
delete(words, beginWord)
for steps := 1; len(queue) > 0; steps++ {
for size := len(queue); size > 0; size-- {
cur := queue[0]
queue = queue[1:]
if cur == endWord {
return steps
}
b := []byte(cur)
for i := range b {
orig := b[i]
for ch := byte('a'); ch <= 'z'; ch++ {
b[i] = ch
if next := string(b); words[next] {
delete(words, next) // visited
queue = append(queue, next)
}
}
b[i] = orig
}
}
}
return 0
}The same grid and BFS ideas solve Rotting Oranges, Walls and Gates, Surrounded Regions and Pacific Atlantic; their tested solutions are on the roadmap topic page.
Complexity
| Approach | Time | Space |
|---|---|---|
| BFS / DFS / Kahn on adjacency list | O(V + E) | O(V) |
| Grid traversal (R rows, C columns) | O(R × C) | O(R × C) worst-case stack / queue |
| Adjacency matrix instead of a list | O(V²) | O(V²) |
| Brute force: try every path | Exponential | O(V) |
V is the number of nodes, E the number of edges. Each node is processed once and each edge is looked at once (twice if undirected), hence the sum.
Common mistakes
Practice ladder
Ordered Easy to Hard. For each one, decide first what the nodes and the edges are.
- 1.Find if Path Exists in GraphEasyCan you walk from one node to the other?
- 2.Flood FillEasyThe grid cells are the nodes.
- 3.Number of IslandsMediumStart a new exploration only from cells you have not seen.
- 4.Clone GraphMedium
- 5.Rotting OrangesMediumMany sources spread at the same time — what do they all share?
- 6.Course ScheduleMediumWhich courses could you take right now?
- 7.Course Schedule IIMedium
- 8.Word LadderHardWords are nodes; two words are linked if they differ by one letter. You want the fewest hops.
- 9.Alien DictionaryHardEach adjacent pair of words gives you one ordering rule.
Which pattern? Drills
Unlabeled problems — pick the pattern, then read why.
You are given a 2D grid of 1s (land) and 0s (water). Count how many separate landmasses there are, where land connects up, down, left and right.
Which pattern?