Back to blog

Graphs & Graph Algorithms: Complete Guide

algorithmsdata-structuresgraphsinterview-prepdsabfs-dfs
Graphs & Graph Algorithms: Complete Guide

Introduction

Graphs are the most general and versatile data structure in computer science. Unlike trees — which are hierarchical with a single root — graphs can model any relationship between entities: social networks, road maps, web pages, course prerequisites, network topology, and more.

If you've worked through Trees & Binary Search Trees, here's the connection: a tree is just a special graph — connected, acyclic, with exactly one path between any two nodes. Graphs remove those constraints, allowing cycles, disconnected components, and multiple paths.

In coding interviews, graph problems appear in roughly 30-35% of technical interviews. The patterns you learn here — BFS, DFS, topological sort, shortest paths — are the tools you'll reach for again and again.

What You'll Learn

✅ Understand graph terminology, types, and representations
✅ Implement graphs using adjacency lists and adjacency matrices
✅ Master BFS and DFS traversals — iterative and recursive
✅ Detect cycles in directed and undirected graphs
✅ Perform topological sort for dependency ordering
✅ Find shortest paths with Dijkstra's algorithm
✅ Use Union-Find for connected component problems
✅ Solve 8 classic graph interview problems step-by-step

Prerequisites


1. Graph Fundamentals

1.1 What Is a Graph?

A graph is a collection of vertices (nodes) connected by edges (links). Unlike a tree, graphs have no root, no parent-child hierarchy, and can contain cycles.

1.2 Graph Terminology

TermDefinition
Vertex (Node)A point in the graph
EdgeA connection between two vertices
AdjacentTwo vertices connected by an edge
DegreeNumber of edges connected to a vertex
In-degree / Out-degreeFor directed graphs: edges pointing in / out of a vertex
PathA sequence of vertices connected by edges
CycleA path that starts and ends at the same vertex
ConnectedEvery vertex is reachable from every other vertex
ComponentA maximal connected subgraph
DAGDirected Acyclic Graph — directed graph with no cycles

1.3 Types of Graphs

TypeDescriptionExample
UndirectedEdges have no directionFriendships (mutual)
Directed (Digraph)Edges have direction (A → B ≠ B → A)Twitter follows, dependencies
WeightedEdges have numeric valuesRoad distances, network latency
UnweightedAll edges are equalSocial connections
CyclicContains at least one cycleRoad networks
AcyclicNo cycles (DAG = Directed Acyclic Graph)Course prerequisites, build systems
DenseMany edges (close to V² edges)Mostly-connected networks
SparseFew edges (close to V edges)Transportation grids

1.4 Real-World Applications

DomainGraph Use Case
Social NetworksUsers as vertices, friendships as edges (Facebook, LinkedIn)
NavigationCities as vertices, roads as weighted edges (Google Maps)
Web CrawlingWeb pages as vertices, hyperlinks as edges (PageRank)
Dependency ResolutionPackages as vertices, dependencies as directed edges (npm, Maven)
Network RoutingRouters as vertices, connections as weighted edges
Recommendation SystemsUsers/items as vertices, interactions as edges

2. Graph Representations

How you store a graph affects the efficiency of every algorithm. Three main representations exist:

2.1 Adjacency Matrix

A 2D array where matrix[i][j] = 1 (or the edge weight) if there's an edge from vertex i to vertex j.

For the graph above (vertices: A=0, B=1, C=2, D=3):

     A  B  C  D
A  [ 0  1  1  0 ]
B  [ 0  0  1  0 ]
C  [ 0  0  0  1 ]
D  [ 0  0  0  0 ]

Pros: O(1) edge lookup, simple to implement Cons: O(V²) space — wasteful for sparse graphs

2.2 Adjacency List

Each vertex maps to a list of its neighbors. The most common representation for interview problems.

A → [B, C]
B → [C]
C → [D]
D → []

Pros: O(V + E) space — efficient for sparse graphs Cons: O(degree) edge lookup (must scan the list)

2.3 Edge List

A simple list of all edges: [(A, B), (A, C), (B, C), (C, D)]

Pros: Simple, easy to iterate over all edges Cons: O(E) edge lookup, no direct neighbor access

2.4 Comparison

RepresentationSpaceAdd EdgeCheck EdgeIterate Neighbors
Adjacency MatrixO(V²)O(1)O(1)O(V)
Adjacency ListO(V + E)O(1)O(degree)O(degree)
Edge ListO(E)O(1)O(E)O(E)

Interview default: Use adjacency list (Map/dict of arrays). It's the most versatile and memory-efficient for typical sparse graphs.


3. Graph Implementation

TypeScript Implementation

// TypeScript - Graph using Adjacency List
class Graph {
  private adjacencyList: Map<number, number[]>;
  private directed: boolean;
 
  constructor(directed = false) {
    this.adjacencyList = new Map();
    this.directed = directed;
  }
 
  addVertex(vertex: number): void {
    if (!this.adjacencyList.has(vertex)) {
      this.adjacencyList.set(vertex, []);
    }
  }
 
  addEdge(u: number, v: number): void {
    this.addVertex(u);
    this.addVertex(v);
    this.adjacencyList.get(u)!.push(v);
    if (!this.directed) {
      this.adjacencyList.get(v)!.push(u); // undirected: add both directions
    }
  }
 
  getNeighbors(vertex: number): number[] {
    return this.adjacencyList.get(vertex) ?? [];
  }
 
  getVertices(): number[] {
    return Array.from(this.adjacencyList.keys());
  }
 
  print(): void {
    for (const [vertex, neighbors] of this.adjacencyList) {
      console.log(`${vertex} → [${neighbors.join(', ')}]`);
    }
  }
}
 
// Usage - Undirected Graph
const g = new Graph();
g.addEdge(0, 1);
g.addEdge(0, 2);
g.addEdge(1, 3);
g.addEdge(2, 3);
g.addEdge(3, 4);
g.print();
// 0 → [1, 2]
// 1 → [0, 3]
// 2 → [0, 3]
// 3 → [1, 2, 4]
// 4 → [3]
 
// Usage - Directed Graph
const dg = new Graph(true);
dg.addEdge(0, 1);
dg.addEdge(0, 2);
dg.addEdge(1, 3);
dg.print();
// 0 → [1, 2]
// 1 → [3]
// 2 → []
// 3 → []

