Graph Traversal: Practice Problems with Full Solutions
Welcome to the practice problems for graph traversal. If you need a refresher on the code, the code templates have the patterns in all 6 languages. Each problem below includes a hint, a visual walkthrough, and the full solution.
Recommended Study Order
The problems are ordered by difficulty, but the progression matters as much as the individual solutions.
- Flood Fill teaches DFS on a grid with nothing else going on. It is the cleanest possible first contact with the visited check.
- Clone Graph moves from grids to node objects. The map does double duty as the visited set and the connection table.
- Rotting Oranges is BFS with a timer. It shows the level-by-level queue pattern and how multiple sources share one queue.
- Pacific Atlantic Water Flow reverses the direction of the search. You start from the edges and flow uphill instead of searching from each cell.
- Word Ladder hides the graph. The nodes are words and the edges are single-letter changes, so the graph is built implicitly as you search.
- Bus Routes is the hardest. The BFS moves between stops, but the unit you pay for is the bus route, and a stop can belong to many routes.
Easy Problems
1. Flood Fill
LeetCode 733 | Difficulty: Easy
Brief: Paint the starting cell and every cell connected to it that shares its original color with a new color.
Why this pattern: The connected region is a graph component in a grid. A DFS from the start cell visits exactly that component, and nothing else.
Key Insight: Save the original color before painting. The search only spreads through cells that still hold the original color, so repainted cells automatically become barriers that stop the recursion.
Visual:
graph TD
S["Start at (sr, sc), save original color"] --> B{"In bounds and still original color?"}
B -->|Yes| P["Paint it, recurse on 4 neighbors"]
P --> B
B -->|No| R["Return to caller"]
Code:
var floodFill = function(image, sr, sc, color) {
const original = image[sr][sc];
if (original === color) return image;
const rows = image.length;
const cols = image[0].length;
function fill(r, c) {
// Painting before recursing is the visited check. A
// painted cell no longer matches the original color.
if (r < 0 || r >= rows || c < 0 || c >= cols || image[r][c] !== original) return;
image[r][c] = color;
fill(r + 1, c);
fill(r - 1, c);
fill(r, c + 1);
fill(r, c - 1);
}
fill(sr, sc);
return image;
};The early return when the start cell already has the target color prevents infinite recursion, since painted cells would otherwise keep matching the original color. Complexity is
Medium Problems
2. Clone Graph
LeetCode 133 | Difficulty: Medium
Brief: Given a connected undirected graph where each node has a value and a list of neighbors, return a deep copy of the graph.
Why this pattern: A deep copy must visit every node and every edge once. DFS does exactly that, and the map between original and clone nodes replaces the usual visited set.
Key Insight: Store the clone in the map before recursing on neighbors. When the graph has cycles, the recursion would otherwise loop forever on the first back edge.
Visual:
graph TD
A["Visit original node"] --> B{"Clone already in map?"}
B -->|Yes| C["Return existing clone"]
B -->|No| D["Create clone, store in map"]
D --> E["Recurse on each neighbor"]
E --> F["Link clone to neighbor clones"]
F --> C
Code:
function Node(val, neighbors) {
this.val = val === undefined ? 0 : val;
this.neighbors = neighbors === undefined ? [] : neighbors;
}
var cloneGraph = function(node) {
if (!node) return null;
const clones = new Map();
function dfs(curr) {
// The clone is stored before recursing, so a back
// edge finds it and the recursion terminates.
if (clones.has(curr)) return clones.get(curr);
const clone = new Node(curr.val);
clones.set(curr, clone);
for (const neighbor of curr.neighbors) {
clone.neighbors.push(dfs(neighbor));
}
return clone;
}
return dfs(node);
};The order of operations is the whole problem. The clone goes into the map before its neighbors are processed, because a cycle can lead back to the current node from any depth. Complexity is
3. Rotting Oranges
LeetCode 994 | Difficulty: Medium
Brief: Every minute, each rotten orange rots its adjacent fresh oranges. Return the minutes until no fresh orange remains, or -1 if some can never rot.
Why this pattern: All rotten oranges spread at the same speed, so this is a multi-source BFS where one minute is one layer of the queue.
Key Insight: Seed the queue with every rotten orange before the first iteration. Draining the queue one layer at a time converts layer count into minutes.
Visual:
graph TD
A["Queue all rotten oranges, count fresh"] --> C{"Queue not empty and fresh remain?"}
C -->|Yes| D["Drain one full layer, one minute"]
D --> E["Rot adjacent fresh oranges, enqueue"]
E --> C
C -->|No| F["Return minutes, or -1 if fresh remain"]
Code:
var orangesRotting = function(grid) {
const rows = grid.length;
const cols = grid[0].length;
const queue = [];
let fresh = 0;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (grid[r][c] === 2) {
queue.push([r, c]);
} else if (grid[r][c] === 1) {
fresh++;
}
}
}
let minutes = 0;
const directions = [[1, 0], [-1, 0], [0, 1], [0, -1]];
while (queue.length > 0 && fresh > 0) {
minutes++;
// The size is captured once, so one loop drains
// exactly one minute's layer.
const size = queue.length;
for (let i = 0; i < size; i++) {
const [r, c] = queue.shift();
for (const [dr, dc] of directions) {
const nr = r + dr;
const nc = c + dc;
if (nr >= 0 && nr < rows && nc >= 0 && nc < cols && grid[nr][nc] === 1) {
grid[nr][nc] = 2;
fresh--;
queue.push([nr, nc]);
}
}
}
}
return fresh === 0 ? minutes : -1;
};The layer-draining loop is the pattern-specific trick. Without the size snapshot, one minute would rot only one orange instead of the whole layer. Complexity is
4. Pacific Atlantic Water Flow
LeetCode 417 | Difficulty: Medium
Brief: Given a height grid where water flows from a cell to neighbors of equal or lower height, find every cell that can reach both the Pacific (top and left edges) and the Atlantic (bottom and right edges).
Why this pattern: Searching from each cell is wasteful. Reversing the flow and searching from the edges visits every cell at most twice, once per ocean.
Key Insight: Start DFS at the border cells and move to neighbors of equal or higher height. A cell is in the answer only if both searches reach it.
Visual:
graph TD
A["DFS from top row and left column"] --> B["Mark cells that reach the Pacific"]
B --> C["DFS from bottom row and right column"]
C --> D["Mark cells that reach the Atlantic"]
D --> E["Return cells marked by both searches"]
Code:
var pacificAtlantic = function(heights) {
const rows = heights.length;
const cols = heights[0].length;
const pacific = new Set();
const atlantic = new Set();
function dfs(r, c, seen, prevHeight) {
// Water drains downhill, so the reverse search only
// climbs to cells of equal or higher height.
if (r < 0 || r >= rows || c < 0 || c >= cols) return;
const key = r + ',' + c;
if (seen.has(key) || heights[r][c] < prevHeight) return;
seen.add(key);
dfs(r + 1, c, seen, heights[r][c]);
dfs(r - 1, c, seen, heights[r][c]);
dfs(r, c + 1, seen, heights[r][c]);
dfs(r, c - 1, seen, heights[r][c]);
}
for (let c = 0; c < cols; c++) {
dfs(0, c, pacific, heights[0][c]);
dfs(rows - 1, c, atlantic, heights[rows - 1][c]);
}
for (let r = 0; r < rows; r++) {
dfs(r, 0, pacific, heights[r][0]);
dfs(r, cols - 1, atlantic, heights[r][cols - 1]);
}
const result = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const key = r + ',' + c;
if (pacific.has(key) && atlantic.has(key)) {
result.push([r, c]);
}
}
}
return result;
};The reversed flow condition is the pattern-specific trick. Instead of simulating water draining from every cell, the search starts at the oceans and only climbs, so each cell is visited at most once per ocean. Complexity is
Hard Problems
5. Word Ladder
LeetCode 127 | Difficulty: Hard
Brief: Given a start word, an end word, and a dictionary, return the length of the shortest transformation sequence where each step changes one letter and every intermediate word is in the dictionary.
Why this pattern: The words form an implicit graph. Each word is a node and each valid one-letter change is an edge. The shortest sequence is a shortest path, so BFS is the correct traversal.
Key Insight: Do not prebuild the graph. Generate the 26 neighbors of each word on the fly and test them against the dictionary set. That keeps the graph implicit and the search lazy.
Visual:
graph LR
A["hit"] --> B["hot"]
B --> C["dot"]
C --> D["dog"]
D --> E["cog"]
Code:
var ladderLength = function(beginWord, endWord, wordList) {
const wordSet = new Set(wordList);
if (!wordSet.has(endWord)) return 0;
const queue = [[beginWord, 1]];
const visited = new Set([beginWord]);
while (queue.length > 0) {
const [word, steps] = queue.shift();
for (let i = 0; i < word.length; i++) {
// The graph is implicit. Only mutations that land
// in the dictionary become edges.
for (let code = 97; code <= 122; code++) {
const next = word.substring(0, i) + String.fromCharCode(code) + word.substring(i + 1);
if (next === endWord) return steps + 1;
if (wordSet.has(next) && !visited.has(next)) {
visited.add(next);
queue.push([next, steps + 1]);
}
}
}
}
return 0;
};Two choices make this problem fast. The dictionary becomes a set, so membership checks are constant time. Neighbors are generated on demand instead of prebuilding an adjacency list, which would waste memory on mutations that are not in the dictionary. Complexity is
6. Bus Routes
LeetCode 815 | Difficulty: Hard
Brief: Given a list of bus routes where each route is a list of stops, return the minimum number of buses needed to travel from a source stop to a target stop, or -1 if it is impossible.
Why this pattern: The stops form an implicit graph, but the cost is per route, not per stop. BFS still finds the minimum, as long as each level of the queue represents one bus ride.
Key Insight: Build a map from each stop to the routes that serve it, and mark routes as taken instead of trying to track individual transfers. A stop can belong to many routes, and taking any of them costs one bus.
Visual:
graph TD
A["Source stop"] --> B["Routes that serve it"]
B --> C["All stops on those routes"]
C --> D{"Target stop found?"}
D -->|Yes| E["Return buses taken"]
D -->|No| F["Next layer: routes for the new stops"]
F --> C
Code:
var numBusesToDestination = function(routes, source, target) {
if (source === target) return 0;
const stopToRoutes = new Map();
for (let i = 0; i < routes.length; i++) {
for (const stop of routes[i]) {
if (!stopToRoutes.has(stop)) stopToRoutes.set(stop, []);
stopToRoutes.get(stop).push(i);
}
}
const queue = [source];
const visitedStops = new Set([source]);
const visitedRoutes = new Set();
let buses = 0;
while (queue.length > 0) {
buses++;
const size = queue.length;
for (let i = 0; i < size; i++) {
const stop = queue.shift();
// Each route is boarded once, since the first
// time it is reached is the cheapest time.
for (const routeIdx of stopToRoutes.get(stop) || []) {
if (visitedRoutes.has(routeIdx)) continue;
visitedRoutes.add(routeIdx);
for (const nextStop of routes[routeIdx]) {
if (nextStop === target) return buses;
if (!visitedStops.has(nextStop)) {
visitedStops.add(nextStop);
queue.push(nextStop);
}
}
}
}
}
return -1;
};The route-level visited set is the trick that separates this from a plain stop BFS. Stops can be reached from many routes, but a route only ever needs to be boarded once, because the first time you reach it is also the cheapest. Marking routes as taken keeps the queue from exploding. Complexity is
Next Steps
These six problems cover the full arc of the pattern. You get grid DFS, node-level traversal, multi-source BFS with layer counting, reverse-flow search, and implicit graphs at two levels of difficulty. If Flood Fill still takes effort, go back to the code templates and drill the skeleton until the queue and the visited set feel automatic. The harder problems are all the same skeleton with one new idea bolted on.