Emmanuel Akinfulubi

Lesson 17 of 2421 min read

Graphs, BFS and DFS

Nodes and edges, which is to say almost anything: maps, networks, dependencies, grids. Two traversals do most of the work and you already know both shapes.

By the end: Represent a graph and traverse it breadth-first and depth-first.

A graph is nodes joined by edges. That is so general that almost anything is a graph: cities and roads, people and friendships, tasks and dependencies, web pages and links, and — importantly for interviews — a grid, where each cell is a node joined to its neighbours.

Trees are graphs with no cycles and one path between any two nodes. So everything you learned in lesson 14 is a special case of this one.

Vocabulary

Three ways to store one

Adjacency list, adjacency matrix, edge list

// The graph:  A → B, A → C, B → D, C → D, D → A
const edges = [['A','B'], ['A','C'], ['B','D'], ['C','D'], ['D','A']];

// 1. EDGE LIST. Compact, but answering "who are A's neighbours" is O(E).
console.log('edge list:', edges);

// 2. ADJACENCY LIST. The default choice: O(V + E) space, O(1) to get neighbours.
const adjacency = new Map();
for (const [from, to] of edges) {
  if (!adjacency.has(from)) adjacency.set(from, []);
  adjacency.get(from).push(to);
}
console.log('adjacency list:', [...adjacency]);
console.log("A's neighbours:", adjacency.get('A'));

// 3. ADJACENCY MATRIX. O(V^2) space, O(1) to ask "is there an edge a→b".
const nodes = ['A', 'B', 'C', 'D'];
const index = new Map(nodes.map((n, i) => [n, i]));
const matrix = nodes.map(() => new Array(nodes.length).fill(0));
for (const [from, to] of edges) matrix[index.get(from)][index.get(to)] = 1;

console.log('adjacency matrix:');
console.log('    ' + nodes.join(' '));
matrix.forEach((row, i) => console.log(nodes[i] + '   ' + row.join(' ')));
console.log('is there an edge A→D?', matrix[index.get('A')][index.get('D')] === 1);

Output


      

Building an adjacency list from the usual input formats

// Interviews give you edges as an array of pairs. Undirected means add both ways.
function buildGraph(n, edgeList, directed = false) {
  const g = new Map();
  for (let i = 0; i < n; i++) g.set(i, []);       // include isolated nodes
  for (const [a, b] of edgeList) {
    g.get(a).push(b);
    if (!directed) g.get(b).push(a);
  }
  return g;
}

const undirected = buildGraph(5, [[0,1],[0,2],[1,3],[3,4]]);
console.log('undirected:', [...undirected]);
console.log('node 0 neighbours:', undirected.get(0), '| node 3:', undirected.get(3));

const directed = buildGraph(4, [[0,1],[1,2],[2,3]], true);
console.log('directed:', [...directed]);
console.log('node 3 has no outgoing edges:', directed.get(3));

Output


      

Depth-first search

Go as deep as you can, then back up and try the next branch. It is the tree traversal from lesson 14 with one addition: a visited set, because a graph can have cycles and you must not loop forever.

DFS, recursive and iterative

const graph = new Map([
  [0, [1, 2]],
  [1, [0, 3]],
  [2, [0, 3]],
  [3, [1, 2, 4]],
  [4, [3]],
]);

function dfsRecursive(start, graph, visited = new Set(), order = []) {
  if (visited.has(start)) return order;      // the only difference from a tree
  visited.add(start);
  order.push(start);
  for (const next of graph.get(start)) dfsRecursive(next, graph, visited, order);
  return order;
}

function dfsIterative(start, graph) {
  const visited = new Set();
  const order = [];
  const stack = [start];
  while (stack.length > 0) {
    const node = stack.pop();
    if (visited.has(node)) continue;
    visited.add(node);
    order.push(node);
    // Push in reverse so the first neighbour is explored first.
    for (let i = graph.get(node).length - 1; i >= 0; i--) stack.push(graph.get(node)[i]);
  }
  return order;
}