Python Implementation

# Python - Graph using Adjacency List
from collections import defaultdict
 
class Graph:
    def __init__(self, directed=False):
        self.adjacency_list = defaultdict(list)
        self.directed = directed
 
    def add_vertex(self, vertex: int) -> None:
        if vertex not in self.adjacency_list:
            self.adjacency_list[vertex] = []
 
    def add_edge(self, u: int, v: int) -> None:
        self.adjacency_list[u].append(v)
        if not self.directed:
            self.adjacency_list[v].append(u)
 
    def get_neighbors(self, vertex: int) -> list[int]:
        return self.adjacency_list[vertex]
 
    def get_vertices(self) -> list[int]:
        return list(self.adjacency_list.keys())
 
    def print_graph(self) -> None:
        for vertex, neighbors in self.adjacency_list.items():
            print(f"{vertex}{neighbors}")
 
# Usage
g = Graph()
g.add_edge(0, 1)
g.add_edge(0, 2)
g.add_edge(1, 3)
g.add_edge(2, 3)
g.add_edge(3, 4)
g.print_graph()

Weighted Graph

For Dijkstra's and other weighted algorithms, store (neighbor, weight) tuples:

// TypeScript - Weighted Graph
class WeightedGraph {
  private adjacencyList: Map<number, [number, number][]>; // [neighbor, weight]
 
  constructor() {
    this.adjacencyList = new Map();
  }
 
  addEdge(u: number, v: number, weight: number): void {
    if (!this.adjacencyList.has(u)) this.adjacencyList.set(u, []);
    if (!this.adjacencyList.has(v)) this.adjacencyList.set(v, []);
    this.adjacencyList.get(u)!.push([v, weight]);
    this.adjacencyList.get(v)!.push([u, weight]); // undirected
  }
 
  getNeighbors(vertex: number): [number, number][] {
    return this.adjacencyList.get(vertex) ?? [];
  }
}
# Python - Weighted Graph
class WeightedGraph:
    def __init__(self):
        self.adjacency_list = defaultdict(list)  # {node: [(neighbor, weight), ...]}
 
    def add_edge(self, u: int, v: int, weight: int) -> None:
        self.adjacency_list[u].append((v, weight))
        self.adjacency_list[v].append((u, weight))  # undirected

BFS explores a graph level by level — first all neighbors of the start node, then their neighbors, and so on. It uses a queue (FIFO).

BFS order from vertex 0: 0 → 1 → 2 → 3 → 4 → 5

BFS Algorithm

  1. Initialize a queue with the start vertex
  2. Mark the start vertex as visited
  3. While queue is not empty:
    • Dequeue the front vertex
    • Process it
    • Enqueue all unvisited neighbors and mark them visited
// TypeScript - BFS
function bfs(graph: Map<number, number[]>, start: number): number[] {
  const visited = new Set<number>();
  const queue: number[] = [start];
  const result: number[] = [];
 
  visited.add(start);
 
  while (queue.length > 0) {
    const vertex = queue.shift()!; // dequeue from front
    result.push(vertex);
 
    for (const neighbor of graph.get(vertex) ?? []) {
      if (!visited.has(neighbor)) {
        visited.add(neighbor);
        queue.push(neighbor);
      }
    }
  }
 
  return result;
}
 
// Build graph as adjacency list Map
const graph = new Map<number, number[]>([
  [0, [1, 2]],
  [1, [0, 3, 4]],
  [2, [0, 5]],
  [3, [1]],
  [4, [1]],
  [5, [2]],
]);
 
console.log(bfs(graph, 0)); // [0, 1, 2, 3, 4, 5]
# Python - BFS
from collections import deque
 
def bfs(graph: dict[int, list[int]], start: int) -> list[int]:
    visited = set([start])
    queue = deque([start])
    result = []
 
    while queue:
        vertex = queue.popleft()
        result.append(vertex)
 
        for neighbor in graph.get(vertex, []):
            if neighbor not in visited:
                visited.add(neighbor)
                queue.append(neighbor)
 
    return result

BFS for Shortest Path (Unweighted)

BFS naturally finds the shortest path in an unweighted graph, because it explores nodes in order of their distance from the source.

// TypeScript - BFS Shortest Path
function bfsShortestPath(
  graph: Map<number, number[]>,
  start: number,
  end: number
): number[] | null {
  if (start === end) return [start];
 
  const visited = new Set<number>([start]);
  // Queue stores [vertex, path to reach it]
  const queue: [number, number[]][] = [[start, [start]]];
 
  while (queue.length > 0) {
    const [vertex, path] = queue.shift()!;
 
    for (const neighbor of graph.get(vertex) ?? []) {
      if (neighbor === end) return [...path, neighbor];
      if (!visited.has(neighbor)) {
        visited.add(neighbor);
        queue.push([neighbor, [...path, neighbor]]);
      }
    }
  }
 
  return null; // no path found
}
# Python - BFS Shortest Path
def bfs_shortest_path(graph: dict, start: int, end: int) -> list[int] | None:
    if start == end:
        return [start]
 
    visited = {start}
    queue = deque([(start, [start])])
 
    while queue:
        vertex, path = queue.popleft()
        for neighbor in graph.get(vertex, []):
            if neighbor == end:
                return path + [neighbor]
            if neighbor not in visited:
                visited.add(neighbor)
                queue.append((neighbor, path + [neighbor]))
 
    return None  # no path

When to use BFS:

  • Shortest path in unweighted graphs
  • Level-by-level traversal (e.g., "minimum steps", "minimum hops")
  • Finding all nodes within a given distance
  • Web crawling (discover nearby pages first)

DFS explores a graph by going as deep as possible along one path before backtracking. It uses a stack (or recursion).

DFS order from vertex 0 (same graph as above): 0 → 1 → 3 → 4 → 2 → 5 (order depends on neighbor ordering)

DFS — Recursive

