Graph Fundamentals
Quick reference
| Adjacency list โ space | โ |
| Adjacency matrix โ space | โ |
| DFS / BFS traversal | O(V + E) |
| Connected components | O(V + E) |
| Cycle detection (directed or undirected) | O(V + E) |
Every tree so far has had two rules baked in: exactly one root with nothing pointing at it, and exactly one path between any two nodes. A graph is what's left when both rules get dropped. Nodes โ usually called vertices โ connect to each other via edges, but there's no single starting point everything hangs off of, a node can connect to many others, and two nodes can even point back and forth at each other. A tree is a graph; most graphs are not trees.
The everyday version of this shape is a friendship map, not a family tree. A family tree has one ancestor at the top and a strict direction โ parent to child, never child to parent. A friendship map has no top: Alice and Bob can be mutually connected, a whole cluster of people can know each other with no single person everyone traces back to, and someone can even belong to two separate friend groups that have no connection to each other at all.
That last part matters more than it sounds like it should. A tree is always one connected piece by definition โ every node is reachable from the root. A graph is under no such obligation. It might be one connected blob, or it might be several separate blobs sitting in the same data structure with no edges between them, and nothing about the graph itself will warn you which one you have.
Representing a graph
structure Graph (adjacency list):
adj: map from node -> list of that node's neighbors
structure Graph (adjacency matrix):
matrix: n x n grid
matrix[i][j] = 1 if an edge connects i and j, else 0DFS โ recursive, with a visited set
function dfs(node, adj, visited):
if node in visited: // already explored from here โ stop
return
visited.add(node)
process(node)
for neighbor in adj[node]:
dfs(neighbor, adj, visited)BFS โ queue-based, with a visited set
function bfs(start, adj):
visited = {start}
queue = Queue()
enqueue(queue, start)
while queue is not empty:
node = dequeue(queue)
process(node)
for neighbor in adj[node]:
if neighbor not in visited:
visited.add(neighbor) // mark visited at enqueue time
enqueue(queue, neighbor)Connected components โ restart DFS/BFS from every unvisited node
function countComponents(nodes, adj):
visited = {}
count = 0
for node in nodes:
if node not in visited:
count += 1
dfs(node, adj, visited) // or bfs โ either fully explores one component
return countCycle detection โ undirected: track the parent
function hasCycleUndirected(node, parent, adj, visited):
visited.add(node)
for neighbor in adj[node]:
if neighbor not in visited:
if hasCycleUndirected(neighbor, node, adj, visited):
return true
elif neighbor != parent: // visited, and NOT where we just came from
return true
return falseCycle detection โ directed: track the current recursion path
function hasCycleDirected(node, adj, visited, onPath):
visited.add(node)
onPath.add(node) // nodes on THIS call's path, right now
for neighbor in adj[node]:
if neighbor in onPath: // still inside the call for this node โ cycle
return true
if neighbor not in visited:
if hasCycleDirected(neighbor, adj, visited, onPath):
return true
onPath.remove(node) // backtracking out โ no longer on the path
return falseBoth representations store the same graph, just trading space for lookup speed differently: an adjacency list only stores edges that actually exist, an adjacency matrix reserves a cell for every possible pair whether an edge is there or not. Every traversal below โ DFS, BFS, components, both cycle checks โ visits each node once and each edge once (twice on an undirected graph, once per direction it's stored), so they all share the same O(V+E) time bound regardless of which one you're running.
| Operation | Time | Space | Why |
|---|---|---|---|
| Adjacency list โ space | โ | O(V + E) | One entry per node, plus one entry per edge in that node's neighbor list โ nothing stored for pairs that aren't connected. |
| Adjacency matrix โ space | โ | O(Vยฒ) | A cell for every possible pair of nodes, whether an edge exists there or not โ wasteful on a sparse graph, but O(1) to check if two specific nodes are connected. |
| DFS / BFS traversal | O(V + E) | O(V) | Every node is visited once, every edge is examined once from each endpoint that stores it. The visited set (and DFS's call stack, or BFS's queue) holds at most every node once. |
| Connected components | O(V + E) | O(V) | The outer loop over every node adds only O(V) โ the real traversal work is still one DFS/BFS pass total, split across however many components exist. |
| Cycle detection (directed or undirected) | O(V + E) | O(V) | Same traversal shape as plain DFS, with one extra set (parent tracking, or onPath) that never holds more than one entry per node. |
Forgetting the visited set entirely
Without a visited check, dfs() or bfs() will walk back onto a node it already processed, follow that node's edges again, and never run out of somewhere to go โ an infinite loop, not a slow answer. A tree traversal never needed this because a tree structurally cannot cycle back on itself. A graph can, and unlike the linked-list case, a cycle here isn't a bug to detect โ it's a normal input to survive.
Using the undirected cycle check on a directed graph, or vice versa
These are genuinely different algorithms, not one algorithm with a minor tweak. On a directed graph, checking 'visited and not the parent' gives false positives constantly โ two separate edges pointing into the same node from different, unrelated branches will trip it even with zero real cycle. On an undirected graph, onPath alone gives false positives too, because the edge back to the immediate parent is always technically 'on the path' and isn't a cycle. Match the check to the graph's edge direction, every time.
A disconnected graph โ one traversal from one start node
dfs/bfs from a single node explores exactly the component that node lives in and nothing else โ every node in a separate component is silently never visited, never counted, never processed. There's no error, no crash, just a wrong answer that looks complete because the traversal ran to the end without failing. The fix from Build It โ loop over every node and restart the traversal on anything still unvisited โ is not an optimization, it's the difference between a correct answer and a quietly wrong one.
Self-loops and multi-edges
Neither is invalid input for a graph, even though neither can happen in a tree. A self-loop (node connects to itself) means node shows up in its own neighbor list โ a naive undirected cycle check can misfire on it, since the neighbor-equals-parent exclusion doesn't apply to a self-edge. Multi-edges (the same pair connected twice) mean a node might appear more than once in another node's adjacency list; traversal still visits it only once thanks to the visited set, but a naive edge-count (for counting components or validating a tree, say) can overcount if it isn't deduplicating.
Sign in to mark problems done โ progress syncs across devices.