Nodes connected by edges. The most general-purpose data structure there is β social networks, maps, dependencies, the internet itself. If things have relationships, you've got a graph.
IntermediateA graph is a set of vertices (also called nodes β individual points or entities in the structure) connected by edges (the links or connections between those nodes). That's the entire formal definition. Unlike trees, there's no root, no parent-child hierarchy, and cycles are perfectly fine.
The concept goes back to 1736, when the mathematician Leonhard Euler tackled the famous KΓΆnigsberg Bridge Problem: could you walk through the city of KΓΆnigsberg crossing each of its seven bridges exactly once? Euler proved it was impossible β and in doing so, invented graph theory. He modeled the landmasses as vertices and the bridges as edges. That's the first graph ever drawn, and it changed mathematics forever.
Here's an analogy that clicks for most people. Think of a city's road map. Intersections are vertices. Roads connecting them are edges. One-way streets are directed edges (they only go one way). Two-way streets are undirected edges. The distance between intersections is the edge weight. GPS navigation is basically graph traversal β finding the shortest weighted path from point A to point B.
make or npm install, a DAG was involved.Graphs are everywhere once you start looking. Seriously β any time you model relationships between things, you're building a graph:
npm, pip, apt all model package dependencies as DAGs. Circular dependencies are the bugs that keep maintainers up at night.Two main approaches, each with different trade-offs:
Adjacency List: For each vertex, keep a list of its neighbors. If the graph has V vertices and E edges, this uses O(V + E) space. Best for sparse graphs (few edges relative to vertices) β which is most real-world graphs. This is what you'll use 90% of the time in interviews and production code. In Python, it's usually a defaultdict(list). In JavaScript, a Map of arrays.
Adjacency Matrix: A VΓV grid where matrix[i][j] = 1 (or the weight) if there's an edge from vertex i to vertex j. Uses O(VΒ²) space regardless of how many edges exist. The big advantage: checking "is there an edge between A and B?" is O(1) β just index into the matrix. The big disadvantage: wastes tons of space when the graph is sparse. A social network with 1 billion users but an average of 300 friends would need a billion Γ billion matrix. That's... not going to fit in memory.
Edge List: A third option you'll occasionally see β just store a list of all edges as (u, v) pairs. Uses O(E) space. Simple, but checking "does edge (u,v) exist?" requires scanning the whole list. It's mainly used as input format or when you need to process edges in order (like Kruskal's algorithm).
Graph traversal means visiting every reachable vertex starting from some source vertex. There are two fundamental traversal strategies, and they're behind nearly every graph algorithm you'll ever write.
Start at a source node. Visit all its immediate neighbors first. Then visit all of their unvisited neighbors. Then all of those neighbors' unvisited neighbors. You expand outward in layers, like ripples spreading from a stone dropped in a pond.
How it works step-by-step:
BFS discovers vertices in order of their distance from the source. All vertices at distance 1 are processed before any vertex at distance 2, and so on. This is why BFS finds shortest paths in unweighted graphs β it's guaranteed to reach a vertex via the fewest possible edges first.
Time: O(V + E). You visit every vertex once and examine every edge once.
Space: O(V) for the visited set and the queue (worst case, all vertices end up in the queue).
Start at a source node. Pick one neighbor and go as deep as possible down that path before backtracking. It's like exploring a maze by always turning left until you hit a dead end, then backing up and trying the next option.
How it works step-by-step:
DFS plunges deep into the graph before exploring breadth. This makes it ideal for problems where you need to explore entire paths β cycle detection, topological sorting, finding strongly connected components, and maze generation.
Time: O(V + E). Same as BFS β each vertex and edge examined once.
Space: O(V) worst case for the call stack (imagine a graph that's just a long chain β DFS goes all the way to the end before backtracking).
Click the canvas to add nodes. Click two nodes in sequence to create an edge between them. Then run BFS or DFS to watch the traversal animate through the graph.
| Operation | Adjacency List | Adjacency Matrix |
|---|---|---|
| Add Vertex | O(1) | O(VΒ²) β resize matrix |
| Add Edge | O(1) | O(1) |
| Remove Vertex | O(V + E) | O(VΒ²) |
| Remove Edge | O(E) worst | O(1) |
| Check Edge Exists | O(degree) | O(1) |
| Get All Neighbors | O(degree) | O(V) |
| BFS / DFS | O(V + E) | O(VΒ²) |
| Space | O(V + E) | O(VΒ²) |
For sparse graphs (E βͺ VΒ²), adjacency lists win on space and traversal time. For dense graphs (E β VΒ²), the matrix's O(1) edge lookup can be worth the space cost. The degree of a vertex is the number of edges connected to it β in an adjacency list, checking if an edge exists requires scanning that vertex's neighbor list, which takes O(degree) time.
from collections import defaultdict, deque
class Graph:
def __init__(self, directed=False):
# defaultdict(list) creates an empty list for any new key automatically
# This avoids KeyError when accessing a vertex with no edges yet
self.adj = defaultdict(list)
self.directed = directed
def add_edge(self, u, v, weight=1):
"""Add an edge from vertex u to vertex v with optional weight."""
self.adj[u].append((v, weight))
if not self.directed:
# Undirected: add the reverse edge too
self.adj[v].append((u, weight))
def add_vertex(self, v):
"""Ensure vertex v exists even if it has no edges."""
if v not in self.adj:
self.adj[v] = []
def bfs(self, start):
"""Traverse the graph in BFS order from start.
Returns list of vertices in the order they were discovered.
BFS visits vertices layer by layer β all vertices at distance d
before any vertex at distance d+1."""
visited = {start} # Set of vertices we've already seen
queue = deque([start]) # deque is O(1) for both append and popleft
order = [] # The final traversal order
while queue:
node = queue.popleft() # Take the OLDEST vertex (FIFO)
order.append(node)
for neighbor, _ in self.adj[node]:
if neighbor not in visited:
visited.add(neighbor) # Mark BEFORE enqueuing
queue.append(neighbor) # to prevent duplicates in queue
return order
def dfs(self, start):
"""Traverse the graph in DFS order from start (iterative).
Returns list of vertices in the order they were discovered.
DFS plunges deep before exploring breadth."""
visited = set()
stack = [start]
order = []
while stack:
node = stack.pop() # Take the NEWEST vertex (LIFO)
if node in visited:
continue # Skip if already processed
visited.add(node)
order.append(node)
# Push neighbors in reverse to visit in natural order
# (stack reverses the order, so we reverse first)
for neighbor, _ in reversed(self.adj[node]):
if neighbor not in visited:
stack.append(neighbor)
return order
def has_cycle_directed(self):
"""Detect cycles in a DIRECTED graph using DFS 3-color method.
WHITE (0) = unvisited, GRAY (1) = in current DFS path,
BLACK (2) = fully processed. A back edge (hitting GRAY) means cycle."""
WHITE, GRAY, BLACK = 0, 1, 2
color = defaultdict(int) # default WHITE
def _dfs(node):
color[node] = GRAY # Entering this node's DFS subtree
for neighbor, _ in self.adj[node]:
if color[neighbor] == GRAY:
return True # Back edge! Cycle found.
if color[neighbor] == WHITE and _dfs(neighbor):
return True
color[node] = BLACK # Done with this node entirely
return False
# Must check all vertices (graph may be disconnected)
return any(
_dfs(v) for v in self.adj if color[v] == WHITE
)
def has_cycle_undirected(self):
"""Detect cycles in an UNDIRECTED graph using DFS with parent tracking.
If we reach an already-visited neighbor that isn't our parent,
that's a cycle."""
visited = set()
def _dfs(node, parent):
visited.add(node)
for neighbor, _ in self.adj[node]:
if neighbor not in visited:
if _dfs(neighbor, node):
return True
elif neighbor != parent:
return True # Visited neighbor that isn't parent = cycle
return False
for v in self.adj:
if v not in visited:
if _dfs(v, -1):
return True
return False
def shortest_path_bfs(self, start, end):
"""Find shortest path in an UNWEIGHTED graph using BFS.
Returns the path as a list of vertices, or empty list if no path."""
if start == end:
return [start]
visited = {start}
queue = deque([(start, [start])]) # (vertex, path_so_far)
while queue:
node, path = queue.popleft()
for neighbor, _ in self.adj[node]:
if neighbor not in visited:
new_path = path + [neighbor]
if neighbor == end:
return new_path
visited.add(neighbor)
queue.append((neighbor, new_path))
return [] # No path exists
# Usage examples
g = Graph(directed=False)
g.add_edge(0, 1)
g.add_edge(0, 2)
g.add_edge(1, 3)
g.add_edge(2, 3)
g.add_edge(3, 4)
print("BFS from 0:", g.bfs(0)) # [0, 1, 2, 3, 4]
print("DFS from 0:", g.dfs(0)) # [0, 1, 3, 2, 4] or similar
print("Shortest 0β4:", g.shortest_path_bfs(0, 4)) # [0, 1, 3, 4]
print("Has cycle:", g.has_cycle_undirected()) # True (0-1-3-2-0)
from collections import deque, defaultdict
def topological_sort(num_vertices, edges):
"""Kahn's algorithm for topological sorting.
Topological sort: order vertices so that for every directed edge uβv,
u comes before v. Only possible on DAGs (no cycles).
Args:
num_vertices: number of vertices (labeled 0 to n-1)
edges: list of (u, v) pairs meaning u must come before v
Returns:
List of vertices in topological order, or empty list if cycle exists.
"""
# Build adjacency list and count incoming edges for each vertex
adj = defaultdict(list)
in_degree = [0] * num_vertices # How many edges point INTO each vertex
for u, v in edges:
adj[u].append(v)
in_degree[v] += 1
# Start with vertices that have no prerequisites (in-degree 0)
queue = deque()
for v in range(num_vertices):
if in_degree[v] == 0:
queue.append(v)
order = []
while queue:
node = queue.popleft()
order.append(node)
# "Remove" this node by decrementing in-degree of its neighbors
for neighbor in adj[node]:
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
queue.append(neighbor) # All prerequisites satisfied
# If we processed all vertices, the sort succeeded
# If not, a cycle prevented some vertices from reaching in-degree 0
if len(order) != num_vertices:
return [] # Cycle detected!
return order
# Example: course prerequisites
# Course 1 requires Course 0, Course 2 requires Course 1, etc.
edges = [(0, 1), (1, 2), (0, 3), (3, 2)]
print(topological_sort(4, edges)) # [0, 1, 3, 2] or [0, 3, 1, 2]
class Graph {
constructor(directed = false) {
this.adj = new Map(); // vertex β array of {node, weight}
this.directed = directed;
}
addVertex(v) {
if (!this.adj.has(v)) this.adj.set(v, []);
}
addEdge(u, v, weight = 1) {
this.addVertex(u);
this.addVertex(v);
this.adj.get(u).push({ node: v, weight });
if (!this.directed) {
this.adj.get(v).push({ node: u, weight });
}
}
bfs(start) {
const visited = new Set([start]);
const queue = [start]; // Use array as queue (shift is O(n) but fine for interviews)
const order = [];
let front = 0; // Pointer optimization: avoid shift()
while (front < queue.length) {
const node = queue[front++]; // Dequeue from front
order.push(node);
for (const { node: neighbor } of this.adj.get(node) || []) {
if (!visited.has(neighbor)) {
visited.add(neighbor);
queue.push(neighbor);
}
}
}
return order;
}
dfs(start) {
const visited = new Set();
const stack = [start];
const order = [];
while (stack.length) {
const node = stack.pop();
if (visited.has(node)) continue;
visited.add(node);
order.push(node);
// Push in reverse for natural ordering
const neighbors = this.adj.get(node) || [];
for (let i = neighbors.length - 1; i >= 0; i--) {
if (!visited.has(neighbors[i].node)) {
stack.push(neighbors[i].node);
}
}
}
return order;
}
// Count connected components in an undirected graph
connectedComponents() {
const visited = new Set();
let count = 0;
for (const vertex of this.adj.keys()) {
if (!visited.has(vertex)) {
count++;
// BFS/DFS to mark all vertices in this component
const stack = [vertex];
while (stack.length) {
const node = stack.pop();
if (visited.has(node)) continue;
visited.add(node);
for (const { node: neighbor } of this.adj.get(node) || []) {
if (!visited.has(neighbor)) stack.push(neighbor);
}
}
}
}
return count;
}
}
Facebook's "People You May Know" feature is graph traversal in action. The idea: suggest friends-of-friends, ranked by how many mutual friends you share. Here's a simplified version:
from collections import defaultdict, Counter
class SocialNetwork:
"""Simplified friend suggestion engine using graph traversal."""
def __init__(self):
self.friends = defaultdict(set) # user β set of friends
def add_friendship(self, user_a, user_b):
"""Friendships are bidirectional (undirected edge)."""
self.friends[user_a].add(user_b)
self.friends[user_b].add(user_a)
def suggest_friends(self, user, max_suggestions=5):
"""Suggest people the user might know, ranked by mutual friends.
Algorithm:
1. Get all friends-of-friends (distance 2 in the graph)
2. Exclude people who are already friends
3. Rank by number of mutual connections (higher = stronger signal)
"""
direct_friends = self.friends[user]
mutual_count = Counter() # candidate β how many mutual friends
for friend in direct_friends:
for fof in self.friends[friend]: # friends-of-friends
if fof != user and fof not in direct_friends:
mutual_count[fof] += 1
# Sort by mutual friend count (descending), then by name for ties
ranked = sorted(mutual_count.items(),
key=lambda x: (-x[1], x[0]))
return [(person, count) for person, count in ranked[:max_suggestions]]
def degrees_of_separation(self, user_a, user_b):
"""BFS to find shortest path length between two users.
Returns -1 if they're not connected at all."""
if user_a == user_b:
return 0
visited = {user_a}
queue = [(user_a, 0)]
front = 0
while front < len(queue):
node, dist = queue[front]
front += 1
for neighbor in self.friends[node]:
if neighbor == user_b:
return dist + 1
if neighbor not in visited:
visited.add(neighbor)
queue.append((neighbor, dist + 1))
return -1 # Not connected
# Demo
network = SocialNetwork()
network.add_friendship("Alice", "Bob")
network.add_friendship("Alice", "Charlie")
network.add_friendship("Bob", "Charlie")
network.add_friendship("Bob", "David")
network.add_friendship("Charlie", "David")
network.add_friendship("Charlie", "Eve")
network.add_friendship("David", "Frank")
suggestions = network.suggest_friends("Alice")
print("Friend suggestions for Alice:")
for person, mutual in suggestions:
print(f" {person} ({mutual} mutual friends)")
# Output:
# David (2 mutual friends) β connected through Bob AND Charlie
# Eve (1 mutual friends) β connected through Charlie
# Frank (0 mutual friends) β wait, actually 0? No, through David...
print(f"\nDegrees of separation (AliceβFrank): "
f"{network.degrees_of_separation('Alice', 'Frank')}") # 3
The real Facebook system is vastly more complex β it considers profile similarity, location, workplace, events attended, and dozens of other signals. But at its core, it's a graph traversal ranked by edge density.
dirs = [(0,1),(0,-1),(1,0),(-1,0)].
Graph problems show up everywhere. About 25% of LeetCode mediums and hards involve graph traversal in some form. Start with BFS/DFS on grids, then move to explicit graphs and shortest paths.
| Problem | Difficulty | Key Technique |
|---|---|---|
| LC 733 β Flood Fill | Easy | BFS/DFS on grid β your first graph problem |
| LC 200 β Number of Islands | Medium | DFS/BFS connected components on a grid |
| LC 133 β Clone Graph | Medium | BFS/DFS + hashmap for deep copying |
| LC 207 β Course Schedule | Medium | Cycle detection / topological sort |
| LC 417 β Pacific Atlantic Water Flow | Medium | Multi-source BFS from ocean edges inward |
| LC 261 β Graph Valid Tree | Medium | Union-Find or DFS cycle check (V-1 edges, connected, no cycles) |
| LC 127 β Word Ladder | Hard | BFS on implicit word graph β build-the-graph pattern |
| LC 269 β Alien Dictionary | Hard | Build graph from comparison pairs + topological sort |