// TypeScript - DFS Recursive
function dfsRecursive(
  graph: Map<number, number[]>,
  vertex: number,
  visited: Set<number> = new Set(),
  result: number[] = []
): number[] {
  visited.add(vertex);
  result.push(vertex);
 
  for (const neighbor of graph.get(vertex) ?? []) {
    if (!visited.has(neighbor)) {
      dfsRecursive(graph, neighbor, visited, result);
    }
  }
 
  return result;
}
# Python - DFS Recursive
def dfs_recursive(graph: dict, vertex: int, visited: set = None, result: list = None) -> list[int]:
    if visited is None:
        visited = set()
    if result is None:
        result = []
 
    visited.add(vertex)
    result.append(vertex)
 
    for neighbor in graph.get(vertex, []):
        if neighbor not in visited:
            dfs_recursive(graph, neighbor, visited, result)
 
    return result

DFS — Iterative (with explicit stack)

// TypeScript - DFS Iterative
function dfsIterative(graph: Map<number, number[]>, start: number): number[] {
  const visited = new Set<number>();
  const stack: number[] = [start];
  const result: number[] = [];
 
  while (stack.length > 0) {
    const vertex = stack.pop()!; // pop from top (LIFO)
    if (visited.has(vertex)) continue;
 
    visited.add(vertex);
    result.push(vertex);
 
    // Push neighbors in reverse order to maintain left-to-right traversal
    for (const neighbor of [...(graph.get(vertex) ?? [])].reverse()) {
      if (!visited.has(neighbor)) {
        stack.push(neighbor);
      }
    }
  }
 
  return result;
}
# Python - DFS Iterative
def dfs_iterative(graph: dict, start: int) -> list[int]:
    visited = set()
    stack = [start]
    result = []
 
    while stack:
        vertex = stack.pop()
        if vertex in visited:
            continue
        visited.add(vertex)
        result.append(vertex)
        for neighbor in reversed(graph.get(vertex, [])):
            if neighbor not in visited:
                stack.append(neighbor)
 
    return result

When to use DFS:

  • Detecting cycles
  • Topological sort
  • Finding all paths between two nodes
  • Maze solving, backtracking problems
  • Connected components

6. Connected Components

A connected component is a maximal subgraph where every vertex is reachable from every other vertex. In a disconnected graph, you need to run BFS/DFS from each unvisited vertex.

// TypeScript - Count Connected Components
function countComponents(n: number, edges: number[][]): number {
  // Build adjacency list
  const graph = new Map<number, number[]>();
  for (let i = 0; i < n; i++) graph.set(i, []);
  for (const [u, v] of edges) {
    graph.get(u)!.push(v);
    graph.get(v)!.push(u);
  }
 
  const visited = new Set<number>();
  let components = 0;
 
  function dfs(vertex: number): void {
    visited.add(vertex);
    for (const neighbor of graph.get(vertex) ?? []) {
      if (!visited.has(neighbor)) dfs(neighbor);
    }
  }
 
  for (let i = 0; i < n; i++) {
    if (!visited.has(i)) {
      dfs(i);
      components++;
    }
  }
 
  return components;
}
 
// Example: 5 vertices, edges [[0,1],[1,2],[3,4]]
// → 2 components: {0,1,2} and {3,4}
console.log(countComponents(5, [[0,1],[1,2],[3,4]])); // 2
# Python - Count Connected Components
def count_components(n: int, edges: list[list[int]]) -> int:
    graph = {i: [] for i in range(n)}
    for u, v in edges:
        graph[u].append(v)
        graph[v].append(u)
 
    visited = set()
 
    def dfs(vertex: int) -> None:
        visited.add(vertex)
        for neighbor in graph[vertex]:
            if neighbor not in visited:
                dfs(neighbor)
 
    components = 0
    for i in range(n):
        if i not in visited:
            dfs(i)
            components += 1
 
    return components

Time: O(V + E) — visit every vertex and edge once Space: O(V) — visited set + recursion stack


7. Cycle Detection

7.1 Undirected Graph — DFS Approach

In an undirected graph, a cycle exists if DFS visits a neighbor that is already visited AND is not the parent of the current vertex.

// TypeScript - Detect Cycle in Undirected Graph
function hasCycleUndirected(n: number, edges: number[][]): boolean {
  const graph = new Map<number, number[]>();
  for (let i = 0; i < n; i++) graph.set(i, []);
  for (const [u, v] of edges) {
    graph.get(u)!.push(v);
    graph.get(v)!.push(u);
  }
 
  const visited = new Set<number>();
 
  function dfs(vertex: number, parent: number): boolean {
    visited.add(vertex);
    for (const neighbor of graph.get(vertex) ?? []) {
      if (!visited.has(neighbor)) {
        if (dfs(neighbor, vertex)) return true;
      } else if (neighbor !== parent) {
        return true; // visited and not parent → cycle!
      }
    }
    return false;
  }
 
  for (let i = 0; i < n; i++) {
    if (!visited.has(i) && dfs(i, -1)) return true;
  }
 
  return false;
}
# Python - Detect Cycle in Undirected Graph
def has_cycle_undirected(n: int, edges: list[list[int]]) -> bool:
    graph = {i: [] for i in range(n)}
    for u, v in edges:
        graph[u].append(v)
        graph[v].append(u)
 
    visited = set()
 
    def dfs(vertex: int, parent: int) -> bool:
        visited.add(vertex)
        for neighbor in graph[vertex]:
            if neighbor not in visited:
                if dfs(neighbor, vertex):
                    return True
            elif neighbor != parent:
                return True  # back edge → cycle
        return False
 
    for i in range(n):
        if i not in visited and dfs(i, -1):
            return True
 
    return False

7.2 Directed Graph — DFS with Colors

For directed graphs, we need to track the current DFS path (recursion stack), not just visited nodes. A cycle exists if we reach a node that's on the current path.

Use three states (colors):

  • WHITE (0): Not visited
  • GRAY (1): Currently in the DFS stack (being processed)
  • BLACK (2): Fully processed (all descendants explored)
