Graphs

Nodes, edges, and the art of choosing the right traversal — BFS for shortest paths, DFS for connectivity and fill, topological sort for ordering, union-find for grouping, and Dijkstra when edges have weight. Grids are just implicit graphs; the algorithms are the same.

Topic guide20 problems
The unlock

Almost every graph problem is the same walk from a starting node, and the only knob you turn is the container that holds “where to go next.” A queue(FIFO) explores in expanding rings — that's BFS, and the ring number is the shortest distance. A stackor recursion (LIFO) plunges down one path to the bottom before backtracking — that's DFS. Same graph, same start; swap the container and the behavior flips.

MENTAL MODEL BFS rings, DFS dives — one walk, two containers

Picture standing on a node with a to-do list of places to explore. Every algorithm in this whole category is: pop a node, mark it, add its unvisited neighbors to the list, repeat. The only thing that changes the personality of the search is which end of the list you pull from.

  • Pull from the front (FIFO queue) → BFS. Neighbors you added first get explored first, so the search fans out in equal-distance shells: all nodes 1 step away, then all 2 steps away, then 3. Because you reach a node the very first time by the fewest edges, BFS hands you the shortest unweighted path for free.
  • Pull from the back (LIFO stack / recursion) → DFS. The most recently added neighbor goes next, so the search commits to one pathand rides it to a dead end before unwinding to try alternatives. Perfect for “is this whole region connected?” and flood-fill.
The reframe →don't memorize two algorithms. Memorize one loop and one decision: do I need the shortest distance (rings → queue) or just to reach everything (dive → stack)?

SEE IT Watch the rings expand, then watch the dive

BFS treats the source as the center of a pond and explores in ripples. Level 0 is the source, level 1 is everything one edge away, level 2 is everything two edges away:

BFS from S  —  the frontier grows outward in equal-distance shells

                level 2
              ┌───────────┐
              │  level 1  │
              │  ┌─────┐  │
              │  │  S  │  │   ◄ level 0 — the source
              │  │ (0) │  │
              │  └─────┘  │
              │   B  C    │   ◄ level 1 — every direct neighbor (dist 1)
              └───────────┘
               D  E  F  G      ◄ level 2 — neighbors-of-neighbors (dist 2)

queue over time:  [S] → [B,C] → [C,D,E] → [D,E,F,G] → ...
                   └ pop S, push its neighbors, then drain THIS ring
                     completely before any node of the next ring.

The first time you reach a node, you reached it by the FEWEST edges.
So in an unweighted graph, level number = shortest distance from S.

DFS, given the same graph and the same start, behaves completely differently: it commits to a single tendril, follows it to the bottom, then backtracks to take the road not yet travelled:

DFS from S  —  follow one path to its end, then back up and branch

        S
        │  go deep ↓
        ▼
        A ──► B ──► D       ◄ dive: S→A→B→D until D is a dead end
                    │
                    │ D done — backtrack ▲ to B, B done — back ▲ to A
                    ▼
        A ──► C             ◄ now explore A's OTHER neighbor, C
              │
              ▼
              E             ◄ dive again: C→E

visit order:  S, A, B, D, (backtrack), C, E
              └ one tendril at a time, all the way down, then unwind.

The call stack IS the path: each active frame is one step from S to "here".
The tell →if the question contains the words “shortest,” “minimum steps,” or “fewest moves” on an unweightedgraph, you want the rings (BFS). If it asks “how many islands / regions” or “can I reach,” the dive (DFS) is the simplest tool.

GRIDS ARE GRAPHS A cell’s neighbors are its edges — no Map required

Half of all graph problems don't look like graphs — they're grids (number of islands, rotting oranges, walls & gates). The unlock: a grid is a graph you never have to build. Each cell is a node, and its four orthogonal neighbors (up / down / left / right) are its edges:

A grid is a graph you never have to build.

     c-1   c   c+1                   the cell (r,c) and its
   ┌─────┬─────┬─────┐                4 implicit edges:
r-1│     │ up  │     │
   ├─────┼─────┼─────┤                       (r-1,c)
 r │left │(r,c)│right│                          ▲
   ├─────┼─────┼─────┤              (r,c-1) ◄─ (r,c) ─► (r,c+1)
r+1│     │down │     │                          ▼
   └─────┴─────┴─────┘                       (r+1,c)

neighbors = for [dr,dc] of [[1,0],[-1,0],[0,1],[0,-1]]:
              (r+dr, c+dc)   — if in bounds and not blocked

No adjacency list, no Map — the four deltas ARE the edges.

You compute neighbors on the fly with the four deltas [[1,0],[-1,0],[0,1],[0,-1]], guarded by a bounds check. Once you see this, every grid problem becomes a plain BFS or DFS — same templates, just a [r,c] tuple instead of a node id.

Diagonals?If the problem counts diagonal adjacency too, use the 8 deltas. The shape of the “neighbors” function is the only thing that changes.

RECURSION SHAPE The DFS dive and the BFS ring loop, in plain English

DFS is the recursion you can write half-asleep: stop if seen, mark, recurse into each neighbor. The call stack is the path back to the source.

dfs(node):                          # "dive": go as deep as possible first

    if node already visited:        # 1. STOP at things you've seen
        return                      #    (prevents infinite loops on cycles)

    mark node visited               # 2. claim it the moment you arrive

    for each neighbor of node:      # 3. plunge into the FIRST neighbor fully,
        dfs(neighbor)               #    only then move to the next one

                                    # the call stack remembers where to back up to.
                                    # recursion = an implicit LIFO stack.

BFS replaces that implicit stack with an explicit FIFO queue. The one line that earns its keep is marking visited on enqueue, plus the level loop that lets you read off distance:

bfs(start):                         # "rings": explore in distance order

    visited = { start }             # mark ON ENQUEUE, never on dequeue
    queue   = [ start ]             # FIFO — first in, first out
    dist    = 0

    while queue not empty:
        for each node in THIS level:   # snapshot the level's size,
            pop node from front        # then drain exactly this ring
            for each neighbor of node:
                if neighbor not visited:
                    mark neighbor visited   # ◄ BEFORE pushing, not after popping!
                    push neighbor to queue
        dist = dist + 1             # one whole ring done → distance grows by 1
The level loop → snapshot queue.length at the top of each while iteration and process exactly that many nodes. Everything you process in that pass is the same distance from the source; bump the distance counter once per pass.

HOW TO THINK The cold-start ladder — run this on any new problem

Faced with an unfamiliar problem, don't guess the algorithm. Climb these rungs and the right tool falls out:

  1. What is a node?Name the “thing” you stand on — a city, a course, a grid cell, a word, a person. If it's a grid, the node is a [r,c] cell.
  2. What is an edge? What connects two nodes? A road, a prerequisite, an up/down/left/right step, a single-letter change. Is it directed (a→b only, like prerequisites) or undirected (a friendship, a road both ways)? Weighted or not?
  3. Shortest path / fewest steps, unweighted?BFS. The ring you reach a node on is its distance. (Rotting Oranges, Word Ladder, Walls & Gates — the last two via multi-source BFS.)
  4. Just connectivity / flood / “how many regions”? DFS flood-fill. Sink each component, count them. (Number of Islands, Max Area of Island, Surrounded Regions.)
  5. Ordering with “must come before” constraints? topological sort on a DAG. A cycle means impossible. (Course Schedule, Alien Dictionary.)
  6. Grouping / “are these connected” / redundant edge? union-find. (Number of Connected Components, Redundant Connection.)
  7. Shortest path with edge weights?Dijkstra (a min-heap-driven BFS). With a step cap or negative weights → Bellman-Ford.
The fork that matters most → “shortest” on unweighted edges is always BFS, never DFS. DFS can find a path but not the shortest one, because it commits to a deep path before exploring closer options.

SAY IT Mark visited at enqueue — the invariant that saves you

Say this out loud before you write BFS: “A node enters the queue at most once, and I mark it visited the instant it enters.” That single invariant is the difference between a correct BFS and the most common graph bug there is.

