Introduction
This guide addresses a critical gap in most graph tutorials: they show you HOW to implement algorithms but never explain: - Why a particular data structure is chosen - How raw input data becomes that structure - When you should pick one representation over another - What trade-offs exist between them
This is the crown jewel of the guide—the section that cost you an interview. We’re fixing that.
Part 1: What IS a Graph, Really?
A graph is a fundamental data structure consisting of: - Vertices (Nodes): Discrete entities - Edges: Connections between vertices
|
Think of a social network: people are vertices, friendships are edges. Think of a map: cities are vertices, roads are edges. Think of a matrix: cells are vertices, adjacent cells are edges (if we treat it as a graph). |
Graph Properties
| Property | Definition |
|---|---|
Example |
Directed |
Each edge has a direction (arrow). A → B doesn’t imply B → A. |
Twitter: you follow someone, they may not follow you back. |
Undirected |
Edges have no direction. A — B means both A → B and B → A exist. |
Facebook: friendship is mutual. |
Weighted |
Each edge has a numerical weight (cost, distance, capacity). |
Maps: the edge weight is the distance between cities. |
Unweighted |
All edges have the same weight (implicitly 1). |
Social graphs: "connected" or "not connected." |
Connected |
There is a path between every pair of vertices. |
A single, unified social network with no isolated cliques. |
Disconnected |
Some vertices have no path between them. |
Multiple separate social networks (islands in a matrix). |
Cyclic |
The graph contains at least one cycle (a path that loops back). |
A — B — C — A (a loop). |
Acyclic |
No cycles exist. Common: Directed Acyclic Graph (DAG). |
Visual Examples with Mermaid
Undirected, Unweighted Graph:
This graph has two components: {A, B, C} and {D, E}. It is disconnected and cyclic.
Directed, Acyclic Graph (DAG):
This is a DAG commonly used for task dependencies. No way to loop back.
Weighted, Directed Graph:
Weights represent distances, costs, or capacities.
Part 2: The Three Representations — DEEP DIVE
When you’re given data in an interview, it will be in one of three forms. Understanding each is critical.
Representation 1: Adjacency List (Most Common)
When Google gives you data: edge list (array of pairs).
// Raw input from Google (edge list)
const edges = [
[0, 1],
[1, 2],
[2, 0],
[3, 4]
];
// Adjacency list: Map<node, array of neighbors>
const graph = new Map([
[0, [1, 2]], // Node 0 connects to 1 and 2
[1, [0, 2]], // Node 1 connects to 0 and 2
[2, [0, 1]], // Node 2 connects to 0 and 1
[3, [4]], // Node 3 connects to 4
[4, [3]] // Node 4 connects to 3
]);
Building from edge list (Undirected Graph):
function buildAdjacencyListUndirected(edges, numNodes) {
const graph = new Map();
// Initialize empty adjacency lists for all nodes
for (let i = 0; i < numNodes; i++) {
graph.set(i, []);
}
// Add edges
for (const [u, v] of edges) {
graph.get(u).push(v);
graph.get(v).push(u); // Add reverse edge for undirected
}
return graph;
}
const graph = buildAdjacencyListUndirected([[0, 1], [1, 2], [2, 0], [3, 4]], 5);
// Result: Map { 0 => [1, 2], 1 => [0, 2], 2 => [1, 0], 3 => [4], 4 => [3] }
Building from edge list (Directed Graph):
function buildAdjacencyListDirected(edges, numNodes) {
const graph = new Map();
for (let i = 0; i < numNodes; i++) {
graph.set(i, []);
}
for (const [u, v] of edges) {
graph.get(u).push(v); // Only u -> v, no reverse edge
}
return graph;
}
Memory Representation:
graph = Map {
0 => [1, 2] <-- Array with 2 elements
1 => [0, 2] <-- Array with 2 elements
2 => [1, 0] <-- Array with 2 elements
3 => [4] <-- Array with 1 element
4 => [3] <-- Array with 1 element
}
Space used: O(V + E)
V = number of entries in the map
E = total elements across all arrays
Complexity Analysis:
| Operation | Time | Why |
|---|---|---|
Check if edge u→v exists |
O(degree of u) |
Must scan the array of neighbors |
Find all neighbors of u |
O(degree of u) |
Direct array access |
Iterate all edges |
O(V + E) |
Visit every node and every edge |
Add edge |
O(1) |
Array push is O(1) amortized |
Remove edge |
O(degree of u) |
Must find and remove from array |
Space Complexity: O(V + E) - V entries in the map - E total neighbors across all lists - For sparse graphs (E << V²), this is very efficient
When to Use:
-
Most real-world graphs (social networks, web graphs) are sparse (E << V²)
-
Default choice for interview graph problems
-
Interview says "connected components" or "neighbors"? Use adjacency list
-
You need to iterate neighbors repeatedly? Adjacency list is best
Example: Graph with 6 nodes, 7 edges:
const edges = [[0,1], [0,2], [1,3], [2,3], [3,4], [4,5], [2,5]];
const graph = buildAdjacencyListUndirected(edges, 6);
// To find neighbors of node 3:
const neighbors = graph.get(3); // [1, 2, 4] — O(1) access!
// To iterate all edges:
for (const [node, neighbors] of graph) {
for (const neighbor of neighbors) {
// Process edge node -> neighbor
}
}
// This is O(V + E) — very efficient
Representation 2: Adjacency Matrix (2D Array)
The structure:
// Adjacency matrix for undirected graph
// 0 1 2 3 4
const matrix = [
[0, 1, 1, 0, 0], // Node 0 connects to 1, 2
[1, 0, 1, 0, 0], // Node 1 connects to 0, 2
[1, 1, 0, 1, 1], // Node 2 connects to 0, 1, 3, 4
[0, 0, 1, 0, 0], // Node 3 connects to 2
[0, 0, 1, 0, 0] // Node 4 connects to 2
];
// matrix[u][v] = 1 if edge u->v exists, 0 otherwise
// To check if 0→1 exists: matrix[0][1] === 1 // O(1)!
Building from edge list:
function buildAdjacencyMatrix(edges, numNodes) {
// Create V×V matrix filled with 0s
const matrix = Array(numNodes)
.fill(0)
.map(() => Array(numNodes).fill(0));
// Add edges
for (const [u, v] of edges) {
matrix[u][v] = 1;
matrix[v][u] = 1; // For undirected graphs
}
return matrix;
}
Memory Representation:
matrix = [
[0, 1, 1, 0, 0]
[1, 0, 1, 0, 0]
[1, 1, 0, 1, 1]
[0, 0, 1, 0, 0]
[0, 0, 1, 0, 0]
]
Space used: O(V²) — always, regardless of number of edges!
Complexity Analysis:
| Operation | Time | Why |
|---|---|---|
Check if edge u→v exists |
O(1) |
Direct array access: matrix[u][v] |
Find all neighbors of u |
O(V) |
Must scan entire row u |
Iterate all edges |
O(V²) |
Visit entire matrix |
Add edge |
O(1) |
Direct assignment: matrix[u][v] = 1 |
Remove edge |
O(1) |
Direct assignment: matrix[u][v] = 0 |
Space Complexity: O(V²) — always, even if graph is sparse!
When to Use:
-
Graphs with V < 1000 and E is dense (E ≈ V²)
-
Problems that ask "is there an edge?" repeatedly
-
You’re building a DP table anyway
-
NOT for sparse graphs (wasteful memory)
-
NOT when V > 10,000 (memory explosion)
Trade-off: Fast edge queries (O(1)) but wasteful space (O(V²)).
Example: Dense graph (complete graph K5):
// Every node connects to every other node
const edges = [[0,1],[0,2],[0,3],[0,4],[1,2],[1,3],[1,4],[2,3],[2,4],[3,4]];
const matrix = buildAdjacencyMatrix(edges, 5);
// Quick check: do nodes 2 and 4 connect?
const exists = matrix[2][4] === 1; // O(1)
// But if V = 10,000, matrix needs 10,000 × 10,000 = 100 million cells!
// Even if only 100 edges exist. Wasteful.
Representation 3: Grid as Implicit Graph (THE KEY INSIGHT)
This is where most tutorials fall short. A 2D grid IS a graph.
// Grid input (example: island matrix)
const grid = [
['1', '1', '0', '0'],
['1', '1', '0', '1'],
['0', '0', '1', '0']
];
// This IS a graph. No need to build an adjacency list!
// Each cell is a node. Adjacent cells are connected.
// The grid itself IS the representation.
Why no explicit graph construction?
Instead of:
// WRONG: Don't do this for grids!
const graph = new Map();
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
graph.set([r, c], []); // Build adjacency list from grid
}
}
// This is extra work and memory.
Do this:
// CORRECT: The grid IS the graph. Use directions array.
const directions = [[0, 1], [0, -1], [1, 0], [-1, 0]]; // right, left, down, up
// To find neighbors of cell (r, c):
function getNeighbors(r, c, grid) {
const neighbors = [];
for (const [dr, dc] of directions) {
const nr = r + dr;
const nc = c + dc;
if (nr >= 0 && nr < grid.length && nc >= 0 && nc < grid[0].length) {
neighbors.push([nr, nc]);
}
}
return neighbors;
}
getNeighbors(0, 0, grid); // [[0, 1], [1, 0]] (right and down are in bounds)
Memory Representation:
grid = [
['1', '1', '0', '0']
['1', '1', '0', '1']
['0', '0', '1', '0']
]
Space used: O(rows × cols) — the grid itself, no extra structure
Neighbors of (1, 1):
- Up: (0, 1) ✓
- Down: (2, 1) ✓
- Left: (1, 0) ✓
- Right: (1, 2) ✓
Complexity Analysis:
| Operation | Time | Why |
|---|---|---|
Check if edge (r,c)→(nr,nc) exists |
O(1) |
Bounds check + grid access |
Find all neighbors of (r,c) |
O(4) = O(1) |
Max 4 neighbors (up/down/left/right) |
Iterate all edges |
O(rows × cols × 4) = O(V) |
Each cell checked once |
Add/remove edges |
O(1) |
Modify grid cell or visited set |
Space Complexity: O(rows × cols) — just the grid, no extra structure.
The KEY INSIGHT:
-
Do NOT build an adjacency list from a grid.
-
Use a directions array to compute neighbors on-the-fly.
-
This saves memory and is more intuitive.
Directions array for different neighborhood types:
// 4-directional (cardinal): up, down, left, right
const directions4 = [[0, 1], [0, -1], [1, 0], [-1, 0]];
// 8-directional (including diagonals)
const directions8 = [
[0, 1], [0, -1], [1, 0], [-1, 0], // Cardinal
[1, 1], [1, -1], [-1, 1], [-1, -1] // Diagonals
];
Part 3: BFS (Breadth-First Search)
Why BFS Works
BFS explores nodes level by level, starting from a source. It uses a queue (FIFO).
Imagine you're in a maze and want to find the shortest exit:
1. Start at your position (enqueue)
2. Check all adjacent cells (neighbors)
3. Mark them as visited and enqueue them
4. Dequeue the next cell and repeat
5. First time you reach the exit? That's the shortest path.
Why? Because you explore all cells at distance 1 before distance 2, etc.
BFS Template
function bfs(startNode, graph) {
const queue = [startNode];
const visited = new Set([startNode]);
while (queue.length > 0) {
const current = queue.shift(); // Dequeue from front
// Process current node
console.log(current);
// Enqueue all unvisited neighbors
for (const neighbor of graph.get(current)) {
if (!visited.has(neighbor)) {
visited.add(neighbor);
queue.push(neighbor); // Enqueue at back
}
}
}
}
BFS on Adjacency List
// Graph as adjacency list
const graph = new Map([
[0, [1, 2]],
[1, [0, 3]],
[2, [0, 3]],
[3, [1, 2]]
]);
function bfsAdjacencyList(startNode, graph) {
const queue = [startNode];
const visited = new Set([startNode]);
const result = [];
while (queue.length > 0) {
const current = queue.shift();
result.push(current);
for (const neighbor of graph.get(current)) {
if (!visited.has(neighbor)) {
visited.add(neighbor);
queue.push(neighbor);
}
}
}
return result;
}
bfsAdjacencyList(0, graph); // [0, 1, 2, 3]
BFS on 2D Grid
function bfsGrid(grid, startRow, startCol) {
const rows = grid.length;
const cols = grid[0].length;
const directions = [[0, 1], [0, -1], [1, 0], [-1, 0]];
const queue = [[startRow, startCol]];
const visited = new Set();
visited.add(`${startRow},${startCol}`);
const result = [];
while (queue.length > 0) {
const [r, c] = queue.shift();
result.push([r, c]);
// Check all 4 neighbors
for (const [dr, dc] of directions) {
const nr = r + dr;
const nc = c + dc;
const key = `${nr},${nc}`;
// In bounds and not visited?
if (nr >= 0 && nr < rows && nc >= 0 && nc < cols && !visited.has(key)) {
visited.add(key);
queue.push([nr, nc]);
}
}
}
return result;
}
const grid = [
['A', 'B', 'C'],
['D', 'E', 'F'],
['G', 'H', 'I']
];
bfsGrid(grid, 0, 0);
// [[0,0], [0,1], [1,0], [0,2], [1,1], [2,0], [1,2], [2,1], [2,2]]
Difference from adjacency list BFS:
- Instead of graph.get(current), compute neighbors using directions array
- Instead of storing node IDs, store [row, col] coordinates
- Visited set uses string keys "r,c" for 2D coordinates
Complexity
-
Time: O(V + E) — visit every node once, examine every edge once
-
Space: O(V) — queue and visited set
When to Use BFS
-
Shortest path in unweighted graph — BFS guarantees shortest path by exploring level-by-level
-
Level-order traversal — process nodes by distance from source
-
Connected components — BFS from each unvisited node counts components
-
Problems asking "minimum X" where X is edge count
|
BFS vs DFS for islands: Both work, but BFS uses more memory (queue stores entire frontier). For island problems, DFS is often preferred because it uses recursion (implicit stack) and the code is cleaner. But conceptually, both solve the problem identically: mark one island’s cells as visited, increment counter, repeat. |
Part 4: DFS (Depth-First Search)
Why DFS Works
DFS explores as deep as possible before backtracking. It uses a stack (LIFO) — often via recursion.
Imagine exploring a cave system:
1. Go down one tunnel as far as possible
2. When you hit a dead end, backtrack
3. Take the next unexplored tunnel
4. Repeat until all reachable areas are explored
This is depth-first exploration.
DFS Template (Recursive)
function dfs(node, graph, visited) {
visited.add(node);
console.log(node);
for (const neighbor of graph.get(node)) {
if (!visited.has(neighbor)) {
dfs(neighbor, graph, visited); // Recursive call = use the stack
}
}
}
const visited = new Set();
dfs(0, graph, visited);
DFS on Adjacency List
function dfsAdjacencyList(startNode, graph) {
const visited = new Set();
const result = [];
function dfsHelper(node) {
visited.add(node);
result.push(node);
for (const neighbor of graph.get(node)) {
if (!visited.has(neighbor)) {
dfsHelper(neighbor);
}
}
}
dfsHelper(startNode);
return result;
}
const graph = new Map([
[0, [1, 2]],
[1, [0, 3]],
[2, [0, 3]],
[3, [1, 2]]
]);
dfsAdjacencyList(0, graph); // [0, 1, 3, 2] or similar (order may vary)
DFS on 2D Grid (Recursive)
function dfsGrid(grid, startRow, startCol, visited) {
const rows = grid.length;
const cols = grid[0].length;
const directions = [[0, 1], [0, -1], [1, 0], [-1, 0]];
function dfsHelper(r, c) {
visited.add(`${r},${c}`);
for (const [dr, dc] of directions) {
const nr = r + dr;
const nc = c + dc;
const key = `${nr},${nc}`;
if (nr >= 0 && nr < rows && nc >= 0 && nc < cols && !visited.has(key)) {
dfsHelper(nr, nc);
}
}
}
dfsHelper(startRow, startCol);
}
// Usage:
const grid = [
['1', '1', '0'],
['0', '1', '0'],
['1', '1', '0']
];
const visited = new Set();
dfsGrid(grid, 0, 0, visited);
// visited now contains all reachable '1' cells from (0, 0)
DFS Iterative (Using Stack)
If you want to avoid recursion (stack overflow concerns with deep graphs):
function dfsIterative(startNode, graph) {
const stack = [startNode];
const visited = new Set([startNode]);
const result = [];
while (stack.length > 0) {
const current = stack.pop(); // Pop from top of stack
result.push(current);
for (const neighbor of graph.get(current)) {
if (!visited.has(neighbor)) {
visited.add(neighbor);
stack.push(neighbor); // Push to stack
}
}
}
return result;
}
Complexity
-
Time: O(V + E) — visit every node once, examine every edge once
-
Space: O(V) — recursion stack (or explicit stack) + visited set
When to Use DFS
-
Connectivity/reachability — mark all reachable nodes from a source
-
Cycle detection — DFS can detect back edges (which indicate cycles)
-
Topological sort — DFS-based approach for DAGs
-
All paths — find all paths between two nodes
-
Island problems — DFS marks entire island in one call
|
For island and connected component problems, DFS is often preferred over BFS because: 1. Code is simpler (recursion vs queue management) 2. Memory usage is often less (recursion stack vs queue) 3. No need to manage a queue |
Part 5: BFS vs DFS Comparison
| Aspect | BFS | DFS |
|---|---|---|
Data Structure |
Queue (FIFO) |
Stack (LIFO) or recursion |
Time Complexity |
O(V + E) |
O(V + E) |
Space Complexity |
O(V) |
O(V) |
Use Case |
Shortest path (unweighted) |
Connectivity, cycles, all paths |
Frontier |
Explores all nodes at distance d before d+1 |
Explores deep before broad |
Memory Spike |
Queue can get large (stores frontier) |
Stack is typically smaller |
Code Simplicity |
Requires queue management |
Simple recursion or explicit stack |
Rule of Thumb: - Default to DFS for connectivity/component problems (like islands) - Use BFS if shortest path is explicitly required - Both work for many problems; DFS is usually simpler
Part 6: PROBLEM - Number of Islands (LeetCode 200)
This is the problem you failed. Let’s solve it completely.
Problem Statement
Given an m × n 2D grid containing '1' (land) and '0' (water), count the number of distinct islands.
An island is formed by connecting adjacent lands horizontally or vertically (not diagonally).
Example:
Input:
[
["1","1","0","0","0"],
["1","1","0","0","0"],
["0","0","1","0","0"],
["0","0","0","1","1"]
]
Output: 3
Explanation:
Island 1: the connected group of '1's in the top-left
Island 2: the '1' in the middle
Island 3: the connected group of '1's in the bottom-right
Why This IS a Graph Problem
-
Nodes: Each cell in the grid
-
Edges: Implicit connections between adjacent cells (4-directional: up, down, left, right)
-
Graph representation: The grid itself is the implicit graph (no need for adjacency list)
-
Problem: Find the number of connected components
Solution Approach
-
Iterate every cell in the grid
-
When you find a '1', that’s a new island
-
Run DFS/BFS from that cell to mark ALL connected land cells as visited
-
Increment island counter each time you start a new DFS/BFS
-
Return the counter
ASCII Art Walkthrough
Initial grid:
1 1 0 0 0
1 1 0 0 0
0 0 1 0 0
0 0 0 1 1
Step 1: Find (0,0) = '1' → Start DFS from (0,0)
DFS marks all connected '1's as '0' (or visited):
X X 0 0 0
X X 0 0 0
0 0 1 0 0
0 0 0 1 1
Island count: 1
Step 2: Continue scanning, find (2,2) = '1' → Start DFS from (2,2)
X X 0 0 0
X X 0 0 0
0 0 X 0 0
0 0 0 1 1
Island count: 2
Step 3: Continue scanning, find (3,3) = '1' → Start DFS from (3,3)
X X 0 0 0
X X 0 0 0
0 0 X 0 0
0 0 0 X X
Island count: 3
Final answer: 3 islands
DFS Solution
function numIslandsDFS(grid) {
if (!grid || grid.length === 0) return 0;
const rows = grid.length;
const cols = grid[0].length;
let islandCount = 0;
const directions = [[0, 1], [0, -1], [1, 0], [-1, 0]];
function dfs(r, c) {
// Mark current cell as visited by changing it to '0'
grid[r][c] = '0';
// Explore all 4 neighbors
for (const [dr, dc] of directions) {
const nr = r + dr;
const nc = c + dc;
// If neighbor is in bounds and is land ('1'), visit it
if (nr >= 0 && nr < rows && nc >= 0 && nc < cols && grid[nr][nc] === '1') {
dfs(nr, nc);
}
}
}
// Scan every cell
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
// Found a new island
if (grid[r][c] === '1') {
dfs(r, c); // Mark entire island as visited
islandCount++;
}
}
}
return islandCount;
}
How to explain this to the interviewer:
|
"The key insight is that the grid itself is an implicit graph. Each '1' cell is a node. Two cells are connected if they’re adjacent and both are '1'. We iterate through every cell. When we find a '1', we know it’s the start of a new island. We then use DFS to mark all cells belonging to that island as visited (by changing them to '0'). Each time we initiate a DFS, we increment our island counter. Time: O(rows × cols) because we visit each cell once. Space: O(rows × cols) in the worst case (recursion depth for entire grid filled with '1'). " |
BFS Solution
function numIslandsBFS(grid) {
if (!grid || grid.length === 0) return 0;
const rows = grid.length;
const cols = grid[0].length;
let islandCount = 0;
const directions = [[0, 1], [0, -1], [1, 0], [-1, 0]];
function bfs(startRow, startCol) {
const queue = [[startRow, startCol]];
grid[startRow][startCol] = '0'; // Mark as visited
while (queue.length > 0) {
const [r, c] = queue.shift();
// Check all 4 neighbors
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] = '0'; // Mark as visited immediately
queue.push([nr, nc]);
}
}
}
}
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (grid[r][c] === '1') {
bfs(r, c);
islandCount++;
}
}
}
return islandCount;
}
Why BFS uses more memory here: The queue can contain O(cols) cells at each level. DFS’s recursion stack is more efficient.
Edge Cases
| Case | Expected Behavior |
|---|---|
Empty grid |
Return 0 |
All water |
Return 0 |
All land |
Return 1 |
Single cell '1' |
Return 1 |
Single cell '0' |
Return 0 |
Diagonal islands (not connected) |
Count as separate islands |
Islands at borders |
Should work fine |
Complexity Analysis
-
Time: O(rows × cols) — each cell visited once
-
Space: O(rows × cols) — recursion stack in worst case (DFS) or queue (BFS)
Part 7: PROBLEM - Shortest Bridge (LeetCode 934)
This is the follow-up that caught you off guard. Let’s break it down completely.
Problem Statement
Given an n × n binary grid containing exactly two islands, find the minimum number of 0`s you must flip to `1 to connect them.
An island is a group of connected 1’s (horizontally or vertically adjacent). You must find the shortest bridge.
Example:
Input:
[
[1,1,0],
[0,1,0],
[1,0,1]
]
Output: 1
Explanation:
We can flip (0,2) from 0 to 1, connecting the left island to the right island.
Initial:
1 1 0
0 1 0
1 0 1
After flip:
1 1 1
0 1 0
1 0 1
Now islands are connected.
Why This Is Hard
This is NOT just "find two islands." It requires: 1. Find the first island (you know one exists) 2. Find the shortest path FROM that island TO the second island 3. Path length = number of 0s to flip
The twist: you want the shortest path FROM ANY CELL of island 1 TO ANY CELL of island 2, not from a single cell.
This is multi-source BFS.
The KEY INSIGHT: Multi-Source BFS
Regular BFS finds the shortest path from a single source to all other nodes.
Multi-source BFS finds the shortest path from multiple sources to a target.
Single-source BFS:
Queue: [start_cell]
Found shortest path when target is reached.
Multi-source BFS:
Queue: [all_cells_of_island_1]
Found shortest path when any cell of island_2 is reached.
Why? Because we're exploring from ALL cells of island 1 simultaneously,
and we stop when we first touch island 2. That's the shortest bridge.
Solution Approach
Step 1: Find the first island using DFS - Scan the grid for a '1' - Use DFS to mark all cells of the first island - Collect all cells of the first island into a set/array
Step 2: Multi-source BFS from the first island - Initialize the queue with ALL cells of the first island - Perform BFS: expand outward layer by layer - Each layer = distance 1 more - First time we hit a cell of the second island, we found the shortest bridge
Step 3: Return distance
ASCII Art Walkthrough
Initial grid (1 = island, 0 = water):
1 1 0
0 1 0
1 0 1
Step 1: DFS to find first island starting from (0,0):
Mark all connected 1's: {(0,0), (0,1), (1,1)} form island 1
Step 2: Multi-source BFS from all cells of island 1:
Distance 0 (initial queue):
Queue: [(0,0), (0,1), (1,1)]
These are cells of island 1
Distance 1 (neighbors of island 1):
From (0,0): neighbors are (0,1)✓ island 1, (1,0) = '0' → add distance 1
From (0,1): neighbors are (0,0)✓, (0,2) = '0' → add distance 1, (1,1)✓
From (1,1): neighbors are (0,1)✓, (1,0) = '0' → add distance 1, (1,2) = '0' → add distance 1, (2,1) = '0' → add distance 1
New frontier at distance 1: [(1,0), (0,2), (1,2), (2,1)]
Distance 2 (neighbors at distance 1):
From (0,2): neighbors are (0,1)✓, (1,2) already have = '0'
From (1,0): neighbors are (0,0)✓, (1,1)✓, (2,0) = '1' ← THIS IS ISLAND 2!
Found island 2 at distance 2!
Return 2 - 1 = 1 (we need to flip 1 cell, which is (0,2) or (1,0) or (1,2) or (2,1))
Actually, shortest bridge is 1 because we can flip (0,2) to connect directly.
The algorithm finds the minimum distance as 2 flips, but the answer is 1.
Let me reconsider...
Actually: the grid shows distance to the NEAREST other-island cell.
Distance 0 = island 1 cells
Distance 1 = water cells 1 step from island 1
Distance 2 = water cells 2 steps from island 1
etc.
When we hit a water cell that's 1 step from island 2, that's the bridge!
Let me redo:
Initial:
1 1 0
0 1 0
1 0 1
Island 1: {(0,0), (0,1), (1,1)}
Island 2: {(2,0), (2,2)}
Multi-source BFS from island 1:
Start: queue = [(0,0), (0,1), (1,1)], distance[island1] = 0
Iteration 1: Process all cells at distance 0
(0,0): neighbors (0,1)✓island1, (1,0)='0'→add distance 1, (−1,0)✗
(0,1): neighbors (0,0)✓, (0,2)='0'→add distance 1, (1,1)✓
(1,1): neighbors (0,1)✓, (1,0)='0'(already seen), (1,2)='0'→add distance 1, (2,1)='0'→add distance 1
Iteration 2: Process cells at distance 1: (1,0), (0,2), (1,2), (2,1)
(1,0): neighbors (0,0)✓, (1,1)✓, (2,0)='1'→ISLAND 2 FOUND! Distance 2
Return 2 - 1 = 1
Complete Solution
function shortestBridge(grid) {
const n = grid.length;
const directions = [[0, 1], [0, -1], [1, 0], [-1, 0]];
// Step 1: Find the first island using DFS
let firstIslandFound = false;
const firstIsland = [];
function dfs(r, c) {
grid[r][c] = 2; // Mark as visited/island 1
firstIsland.push([r, c]);
for (const [dr, dc] of directions) {
const nr = r + dr;
const nc = c + dc;
if (nr >= 0 && nr < n && nc >= 0 && nc < n && grid[nr][nc] === 1) {
dfs(nr, nc);
}
}
}
// Find first island
outerLoop: for (let r = 0; r < n && !firstIslandFound; r++) {
for (let c = 0; c < n && !firstIslandFound; c++) {
if (grid[r][c] === 1) {
dfs(r, c);
firstIslandFound = true;
}
}
}
// Step 2: Multi-source BFS from the first island
const queue = [];
// Initialize queue with all cells of the first island
for (const [r, c] of firstIsland) {
queue.push([r, c, 0]); // [row, col, distance]
}
const visited = new Set();
for (const [r, c] of firstIsland) {
visited.add(`${r},${c}`);
}
// BFS
while (queue.length > 0) {
const [r, c, distance] = queue.shift();
for (const [dr, dc] of directions) {
const nr = r + dr;
const nc = c + dc;
const key = `${nr},${nc}`;
if (nr >= 0 && nr < n && nc >= 0 && nc < n && !visited.has(key)) {
visited.add(key);
// Found a cell of the second island
if (grid[nr][nc] === 1) {
return distance; // Distance is number of 0s to flip
}
// Enqueue water cell (grid[nr][nc] === 0)
queue.push([nr, nc, distance + 1]);
}
}
}
return -1; // Should never reach here if exactly 2 islands exist
}
How to explain to the interviewer:
|
"The key insight is that this is a multi-source BFS problem. First, I use DFS to identify all cells of the first island. This tells me exactly where island 1 is. Then, I perform BFS starting from ALL cells of island 1 simultaneously. As BFS expands outward level by level, each level represents distance 1 away from island 1. When I encounter a cell from island 2, I know the minimum distance to connect them. The beauty of multi-source BFS is that it naturally finds the shortest path between two groups of nodes, not just two individual nodes. Time: O(n²) - we visit each cell once Space: O(n²) - queue and visited set " |
Test Cases
| Case | Expected Output |
|---|---|
Two islands touching diagonally |
1 |
Two islands with 1 cell water between |
1 |
Two islands separated by 3 water cells |
3 |
Large grid with far islands |
Correct distance |
Part 8: More Graph Problems
Clone Graph (LeetCode 133)
What it teaches: BFS/DFS with hash maps, deep copying, undirected graphs with node objects.
Key difference from previous problems: - Input is a node object (not edge list or grid) - Must deep copy the graph (return a new graph, not modify original) - Use a hash map to track old→new node mappings
Template:
function cloneGraph(node) {
if (!node) return null;
const visited = new Map(); // old_node → new_node
function dfs(oldNode) {
if (visited.has(oldNode)) {
return visited.get(oldNode);
}
const newNode = new Node(oldNode.val);
visited.set(oldNode, newNode);
for (const neighbor of oldNode.neighbors) {
newNode.neighbors.push(dfs(neighbor));
}
return newNode;
}
return dfs(node);
}
Course Schedule (LeetCode 207)
What it teaches: Topological sort, cycle detection in directed graphs, Kahn’s algorithm.
Problem: Given n courses and prerequisites (edge list), determine if you can finish all courses.
Key insight: This is cycle detection. If there’s a cycle in the prerequisite graph, you can’t finish all courses.
Solution approach: 1. Build directed graph: prerequisite A → course A depends on prerequisite B → B 2. Use BFS-based topological sort (Kahn’s algorithm): - Count in-degree of each node - Process nodes with in-degree 0 - Remove edges from processed nodes - If all nodes processed, no cycle (return true) - If some nodes unprocessed, cycle exists (return false)
Template:
function canFinish(numCourses, prerequisites) {
// Build adjacency list and in-degree array
const graph = new Map();
const inDegree = new Array(numCourses).fill(0);
for (let i = 0; i < numCourses; i++) {
graph.set(i, []);
}
for (const [course, prereq] of prerequisites) {
graph.get(prereq).push(course); // prereq → course
inDegree[course]++;
}
// Kahn's algorithm: topological sort
const queue = [];
for (let i = 0; i < numCourses; i++) {
if (inDegree[i] === 0) {
queue.push(i);
}
}
let processedCount = 0;
while (queue.length > 0) {
const course = queue.shift();
processedCount++;
for (const nextCourse of graph.get(course)) {
inDegree[nextCourse]--;
if (inDegree[nextCourse] === 0) {
queue.push(nextCourse);
}
}
}
return processedCount === numCourses; // true if no cycle, false if cycle
}
Part 9: Dijkstra’s Algorithm
Why BFS Doesn’t Work for Weighted Graphs
BFS assumes all edges have weight 1. With weighted edges, BFS fails:
Graph:
A --5--> B --1--> C
A --2--> C
BFS from A:
Distance to C via B: 5 + 1 = 6
Distance to C direct: 2
But BFS might find B first (distance 1), then through B to C (distance 6),
missing the direct shorter path of 2.
Dijkstra solves this by always processing the closest unvisited node next.
How Dijkstra Works
Greedy insight: Always process the node with the smallest tentative distance next.
Algorithm: 1. Initialize distances: dist[start] = 0, all others = ∞ 2. Use a min-heap (priority queue) to track unvisited nodes by distance 3. Process nodes in order of increasing distance 4. For each processed node, relax its edges (update distances to neighbors) 5. Once a node is processed, its shortest distance is finalized
function dijkstra(graph, start) {
const distances = new Map();
const visited = new Set();
const pq = new MinHeap(); // Min-heap: (distance, node)
// Initialize distances
for (const node of graph.keys()) {
distances.set(node, Infinity);
}
distances.set(start, 0);
pq.push([0, start]);
while (!pq.isEmpty()) {
const [dist, node] = pq.pop();
if (visited.has(node)) continue;
visited.add(node);
// Relax edges
for (const [neighbor, weight] of graph.get(node)) {
const newDist = dist + weight;
if (newDist < distances.get(neighbor)) {
distances.set(neighbor, newDist);
pq.push([newDist, neighbor]);
}
}
}
return distances;
}
Complexity
-
Time: O((V + E) log V) with min-heap
-
Pop from heap: V times, O(log V) each = O(V log V)
-
Push to heap: at most E times (each edge), O(log V) each = O(E log V)
-
Total: O((V + E) log V)
-
Space: O(V) for distances, visited, heap
When to Use Dijkstra
-
Shortest path in weighted graph (all weights non-negative)
-
BFS doesn’t work (weights matter)
-
Bellman-Ford is slower (O(VE)) unless you need negative weights
-
Common: GPS navigation, network routing, game pathfinding
Summary Table
| Algorithm | Best For | Time | Space | Weights? |
|---|---|---|---|---|
BFS |
Shortest path (unweighted) |
O(V+E) |
O(V) |
No |
DFS |
Connectivity, cycles, paths |
O(V+E) |
O(V) |
No |
Dijkstra |
Shortest path (weighted) |
O((V+E)logV) |
O(V) |
Yes (non-negative) |
Bellman-Ford |
Shortest path (any weights) |
O(VE) |
O(V) |
Yes (including negative) |
Kahn’s (Topological) |
Detect cycles, order tasks |
O(V+E) |
O(V) |
No |
Interview Tips
Before Coding
-
Clarify the input format:
-
Edge list? Adjacency list? 2D grid? Weighted?
-
Google usually gives edge lists or grids
-
-
Identify the problem type:
-
"Count connected components" → DFS/BFS
-
"Find shortest path" → BFS (unweighted) or Dijkstra (weighted)
-
"Detect cycle" → DFS with color coding or Kahn’s algorithm
-
"Topological order" → DFS post-order or Kahn’s
-
"Grid problem" → Treat grid as implicit graph with directions array
-
-
Choose your representation:
-
Edge list → build adjacency list (default)
-
Grid → use directions array (don’t build explicit adjacency list)
-
Weighted graph → adjacency list with [(neighbor, weight)] pairs
-
During Coding
|
Say things like: "The grid is an implicit graph. Each cell is a node, adjacent cells are edges. I don’t need to build an explicit adjacency list—I’ll use a directions array to compute neighbors on-the-fly." "For finding connected components, DFS is simpler than BFS. I’ll mark cells as visited by modifying the grid in-place (or using a visited set if the problem forbids modifying input)." "For the shortest bridge problem, this is multi-source BFS. I need to find the first island, then do BFS from ALL its cells to find the shortest distance to the second island." |
Handling Edge Cases
-
Empty graph: return 0 or empty result
-
Single node: handle specially if needed
-
Disconnected components: make sure to iterate all nodes
-
All visited: queue becomes empty, loop terminates gracefully
-
No solution: ensure your algorithm returns appropriate default (−1, false, etc.)
Conclusion
The key to mastering graph problems is understanding:
-
What graph representation to use — and WHY
-
How to build that representation from raw input
-
When to use BFS vs DFS — not just "both work"
-
How to recognize graph patterns — especially grids as implicit graphs
-
The difference between single-source and multi-source BFS
Master these, and you won’t be caught off guard by follow-ups.