// TypeScript - Detect Cycle in Directed Graph
function hasCycleDirected(n: number, edges: number[][]): boolean {
  const graph = new Map<number, number[]>();
  for (let i = 0; i < n; i++) graph.set(i, []);
  for (const [u, v] of edges) graph.get(u)!.push(v);
 
  const WHITE = 0, GRAY = 1, BLACK = 2;
  const color = new Array(n).fill(WHITE);
 
  function dfs(vertex: number): boolean {
    color[vertex] = GRAY; // mark as in-progress
 
    for (const neighbor of graph.get(vertex) ?? []) {
      if (color[neighbor] === GRAY) return true; // back edge → cycle!
      if (color[neighbor] === WHITE && dfs(neighbor)) return true;
    }
 
    color[vertex] = BLACK; // fully processed
    return false;
  }
 
  for (let i = 0; i < n; i++) {
    if (color[i] === WHITE && dfs(i)) return true;
  }
 
  return false;
}
# Python - Detect Cycle in Directed Graph
def has_cycle_directed(n: int, edges: list[list[int]]) -> bool:
    graph = {i: [] for i in range(n)}
    for u, v in edges:
        graph[u].append(v)
 
    WHITE, GRAY, BLACK = 0, 1, 2
    color = [WHITE] * n
 
    def dfs(vertex: int) -> bool:
        color[vertex] = GRAY
        for neighbor in graph[vertex]:
            if color[neighbor] == GRAY:
                return True  # cycle!
            if color[neighbor] == WHITE and dfs(neighbor):
                return True
        color[vertex] = BLACK
        return False
 
    for i in range(n):
        if color[i] == WHITE and dfs(i):
            return True
 
    return False

8. Topological Sort

Topological sort orders the vertices of a directed acyclic graph (DAG) so that for every directed edge u → v, vertex u comes before v in the ordering.

Real-world analogy: Course prerequisites. If course A is required before course B, A must appear before B in the sequence.

Valid topological orders: A, B, C, D or B, A, C, D

8.1 DFS-Based Topological Sort

Process nodes in post-order (add to result AFTER all descendants are processed), then reverse.

// TypeScript - Topological Sort (DFS)
function topologicalSortDFS(n: number, edges: number[][]): number[] {
  const graph = new Map<number, number[]>();
  for (let i = 0; i < n; i++) graph.set(i, []);
  for (const [u, v] of edges) graph.get(u)!.push(v);
 
  const visited = new Set<number>();
  const result: number[] = [];
 
  function dfs(vertex: number): void {
    visited.add(vertex);
    for (const neighbor of graph.get(vertex) ?? []) {
      if (!visited.has(neighbor)) dfs(neighbor);
    }
    result.push(vertex); // add AFTER all descendants
  }
 
  for (let i = 0; i < n; i++) {
    if (!visited.has(i)) dfs(i);
  }
 
  return result.reverse(); // reverse to get topological order
}
 
// Example: 4 courses, prerequisites [[0,2],[1,2],[1,3],[2,3]]
// → valid order: [0, 1, 2, 3] or [1, 0, 2, 3]
# Python - Topological Sort (DFS)
def topological_sort_dfs(n: int, edges: list[list[int]]) -> list[int]:
    graph = {i: [] for i in range(n)}
    for u, v in edges:
        graph[u].append(v)
 
    visited = set()
    result = []
 
    def dfs(vertex: int) -> None:
        visited.add(vertex)
        for neighbor in graph[vertex]:
            if neighbor not in visited:
                dfs(neighbor)
        result.append(vertex)  # append after all descendants
 
    for i in range(n):
        if i not in visited:
            dfs(i)
 
    return result[::-1]  # reverse

8.2 Kahn's Algorithm (BFS-Based)

Repeatedly remove vertices with in-degree 0 (no dependencies). This approach also detects cycles — if the result doesn't contain all vertices, a cycle exists.

// TypeScript - Kahn's Algorithm
function topologicalSortKahn(n: number, edges: number[][]): number[] | null {
  const graph = new Map<number, number[]>();
  const inDegree = new Array(n).fill(0);
 
  for (let i = 0; i < n; i++) graph.set(i, []);
  for (const [u, v] of edges) {
    graph.get(u)!.push(v);
    inDegree[v]++;
  }
 
  // Start with all vertices that have no prerequisites
  const queue: number[] = [];
  for (let i = 0; i < n; i++) {
    if (inDegree[i] === 0) queue.push(i);
  }
 
  const result: number[] = [];
 
  while (queue.length > 0) {
    const vertex = queue.shift()!;
    result.push(vertex);
 
    for (const neighbor of graph.get(vertex) ?? []) {
      inDegree[neighbor]--;
      if (inDegree[neighbor] === 0) queue.push(neighbor);
    }
  }
 
  // If result doesn't include all vertices → cycle exists
  return result.length === n ? result : null;
}
# Python - Kahn's Algorithm
from collections import deque
 
def topological_sort_kahn(n: int, edges: list[list[int]]) -> list[int] | None:
    graph = {i: [] for i in range(n)}
    in_degree = [0] * n
 
    for u, v in edges:
        graph[u].append(v)
        in_degree[v] += 1
 
    queue = deque([i for i in range(n) if in_degree[i] == 0])
    result = []
 
    while queue:
        vertex = queue.popleft()
        result.append(vertex)
        for neighbor in graph[vertex]:
            in_degree[neighbor] -= 1
            if in_degree[neighbor] == 0:
                queue.append(neighbor)
 
    return result if len(result) == n else None  # None means cycle detected
ApproachCycle DetectionOrderComplexity
DFS (post-order + reverse)Separate step neededAny valid orderO(V + E)
Kahn's (BFS)Built-in (result length ≠ n)Lexicographically smallest if min-heapO(V + E)

9. Shortest Path Algorithms

9.1 BFS for Unweighted Graphs

For unweighted graphs, BFS gives the shortest path (fewest edges). Already covered in Section 4.

9.2 Dijkstra's Algorithm

For weighted graphs with non-negative weights, Dijkstra's finds the shortest path from a source to all other vertices.

Core idea: Greedily pick the unvisited vertex with the smallest known distance and relax its edges.

Starting from vertex 0: distances = {0:0, 2:2, 1:3, 3:6, 4:7}

// TypeScript - Dijkstra's Algorithm (with min-heap via sorted array)
function dijkstra(
  graph: Map<number, [number, number][]>, // node → [(neighbor, weight)]
  start: number
): Map<number, number> {
  const distances = new Map<number, number>();
  const visited = new Set<number>();
 
  // Initialize all distances to Infinity
  for (const vertex of graph.keys()) {
    distances.set(vertex, Infinity);
  }
  distances.set(start, 0);
 
  // Priority queue simulation: [distance, vertex]
  const pq: [number, number][] = [[0, start]];
 
  while (pq.length > 0) {
    // Get the vertex with the smallest distance
    pq.sort((a, b) => a[0] - b[0]);
    const [dist, vertex] = pq.shift()!;
 
    if (visited.has(vertex)) continue;
    visited.add(vertex);
 
    for (const [neighbor, weight] of graph.get(vertex) ?? []) {
      const newDist = dist + weight;
      if (newDist < (distances.get(neighbor) ?? Infinity)) {
        distances.set(neighbor, newDist);
        pq.push([newDist, neighbor]);
      }
    }
  }
 
  return distances;
}
 
