AdvancedDSA ยท Lesson 1

Graphs: BFS, DFS, and Shortest Paths

Represent and traverse graphs using BFS and DFS, implement Dijkstra, Bellman-Ford, and union-find

Graph Representations

# Adjacency List (most common for sparse graphs)
graph_directed = {
    0: [1, 2],
    1: [3],
    2: [3, 4],
    3: [5],
    4: [5],
    5: []
}

# Weighted adjacency list
weighted_graph = {
    'A': [('B', 4), ('C', 2)],
    'B': [('D', 5)],
    'C': [('B', 1), ('D', 8)],
    'D': []
}

# Adjacency Matrix (for dense graphs, O(1) edge lookup)
# matrix[i][j] = weight (0 = no edge)
matrix = [
    [0, 1, 1, 0],
    [0, 0, 0, 1],
    [0, 0, 0, 1],
    [0, 0, 0, 0],
]

# Edge list (for algorithms like Kruskal's)
edges = [(0, 1, 4), (0, 2, 2), (1, 3, 5), (2, 3, 8)]  # (u, v, weight)

BFS โ€” Breadth-First Search

from collections import deque

def bfs(graph, start):
    """O(V + E)"""
    visited = {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

# BFS for shortest path in unweighted graph
def shortest_path_bfs(graph, start, end):
    if start == end:
        return [start]
    visited = {start}
    queue = deque([(start, [start])])
    while queue:
        node, path = queue.popleft()
        for neighbor in graph.get(node, []):
            if neighbor == end:
                return path + [neighbor]
            if neighbor not in visited:
                visited.add(neighbor)
                queue.append((neighbor, path + [neighbor]))
    return None  # no path

DFS โ€” Depth-First Search

def dfs_recursive(graph, start, visited=None):
    if visited is None:
        visited = set()
    visited.add(start)
    result = [start]
    for neighbor in graph.get(start, []):
        if neighbor not in visited:
            result.extend(dfs_recursive(graph, neighbor, visited))
    return result

def dfs_iterative(graph, start):
    visited = set()
    stack = [start]
    result = []
    while stack:
        node = stack.pop()
        if node not in visited:
            visited.add(node)
            result.append(node)
            for neighbor in reversed(graph.get(node, [])):
                if neighbor not in visited:
                    stack.append(neighbor)
    return result

Cycle Detection

def has_cycle_directed(graph):
    """DFS with coloring: white=0, gray=1, black=2"""
    color = {node: 0 for node in graph}

    def dfs(node):
        color[node] = 1  # processing
        for neighbor in graph.get(node, []):
            if color[neighbor] == 1:
                return True  # back edge = cycle
            if color[neighbor] == 0 and dfs(neighbor):
                return True
        color[node] = 2  # done
        return False

    return any(dfs(node) for node in graph if color[node] == 0)

def has_cycle_undirected(graph):
    visited = set()
    def dfs(node, parent):
        visited.add(node)
        for neighbor in graph.get(node, []):
            if neighbor not in visited:
                if dfs(neighbor, node):
                    return True
            elif neighbor != parent:
                return True  # back edge to non-parent
        return False

    return any(dfs(node, -1) for node in graph if node not in visited)

Topological Sort

from collections import deque

def topological_sort_kahn(graph, in_degree):
    """Kahn's algorithm (BFS-based) โ€” O(V + E)"""
    queue = deque(node for node in in_degree if in_degree[node] == 0)
    order = []

    while queue:
        node = queue.popleft()
        order.append(node)
        for neighbor in graph.get(node, []):
            in_degree[neighbor] -= 1
            if in_degree[neighbor] == 0:
                queue.append(neighbor)

    if len(order) != len(in_degree):
        return None  # cycle detected
    return order

# Course Schedule example
def can_finish(num_courses, prerequisites):
    graph = {i: [] for i in range(num_courses)}
    in_deg = {i: 0 for i in range(num_courses)}
    for course, prereq in prerequisites:
        graph[prereq].append(course)
        in_deg[course] += 1
    topo = topological_sort_kahn(graph, in_deg)
    return topo is not None and len(topo) == num_courses

print(can_finish(4, [[1,0],[2,1],[3,2]]))  # True
print(can_finish(2, [[0,1],[1,0]]))         # False (cycle)

Dijkstra's Algorithm

import heapq

def dijkstra(graph, start):
    """Shortest paths from start to all nodes โ€” O((V+E) log V)"""
    dist = {node: float('inf') for node in graph}
    dist[start] = 0
    prev = {node: None for node in graph}
    pq = [(0, start)]

    while pq:
        d, u = heapq.heappop(pq)
        if d > dist[u]:
            continue  # outdated entry
        for v, weight in graph.get(u, []):
            if dist[u] + weight < dist[v]:
                dist[v] = dist[u] + weight
                prev[v] = u
                heapq.heappush(pq, (dist[v], v))

    return dist, prev

def reconstruct_path(prev, start, end):
    path = []
    curr = end
    while curr is not None:
        path.append(curr)
        curr = prev[curr]
    return path[::-1] if path[-1] == start else []

# Example
graph = {
    'A': [('B', 4), ('C', 2)],
    'B': [('D', 5), ('C', 1)],
    'C': [('B', 1), ('D', 8)],
    'D': []
}
dist, prev = dijkstra(graph, 'A')
print(dist)   # {'A': 0, 'B': 3, 'C': 2, 'D': 8}
print(reconstruct_path(prev, 'A', 'D'))  # ['A', 'C', 'B', 'D']

Union-Find (Disjoint Set Union)

class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))
        self.rank = [0] * n
        self.components = n

    def find(self, x):
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])  # path compression
        return self.parent[x]

    def union(self, x, y):
        px, py = self.find(x), self.find(y)
        if px == py:
            return False  # already in same component
        # Union by rank
        if self.rank[px] < self.rank[py]:
            px, py = py, px
        self.parent[py] = px
        if self.rank[px] == self.rank[py]:
            self.rank[px] += 1
        self.components -= 1
        return True

    def connected(self, x, y):
        return self.find(x) == self.find(y)