console.log('DFS recursive:', dfsRecursive(0, graph));
console.log('DFS iterative:', dfsIterative(0, graph));

Output


      

Breadth-first search

Explore everything one step away, then everything two steps away. Because it fans out evenly, the first time BFS reaches a node it has found the shortest path to it — on an unweighted graph. That is why BFS is the answer to almost every "minimum number of steps" question.

BFS with distances

const graph = new Map([
  [0, [1, 2]],
  [1, [0, 3]],
  [2, [0, 3]],
  [3, [1, 2, 4]],
  [4, [3]],
]);

function bfs(start, graph) {
  const visited = new Set([start]);
  const distance = new Map([[start, 0]]);
  const parent = new Map([[start, null]]);
  const queue = [start];
  let head = 0;                                 // head pointer, never shift

  while (head < queue.length) {
    const node = queue[head++];
    for (const next of graph.get(node)) {
      if (visited.has(next)) continue;
      visited.add(next);                        // mark on ENQUEUE
      distance.set(next, distance.get(node) + 1);
      parent.set(next, node);
      queue.push(next);
    }
  }
  return { order: queue, distance, parent };
}

const result = bfs(0, graph);
console.log('BFS order:', result.order);
console.log('distances from 0:', [...result.distance]);

// Rebuild the actual shortest path by walking parents backwards.
function pathTo(target, parent) {
  const path = [];
  for (let n = target; n !== null && n !== undefined; n = parent.get(n)) path.push(n);
  return path.reverse();
}
console.log('shortest path 0 → 4:', pathTo(4, result.parent));

Output


      

Storing the parent gives you the path, not just the length

const graph = new Map([[ 'A', ['B','C'] ], [ 'B', ['D'] ], [ 'C', ['D','E'] ], [ 'D', ['F'] ], [ 'E', ['F'] ], [ 'F', [] ]]);

function shortestPath(start, goal, graph) {
  if (start === goal) return [start];
  const parent = new Map([[start, null]]);
  const queue = [start];
  let head = 0;

  while (head < queue.length) {
    const node = queue[head++];
    for (const next of graph.get(node) ?? []) {
      if (parent.has(next)) continue;           // parent map doubles as visited
      parent.set(next, node);
      if (next === goal) {
        const path = [];
        for (let n = goal; n !== null; n = parent.get(n)) path.push(n);
        return path.reverse();
      }
      queue.push(next);
    }
  }
  return null;                                   // unreachable
}

console.log('A → F:', shortestPath('A', 'F', graph));
console.log('A → A:', shortestPath('A', 'A', graph));
console.log('F → A:', shortestPath('F', 'A', graph), '← no path, directed graph');

Output


      

Counting connected components

Run a traversal from every node you have not seen yet. Each traversal you start is one new component. This template answers a surprising number of questions.

Components, and the template that finds them

function countComponents(n, edgeList) {
  const g = new Map();
  for (let i = 0; i < n; i++) g.set(i, []);
  for (const [a, b] of edgeList) { g.get(a).push(b); g.get(b).push(a); }

  const visited = new Set();
  const components = [];

  for (let start = 0; start < n; start++) {
    if (visited.has(start)) continue;
    // A new component begins here.
    const group = [];
    const stack = [start];
    while (stack.length) {
      const node = stack.pop();
      if (visited.has(node)) continue;
      visited.add(node);
      group.push(node);
      for (const next of g.get(node)) if (!visited.has(next)) stack.push(next);
    }
    components.push(group.sort((a, b) => a - b));
  }
  return { count: components.length, components };
}

console.log(countComponents(5, [[0,1],[1,2],[3,4]]));
console.log(countComponents(4, []), '← no edges: every node is its own component');
console.log(countComponents(3, [[0,1],[1,2],[0,2]]), '← one component with a cycle');

Output


      

Grids are graphs

This is the most valuable realisation in the lesson. A 2D grid is a graph where each cell is a node and its neighbours are the cells above, below, left and right. So every grid question is a BFS or DFS question, and you already know how to write both.