// Usage
const wGraph = new Map<number, [number, number][]>([
  [0, [[1, 4], [2, 2]]],
  [1, [[3, 3]]],
  [2, [[1, 1], [3, 5]]],
  [3, [[4, 1]]],
  [4, []],
]);
 
const dist = dijkstra(wGraph, 0);
console.log(dist); // Map { 0 → 0, 1 → 3, 2 → 2, 3 → 6, 4 → 7 }
# Python - Dijkstra's Algorithm (using heapq)
import heapq
 
def dijkstra(graph: dict[int, list[tuple[int, int]]], start: int) -> dict[int, int]:
    # graph[u] = [(v, weight), ...]
    distances = {vertex: float('inf') for vertex in graph}
    distances[start] = 0
 
    # Min-heap: (distance, vertex)
    heap = [(0, start)]
 
    while heap:
        dist, vertex = heapq.heappop(heap)
 
        if dist > distances[vertex]:
            continue  # stale entry, skip
 
        for neighbor, weight in graph.get(vertex, []):
            new_dist = dist + weight
            if new_dist < distances[neighbor]:
                distances[neighbor] = new_dist
                heapq.heappush(heap, (new_dist, neighbor))
 
    return distances
 
# Usage
graph = {
    0: [(1, 4), (2, 2)],
    1: [(3, 3)],
    2: [(1, 1), (3, 5)],
    3: [(4, 1)],
    4: [],
}
print(dijkstra(graph, 0))  # {0: 0, 1: 3, 2: 2, 3: 6, 4: 7}

Time: O((V + E) log V) with a proper min-heap Limitation: Does NOT work with negative edge weights (use Bellman-Ford for that)


10. Union-Find (Disjoint Set Union)

Union-Find (also called Disjoint Set Union / DSU) is a data structure that efficiently tracks which elements belong to the same connected component.

Key operations:

  • find(x): Which component does x belong to? (returns the root/representative)
  • union(x, y): Merge the components of x and y

Optimizations:

  • Path compression: When calling find, make all nodes on the path point directly to the root
  • Union by rank: Always attach the smaller tree under the root of the larger tree
// TypeScript - Union-Find with Path Compression + Union by Rank
class UnionFind {
  private parent: number[];
  private rank: number[];
 
  constructor(n: number) {
    this.parent = Array.from({ length: n }, (_, i) => i); // each node is its own parent
    this.rank = new Array(n).fill(0);
  }
 
  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 rootX = this.find(x);
    const rootY = this.find(y);
 
    if (rootX === rootY) return false; // already in same component
 
    // Union by rank: attach smaller tree under larger
    if (this.rank[rootX] < this.rank[rootY]) {
      this.parent[rootX] = rootY;
    } else if (this.rank[rootX] > this.rank[rootY]) {
      this.parent[rootY] = rootX;
    } else {
      this.parent[rootY] = rootX;
      this.rank[rootX]++;
    }
 
    return true; // merged two different components
  }
 
  connected(x: number, y: number): boolean {
    return this.find(x) === this.find(y);
  }
}
 
// Usage
const uf = new UnionFind(5);
uf.union(0, 1);
uf.union(1, 2);
uf.union(3, 4);
console.log(uf.connected(0, 2)); // true  (same component)
console.log(uf.connected(0, 3)); // false (different components)
# Python - Union-Find
class UnionFind:
    def __init__(self, n: int):
        self.parent = list(range(n))
        self.rank = [0] * n
 
    def find(self, x: int) -> int:
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])  # path compression
        return self.parent[x]
 
    def union(self, x: int, y: int) -> bool:
        root_x, root_y = self.find(x), self.find(y)
        if root_x == root_y:
            return False  # already connected
 
        if self.rank[root_x] < self.rank[root_y]:
            self.parent[root_x] = root_y
        elif self.rank[root_x] > self.rank[root_y]:
            self.parent[root_y] = root_x
        else:
            self.parent[root_y] = root_x
            self.rank[root_x] += 1
 
        return True  # merged
 
    def connected(self, x: int, y: int) -> bool:
        return self.find(x) == self.find(y)

Time complexity: Nearly O(1) per operation (amortized O(α(n)) where α is the inverse Ackermann function — essentially constant for all practical inputs)

When to use Union-Find vs DFS/BFS:

  • Use Union-Find when you need to efficiently merge components and check connectivity dynamically (edges added over time)
  • Use DFS/BFS when you need to traverse or process graph structure

11. Classic Interview Problems

Problem 1: Number of Islands

Given a 2D grid of '1' (land) and '0' (water), count the number of islands. An island is surrounded by water and formed by connecting adjacent lands horizontally or vertically.

Approach: DFS/BFS from each unvisited land cell, marking all connected land as visited.

// TypeScript - Number of Islands
function numIslands(grid: string[][]): number {
  const rows = grid.length;
  const cols = grid[0].length;
  let count = 0;
 
  function dfs(r: number, c: number): void {
    if (r < 0 || r >= rows || c < 0 || c >= cols || grid[r][c] !== '1') return;
    grid[r][c] = '0'; // mark visited by sinking the island
    dfs(r + 1, c);
    dfs(r - 1, c);
    dfs(r, c + 1);
    dfs(r, c - 1);
  }
 
  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      if (grid[r][c] === '1') {
        count++;
        dfs(r, c);
      }
    }
  }
 
  return count;
}
 
// grid = [["1","1","0"],["0","1","0"],["0","0","1"]] → 2
# Python - Number of Islands
def num_islands(grid: list[list[str]]) -> int:
    rows, cols = len(grid), len(grid[0])
    count = 0
 
    def dfs(r: int, c: int) -> None:
        if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] != '1':
            return
        grid[r][c] = '0'  # mark visited
        dfs(r + 1, c); dfs(r - 1, c)
        dfs(r, c + 1); dfs(r, c - 1)
 
    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == '1':
                count += 1
                dfs(r, c)
 
    return count