# Minimum Spanning Tree โ€” Kruskal's Algorithm
def kruskal_mst(n, edges):
    """edges: [(weight, u, v)]"""
    edges.sort()
    uf = UnionFind(n)
    mst = []
    total_weight = 0

    for weight, u, v in edges:
        if uf.union(u, v):
            mst.append((u, v, weight))
            total_weight += weight
            if len(mst) == n - 1:
                break

    return mst, total_weight

edges = [(4, 0, 1), (2, 0, 2), (1, 1, 2), (5, 1, 3), (8, 2, 3)]
mst, cost = kruskal_mst(4, edges)
print(mst)   # [(0,2,2), (1,2,1), (1,3,5)] or similar
print(cost)  # 8

Exercises

Exercise 1: Number of Islands

Count distinct islands in a 2D grid (DFS/BFS).

Solution:

def num_islands(grid):
    if not grid:
        return 0
    rows, cols = len(grid), len(grid[0])
    count = 0

    def dfs(r, c):
        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':
                dfs(r, c)
                count += 1
    return count

grid = [
    ["1","1","0","0","0"],
    ["1","1","0","0","0"],
    ["0","0","1","0","0"],
    ["0","0","0","1","1"]
]
print(num_islands(grid))  # 3

Exercise 2: Word Ladder

Find shortest transformation sequence from beginWord to endWord.

Solution:

from collections import deque

def word_ladder(begin, end, word_list):
    word_set = set(word_list)
    if end not in word_set:
        return 0
    queue = deque([(begin, 1)])
    visited = {begin}
    while queue:
        word, length = queue.popleft()
        for i in range(len(word)):
            for c in 'abcdefghijklmnopqrstuvwxyz':
                new_word = word[:i] + c + word[i+1:]
                if new_word == end:
                    return length + 1
                if new_word in word_set and new_word not in visited:
                    visited.add(new_word)
                    queue.append((new_word, length + 1))
    return 0

print(word_ladder("hit", "cog", ["hot","dot","dog","lot","log","cog"]))
# 5 (hit โ†’ hot โ†’ dot โ†’ dog โ†’ cog)