The classic failure: you only check visited when you dequeue a node. But between an enqueue and its dequeue, the queue can fill with duplicate copiesof the same node — two different neighbors both push it before either is processed. You then process it twice, double-count it, and in multi-source BFS you record the wrong distance (a farther source overwrites a nearer one).

The fix is one line in the right place → add to visited in the same breath as queue.push(...), never at queue.shift(). Said differently: a node is “spoken for” the moment it's scheduled, not when it's served.

WHEN IT BREAKS Three failures that look like the algorithm is wrong

  • No visited set → infinite loop.Any cycle (and every undirected edge is a 2-cycle) sends you bouncing forever. The visited set isn't an optimization; it's correctness.
  • Undirected edge added in one direction only. Building the adjacency list, an undirected edge needs both adj[u].push(v) and adj[v].push(u). Forget one and half your graph silently vanishes.
  • Recursive DFS on a huge grid → stack overflow.A 200×200 grid can nest tens of thousands of calls deep. When the recursion limit is the enemy, convert the dive to an explicit stack: const stack = [[r,c]] and a whileloop — same LIFO order, no call-stack limit.
Debug reflex → wrong shortest-path distances almost always trace back to marking visited too late (the SAY IT rule). Infinite hangs trace back to a missing or mis-placed visited set. Check those two before suspecting your logic.

MNEMONIC BFS rings, DFS dives.

BFS rings, DFS dives. Same graph, same start — the only knob is queue (FIFO) vs stack (LIFO). FIFO expands in shells of equal distance (so it finds shortest paths); LIFO plunges down one branch before backing up. Step the Visualize tab in each mode and watch the visit order flip.

MODEL Graphs 101 — and why grids count too

A graph is a set of nodes (vertices) connected by edges. Edges can be directed or undirected, weighted or unweighted, and there can be cycles (unlike trees). The canonical representation is an adjacency list: Map<node, neighbor[]>.

A 2-D grid is an implicit graph: each cell is a node and its up/down/left/right neighbors are its edges. You never build the adjacency list explicitly — you compute neighbors on the fly with the four-direction deltas [[1,0],[-1,0],[0,1],[0,-1]].

Mandatory: a visited set. Without it, every traversal loops forever on cycles (or re-processes grid cells). Mark a node visited at enqueue/push time, not at process time, or BFS will enqueue the same node many times before it ever dequeues it.

DECISION TREE Picking the right algorithm

