Skip to content

Graphs

A graph is a non-linear data structure consisting of vertices (nodes) connected by edges. Graphs model relationships and are fundamental to many real-world problems.

Interactive Pathfinding Visualizer

Draw walls on the grid, place start and end points, then run BFS, DFS, Dijkstra’s, or A* to visualize how each algorithm explores the search space and finds the shortest path.

Pathfinding Visualizer

Shortest path (unweighted)

Draw Tool
Actions
Statistics
Cells Visited: 0Path Length:
EmptyWallStartEndVisitedPath

Graph Terminology

A ───── B
/│\ │
/ │ \ │
C │ D ── E
│ /
F ────/
Terminology:
- Vertices (nodes): A, B, C, D, E, F
- Edges: Connections between vertices
- Degree of A: 4 (number of edges)
- Path A→E: A → B → E or A → D → E
- Cycle: A → B → E → D → A

Graph Types

TypeDescriptionExample
DirectedEdges have directionTwitter follows
UndirectedEdges are bidirectionalFacebook friends
WeightedEdges have costsRoad distances
UnweightedAll edges equalSocial connections
CyclicContains cyclesRoad networks
AcyclicNo cycles (DAG)Task dependencies

Graph Representations

Adjacency List (Preferred)

Space efficient for sparse graphs.

from collections import defaultdict
# Using dictionary
graph = {
'A': ['B', 'C', 'D'],
'B': ['A', 'E'],
'C': ['A'],
'D': ['A', 'E'],
'E': ['B', 'D']
}
# Using defaultdict
graph = defaultdict(list)
edges = [('A', 'B'), ('A', 'C'), ('B', 'D')]
for u, v in edges:
graph[u].append(v)
graph[v].append(u) # For undirected
# Weighted graph
weighted_graph = {
'A': [('B', 5), ('C', 3)],
'B': [('A', 5), ('D', 2)],
'C': [('A', 3)],
'D': [('B', 2)]
}

Adjacency Matrix

Good for dense graphs or when edge lookup is frequent.

# n x n matrix where matrix[i][j] = 1 if edge exists
# A B C D
# A [[0, 1, 1, 0],
# B [1, 0, 0, 1],
# C [1, 0, 0, 0],
# D [0, 1, 0, 0]]
n = 4
matrix = [[0] * n for _ in range(n)]
# Add edge (undirected)
def add_edge(u, v):
matrix[u][v] = 1
matrix[v][u] = 1
# Check if edge exists
def has_edge(u, v):
return matrix[u][v] == 1

Graph Traversals

Breadth-First Search (BFS)

Explores level by level. Finds shortest path in unweighted graphs.

from collections import deque
def bfs(graph, start):
"""
Traverse graph in breadth-first order.
Time: O(V + E), Space: O(V)
"""
visited = set([start])
queue = deque([start])
result = []
while queue:
node = queue.popleft()
result.append(node)
for neighbor in graph[node]:
if neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)
return result
def shortest_path_bfs(graph, start, end):
"""Find shortest path in unweighted graph"""
if start == end:
return [start]
visited = set([start])
queue = deque([(start, [start])])
while queue:
node, path = queue.popleft()
for neighbor in graph[node]:
if neighbor == end:
return path + [neighbor]
if neighbor not in visited:
visited.add(neighbor)
queue.append((neighbor, path + [neighbor]))
return [] # No path found

Depth-First Search (DFS)

Explores as deep as possible before backtracking.

def dfs_recursive(graph, node, visited=None):
"""
DFS using recursion.
Time: O(V + E), Space: O(V)
"""
if visited is None:
visited = set()
visited.add(node)
result = [node]
for neighbor in graph[node]:
if neighbor not in visited:
result.extend(dfs_recursive(graph, neighbor, visited))
return result
def dfs_iterative(graph, start):
"""DFS using stack"""
visited = set()
stack = [start]
result = []
while stack:
node = stack.pop()
if node not in visited:
visited.add(node)
result.append(node)
# Add neighbors in reverse for correct order
for neighbor in reversed(graph[node]):
if neighbor not in visited:
stack.append(neighbor)
return result

Common Patterns

1. Cycle Detection