Time: O(M × N). Space: O(M × N) recursion stack in worst case.


Problem 2: Clone Graph

Given a reference to a node in a connected undirected graph, return a deep copy of the graph.

Approach: DFS/BFS with a visited map that maps old nodes to their clones.

// TypeScript - Clone Graph
class GraphNode {
  val: number;
  neighbors: GraphNode[];
  constructor(val = 0, neighbors: GraphNode[] = []) {
    this.val = val;
    this.neighbors = neighbors;
  }
}
 
function cloneGraph(node: GraphNode | null): GraphNode | null {
  if (!node) return null;
 
  const visited = new Map<GraphNode, GraphNode>();
 
  function dfs(curr: GraphNode): GraphNode {
    if (visited.has(curr)) return visited.get(curr)!;
 
    const clone = new GraphNode(curr.val);
    visited.set(curr, clone);
 
    for (const neighbor of curr.neighbors) {
      clone.neighbors.push(dfs(neighbor));
    }
 
    return clone;
  }
 
  return dfs(node);
}
# Python - Clone Graph
class Node:
    def __init__(self, val=0, neighbors=None):
        self.val = val
        self.neighbors = neighbors or []
 
def clone_graph(node: 'Node | None') -> 'Node | None':
    if not node:
        return None
 
    visited = {}  # old_node → clone
 
    def dfs(curr: Node) -> Node:
        if curr in visited:
            return visited[curr]
        clone = Node(curr.val)
        visited[curr] = clone
        for neighbor in curr.neighbors:
            clone.neighbors.append(dfs(neighbor))
        return clone
 
    return dfs(node)

Time: O(V + E). Space: O(V) for the visited map.


Problem 3: Course Schedule (Cycle Detection / Topological Sort)

There are n courses (0 to n-1). Given prerequisites pairs [a, b] meaning "take b before a", determine if you can finish all courses.

Approach: Detect cycle in a directed graph. If a cycle exists, finishing all courses is impossible.

// TypeScript - Course Schedule
function canFinish(numCourses: number, prerequisites: number[][]): boolean {
  const graph = new Map<number, number[]>();
  for (let i = 0; i < numCourses; i++) graph.set(i, []);
  for (const [course, prereq] of prerequisites) {
    graph.get(prereq)!.push(course);
  }
 
  // 0 = unvisited, 1 = in-progress, 2 = done
  const state = new Array(numCourses).fill(0);
 
  function hasCycle(node: number): boolean {
    if (state[node] === 1) return true;  // cycle!
    if (state[node] === 2) return false; // already safe
 
    state[node] = 1; // mark in-progress
    for (const next of graph.get(node) ?? []) {
      if (hasCycle(next)) return true;
    }
    state[node] = 2; // mark done
    return false;
  }
 
  for (let i = 0; i < numCourses; i++) {
    if (hasCycle(i)) return false;
  }
 
  return true;
}
# Python - Course Schedule
def can_finish(num_courses: int, prerequisites: list[list[int]]) -> bool:
    graph = {i: [] for i in range(num_courses)}
    for course, prereq in prerequisites:
        graph[prereq].append(course)
 
    UNVISITED, IN_PROGRESS, DONE = 0, 1, 2
    state = [UNVISITED] * num_courses
 
    def has_cycle(node: int) -> bool:
        if state[node] == IN_PROGRESS:
            return True
        if state[node] == DONE:
            return False
        state[node] = IN_PROGRESS
        for nxt in graph[node]:
            if has_cycle(nxt):
                return True
        state[node] = DONE
        return False
 
    return not any(has_cycle(i) for i in range(num_courses))

Time: O(V + E). This is exactly the directed cycle detection from Section 7.2.


Problem 4: Word Ladder (BFS Shortest Transformation)

Given two words beginWord and endWord, and a word list, find the length of the shortest transformation sequence from beginWord to endWord, changing one letter at a time (each intermediate word must be in the list).

Approach: BFS — each word is a vertex, edges connect words that differ by one letter.

// TypeScript - Word Ladder
function ladderLength(beginWord: string, endWord: string, wordList: string[]): number {
  const wordSet = new Set(wordList);
  if (!wordSet.has(endWord)) return 0;
 
  const queue: [string, number][] = [[beginWord, 1]]; // [word, steps]
  const visited = new Set([beginWord]);
 
  while (queue.length > 0) {
    const [word, steps] = queue.shift()!;
 
    for (let i = 0; i < word.length; i++) {
      for (let c = 97; c <= 122; c++) { // 'a' to 'z'
        const newWord = word.slice(0, i) + String.fromCharCode(c) + word.slice(i + 1);
        if (newWord === endWord) return steps + 1;
        if (wordSet.has(newWord) && !visited.has(newWord)) {
          visited.add(newWord);
          queue.push([newWord, steps + 1]);
        }
      }
    }
  }
 
  return 0; // no transformation sequence found
}
# Python - Word Ladder
def ladder_length(begin_word: str, end_word: str, word_list: list[str]) -> int:
    word_set = set(word_list)
    if end_word not in word_set:
        return 0
 
    queue = deque([(begin_word, 1)])
    visited = {begin_word}
 
    while queue:
        word, steps = queue.popleft()
        for i in range(len(word)):
            for c in 'abcdefghijklmnopqrstuvwxyz':
                new_word = word[:i] + c + word[i+1:]
                if new_word == end_word:
                    return steps + 1
                if new_word in word_set and new_word not in visited:
                    visited.add(new_word)
                    queue.append((new_word, steps + 1))
 
    return 0

Time: O(M² × N) where M = word length, N = word list size.


Problem 5: Pacific Atlantic Water Flow

Given an M×N island, water flows to adjacent cells with equal or lower height. Water can flow to the Pacific (top/left edges) and Atlantic (bottom/right edges). Return all cells that can flow to both oceans.

Approach: Reverse BFS/DFS from ocean borders inward. A cell can reach an ocean if it's reachable going "uphill" from the border.