Counting islands: DFS on a grid

function countIslands(grid) {
  if (grid.length === 0) return 0;
  const rows = grid.length, cols = grid[0].length;
  const seen = Array.from({ length: rows }, () => new Array(cols).fill(false));
  // The four directions, written once. Diagonals would add four more pairs.
  const DIRS = [[-1, 0], [1, 0], [0, -1], [0, 1]];
  let islands = 0;

  const flood = (r, c) => {
    const stack = [[r, c]];
    while (stack.length) {
      const [y, x] = stack.pop();
      // Bounds check FIRST, then the seen check, then the land check.
      if (y < 0 || y >= rows || x < 0 || x >= cols) continue;
      if (seen[y][x] || grid[y][x] !== '1') continue;
      seen[y][x] = true;
      for (const [dy, dx] of DIRS) stack.push([y + dy, x + dx]);
    }
  };

  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      if (grid[r][c] === '1' && !seen[r][c]) { islands++; flood(r, c); }
    }
  }
  return islands;
}

console.log(countIslands([
  ['1','1','0','0','0'],
  ['1','1','0','0','0'],
  ['0','0','1','0','0'],
  ['0','0','0','1','1'],
]));   // 3

console.log(countIslands([['1','1','1'],['0','1','0'],['1','1','1']]));   // 1
console.log(countIslands([['0']]));                                        // 0

Output


      

Shortest path through a grid: BFS, not DFS

function shortestGridPath(grid) {
  const rows = grid.length, cols = grid[0].length;
  if (grid[0][0] === 1 || grid[rows-1][cols-1] === 1) return -1;

  const DIRS = [[-1,0],[1,0],[0,-1],[0,1]];
  const seen = Array.from({ length: rows }, () => new Array(cols).fill(false));
  const queue = [[0, 0, 1]];        // row, col, steps taken
  let head = 0;
  seen[0][0] = true;

  while (head < queue.length) {
    const [r, c, steps] = queue[head++];
    if (r === rows - 1 && c === cols - 1) return steps;

    for (const [dr, dc] of DIRS) {
      const nr = r + dr, nc = c + dc;
      if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;
      if (seen[nr][nc] || grid[nr][nc] === 1) continue;
      seen[nr][nc] = true;
      queue.push([nr, nc, steps + 1]);
    }
  }
  return -1;
}

console.log(shortestGridPath([[0,0,0],[1,1,0],[1,1,0]]));   // 5
console.log(shortestGridPath([[0,1],[1,0]]));               // -1, blocked
console.log(shortestGridPath([[0]]));                        // 1

Output


      

Cycle detection

Different for directed and undirected graphs, and knowing why is worth marks.

Undirected: you have found a cycle if you reach a visited node that is not the one you just came from.

Directed: you need three states, because reaching a visited node is fine if that node's exploration already finished. A cycle only exists if you reach a node still in progress on the current path.

Undirected: track where you came from

function hasCycleUndirected(n, edgeList) {
  const g = new Map();
  for (let i = 0; i < n; i++) g.set(i, []);
  for (const [a, b] of edgeList) { g.get(a).push(b); g.get(b).push(a); }

  const visited = new Set();

  const dfs = (node, parent) => {
    visited.add(node);
    for (const next of g.get(node)) {
      if (next === parent) continue;            // the edge we arrived on
      if (visited.has(next)) return true;       // a different way back: cycle
      if (dfs(next, node)) return true;
    }
    return false;
  };

  for (let i = 0; i < n; i++) if (!visited.has(i) && dfs(i, -1)) return true;
  return false;
}

console.log('tree (0-1, 1-2):', hasCycleUndirected(3, [[0,1],[1,2]]));           // false
console.log('triangle:        ', hasCycleUndirected(3, [[0,1],[1,2],[2,0]]));    // true
console.log('two components:  ', hasCycleUndirected(5, [[0,1],[2,3],[3,4],[4,2]])); // true