def has_cycle_undirected(graph, n):
"""
Detect cycle in undirected graph using DFS.
Time: O(V + E), Space: O(V)
A cycle exists if we visit a node that's already
visited and is not the parent of current node.
"""
visited = set()
def dfs(node, parent):
visited.add(node)
for neighbor in graph[node]:
if neighbor not in visited:
if dfs(neighbor, node):
return True
elif neighbor != parent: # Found cycle
return True
return False
for node in range(n):
if node not in visited:
if dfs(node, -1):
return True
return False
def has_cycle_directed(graph, n):
"""
Detect cycle in directed graph using colors.
WHITE=unvisited, GRAY=in progress, BLACK=finished
"""
WHITE, GRAY, BLACK = 0, 1, 2
color = [WHITE] * n
def dfs(node):
color[node] = GRAY
for neighbor in graph[node]:
if color[neighbor] == GRAY: # Back edge = cycle
return True
if color[neighbor] == WHITE and dfs(neighbor):
return True
color[node] = BLACK
return False
for node in range(n):
if color[node] == WHITE:
if dfs(node):
return True
return False

2. Topological Sort

Topological Sort: Order nodes so all edges point forward
5 → 0 ← 4
↓ ↓
2 → 3 → 1
In-degrees: 0:2, 1:2, 2:1, 3:1, 4:0, 5:0
Start with: 4, 5 (in-degree 0)
Result: [4, 5, 0, 2, 3, 1] or [5, 4, 2, 0, 3, 1]
from collections import deque
def topological_sort_kahn(graph, n):
"""
Topological sort using Kahn's algorithm (BFS).
Time: O(V + E), Space: O(V)
Algorithm:
1. Calculate in-degree for each node
2. Start with nodes having in-degree 0
3. Remove node, decrement neighbors' in-degree
4. Add nodes that reach in-degree 0
"""
in_degree = [0] * n
for node in range(n):
for neighbor in graph[node]:
in_degree[neighbor] += 1
queue = deque([i for i in range(n) if in_degree[i] == 0])
result = []
while queue:
node = queue.popleft()
result.append(node)
for neighbor in graph[node]:
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
queue.append(neighbor)
return result if len(result) == n else [] # Empty if cycle
def topological_sort_dfs(graph, n):
"""Topological sort using DFS (post-order)"""
visited = set()
stack = []
def dfs(node):
visited.add(node)
for neighbor in graph[node]:
if neighbor not in visited:
dfs(neighbor)
stack.append(node) # Add after all descendants
for node in range(n):
if node not in visited:
dfs(node)
return stack[::-1] # Reverse post-order

3. Connected Components

from collections import defaultdict
def count_components(n, edges):
"""
Count connected components in undirected graph.
Time: O(V + E), Space: O(V + E)
Algorithm:
1. Build adjacency list
2. DFS/BFS from each unvisited node
3. Each DFS explores one component
"""
graph = defaultdict(list)
for u, v in edges:
graph[u].append(v)
graph[v].append(u)
visited = set()
count = 0
def dfs(node):
visited.add(node)
for neighbor in graph[node]:
if neighbor not in visited:
dfs(neighbor)
for node in range(n):
if node not in visited:
dfs(node)
count += 1
return count
# Using Union-Find (more efficient for large graphs)
def count_components_uf(n, edges):
parent = list(range(n))
def find(x):
if parent[x] != x:
parent[x] = find(parent[x])
return parent[x]
def union(x, y):
px, py = find(x), find(y)
if px != py:
parent[px] = py
return True
return False
components = n
for u, v in edges:
if union(u, v):
components -= 1
return components

4. Dijkstra’s Shortest Path

Dijkstra's Algorithm: Shortest path in weighted graph (non-negative)
Graph:
A --1-- B
| |
4 2
| |
C --3-- D
Start: A
distances: {A:0, B:∞, C:∞, D:∞}
Process A(0): update B=1, C=4
Process B(1): update D=1+2=3
Process D(3): no improvement
Process C(4): no improvement
Final: {A:0, B:1, C:4, D:3}
import heapq
def dijkstra(graph, start):
"""
Find shortest paths from start to all vertices.
Time: O((V + E) log V), Space: O(V)
Algorithm:
1. Initialize distances to infinity, start to 0
2. Use min-heap priority queue
3. Process node with smallest distance
4. Relax edges to neighbors
"""
distances = {node: float('inf') for node in graph}
distances[start] = 0
pq = [(0, start)] # (distance, node)
while pq:
dist, node = heapq.heappop(pq)
# Skip if we've found a better path
if dist > distances[node]:
continue
for neighbor, weight in graph[node]:
new_dist = dist + weight
if new_dist < distances[neighbor]:
distances[neighbor] = new_dist
heapq.heappush(pq, (new_dist, neighbor))
return distances
# With path reconstruction
def dijkstra_with_path(graph, start, end):
distances = {node: float('inf') for node in graph}
distances[start] = 0
parent = {start: None}
pq = [(0, start)]
while pq:
dist, node = heapq.heappop(pq)
if node == end:
break
if dist > distances[node]:
continue
for neighbor, weight in graph[node]:
new_dist = dist + weight
if new_dist < distances[neighbor]:
distances[neighbor] = new_dist
parent[neighbor] = node
heapq.heappush(pq, (new_dist, neighbor))
# Reconstruct path
path = []
node = end
while node is not None:
path.append(node)
node = parent.get(node)
return path[::-1], distances[end]

