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
- Understanding of Big O Notation & Complexity Analysis
- Familiarity with Problem-Solving Patterns
- Completed Stacks & Queues (BFS uses queues, DFS uses stacks)
- Completed Trees & Binary Search Trees (graphs generalize trees)
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
| Term | Definition |
|---|---|
| Vertex (Node) | A point in the graph |
| Edge | A connection between two vertices |
| Adjacent | Two vertices connected by an edge |
| Degree | Number of edges connected to a vertex |
| In-degree / Out-degree | For directed graphs: edges pointing in / out of a vertex |
| Path | A sequence of vertices connected by edges |
| Cycle | A path that starts and ends at the same vertex |
| Connected | Every vertex is reachable from every other vertex |
| Component | A maximal connected subgraph |
| DAG | Directed Acyclic Graph — directed graph with no cycles |
1.3 Types of Graphs
| Type | Description | Example |
|---|---|---|
| Undirected | Edges have no direction | Friendships (mutual) |
| Directed (Digraph) | Edges have direction (A → B ≠ B → A) | Twitter follows, dependencies |
| Weighted | Edges have numeric values | Road distances, network latency |
| Unweighted | All edges are equal | Social connections |
| Cyclic | Contains at least one cycle | Road networks |
| Acyclic | No cycles (DAG = Directed Acyclic Graph) | Course prerequisites, build systems |
| Dense | Many edges (close to V² edges) | Mostly-connected networks |
| Sparse | Few edges (close to V edges) | Transportation grids |
1.4 Real-World Applications
| Domain | Graph Use Case |
|---|---|
| Social Networks | Users as vertices, friendships as edges (Facebook, LinkedIn) |
| Navigation | Cities as vertices, roads as weighted edges (Google Maps) |
| Web Crawling | Web pages as vertices, hyperlinks as edges (PageRank) |
| Dependency Resolution | Packages as vertices, dependencies as directed edges (npm, Maven) |
| Network Routing | Routers as vertices, connections as weighted edges |
| Recommendation Systems | Users/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
| Representation | Space | Add Edge | Check Edge | Iterate Neighbors |
|---|---|---|---|---|
| Adjacency Matrix | O(V²) | O(1) | O(1) | O(V) |
| Adjacency List | O(V + E) | O(1) | O(degree) | O(degree) |
| Edge List | O(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)) # undirected4. BFS (Breadth-First Search)
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
- Initialize a queue with the start vertex
- Mark the start vertex as visited
- 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 resultBFS 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 pathWhen 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)
5. DFS (Depth-First Search)
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 resultDFS — 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 resultWhen 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 componentsTime: 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 False7.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 False8. 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] # reverse8.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| Approach | Cycle Detection | Order | Complexity |
|---|---|---|---|
| DFS (post-order + reverse) | Separate step needed | Any valid order | O(V + E) |
| Kahn's (BFS) | Built-in (result length ≠ n) | Lexicographically smallest if min-heap | O(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 doesxbelong to? (returns the root/representative)union(x, y): Merge the components ofxandy
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 countTime: 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
ncourses (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
beginWordandendWord, and a word list, find the length of the shortest transformation sequence frombeginWordtoendWord, 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 0Time: 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
nnodes andnedges (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, andkmax stops, find the cheapest price from source to destination with at mostkstops. Return-1if 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 -1Problem 8: Graph Valid Tree
Given
nnodes (0 to n-1) and a list of edges, determine if they form a valid tree.
A valid tree has two properties:
- Exactly
n - 1edges (a tree with n nodes has n-1 edges) - 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) == n12. Pattern Recognition Guide
When you see a graph problem, identify the pattern first:
| Pattern | Signal Words | Algorithm | Example |
|---|---|---|---|
| Grid traversal | "2D grid", "island", "matrix", "cells" | BFS or DFS on grid | Number of Islands |
| Shortest path (unweighted) | "minimum steps", "minimum hops", "fewest moves" | BFS | Word Ladder |
| Shortest path (weighted) | "minimum cost", "cheapest price", "minimum distance" | Dijkstra's | Cheapest Flights |
| Detect cycle | "can finish", "deadlock", "circular dependency" | DFS with colors (directed) / Union-Find | Course Schedule |
| Topological order | "prerequisites", "build order", "task dependencies" | Kahn's BFS or DFS post-order | Course Schedule II |
| Connected components | "count regions", "isolated groups", "number of components" | DFS/BFS from each unvisited | Number of Islands |
| Union-Find | "merge groups", "redundant connection", "dynamic connectivity" | Union-Find | Redundant Connection |
| Clone/copy graph | "deep copy", "clone" | DFS with visited map | Clone 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
| Algorithm | Time | Space | Notes |
|---|---|---|---|
| BFS | O(V + E) | O(V) | Queue-based, level-by-level |
| DFS | O(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 op | O(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
| Representation | Space | Best For |
|---|---|---|
| Adjacency Matrix | O(V²) | Dense graphs, O(1) edge check |
| Adjacency List | O(V + E) | Sparse graphs (most problems) |
| Edge List | O(E) | Kruskal's MST algorithm |
Graph Density
| Property | Formula | Dense | Sparse |
|---|---|---|---|
| Max edges (directed) | V × (V-1) | E ≈ V² | E ≈ V |
| Max edges (undirected) | V × (V-1) / 2 | E ≈ V² | E ≈ V |
14. Practice Problems
Easy (Build Foundation)
| # | Problem | Pattern | Approach |
|---|---|---|---|
| 1 | Number of Islands | Grid DFS/BFS | DFS, sink visited land |
| 2 | Flood Fill | Grid DFS | DFS from source cell |
| 3 | Find if Path Exists in Graph | Basic traversal | BFS/DFS or Union-Find |
| 4 | Connected Components in Undirected Graph | Components | DFS from each unvisited |
| 5 | Clone Graph | DFS + hashmap | Recursive DFS with clone map |
Medium (Build Confidence)
| # | Problem | Pattern | Approach |
|---|---|---|---|
| 6 | Course Schedule | Cycle detection | DFS with 3 colors |
| 7 | Course Schedule II | Topological sort | Kahn's or DFS post-order |
| 8 | Word Ladder | BFS shortest path | BFS with word mutations |
| 9 | Pacific Atlantic Water Flow | Multi-source BFS | Reverse BFS from borders |
| 10 | Rotting Oranges | Multi-source BFS | BFS from all rotten cells simultaneously |
| 11 | Redundant Connection | Union-Find | First edge forming a cycle |
| 12 | Graph Valid Tree | Tree check | n-1 edges + all connected |
Hard (Master Level)
| # | Problem | Pattern | Approach |
|---|---|---|---|
| 13 | Cheapest Flights Within K Stops | Dijkstra variant | Modified Dijkstra with stops constraint |
| 14 | Alien Dictionary | Topological sort | Build graph from adjacent words |
| 15 | Serialize and Deserialize Binary Tree | BFS encoding | Level-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.