Output


      

Directed: three colours

function hasCycleDirected(n, edgeList) {
  const g = new Map();
  for (let i = 0; i < n; i++) g.set(i, []);
  for (const [a, b] of edgeList) g.get(a).push(b);

  // 0 = unvisited, 1 = in progress on the current path, 2 = finished
  const state = new Array(n).fill(0);

  const dfs = (node) => {
    if (state[node] === 1) return true;      // back edge: cycle
    if (state[node] === 2) return false;     // already fully explored, fine
    state[node] = 1;
    for (const next of g.get(node)) if (dfs(next)) return true;
    state[node] = 2;                          // done with this node
    return false;
  };

  for (let i = 0; i < n; i++) if (dfs(i)) return true;
  return false;
}

console.log('chain 0→1→2:      ', hasCycleDirected(3, [[0,1],[1,2]]));            // false
console.log('cycle 0→1→2→0:    ', hasCycleDirected(3, [[0,1],[1,2],[2,0]]));      // true
console.log('diamond, no cycle:', hasCycleDirected(4, [[0,1],[0,2],[1,3],[2,3]])); // false
console.log('self loop:        ', hasCycleDirected(1, [[0,0]]));                   // true

Output


      

Bipartite checking

Two-colouring a graph

// Can the nodes be split into two groups with no edge inside a group?
// BFS, colouring each neighbour the opposite colour. A conflict means no.
function isBipartite(n, edgeList) {
  const g = new Map();
  for (let i = 0; i < n; i++) g.set(i, []);
  for (const [a, b] of edgeList) { g.get(a).push(b); g.get(b).push(a); }

  const colour = new Array(n).fill(0);       // 0 = uncoloured, 1 and -1 = the groups

  for (let start = 0; start < n; start++) {
    if (colour[start] !== 0) continue;
    colour[start] = 1;
    const queue = [start];
    let head = 0;
    while (head < queue.length) {
      const node = queue[head++];
      for (const next of g.get(node)) {
        if (colour[next] === colour[node]) return false;    // same group: conflict
        if (colour[next] === 0) { colour[next] = -colour[node]; queue.push(next); }
      }
    }
  }
  return true;
}

console.log('square (4-cycle):  ', isBipartite(4, [[0,1],[1,2],[2,3],[3,0]]));    // true
console.log('triangle (3-cycle):', isBipartite(3, [[0,1],[1,2],[2,0]]));          // false
console.log('a tree:            ', isBipartite(3, [[0,1],[1,2]]));                // true

Output


      

An odd-length cycle is exactly what makes a graph non-bipartite, which is why the triangle fails and the square passes. This shows up as "can these people be split into two teams" and similar.

Complexity

Your turn: five graph problems

  1. maxAreaOfIsland(grid) — the size of the largest connected group of 1s.
  2. cloneGraph(node) — deep-copy a graph. Keep a Map from original node to its copy so shared nodes are copied once.
  3. canFinishCourses(n, prerequisites) — is the dependency graph acyclic? Directed cycle detection.
  4. wordLadder(begin, end, wordList) — fewest single-letter changes to get from one word to another, where every intermediate must be in the list. BFS where neighbours are words differing by one letter.
  5. surroundedRegions(board) — flip every 'O' to 'X' unless its group touches the border. Trick: start from the border and mark everything reachable as safe, then flip the rest.

Editable. Fill them in, then run.

Output


      

Recap

  • Store graphs as an adjacency list — a Map from node to neighbours — and seed every node so isolated ones are not missing.
  • A visited set is mandatory in a graph, unlike a tree, or a cycle becomes an infinite loop.
  • BFS finds shortest paths on unweighted graphs because it explores by distance; DFS does not.
  • A grid is a graph: each cell is a node with four neighbours. Use a DIRS array and always bounds-check first.
  • Undirected cycle detection tracks the node you came from; directed needs three states to separate "on the current path" from "already finished".

Saved in this browser only.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.