5. Union-Find (Disjoint Set)

class UnionFind:
"""
Efficient for dynamic connectivity queries.
Time: O(α(n)) ≈ O(1) amortized per operation
Optimizations:
- Path compression: flatten tree during find
- Union by rank: attach smaller tree under larger
"""
def __init__(self, n):
self.parent = list(range(n))
self.rank = [0] * n
def find(self, x):
"""Find root with path compression"""
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, x, y):
"""Unite sets containing x and y"""
px, py = self.find(x), self.find(y)
if px == py:
return False # Already connected
# 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
return True
def connected(self, x, y):
"""Check if x and y are in same set"""
return self.find(x) == self.find(y)
# Example usage
uf = UnionFind(5)
uf.union(0, 1)
uf.union(2, 3)
uf.union(1, 2)
print(uf.connected(0, 3)) # True
print(uf.connected(0, 4)) # False

6. Bipartite Check

Bipartite Graph: Can be 2-colored with no adjacent same colors
Bipartite: Not Bipartite:
A(0) ─── B(1) A(0) ─── B(1)
│ │ \ │
C(1) ─── D(0) C(1)─/
0 and 1 represent two colors.
Not bipartite if odd cycle exists.
from collections import deque
def is_bipartite(graph, n):
"""
Check if graph can be 2-colored.
Time: O(V + E), Space: O(V)
Algorithm:
1. BFS/DFS coloring: alternate colors
2. If neighbor has same color → not bipartite
3. Graph is bipartite iff no odd cycles
"""
color = [-1] * n
def bfs(start):
queue = deque([start])
color[start] = 0
while queue:
node = queue.popleft()
for neighbor in graph[node]:
if color[neighbor] == -1:
color[neighbor] = 1 - color[node]
queue.append(neighbor)
elif color[neighbor] == color[node]:
return False # Same color = not bipartite
return True
# Check all components
for node in range(n):
if color[node] == -1:
if not bfs(node):
return False
return True
# DFS version
def is_bipartite_dfs(graph, n):
color = [-1] * n
def dfs(node, c):
color[node] = c
for neighbor in graph[node]:
if color[neighbor] == -1:
if not dfs(neighbor, 1 - c):
return False
elif color[neighbor] == c:
return False
return True
for node in range(n):
if color[node] == -1:
if not dfs(node, 0):
return False
return True

Time Complexity

AlgorithmTimeSpace
BFS/DFSO(V + E)O(V)
DijkstraO((V + E) log V)O(V)
Bellman-FordO(V × E)O(V)
Floyd-WarshallO(V³)O(V²)
Topological SortO(V + E)O(V)
Union-FindO(α(n))O(V)

Practice Problems

Easy

ProblemPatternCompanies
Number of IslandsDFS/BFS GridAmazon, Google, Meta
Flood FillDFS/BFSAmazon, Google
Find Center of Star GraphDegree CountAmazon

Medium

ProblemPatternCompanies
Clone GraphDFS + Hash MapAmazon, Meta
Course ScheduleTopological SortAmazon, Google, Meta
Number of ProvincesUnion-Find/DFSAmazon, Google
Pacific Atlantic Water FlowMulti-source BFSAmazon, Google
Rotting OrangesMulti-source BFSAmazon, Microsoft

Hard

ProblemPatternCompanies
Word LadderBFSAmazon, Meta
Alien DictionaryTopological SortMeta, Google
Shortest Path with ObstaclesBFS + StateGoogle
Network Delay TimeDijkstraAmazon, Google

Key Takeaways

  1. BFS for shortest path - Unweighted graphs, level-order
  2. DFS for exploration - Paths, cycles, connected components
  3. Topological sort for DAGs - Dependencies, ordering
  4. Union-Find for connectivity - Dynamic graph queries
  5. Dijkstra for weighted graphs - Single-source shortest path

Next Steps