The single most important skill in graph problems is choosing before you code:

  • BFS — shortest path / minimum steps in an unweighted graph or grid. Also used for level-order processing. Time: O(V+E).
  • Multi-source BFS — seed the queue with allsources at level 0. Rotting Oranges, Walls & Gates, Pacific-Atlantic. Same cost as single BFS.
  • DFS — connectivity, flood fill, cycle detection, reachability. Cheaper stack space than BFS on deep paths; prefer iterative DFS on large grids to avoid call-stack overflow.
  • Topological sort (Kahn's / DFS)— ordering nodes in a DAG when some must come before others (course prerequisites). Cycle detection is free: if Kahn's order is shorter than V, a cycle exists.
  • Union-Find — grouping nodes into components; detecting redundant edges. Nearly O(1) per operation with path compression + union by rank.
  • Dijkstra — shortest path in a weighted graph with non-negative edges. Min-heap priority queue; O((V+E) log V).
  • Bellman-Ford / DP — shortest path with at most K edges, or graphs with negative weights. Relax all edges up to K+1 times; O(K·E).
  • Prim / Kruskal (MST) — minimum cost to connect all nodes. Prim = Dijkstra variant; Kruskal = sort edges + union-find.

COST Complexity at a glance

BFS / DFS
O(V+E)
Visit each node and edge once.
Union-Find
O(α·n)
Near-constant per op. α is inverse Ackermann.
Dijkstra
O((V+E) log V)
Min-heap overhead per relaxation.

For grids of size R×C, substitute V = R·C and E = O(R·C) — at most 4 edges per cell.

KEY IDEA Mark visited at enqueue, not dequeue

The classic BFS double-count bug → if you check visited only when you pop a node, multiple copies of the same node can sit in the queue simultaneously. Mark the node as visited the moment you push it. This is especially painful in multi-source BFS where two sources can both enqueue the same cell before either processes it — giving the wrong distance.

DFS does not have this problem in the same way (recursive call stack prevents re-entry), but an iterative DFS stack can if you are not careful.

RUN IT BFS rings, DFS dives

step 0 / 12
STARTBFS from A. Seed the queue with the start node. BFS rings, DFS dives — the only difference is which end we pull from.
ABCDEFG
queue (FIFO)
A
currentfrontier (in queue/stack)visitedtree edge
slowfast

TRIGGERS When you see ___ → reach for ___

Reach for a graph algorithm when the problem is about relationships between entities — reachability, ordering, grouping, or cost of traversal — and the relationships form a network rather than a simple linear or nested structure.

"shortest path" / "minimum steps" in an unweighted grid or graphBFS — levels naturally equal distance
"spread / rot / flood from multiple sources simultaneously"multi-source BFS — seed all sources at level 0
"prerequisites / can finish / valid ordering / alien dictionary"topological sort + cycle detection (Kahn's BFS or DFS)
"connected components / are X and Y connected / redundant edge"union-find (or DFS component count)
"weighted shortest path" with non-negative weightsDijkstra with a min-heap
"cheapest / shortest with at most K stops / steps"Bellman-Ford DP — relax edges at most K+1 times
"minimum cost to connect all points / nodes"MST — Prim's (min-heap) or Kruskal (sort + union-find)

RED FLAGSWhen it's NOT this pattern

  • The problem is really a tree. If there are no cycles and the structure is hierarchical, it lives in the Trees category — tree DFS/BFS is simpler and has tighter guarantees (e.g. exactly n−1 edges).
  • Longest path in a DAG. BFS and Dijkstra find shortest paths. For longest paths in a DAG, use DP with memoization along the topological order — it is not a pure graph traversal problem.
  • The graph is dense and tiny.For small graphs (V < 20), Floyd-Warshall or even brute-force bitmask DP can be simpler than implementing Dijkstra correctly.
  • It is really an interval / array problem in disguise.If the "connections" are just index comparisons (e.g. jump game), reach for greedy or DP first — modeling it as a graph adds unnecessary overhead.

TEMPLATE BFS with queue + visited (graph and grid)

When → Shortest path or level-order traversal in an unweighted graph or grid. Always mark visited at enqueue time. The grid variant computes neighbors on-the-fly with direction deltas and a bounds check.

bfs-with-queue-visited-graph-and-grid-.ts
function bfs(graph: Map<number, number[]>, start: number): Set<number> {
  const visited = new Set<number>([start]);  // mark at enqueue, not dequeue
  const queue: number[] = [start];

  while (queue.length) {
    const node = queue.shift()!;             // dequeue front

    for (const neighbor of graph.get(node) ?? []) {
      if (visited.has(neighbor)) continue;   // already seen
      visited.add(neighbor);                 // mark BEFORE pushing
      queue.push(neighbor);
    }
  }
  return visited;
}

// Grid variant — 4-directional BFS
function bfsGrid(grid: string[][], sr: number, sc: number): void {
  const rows = grid.length, cols = grid[0].length;
  const visited = new Set<string>();
  const queue: [number, number][] = [[sr, sc]];
  visited.add(`${sr},${sc}`);

  const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
  while (queue.length) {
    const [r, c] = queue.shift()!;
    for (const [dr, dc] of dirs) {
      const nr = r + dr, nc = c + dc;
      if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;  // bounds
      const key = `${nr},${nc}`;
      if (visited.has(key) || grid[nr][nc] === '0') continue;
      visited.add(key);
      queue.push([nr, nc]);
    }
  }
}
Key: use a proper FIFO queue (shift() or a deque library in production). For distance tracking, push [node, dist] or process level by level with a size snapshot.

TEMPLATE DFS recursive flood fill

When → Connectivity, flood fill, component counting, or reachability in a grid or graph. Mutate the grid (or use a visited set) to mark cells. Prefer iterative DFS for grids larger than ~300×300 to avoid call-stack overflow.

dfs-recursive-flood-fill.ts
// Recursive DFS flood fill — mutates grid in place (marks visited with 'X')
function dfs(grid: string[][], r: number, c: number): void {
  const rows = grid.length, cols = grid[0].length;
  if (r < 0 || r >= rows || c < 0 || c >= cols) return;  // out of bounds
  if (grid[r][c] !== '1') return;                         // not a target cell

  grid[r][c] = 'X';  // mark visited by mutating (alternative: use a Set)

  dfs(grid, r + 1, c);
  dfs(grid, r - 1, c);
  dfs(grid, r, c + 1);
  dfs(grid, r, c - 1);
}

// Count connected components (e.g. Number of Islands)
function countComponents(grid: string[][]): number {
  let count = 0;
  for (let r = 0; r < grid.length; r++) {
    for (let c = 0; c < grid[0].length; c++) {
      if (grid[r][c] === '1') {
        dfs(grid, r, c);  // sinks the whole island
        count++;
      }
    }
  }
  return count;
}
Stack overflow risk: a 300×300 grid can produce 90 000 recursive calls. For LeetCode constraints that large, convert to an explicit stack: const stack = [[r, c]] and loop.

TEMPLATE Topological sort — Kahn's BFS

When → Ordering nodes in a DAG (course schedule, build order, alien alphabet). Also detects cycles for free: if the output order is shorter than numNodes, a cycle exists.

topological-sort-kahn-s-bfs.ts
// Kahn's algorithm — BFS-based topological sort with cycle detection
function topoSort(numNodes: number, edges: [number, number][]): number[] {
  const indegree = new Array(numNodes).fill(0);
  const adj = new Map<number, number[]>();
  for (let i = 0; i < numNodes; i++) adj.set(i, []);

  for (const [u, v] of edges) {
    adj.get(u)!.push(v);
    indegree[v]++;
  }

  // Seed queue with all zero-indegree nodes
  const queue: number[] = [];
  for (let i = 0; i < numNodes; i++) {
    if (indegree[i] === 0) queue.push(i);
  }

  const order: number[] = [];
  while (queue.length) {
    const node = queue.shift()!;
    order.push(node);
    for (const nb of adj.get(node)!) {
      if (--indegree[nb] === 0) queue.push(nb);  // prerequisite satisfied
    }
  }

  // If order.length < numNodes → cycle detected (not a valid DAG)
  return order.length === numNodes ? order : [];
}
Cycle detection is free: if order.length < numNodes, some nodes never reached indegree 0, meaning they are part of a cycle — return [] or false.

TEMPLATE Union-Find (path compression + union by rank)

When → Grouping nodes into components, checking connectivity, or detecting the edge that closes a cycle. Near-O(1) per operation; much simpler than DFS when you only need component membership, not traversal order.

union-find-path-compression-union-by-rank-.ts
class UnionFind {
  private parent: number[];
  private rank: number[];
  public components: number;

  constructor(n: number) {
    this.parent = Array.from({ length: n }, (_, i) => i);  // each node owns itself
    this.rank = new Array(n).fill(0);
    this.components = n;
  }

  find(x: number): number {
    if (this.parent[x] !== x) {
      this.parent[x] = this.find(this.parent[x]);  // path compression
    }
    return this.parent[x];
  }

  union(x: number, y: number): boolean {
    const px = this.find(x), py = this.find(y);
    if (px === py) return false;  // already in the same set → cycle!

    // Union by rank keeps the tree flat
    if (this.rank[px] < this.rank[py]) this.parent[px] = py;
    else if (this.rank[px] > this.rank[py]) this.parent[py] = px;
    else { this.parent[py] = px; this.rank[px]++; }

    this.components--;
    return true;
  }
}
Redundant edge pattern: iterate edges; call union(u, v). If it returns false, the edge connects two already-connected nodes — that is your redundant (cycle-closing) edge.

PITFALL Forgetting the visited set — infinite loops

Any graph with a cycle will loop forever without a visited set. Even acyclic graphs waste exponential time revisiting nodes if you skip it. Always allocate visited before the traversal and add nodes before exploring their neighbors.

PITFALL Marking visited at dequeue instead of enqueue (BFS)

If you only mark a node visited when you pop it from the queue, the same node can be pushed multiple times before it is ever popped — each copy then processes all its neighbors again. In a dense graph this silently gives wrong shortest-path distances and blows up runtime. Mark immediately on push.

PITFALL Mixing directed and undirected edges

When building an adjacency list for an undirected graph, add both directions: adj[u].push(v); adj[v].push(u);. For a directed graph, add only one. Getting this wrong in Course Schedule (directed) or Graph Valid Tree (undirected) produces silent incorrect answers.

PITFALL Grid bounds + call-stack overflow

Always check r < 0 || r >= rows || c < 0 || c >= cols before accessing grid[r][c]. On large grids (200×200+), recursive DFS can overflow the JavaScript call stack — convert to an explicit stack with an array and a while loop.

PROBLEMS

#200Number of IslandsDFS/BFS flood fill: for each unvisited land cell, sink the whole island and increment the counter.#133Clone GraphDFS/BFS with an old→new node map: if the clone already exists, return it; otherwise create it and recurse on neighbors.#417Pacific Atlantic Water FlowMulti-source DFS/BFS inward from both ocean borders; the answer is cells reachable from both.#207Course ScheduleTopological sort / cycle detection via Kahn's: if the output order length < numCourses, a cycle exists → return false.#269Alien DictionaryBuild a directed edge (a→b) from the first differing character of each adjacent word pair, then run Kahn's topo sort; a cycle means invalid.#261Graph Valid TreeUnion-Find: valid tree iff exactly n−1 edges AND union never returns false (no cycle). Or BFS/DFS fully connected with no back edge.#323Number of Connected Components in an Undirected GraphUnion-Find: start with n components, decrement on each successful union; the remaining count is the answer.#695Max Area of IslandDFS flood fill that returns the count of cells sunk; track the running maximum.#130Surrounded RegionsDFS/BFS from every border 'O' cell, mark reachable cells safe; then flip all remaining 'O' cells to 'X'.#994Rotting OrangesMulti-source BFS: seed all rotten oranges at level 0, BFS outward counting levels (minutes). If fresh oranges remain after BFS, return −1.#286Walls and GatesMulti-source BFS from all gate cells (value 0) simultaneously; each cell gets the minimum distance to any gate.#210Course Schedule IIKahn's topo sort returning the order array; empty array means a cycle was detected.#684Redundant ConnectionUnion-Find: process edges in order; the first edge whose union returns false (both endpoints already connected) is the redundant one.#127Word LadderBFS over word transformations: neighbors are all words differing by one character; shortest path = fewest transformations.#332Reconstruct ItineraryHierholzer's algorithm for Euler path: DFS greedily taking the lexicographically smallest neighbor, append to result on backtrack (post-order).#1584Min Cost to Connect All PointsPrim's MST with a min-heap: start from any node, always extend to the cheapest reachable unvisited node. O(n² log n).#743Network Delay TimeDijkstra from the source node; the answer is the max value in the dist array — if any node is unreachable, return −1.#778Swim in Rising WaterDijkstra/min-heap where edge weight = max(current path max, next cell value); minimize the maximum elevation encountered.#787Cheapest Flights Within K StopsBellman-Ford DP: relax all edges exactly K+1 times (one per hop), using a copy of the previous round's distances to avoid chaining within the same round.#54201 MatrixCompute each cell’s distance to the nearest 0 by seeding a BFS queue with every 0 at once and expanding outward — the first time a cell is reached is its shortest distance. O(m·n), versus a two-pass DP alternative.