Graph: Collection of vertices (nodes) connected by edges.
Idea: For each vertex, store a list of its neighbors.
graph = {
'A': [('B', 5), ('C', 3)],
'B': [('D', 1)],
'C': [('D', 2), ('E', 4)],
'D': [('E', 2)],
'E': []
}
# For unweighted graphs:
graph = {
'A': ['B', 'C'],
'B': ['D'],
'C': ['D', 'E'],
'D': ['E'],
'E': []
}
# Space: O(V + E) where V = vertices, E = edges
# Check if edge exists: O(degree of vertex)
Pros: Space-efficient for sparse graphs, easy to iterate neighbors
Cons: Slow to check if specific edge exists
Idea: 2D array where matrix[i][j] = weight of edge from i to j (0 or ∞ if no edge)
import numpy as np
# Vertices: A=0, B=1, C=2, D=3, E=4
graph = np.array([
# A B C D E
[0, 5, 3, 0, 0], # A
[0, 0, 0, 1, 0], # B
[0, 0, 0, 2, 4], # C
[0, 0, 0, 0, 2], # D
[0, 0, 0, 0, 0] # E
])
# Space: O(V²)
# Check if edge exists: O(1)
Pros: Fast edge lookup O(1), simple to implement
Cons: Space-inefficient for sparse graphs O(V²)
| Scenario | Adjacency List | Adjacency Matrix |
|---|---|---|
| Sparse graph (few edges) | ✅ Better (O(V + E) space) | ❌ Wasteful (O(V²) space) |
| Dense graph (many edges) | ✅ Still good | ✅ Good (if E ≈ V²) |
| Iterate neighbors | ✅ Fast O(degree) | ❌ Slow O(V) |
| Check edge exists | ❌ O(degree) | ✅ O(1) |
| Most interview problems | ✅ Use this | Rare (Floyd-Warshall) |
Idea: Explore as far as possible along each branch before backtracking.
Analogy: Maze exploration - keep going straight until you hit a dead end, then backtrack.
Visit order: A → B → D → E → C → F (go deep first!)
def dfs_recursive(graph, start, visited=None):
"""
Depth-First Search using recursion
How it works:
1. Mark current node as visited
2. Process current node (print, add to result, etc.)
3. For each unvisited neighbor, recursively call DFS
4. Recursion call stack handles backtracking automatically
Time: O(V + E) - visit each vertex once, explore each edge once
Space: O(V) - visited set + recursion stack depth
"""
if visited is None:
visited = set()
visited.add(start)
print(start, end=' ') # Process node
for neighbor in graph.get(start, []):
if neighbor not in visited:
dfs_recursive(graph, neighbor, visited)
return visited
# Example usage
graph = {
'A': ['B', 'C'],
'B': ['D', 'E'],
'C': ['F'],
'D': [],
'E': [],
'F': []
}
dfs_recursive(graph, 'A') # Output: A B D E C F
def dfs_iterative(graph, start):
"""
Depth-First Search using explicit stack
How it works:
1. Push start node onto stack
2. While stack not empty:
- Pop node from stack
- If not visited, mark visited and process
- Push all unvisited neighbors onto stack
3. Stack (LIFO) ensures we go deep before wide
Time: O(V + E)
Space: O(V) - visited set + stack
"""
visited = set()
stack = [start]
while stack:
node = stack.pop() # LIFO - Last In First Out
if node not in visited:
visited.add(node)
print(node, end=' ') # Process node
# Add neighbors to stack (in reverse for same order as recursive)
for neighbor in reversed(graph.get(node, [])):
if neighbor not in visited:
stack.append(neighbor)
return visited
dfs_iterative(graph, 'A') # Output: A B D E C F
def has_cycle_directed(graph):
"""
Detect cycle in directed graph using DFS
How it works:
- White (0): Not visited yet
- Gray (1): Currently in recursion stack (visiting)
- Black (2): Completely processed
If we encounter a GRAY node, there's a cycle (back edge)
Example cycle: A → B → C → A
When visiting C, we see A is gray (still in stack) = cycle!
"""
WHITE, GRAY, BLACK = 0, 1, 2
color = {node: WHITE for node in graph}
def dfs(node):
if color[node] == GRAY: # Back edge = cycle
return True
if color[node] == BLACK: # Already processed
return False
color[node] = GRAY # Mark as visiting
for neighbor in graph.get(node, []):
if dfs(neighbor):
return True
color[node] = BLACK # Mark as processed
return False
for node in graph:
if color[node] == WHITE:
if dfs(node):
return True
return False
# Example: Graph with cycle
graph_with_cycle = {
'A': ['B'],
'B': ['C'],
'C': ['A'], # Back edge to A = cycle!
}
print(has_cycle_directed(graph_with_cycle)) # True
def topological_sort(graph):
"""
Topological Sort using DFS
Definition: Linear ordering of vertices such that for every
directed edge u→v, u comes before v in the ordering.
Use cases:
- Task scheduling with dependencies
- Course prerequisites (take A before B)
- Build systems (compile X before Y)
How it works:
1. Do DFS from each unvisited node
2. After visiting all descendants, add node to result
3. Reverse the result (post-order DFS reversed)
Note: Only works for DAGs (Directed Acyclic Graphs)
If cycle exists, topological sort is impossible!
"""
visited = set()
stack = [] # Store result in reverse post-order
def dfs(node):
visited.add(node)
for neighbor in graph.get(node, []):
if neighbor not in visited:
dfs(neighbor)
stack.append(node) # Add AFTER visiting all descendants
# Visit all nodes
for node in graph:
if node not in visited:
dfs(node)
return stack[::-1] # Reverse to get topological order
# Example: Course prerequisites
# Must take A before B, A before C, B before D, C before D
courses = {
'A': ['B', 'C'],
'B': ['D'],
'C': ['D'],
'D': []
}
print(topological_sort(courses)) # ['A', 'C', 'B', 'D'] or ['A', 'B', 'C', 'D']
# Both valid! A must be first, D must be last
def count_connected_components(graph):
"""
Count number of connected components using DFS
Connected component: Set of vertices where each vertex
is reachable from every other vertex in the set
How it works:
1. For each unvisited node, do DFS (finds one component)
2. Count how many times we start a new DFS
Example:
A—B—C D—E F → 3 components
"""
visited = set()
count = 0
def dfs(node):
visited.add(node)
for neighbor in graph.get(node, []):
if neighbor not in visited:
dfs(neighbor)
for node in graph:
if node not in visited:
dfs(node) # Explore one component
count += 1 # Found a new component
return count
# Example: 3 separate components
components_graph = {
'A': ['B'], 'B': ['A', 'C'], 'C': ['B'], # Component 1
'D': ['E'], 'E': ['D'], # Component 2
'F': [] # Component 3
}
print(count_connected_components(components_graph)) # 3
| Operation | Time Complexity | Space Complexity |
|---|---|---|
| DFS Traversal | O(V + E) | O(V) - visited + stack/recursion |
| Cycle Detection | O(V + E) | O(V) |
| Topological Sort | O(V + E) | O(V) |
| Connected Components | O(V + E) | O(V) |
Idea: Explore all neighbors at current depth before going deeper.
Analogy: Ripple in water - spread outward in concentric circles.
Visit order: A (level 0) → B, C (level 1) → D, E, F (level 2)
from collections import deque
def bfs(graph, start):
"""
Breadth-First Search using queue
How it works:
1. Add start node to queue
2. While queue not empty:
- Dequeue node (FIFO - First In First Out)
- If not visited, mark visited and process
- Enqueue all unvisited neighbors
3. Queue (FIFO) ensures we explore level by level
Key difference from DFS:
- DFS uses stack (LIFO) → go deep
- BFS uses queue (FIFO) → go wide
Time: O(V + E)
Space: O(V) - visited + queue
"""
visited = set()
queue = deque([start])
visited.add(start)
while queue:
node = queue.popleft() # FIFO - First In First Out
print(node, end=' ') # Process node
for neighbor in graph.get(node, []):
if neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)
return visited
graph = {
'A': ['B', 'C'],
'B': ['D', 'E'],
'C': ['F'],
'D': [],
'E': [],
'F': []
}
bfs(graph, 'A') # Output: A B C D E F
from collections import deque
def shortest_path_bfs(graph, start, end):
"""
Find shortest path in UNWEIGHTED graph using BFS
Why BFS works for shortest path:
- BFS explores nodes level by level
- First time we reach target = shortest path
- All paths at distance d are explored before distance d+1
Note: For WEIGHTED graphs, use Dijkstra's algorithm!
Returns: (distance, path)
"""
if start == end:
return (0, [start])
visited = set([start])
queue = deque([(start, [start])]) # (node, path_to_node)
while queue:
node, path = queue.popleft()
for neighbor in graph.get(node, []):
if neighbor not in visited:
new_path = path + [neighbor]
if neighbor == end:
return (len(new_path) - 1, new_path)
visited.add(neighbor)
queue.append((neighbor, new_path))
return (float('inf'), []) # No path exists
# Example: Social network (6 degrees of separation)
social_network = {
'Alice': ['Bob', 'Carol'],
'Bob': ['Alice', 'David'],
'Carol': ['Alice', 'Eve'],
'David': ['Bob', 'Frank'],
'Eve': ['Carol'],
'Frank': ['David']
}
distance, path = shortest_path_bfs(social_network, 'Alice', 'Frank')
print(f"Distance: {distance}") # 3
print(f"Path: {' → '.join(path)}") # Alice → Bob → David → Frank
from collections import deque
def level_order_traversal(graph, start):
"""
Return nodes grouped by level (distance from start)
Use case: Organizational hierarchy, family tree levels, etc.
How it works:
- Track level for each node
- Group nodes by level
Returns: [[level 0 nodes], [level 1 nodes], [level 2 nodes], ...]
"""
if start not in graph:
return []
visited = set([start])
queue = deque([(start, 0)]) # (node, level)
levels = [[]] # levels[i] = nodes at level i
while queue:
node, level = queue.popleft()
# Extend levels list if needed
if level >= len(levels):
levels.append([])
levels[level].append(node)
for neighbor in graph.get(node, []):
if neighbor not in visited:
visited.add(neighbor)
queue.append((neighbor, level + 1))
return levels
result = level_order_traversal(graph, 'A')
print(result) # [['A'], ['B', 'C'], ['D', 'E', 'F']]
from collections import deque
def bfs_01(graph, start, end):
"""
Shortest path for graph with edge weights 0 or 1
Optimization over Dijkstra for this special case:
- Use deque instead of priority queue
- Weight 0 edges: Add to front (addFirst)
- Weight 1 edges: Add to back (addLast)
Why it works: Maintains distance-sorted order in deque
Time: O(V + E) - faster than Dijkstra's O((V+E) log V)
"""
dist = {node: float('inf') for node in graph}
dist[start] = 0
dq = deque([start])
while dq:
node = dq.popleft()
if node == end:
return dist[end]
for neighbor, weight in graph.get(node, []):
new_dist = dist[node] + weight
if new_dist < dist[neighbor]:
dist[neighbor] = new_dist
if weight == 0:
dq.appendleft(neighbor) # Add to front
else: # weight == 1
dq.append(neighbor) # Add to back
return dist[end]
# Example: Maze with teleporters (0 cost) and normal moves (1 cost)
maze = {
'A': [('B', 1), ('C', 0)], # Teleporter to C (free)
'B': [('D', 1)],
'C': [('D', 1), ('E', 0)], # Teleporter to E
'D': [('F', 1)],
'E': [('F', 0)], # Teleporter to F
'F': []
}
print(bfs_01(maze, 'A', 'F')) # 1 (A→C→E→F via teleporters, then 1 normal step)
| Aspect | DFS (Stack/Recursion) | BFS (Queue) |
|---|---|---|
| Data Structure | Stack (LIFO) | Queue (FIFO) |
| Exploration | Deep first | Wide first (level by level) |
| Shortest Path | ❌ Not guaranteed | ✅ Yes (unweighted graphs) |
| Memory Usage | O(height) - better for wide graphs | O(width) - better for deep graphs |
| Cycle Detection | ✅ Efficient (3-color method) | ✅ Possible (less common) |
| Topological Sort | ✅ Natural fit | ✅ Kahn's algorithm |
| Path Finding | Any path | Shortest path (unweighted) |
| Implementation | Recursive or iterative | Iterative only (queue) |
Problem: Find shortest path from source to all vertices in weighted graph (non-negative weights).
Idea: Greedily select closest unvisited vertex, update distances to neighbors.
Shortest distances from A: A=0, C=2, B=3 (via C), D=9, E=11
import heapq
def dijkstra(graph, start):
"""
Dijkstra's Algorithm for shortest path
How it works:
1. Initialize distances: start=0, others=∞
2. Use min-heap (priority queue) to always process closest vertex
3. For each vertex:
- Pop closest unvisited vertex from heap
- For each neighbor, try to relax (update distance if shorter path found)
- If distance improved, add to heap
Why it works:
- Greedy: Always expand closest vertex (optimal choice)
- When we pop a vertex from heap, we've found shortest path to it
- Once vertex is visited, its distance is final (can't improve)
Limitation: ONLY works with NON-NEGATIVE edge weights
(Use Bellman-Ford for negative weights)
Time: O((V + E) log V) with min-heap
Space: O(V)
"""
# Initialize distances
dist = {node: float('inf') for node in graph}
dist[start] = 0
# Min-heap: (distance, node)
heap = [(0, start)]
visited = set()
while heap:
current_dist, node = heapq.heappop(heap)
# Skip if already visited (duplicate in heap)
if node in visited:
continue
visited.add(node)
# Relax edges (try to find shorter paths)
for neighbor, weight in graph.get(node, []):
new_dist = current_dist + weight
if new_dist < dist[neighbor]:
dist[neighbor] = new_dist
heapq.heappush(heap, (new_dist, neighbor))
return dist
# Example: Road network with distances
roads = {
'A': [('B', 4), ('C', 2)],
'B': [('D', 5)],
'C': [('B', 1), ('D', 8), ('E', 10)],
'D': [('E', 2)],
'E': []
}
distances = dijkstra(roads, 'A')
print(distances) # {'A': 0, 'C': 2, 'B': 3, 'D': 8, 'E': 10}
import heapq
def dijkstra_with_path(graph, start, end):
"""
Dijkstra with path reconstruction
Returns: (distance, path)
"""
dist = {node: float('inf') for node in graph}
dist[start] = 0
parent = {node: None for node in graph}
heap = [(0, start)]
visited = set()
while heap:
current_dist, node = heapq.heappop(heap)
if node in visited:
continue
visited.add(node)
# Early exit if we reached target
if node == end:
break
for neighbor, weight in graph.get(node, []):
new_dist = current_dist + weight
if new_dist < dist[neighbor]:
dist[neighbor] = new_dist
parent[neighbor] = node # Track path
heapq.heappush(heap, (new_dist, neighbor))
# Reconstruct path
path = []
current = end
while current is not None:
path.append(current)
current = parent[current]
path.reverse()
return (dist[end], path)
distance, path = dijkstra_with_path(roads, 'A', 'E')
print(f"Distance: {distance}") # 10
print(f"Path: {' → '.join(path)}") # A → C → D → E
Alternative to DFS-based topological sort. Better for detecting cycles.
from collections import deque, defaultdict
def topological_sort_kahns(graph):
"""
Kahn's Algorithm for Topological Sort using BFS
How it works:
1. Calculate in-degree (number of incoming edges) for each node
2. Add all nodes with in-degree 0 to queue (no prerequisites)
3. While queue not empty:
- Dequeue node, add to result
- Decrease in-degree of neighbors
- If neighbor's in-degree becomes 0, add to queue
4. If result has all nodes: success. Else: cycle exists!
Advantage over DFS: Easier to detect cycles
"""
# Calculate in-degrees
in_degree = defaultdict(int)
all_nodes = set(graph.keys())
for node in graph:
all_nodes.update(graph[node])
for node in all_nodes:
in_degree[node] = 0
for node in graph:
for neighbor in graph[node]:
in_degree[neighbor] += 1
# Add nodes with no prerequisites
queue = deque([node for node in all_nodes if in_degree[node] == 0])
result = []
while queue:
node = queue.popleft()
result.append(node)
# Remove this node's edges
for neighbor in graph.get(node, []):
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
queue.append(neighbor)
# Check for cycle
if len(result) != len(all_nodes):
raise ValueError("Graph has a cycle - topological sort impossible!")
return result
# Example: Build system dependencies
build_deps = {
'main.o': ['main.c'],
'utils.o': ['utils.c'],
'app': ['main.o', 'utils.o'],
'main.c': [],
'utils.c': []
}
print(topological_sort_kahns(build_deps))
# ['main.c', 'utils.c', 'main.o', 'utils.o', 'app']
Purpose: Efficiently track disjoint sets and merge them.
Operations: Find (which set?), Union (merge sets)
Use cases: Kruskal's MST, cycle detection, connected components
class UnionFind:
"""
Union-Find with path compression and union by rank
Time Complexity: O(α(n)) ≈ O(1) amortized
where α is inverse Ackermann function (practically constant)
"""
def __init__(self, n):
"""Initialize n disjoint sets"""
self.parent = list(range(n))
self.rank = [0] * n
def find(self, x):
"""
Find representative of set containing x
Path compression: Make all nodes point directly to root
"""
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x]) # Path compression
return self.parent[x]
def union(self, x, y):
"""
Merge sets containing x and y
Union by rank: Attach smaller tree under larger tree
Returns: True if sets were different (merge happened)
"""
root_x = self.find(x)
root_y = self.find(y)
if root_x == root_y:
return False # Already in same set
# Union by rank
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
def connected(self, x, y):
"""Check if x and y are in same set"""
return self.find(x) == self.find(y)
# Example: Detect cycle in undirected graph
def has_cycle_undirected(n, edges):
"""
Detect cycle in undirected graph using Union-Find
How it works:
- For each edge (u, v):
- If u and v already in same set → cycle!
- Else: union them
"""
uf = UnionFind(n)
for u, v in edges:
if uf.connected(u, v):
return True # Cycle detected
uf.union(u, v)
return False
edges = [(0, 1), (1, 2), (2, 0)] # Triangle = cycle
print(has_cycle_undirected(3, edges)) # True
| Problem Type | Algorithm | Example |
|---|---|---|
| Shortest path (unweighted) | BFS | 6 degrees of separation |
| Shortest path (weighted, non-negative) | Dijkstra | GPS navigation |
| Shortest path (negative weights) | Bellman-Ford | Currency arbitrage |
| All-pairs shortest path | Floyd-Warshall | Distance between all cities |
| Cycle detection (directed) | DFS (3-color) | Deadlock detection |
| Cycle detection (undirected) | Union-Find or DFS | Network loops |
| Topological sort | DFS or Kahn's | Task scheduling, build systems |
| Connected components | DFS or Union-Find | Social network clusters |
| Minimum spanning tree | Kruskal (Union-Find) or Prim | Network cable layout |
| Find any path | DFS | Maze solving |
| Level-by-level traversal | BFS | Org chart levels |