// TypeScript - Pacific Atlantic Water Flow
function pacificAtlantic(heights: number[][]): number[][] {
  const rows = heights.length;
  const cols = heights[0].length;
  const pacific = new Set<string>();
  const atlantic = new Set<string>();
  const dirs = [[0,1],[0,-1],[1,0],[-1,0]];
 
  function bfs(starts: number[][], visited: Set<string>): void {
    const queue: number[][] = [...starts];
    for (const [r, c] of starts) visited.add(`${r},${c}`);
 
    while (queue.length > 0) {
      const [r, c] = queue.shift()!;
      for (const [dr, dc] of dirs) {
        const nr = r + dr, nc = c + dc;
        const key = `${nr},${nc}`;
        if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;
        if (visited.has(key)) continue;
        if (heights[nr][nc] < heights[r][c]) continue; // water flows downhill; we go uphill
        visited.add(key);
        queue.push([nr, nc]);
      }
    }
  }
 
  // Pacific: top row + left column
  const pacificStarts = [
    ...Array.from({ length: rows }, (_, r) => [r, 0]),
    ...Array.from({ length: cols }, (_, c) => [0, c]),
  ];
  // Atlantic: bottom row + right column
  const atlanticStarts = [
    ...Array.from({ length: rows }, (_, r) => [r, cols - 1]),
    ...Array.from({ length: cols }, (_, c) => [rows - 1, c]),
  ];
 
  bfs(pacificStarts, pacific);
  bfs(atlanticStarts, atlantic);
 
  const result: number[][] = [];
  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      if (pacific.has(`${r},${c}`) && atlantic.has(`${r},${c}`)) {
        result.push([r, c]);
      }
    }
  }
 
  return result;
}
# Python - Pacific Atlantic Water Flow
def pacific_atlantic(heights: list[list[int]]) -> list[list[int]]:
    rows, cols = len(heights), len(heights[0])
    dirs = [(0,1),(0,-1),(1,0),(-1,0)]
 
    def bfs(starts: list[tuple[int,int]]) -> set[tuple[int,int]]:
        visited = set(starts)
        queue = deque(starts)
        while queue:
            r, c = queue.popleft()
            for dr, dc in dirs:
                nr, nc = r + dr, c + dc
                if (0 <= nr < rows and 0 <= nc < cols
                        and (nr, nc) not in visited
                        and heights[nr][nc] >= heights[r][c]):
                    visited.add((nr, nc))
                    queue.append((nr, nc))
        return visited
 
    pacific_starts = [(r, 0) for r in range(rows)] + [(0, c) for c in range(cols)]
    atlantic_starts = [(r, cols-1) for r in range(rows)] + [(rows-1, c) for c in range(cols)]
 
    pacific = bfs(pacific_starts)
    atlantic = bfs(atlantic_starts)
 
    return [[r, c] for r in range(rows) for c in range(cols) if (r, c) in pacific and (r, c) in atlantic]

Time: O(M × N). Space: O(M × N).


Problem 6: Redundant Connection (Union-Find)

Given a tree with n nodes and n edges (one extra edge was added, creating a cycle), find and return the redundant edge.

Approach: Process edges one by one with Union-Find. The first edge where both endpoints are already in the same component is the redundant edge.

// TypeScript - Redundant Connection
function findRedundantConnection(edges: number[][]): number[] {
  const n = edges.length;
  const uf = new UnionFind(n + 1); // 1-indexed
 
  for (const [u, v] of edges) {
    if (!uf.union(u, v)) return [u, v]; // already connected → redundant
  }
 
  return [];
}
 
// edges = [[1,2],[1,3],[2,3]] → [2,3] (the edge that creates the cycle)
# Python - Redundant Connection
def find_redundant_connection(edges: list[list[int]]) -> list[int]:
    n = len(edges)
    uf = UnionFind(n + 1)  # 1-indexed
 
    for u, v in edges:
        if not uf.union(u, v):
            return [u, v]  # already in same component
 
    return []

Time: O(n × α(n)) ≈ O(n). Space: O(n).


Problem 7: Cheapest Flights Within K Stops (Dijkstra Variant)

Given flights (from, to, price), a source, destination, and k max stops, find the cheapest price from source to destination with at most k stops. Return -1 if impossible.

Approach: Modified Dijkstra or Bellman-Ford with a constraint on the number of hops. Use BFS-style relaxation tracking (stops, cost) in the priority queue.

// TypeScript - Cheapest Flights Within K Stops
function findCheapestPrice(
  n: number, flights: number[][], src: number, dst: number, k: number
): number {
  const graph = new Map<number, [number, number][]>();
  for (let i = 0; i < n; i++) graph.set(i, []);
  for (const [from, to, price] of flights) {
    graph.get(from)!.push([to, price]);
  }
 
  // [cost, node, stops_remaining]
  const pq: [number, number, number][] = [[0, src, k + 1]];
  const visited = new Map<number, number>(); // node → max stops seen
 
  while (pq.length > 0) {
    pq.sort((a, b) => a[0] - b[0]);
    const [cost, node, stopsLeft] = pq.shift()!;
 
    if (node === dst) return cost;
    if (stopsLeft === 0) continue;
 
    // Skip if we've visited this node with more stops remaining
    if ((visited.get(node) ?? -1) >= stopsLeft) continue;
    visited.set(node, stopsLeft);
 
    for (const [next, price] of graph.get(node) ?? []) {
      pq.push([cost + price, next, stopsLeft - 1]);
    }
  }
 
  return -1;
}
# Python - Cheapest Flights Within K Stops
def find_cheapest_price(n: int, flights: list[list[int]], src: int, dst: int, k: int) -> int:
    graph = defaultdict(list)
    for frm, to, price in flights:
        graph[frm].append((to, price))
 
    # (cost, node, stops_remaining)
    heap = [(0, src, k + 1)]
    visited = {}  # node → max stops_remaining seen
 
    while heap:
        cost, node, stops_left = heapq.heappop(heap)
        if node == dst:
            return cost
        if stops_left == 0:
            continue
        if visited.get(node, -1) >= stops_left:
            continue
        visited[node] = stops_left
        for nxt, price in graph[node]:
            heapq.heappush(heap, (cost + price, nxt, stops_left - 1))
 
    return -1

Problem 8: Graph Valid Tree

Given n nodes (0 to n-1) and a list of edges, determine if they form a valid tree.

A valid tree has two properties:

  1. Exactly n - 1 edges (a tree with n nodes has n-1 edges)
  2. All nodes are connected (no disconnected components)
