Graph Algorithms: BFS, DFS, Topological Sort & Shortest Paths
A comprehensive guide to graph traversal, cycle detection, topological ordering, and shortest path algorithms from Dijkstra to Bellman-Ford.
Graph Representations
Graphs can be represented as adjacency lists (space-efficient for sparse graphs), adjacency matrices (fast edge lookup), or edge lists. For most problems, adjacency lists are optimal — O(V + E) space and efficient traversal.
# Adjacency list from edge list
def build_graph(edges: list[list[int]], directed=False) -> dict[int, list[int]]:
graph = {}
for u, v in edges:
graph.setdefault(u, []).append(v)
if not directed:
graph.setdefault(v, []).append(u)
return graphBreadth-First Search (BFS)
BFS explores nodes level by level, using a queue to track the frontier. It guarantees the shortest path in unweighted graphs and is ideal for finding the minimum number of steps between two nodes.
from collections import deque
def bfs(graph: dict, start: int) -> list[int]:
visited = set([start])
queue = deque([start])
order = []
while queue:
node = queue.popleft()
order.append(node)
for neighbor in graph.get(node, []):
if neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)
return order
def shortest_path_unweighted(graph: dict, start: int, end: int) -> list[int]:
"""Find shortest path in an unweighted graph using BFS."""
visited = {start}
queue = deque([(start, [start])])
while queue:
node, path = queue.popleft()
if node == end:
return path
for neighbor in graph.get(node, []):
if neighbor not in visited:
visited.add(neighbor)
queue.append((neighbor, path + [neighbor]))
return [] # No path existsDepth-First Search (DFS)
DFS dives deep before backtracking. It's essential for cycle detection, topological sorting, connected components, and exploring all paths. Both recursive and iterative implementations are important to know.
def dfs_recursive(graph: dict, start: int) -> list[int]:
visited = set()
order = []
def explore(node):
visited.add(node)
order.append(node)
for neighbor in graph.get(node, []):
if neighbor not in visited:
explore(neighbor)
explore(start)
return order
def count_connected_components(n: int, edges: list[list[int]]) -> int:
"""Count connected components in an undirected graph."""
graph = build_graph(edges)
visited = set()
components = 0
for node in range(n):
if node not in visited:
components += 1
stack = [node]
while stack:
curr = stack.pop()
if curr in visited:
continue
visited.add(curr)
stack.extend(graph.get(curr, []))
return componentsBFS runs in O(V + E) time where V is vertices and E is edges. Space complexity is O(V) for the visited set and queue. DFS has the same complexity but uses O(V) stack space (recursion depth).
Cycle Detection
def has_cycle_directed(graph: dict, n: int) -> bool:
"""Detect cycle in a directed graph using DFS coloring."""
WHITE, GRAY, BLACK = 0, 1, 2
color = [WHITE] * n
def dfs(node):
color[node] = GRAY
for neighbor in graph.get(node, []):
if color[neighbor] == GRAY: # Back edge = cycle
return True
if color[neighbor] == WHITE and dfs(neighbor):
return True
color[node] = BLACK
return False
return any(color[i] == WHITE and dfs(i) for i in range(n))Topological Sort (Kahn's Algorithm)
Topological sort orders vertices so every edge goes from earlier to later. It only works on DAGs (directed acyclic graphs). Kahn's algorithm uses BFS with in-degree counting. If the result doesn't include all nodes, the graph has a cycle.
def topological_sort(n: int, edges: list[list[int]]) -> list[int]:
"""Kahn's algorithm: BFS-based topological sort."""
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)
order = []
while queue:
node = queue.popleft()
order.append(node)
for neighbor in graph[node]:
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
queue.append(neighbor)
return order if len(order) == n else [] # Empty = has cycleDijkstra's Shortest Path
Dijkstra's algorithm finds the shortest path in a weighted graph with non-negative edges. It uses a priority queue to always process the closest unvisited node. Time complexity: O((V + E) log V) with a binary heap.
import heapq
def dijkstra(graph: dict[int, list[tuple[int, int]]], start: int) -> dict[int, int]:
"""Shortest distances from start. graph[u] = [(v, weight), ...]"""
dist = {start: 0}
heap = [(0, start)]
while heap:
d, u = heapq.heappop(heap)
if d > dist.get(u, float("inf")):
continue
for v, weight in graph.get(u, []):
new_dist = d + weight
if new_dist < dist.get(v, float("inf")):
dist[v] = new_dist
heapq.heappush(heap, (new_dist, v))
return distUse BFS for unweighted shortest paths. Use Dijkstra for weighted (non-negative) graphs. Use Bellman-Ford when edges can be negative. Use Floyd-Warshall for all-pairs shortest paths.