backend / dsa / graphs / 01_bfs_dfs_topo_unionfind.md

Graph algorithms — BFS, DFS, topological sort, union-find

4 interview angles 4 min read source

Graph algorithms — BFS, DFS, topological sort, union-find

Representation

# Adjacency list — most common
graph = {
    "a": ["b", "c"],
    "b": ["c", "d"],
    "c": ["d"],
    "d": [],
}

# Or as defaultdict for dynamic building:
from collections import defaultdict
graph = defaultdict(list)
for u, v in edges:
    graph[u].append(v)
    graph[v].append(u)   # for undirected

For dense graphs or weighted graphs, sometimes adjacency matrix or list of (weight, neighbor) tuples.

BFS — shortest path in unweighted graph

from collections import deque

def bfs_shortest_path(graph, start, target):
    if start == target: return 0
    visited = {start}
    q = deque([(start, 0)])
    while q:
        node, dist = q.popleft()
        for n in graph[node]:
            if n == target:
                return dist + 1
            if n not in visited:
                visited.add(n)
                q.append((n, dist + 1))
    return -1

BFS on an unweighted graph gives shortest path in O(V+E). For weighted graphs, use Dijkstra (see weighted file).

DFS — recursive

def dfs(graph, start, visited=None):
    if visited is None:
        visited = set()
    visited.add(start)
    for n in graph[start]:
        if n not in visited:
            dfs(graph, n, visited)
    return visited

For deep graphs, convert to iterative with an explicit stack to avoid recursion-depth issues.

Pattern: connected components

def count_components(graph):
    visited = set()
    count = 0
    for node in graph:
        if node not in visited:
            count += 1
            stack = [node]
            while stack:
                n = stack.pop()
                if n in visited: continue
                visited.add(n)
                stack.extend(graph[n])
    return count

Topological sort (Kahn’s algorithm — BFS-based)

For DAGs (directed acyclic graphs). Order nodes so every edge u→v has u before v.

from collections import defaultdict, deque

def topological_sort(num_nodes, edges):
    graph = defaultdict(list)
    in_degree = [0] * num_nodes
    for u, v in edges:
        graph[u].append(v)
        in_degree[v] += 1
    
    q = deque(i for i in range(num_nodes) if in_degree[i] == 0)
    order = []
    while q:
        node = q.popleft()
        order.append(node)
        for n in graph[node]:
            in_degree[n] -= 1
            if in_degree[n] == 0:
                q.append(n)
    
    if len(order) != num_nodes:
        return None   # cycle detected
    return order

Use cases: course scheduling, build dependencies, package install order. Cycle detection is free — if the result has fewer nodes than the input, there was a cycle.

DFS-based topological sort (post-order reversed)

def topo_dfs(graph):
    visited = set()
    on_stack = set()
    order = []
    
    def visit(node):
        if node in on_stack:
            raise ValueError("cycle")
        if node in visited:
            return
        on_stack.add(node)
        for n in graph[node]:
            visit(n)
        on_stack.discard(node)
        visited.add(node)
        order.append(node)
    
    for node in graph:
        if node not in visited:
            visit(node)
    
    return order[::-1]

Same complexity (O(V+E)). Choose Kahn’s for cycle detection clarity; DFS-based when you’re already doing DFS for other reasons.

Union-Find (Disjoint Set Union)

For dynamic connectivity / “are these in the same group” queries. Operations: find(x), union(x, y). With path compression and union by rank: nearly O(1) per operation (amortized α(n)).

class UnionFind:
    def __init__(self, n: int):
        self.parent = list(range(n))
        self.rank = [0] * n
    
    def find(self, x: int) -> int:
        root = x
        while self.parent[root] != root:
            root = self.parent[root]
        # Path compression
        while self.parent[x] != root:
            self.parent[x], x = root, self.parent[x]
        return root
    
    def union(self, x: int, y: int) -> bool:
        rx, ry = self.find(x), self.find(y)
        if rx == ry:
            return False    # already in same set
        # Union by rank
        if self.rank[rx] < self.rank[ry]:
            rx, ry = ry, rx
        self.parent[ry] = rx
        if self.rank[rx] == self.rank[ry]:
            self.rank[rx] += 1
        return True

Use cases: Kruskal’s MST, dynamic connectivity, “number of provinces”, “redundant connection” detection.

Problem: number of islands

def num_islands(grid):
    if not grid: return 0
    rows, cols = len(grid), len(grid[0])
    count = 0
    
    def dfs(r, c):
        if not (0 <= r < rows and 0 <= c < cols) or grid[r][c] != "1":
            return
        grid[r][c] = "0"   # mark visited
        for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
            dfs(r + dr, c + dc)
    
    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == "1":
                count += 1
                dfs(r, c)
    return count

Grids are graphs in disguise. Each cell connects to its 4 (or 8) neighbors.

Problem: course schedule (cycle detection)

def can_finish(num_courses, prerequisites):
    graph = defaultdict(list)
    in_degree = [0] * num_courses
    for c, p in prerequisites:
        graph[p].append(c)
        in_degree[c] += 1
    
    q = deque(i for i in range(num_courses) if in_degree[i] == 0)
    completed = 0
    while q:
        course = q.popleft()
        completed += 1
        for next_ in graph[course]:
            in_degree[next_] -= 1
            if in_degree[next_] == 0:
                q.append(next_)
    return completed == num_courses

Topological sort with cycle detection.

Pitfalls

  • Forgetting to mark visited before recursing — leads to revisits and stack overflow.
  • BFS without visited set on cyclic graphs — infinite loop.
  • DFS on adjacency matrix vs list — matrix is O(V²) per node visit.
  • Mutating the input grid as a “visited marker” — fine, but document it.
  • Off-by-one in BFS distance: distance to start is 0, not 1.

Interview angle

  • “How do you detect a cycle in a graph?” - directed: DFS with three colours (unvisited, in-progress, done), where an edge to an in-progress node is a back edge and therefore a cycle. Undirected: DFS tracking the parent, or union-find where an edge joining two nodes already in the same set closes a cycle.
  • “What is topological sort for, and when is it impossible?” - ordering with dependencies: build systems, task scheduling, course prerequisites. It exists only for a DAG, so if Kahn’s algorithm finishes with nodes remaining, the graph has a cycle. That’s the standard cycle test.
  • “Kahn’s or DFS-based topological sort?” - Kahn’s (repeatedly remove in-degree-zero nodes) detects cycles naturally and is easier to explain; the DFS version pushes nodes on finish and reverses. Both O(V+E).
  • “When is union-find the right structure?” - dynamic connectivity and grouping: Kruskal’s MST, counting connected components, detecting cycles as you add edges. With path compression and union by rank it’s effectively O(1) amortised per operation.