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:

Diagram

This graph has two components: {A, B, C} and {D, E}. It is disconnected and cyclic.

Directed, Acyclic Graph (DAG):

Diagram

This is a DAG commonly used for task dependencies. No way to loop back.

Weighted, Directed Graph:

Diagram

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
];

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.

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

  1. Iterate every cell in the grid

  2. When you find a '1', that’s a new island

  3. Run DFS/BFS from that cell to mark ALL connected land cells as visited

  4. Increment island counter each time you start a new DFS/BFS

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

  1. Clarify the input format:

    • Edge list? Adjacency list? 2D grid? Weighted?

    • Google usually gives edge lists or grids

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

  3. 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:

  1. What graph representation to use — and WHY

  2. How to build that representation from raw input

  3. When to use BFS vs DFS — not just "both work"

  4. How to recognize graph patterns — especially grids as implicit graphs

  5. The difference between single-source and multi-source BFS

Master these, and you won’t be caught off guard by follow-ups.