// TypeScript - Graph Valid Tree
function validTree(n: number, edges: number[][]): boolean {
  // Must have exactly n-1 edges
  if (edges.length !== n - 1) return false;
 
  const graph = new Map<number, number[]>();
  for (let i = 0; i < n; i++) graph.set(i, []);
  for (const [u, v] of edges) {
    graph.get(u)!.push(v);
    graph.get(v)!.push(u);
  }
 
  // BFS from node 0 — must visit all n nodes
  const visited = new Set<number>([0]);
  const queue = [0];
 
  while (queue.length > 0) {
    const node = queue.shift()!;
    for (const neighbor of graph.get(node) ?? []) {
      if (!visited.has(neighbor)) {
        visited.add(neighbor);
        queue.push(neighbor);
      }
    }
  }
 
  return visited.size === n; // all nodes reachable → valid tree
}
# Python - Graph Valid Tree
def valid_tree(n: int, edges: list[list[int]]) -> bool:
    if len(edges) != n - 1:
        return False
 
    graph = {i: [] for i in range(n)}
    for u, v in edges:
        graph[u].append(v)
        graph[v].append(u)
 
    visited = {0}
    queue = deque([0])
 
    while queue:
        node = queue.popleft()
        for neighbor in graph[node]:
            if neighbor not in visited:
                visited.add(neighbor)
                queue.append(neighbor)
 
    return len(visited) == n

12. Pattern Recognition Guide

When you see a graph problem, identify the pattern first:

PatternSignal WordsAlgorithmExample
Grid traversal"2D grid", "island", "matrix", "cells"BFS or DFS on gridNumber of Islands
Shortest path (unweighted)"minimum steps", "minimum hops", "fewest moves"BFSWord Ladder
Shortest path (weighted)"minimum cost", "cheapest price", "minimum distance"Dijkstra'sCheapest Flights
Detect cycle"can finish", "deadlock", "circular dependency"DFS with colors (directed) / Union-FindCourse Schedule
Topological order"prerequisites", "build order", "task dependencies"Kahn's BFS or DFS post-orderCourse Schedule II
Connected components"count regions", "isolated groups", "number of components"DFS/BFS from each unvisitedNumber of Islands
Union-Find"merge groups", "redundant connection", "dynamic connectivity"Union-FindRedundant Connection
Clone/copy graph"deep copy", "clone"DFS with visited mapClone Graph

Decision tree for graph problems:

Interview heuristics:

  • "Minimum steps / hops" → BFS (explores level by level)
  • "All paths / any path" → DFS (explores depth first)
  • "Course schedule / prerequisites" → Topological sort + cycle detection
  • "Connect regions / merge groups" → Union-Find
  • "2D grid problems" → Treat as graph with 4-directional neighbors
  • "Clone / copy structure" → DFS with visited hashmap

13. Complexity Reference

Time Complexity by Algorithm

AlgorithmTimeSpaceNotes
BFSO(V + E)O(V)Queue-based, level-by-level
DFSO(V + E)O(V)Stack/recursion, depth-first
Topological Sort (DFS)O(V + E)O(V)Post-order + reverse
Topological Sort (Kahn's)O(V + E)O(V)BFS with in-degree
Dijkstra's (binary heap)O((V + E) log V)O(V)Non-negative weights only
Union-Find (with optimizations)O(α(n)) per opO(V)Nearly O(1) amortized
Cycle Detection (undirected)O(V + E)O(V)DFS with parent tracking
Cycle Detection (directed)O(V + E)O(V)DFS with 3-color states

V = vertices, E = edges, α = inverse Ackermann function

Space Complexity for Graph Storage

RepresentationSpaceBest For
Adjacency MatrixO(V²)Dense graphs, O(1) edge check
Adjacency ListO(V + E)Sparse graphs (most problems)
Edge ListO(E)Kruskal's MST algorithm

Graph Density

PropertyFormulaDenseSparse
Max edges (directed)V × (V-1)E ≈ V²E ≈ V
Max edges (undirected)V × (V-1) / 2E ≈ V²E ≈ V

14. Practice Problems

Easy (Build Foundation)

#ProblemPatternApproach
1Number of IslandsGrid DFS/BFSDFS, sink visited land
2Flood FillGrid DFSDFS from source cell
3Find if Path Exists in GraphBasic traversalBFS/DFS or Union-Find
4Connected Components in Undirected GraphComponentsDFS from each unvisited
5Clone GraphDFS + hashmapRecursive DFS with clone map

Medium (Build Confidence)

#ProblemPatternApproach
6Course ScheduleCycle detectionDFS with 3 colors
7Course Schedule IITopological sortKahn's or DFS post-order
8Word LadderBFS shortest pathBFS with word mutations
9Pacific Atlantic Water FlowMulti-source BFSReverse BFS from borders
10Rotting OrangesMulti-source BFSBFS from all rotten cells simultaneously
11Redundant ConnectionUnion-FindFirst edge forming a cycle
12Graph Valid TreeTree checkn-1 edges + all connected

Hard (Master Level)

#ProblemPatternApproach
13Cheapest Flights Within K StopsDijkstra variantModified Dijkstra with stops constraint
14Alien DictionaryTopological sortBuild graph from adjacent words
15Serialize and Deserialize Binary TreeBFS encodingLevel-order with null markers

Summary

Graphs are the most powerful and flexible data structure — they model almost any real-world relationship.

The fundamental algorithms:

  • BFS: Shortest path (unweighted), level-by-level exploration
  • DFS: Cycle detection, topological sort, all-paths problems
  • Dijkstra's: Shortest path with non-negative weights
  • Union-Find: Dynamic connectivity, cycle detection, merging components

The key patterns:

  • Grid problems → Treat as implicit graph (4-directional neighbors)
  • Prerequisites / ordering → Topological sort (Kahn's or DFS)
  • Cycle detection → DFS colors (directed) or parent tracking (undirected)
  • Dynamic merging → Union-Find
  • Minimum cost path → Dijkstra's

What's next in the DSA series? The next post covers Heaps & Priority Queues — the data structure that powers Dijkstra's algorithm and "top-K" problems. You'll build a MinHeap from scratch and master the patterns for finding the K largest/smallest elements efficiently.

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.