Graphs
A graph is a data structure consisting of vertices (nodes) and edges (connections between vertices). Graphs model relationships: networks, maps, dependencies, social connections, state machines.
Definitions
Directed graph (digraph): edges have a direction. Edge $(u, v)$ goes from $u$ to $v$ but not necessarily from $v$ to $u$.
Undirected graph: edges are bidirectional. Edge ${u, v}$ connects $u$ and $v$.
Weighted graph: edges have associated numeric weights (costs, distances).
Path: a sequence of vertices connected by edges.
Cycle: a path that starts and ends at the same vertex.
DAG (Directed Acyclic Graph): a directed graph with no cycles.
Connected graph: every pair of vertices is connected by a path (undirected). Strongly connected: every vertex is reachable from every other vertex (directed).
Degree: number of edges incident to a vertex. In-degree / out-degree for directed graphs.
Tree: a connected, acyclic undirected graph. $n$ vertices, $n-1$ edges.
Graph Representations
Adjacency List
For each vertex, store a list of its neighbors. Space: $O(V + E)$.
graph = {
0: [1, 2],
1: [0, 3],
2: [0, 3],
3: [1, 2]
}
Best for: sparse graphs (most real-world graphs). Efficient edge iteration.
Adjacency Matrix
$n \times n$ boolean (or weight) matrix where matrix[u][v] = True if edge $(u, v)$ exists. Space: $O(V^2)$.
Best for: dense graphs; $O(1)$ edge existence query.
Edge List
A list of (u, v) or (u, v, weight) tuples. Space: $O(E)$.
Best for: Kruskal’s MST algorithm.
Graph Traversal
Breadth-First Search (BFS)
Explores layer by layer. Finds shortest paths (unweighted).
from collections import deque
def bfs(graph, start):
visited = {start}
q = deque([start])
while q:
node = q.popleft()
print(node)
for neighbor in graph[node]:
if neighbor not in visited:
visited.add(neighbor)
q.append(neighbor)
Time: $O(V + E)$. Space: $O(V)$ (queue).
Applications: shortest path (unweighted), level-order traversal, bipartite check, web crawling.
Depth-First Search (DFS)
Explores as deep as possible before backtracking.
def dfs(graph, node, visited=None):
if visited is None: visited = set()
visited.add(node)
print(node)
for neighbor in graph[node]:
if neighbor not in visited:
dfs(graph, neighbor, visited)
Time: $O(V + E)$. Space: $O(V)$ (recursion stack).
Applications: cycle detection, topological sort, connected components, SCC, maze solving.
Shortest Paths
Dijkstra’s Algorithm
Single-source shortest paths in a weighted graph with non-negative weights.
import heapq
def dijkstra(graph, src):
dist = {v: float('inf') for v in graph}
dist[src] = 0
heap = [(0, src)] # (distance, vertex)
while heap:
d, u = heapq.heappop(heap)
if d > dist[u]: continue
for v, w in graph[u]:
if dist[u] + w < dist[v]:
dist[v] = dist[u] + w
heapq.heappush(heap, (dist[v], v))
return dist
Time: $O((V + E) \log V)$ with a binary heap.
Bellman-Ford Algorithm
Handles negative-weight edges. Detects negative cycles.
Algorithm: relax all edges $V-1$ times. If a relaxation succeeds in the $V$-th iteration, there is a negative cycle.
Time: $O(VE)$.
Floyd-Warshall
All-pairs shortest paths. $O(V^3)$ time, $O(V^2)$ space.
\[d[i][j] = \min(d[i][j], d[i][k] + d[k][j])\]Minimum Spanning Tree (MST)
A spanning tree (connects all vertices) with minimum total edge weight.
Kruskal’s Algorithm
- Sort all edges by weight.
- Greedily add the lightest edge that does not create a cycle.
- Use Union-Find to detect cycles.
Time: $O(E \log E)$.
Prim’s Algorithm
- Start from any vertex.
- Greedily add the minimum-weight edge connecting the MST to a non-MST vertex.
- Use a priority queue.
Time: $O((V + E) \log V)$.
Topological Sort
A linear ordering of vertices in a DAG such that for every directed edge $(u, v)$, $u$ comes before $v$.
Kahn’s algorithm (BFS-based):
from collections import deque
def topo_sort(graph, n):
in_degree = [0] * n
for u in range(n):
for v in graph[u]:
in_degree[v] += 1
q = deque([u for u in range(n) if in_degree[u] == 0])
order = []
while q:
u = q.popleft()
order.append(u)
for v in graph[u]:
in_degree[v] -= 1
if in_degree[v] == 0:
q.append(v)
return order if len(order) == n else [] # empty if cycle
Applications: build systems, dependency resolution, course scheduling, compiler passes.
Union-Find (Disjoint Set Union)
Tracks connected components. Supports union(u, v) and find(u) (returns component representative).
Path compression + union by rank: amortized $O(\alpha(n)) \approx O(1)$.
class UnionFind:
def __init__(self, n):
self.parent = list(range(n))
self.rank = [0] * n
def find(self, x):
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, x, y):
px, py = self.find(x), self.find(y)
if px == py: return False
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
return True