Graph Traversal Algorithms β How to Invent BFS, DFS & Dijkstra from Scratch
BFS, DFS, and Dijkstra follow from a visited record and a choice of where to take the next vertex from: a queue, a stack, or a priority queue. Starting without knowing the algorithms, this article builds all three from the problem.
If you start from the problem "visit every vertex of a graph" without knowing BFS, DFS, or Dijkstra, the three algorithms come together in this order. First, keep a record of visited vertices (visited) so that cycles don't make you loop forever. Next, process the vertices you find nearest-first with a queue and you get BFS; process them deepest-first with a stack and you get DFS. When edges have weights, replace the queue with a priority queue ordered by distance and you get Dijkstra's algorithm.
Once you see why each data structure is needed, you don't need to memorize the steps: the same reasoning rebuilds them.
Show transcript
BFS, DFS, and Dijkstra are not just procedures to memorize. We'll trace how you can reason your way to inventing them from the graph problem itself.
Start with an unfamiliar city: how do you visit every location without missing any? Treat intersections as vertices and roads as edges, and the city becomes a graph G, made of a vertex set V and an edge set E. Road networks, social networks, the web, and dependency graphs all fit this same shape.
The operation traversal needs most often is simple: list this vertex's neighbors. An adjacency list stores neighbors for each vertex, using O of V plus E space. An adjacency matrix checks whether an edge exists in O of one, but needs O of V squared space. Traversal repeatedly enumerates neighbors, so for most searches the adjacency list is the natural default.
The first naive idea is to walk to any neighbor you can reach. It immediately breaks. Graphs can have cycles, so you may loop A, B, C, A, revisiting the same places forever. The first insight is that we need a visited set, so discovered vertices are not revisited.
Now, when several unvisited neighbors are available, which one should come first? If you want the nearest place, you do not charge down one road; you expand like ripples. Whatever you discover earlier, you process earlier: a FIFO queue. That's BFS.
With a queue, every distance d vertex is processed before any distance d plus one vertex. So in an unweighted graph, the distance at first discovery is guaranteed to be shortest. Each vertex enters the queue once and each edge is checked once, so time is O of V plus E. In BFS, we mark a vertex when it is enqueued, not when it is removed from the queue. That prevents the same vertex from being enqueued twice via two different neighbors.
A different question suggests a different order: to exhaust a maze, follow one path to the end. At a dead end, return to the last fork. Returning from a recursive call gives you exactly that. Without recursion, you use a LIFO stack: process the most recent, deepest vertex first. That's DFS. DFS also gives discovery and finish times; on a DAG, reversing the finish order gives a topological sort.
BFS and DFS have the same complexity. What changes is only the order of processing. Use BFS when distance matters: unweighted shortest paths, nodes within k hops, and bipartite testing. Use DFS when structure matters: cycle detection, topological sort, and connected components.
What if each road has a different travel time? BFS only counts edges, not total weight. The idea is to sort the queue by distance: replace the FIFO queue with a priority queue. That is Dijkstra: extract the minimum-distance vertex, then relax distances to its neighbors. The dist table starts its estimates at zero for the source and infinity elsewhere, then lowers them as better routes appear.
Why is it safe to finalize the current minimum-distance vertex? All unprocessed vertices are at least that far away, and with non-negative edges, a detour cannot reduce distance. Negative edges break that reasoning. For them, use Bellman-Ford instead. With a binary heap, Dijkstra runs in O of V plus E, times log V.
Looking back, the key is to read the problem structure and supply the missing behavior with a data structure. Visited sets, queues, stacks, and priority queues naturally lead to the three algorithms. In the article, you can compare graph representations and step through BFS, DFS, and Dijkstra in interactive demos. Instead of memorizing, take away this path of reinvention.
Start With the Problem
Before thinking about algorithms, decide what you want to solve.
The Fundamental Question: "Visit Every Place"
Imagine you're dropped into an unfamiliar city. You have no map, but you can see which roads lead away from where you are. How should you walk to visit every location without missing any?
This is the graph traversal problem.
To abstract the city, call intersections vertices and roads edges. The collection of vertices and edges is a graph.
Graph G = (V, E)
V: set of vertices {A, B, C, D}
E: set of edges {(A,B), (A,C), (B,C), (B,D), (C,D)}Many real-world problems can be modeled as graphs:
- Road networks: intersections are vertices, roads are edges
- Social networks: users are vertices, friendships are edges
- The Web: pages are vertices, hyperlinks are edges
- Dependencies: packages are vertices, dependencies are edges
When edges have direction, we get a directed graph (one-way streets). When edges have costs (distances, travel times), we get a weighted graph. In both cases, the basic move of traversal is the same: follow an edge from a vertex to a neighbor.
Graph Representation: Build the "Who Are My Neighbors?" Machinery
Next, decide how to represent a graph in code. Here too, we work backwards from the operations we need.
The most frequent operation in traversal is listing all neighbors of a given vertex, so we choose a representation that makes this fast.
Graph Representation β Adjacency List vs Matrix
Adjacency List
Space: O(V + E)
Neighbor listing, edge lookup: O(degree)
β Suited to sparse graphs
Adjacency Matrix
| A | B | C | D | |
|---|---|---|---|---|
| A | 0 | 1 | 1 | 0 |
| B | 1 | 0 | 1 | 1 |
| C | 1 | 1 | 0 | 1 |
| D | 0 | 1 | 1 | 0 |
Space: O(VΒ²)
Neighbor listing: O(V); edge lookup: O(1)
β Suited to dense graphs
Adjacency List β "Who are this person's friends?"
Store a list of neighbors for each vertex. This suits sparse graphs (graphs with few edges).
graph = {
'A': ['B', 'C'],
'B': ['A', 'C', 'D'],
'C': ['A', 'B', 'D'],
'D': ['B', 'C']
}Space is , and listing neighbors takes . All the traversal algorithms in this article assume an adjacency list.
Adjacency Matrix β "Are these two people friends?"
Build a table and set 1 where an edge exists. You can check whether an edge exists between two specific vertices in , but the table needs space.
# A B C D
matrix = [
[0, 1, 1, 0], # A
[1, 0, 1, 1], # B
[1, 1, 0, 1], # C
[0, 1, 1, 0], # D
]Thought Experiment 1: The Most Naive Traversal
Given a graph, how do we visit every vertex?
The First Wall: "I Keep Revisiting the Same Place"
The first idea is "if you can go to a neighbor, go." But unlike trees, graphs can have cycles (loops). AβBβCβAβBββ¦ and you loop forever.
So we keep a visited record to avoid visiting the same vertex twice.
visited = set() # Without this, we loop foreverThis visited record appears in all three algorithms in this article (in Dijkstra, the distance table dist plays that role). Because graphs can have cycles, some form of it is always needed.
The Next Question: "When I Have Multiple Unvisited Neighbors, Which Do I Visit First?"
You're at vertex A with two unvisited neighbors, B and C. Which one you visit first, and when you visit the vertices you find beyond them, splits the approach into two strategies.
The BFS Insight β "Before Going Far, Check Everything Nearby"
Reframe the Problem: "Where's the Nearest Convenience Store?"
If you were looking for the nearest convenience store in an unfamiliar city, you wouldn't charge down a single street forever. You'd check all the blocks right around you first, then the ring outside them, spreading outward in concentric circles.
BFS (Breadth-First Search) does this on a graph:
If I check nearby places first, then whatever I find is guaranteed to be the closest.
The Design Core: Use a "Waiting Line"
To see how to implement "process nearby vertices first," trace it from starting vertex A:
- Add A's neighbors B and C to a "to-do list"
- Process B. Add B's neighbors D and E to the "to-do list"
- Don't process D or E yet; process C first
C goes first because it entered the list before D and E. C is at distance 1 from the start, while D and E are at distance 2. In BFS, a vertex discovered earlier is closer to the start, so a queue (FIFO: first in, first out) processes vertices nearest-first.
Why a Queue Guarantees Shortest Paths
Because of FIFO order, all vertices at distance are processed before any vertex at distance .
We can check this by induction:
- The source (distance 0) is processed first β
- When a distance- vertex is processed, its undiscovered neighbors (distance ) are enqueued
- FIFO ensures all distance- vertices are processed before distance- vertices
- If vertex is first discovered at distance and a shorter path existed, would already have been discovered at an earlier level, which is a contradiction
Formally, the following invariant always holds: the distances of the vertices in the queue are non-decreasing from front to back, and the front and back differ by at most 1.
The Algorithm
In code:
from collections import deque
def bfs(graph, start):
visited = set() # Insight 1: don't revisit vertices
queue = deque([start]) # Insight 2: queue ensures "nearest first"
visited.add(start)
order = []
while queue:
vertex = queue.popleft() # First in, first out (FIFO)
order.append(vertex)
for neighbor in graph[vertex]:
if neighbor not in visited:
visited.add(neighbor) # Mark visited when enqueuing
queue.append(neighbor)
return ordervisited.add(neighbor) is called at enqueue time, not dequeue time. If we waited until dequeue and both B and C were neighbors of D, D would be enqueued twice: once while processing B and once while processing C. Marking a vertex the moment it is discovered prevents this double enqueue.
Step Through It
The visualizer below shows BFS in action:
- The queue always holds vertices in order of distance from the start
- All distance-1 vertices are processed before any distance-2 vertex
- The visited region spreads out from the start like a ripple
Graph Traversal Visualizer β BFS vs DFS
Compare how BFS and DFS traverse the same graph. Press play or step manually.
Start BFS (Breadth-First Search) from vertex A. The queue makes it explore vertices nearest-first, so the search spreads outward like ripples on water.
BFS Complexity
- Time: β each vertex enters the queue once, and each adjacency-list entry is examined once
- Space: β queue and visited set
The DFS Insight β "Go All the Way Down One Path First"
A Different Problem: "Is There an Exit?"
Now the question is not "what's nearest?" but "Does this maze have an exit? Can I explore every room?"
People walking through a maze usually do this:
- At a fork, pick one path and walk as far as possible
- At a dead end, go back to the last fork
- Try a different path, again going as far as possible
- Repeat until every path has been tried
Ignore whether a path is shortest and explore every path without missing any: that's DFS (Depth-First Search).
The Design Core: Recursion as a Natural Structure
This strategy can be written directly as recursion.
At vertex , we explore all of 's neighbors. Exploring a neighbor means exploring all of 's neighbors while at . The same problem repeats one vertex further along:
explore(v):
mark v as visited
for each neighbor u of v:
if u is unvisited, call explore(u)This recursion goes as deep as it can and turns back at dead ends. When explore(u) returns, control goes back to explore(v); that return is the backtracking.
Why a "Stack"? β Recursion and the Call Stack
If you try to write DFS without recursion, you can see why it needs a stack.
In BFS, we put "vertices to process later" into a queue. DFS stores them too, but it wants to process the most recently added vertex, the deepest one, first. Taking out the last thing put in is what a stack (LIFO: last in, first out) does. A recursive call does the same thing internally with the call stack.
The central difference between BFS and DFS is which end the next vertex is taken from:
BFS: queue.popleft() β first added (nearest) processed first
DFS: stack.pop() β last added (deepest) processed firstChanging how vertices are taken out changes the traversal order, and with it the problems each traversal suits. (In the stack version below, the point at which a vertex is marked visited also differs from BFS.)
The Algorithm (Recursive)
def dfs(graph, start, visited=None):
if visited is None:
visited = set()
visited.add(start)
print(start) # Process vertex
for neighbor in graph[start]:
if neighbor not in visited:
dfs(graph, neighbor, visited) # Recurse = go deeper
# Returning here = backtracking
return visitedThe Algorithm (Iterative with Stack)
When recursion depth is a concern:
def dfs_iterative(graph, start):
visited = set()
stack = [start]
order = []
while stack:
vertex = stack.pop() # Last in, first out (LIFO)
if vertex in visited:
continue
visited.add(vertex)
order.append(vertex)
for neighbor in reversed(graph[vertex]):
if neighbor not in visited:
stack.append(neighbor)
return orderStep Through It
Switch the visualizer above to DFS mode to see how it differs from BFS:
- The call stack grows deeper in one direction
- Backtracking happens at dead ends
- The visit order differs from BFS and does not go nearest-first
Discovery and Finish Times β DFS's Bonus Information
DFS can record a discovery time (when each vertex was discovered) and a finish time (when its exploration finished). BFS doesn't give you this information.
time = 0
def dfs_with_timestamps(graph, vertex, visited, discovery, finish):
global time
time += 1
discovery[vertex] = time # Discovery time
visited.add(vertex)
for neighbor in graph[vertex]:
if neighbor not in visited:
dfs_with_timestamps(graph, neighbor, visited, discovery, finish)
time += 1
finish[vertex] = time # Finish timeOn a directed acyclic graph (DAG), the reverse of DFS finish order is a topological sort. With the convention that an edge means " must come before ," every edge in a DAG satisfies , so reversing finish times puts before . If the graph has a cycle, topological order does not exist, so DFS has to detect the cycle and report an error.
BFS and DFS Have the Same Complexity
- Time:
- Space:
Both process each vertex once and each adjacency-list entry once, so their complexity is the same. What differs is the processing order, and that difference decides which problems each one suits.
Comparing BFS and DFS Side by Side
BFS vs DFS Comparison
| Aspect | BFS | DFS |
|---|---|---|
| Data Structure | Queue (FIFO) | Stack / Recursion (LIFO) |
| Traversal Order | Level by level (breadth first) | As deep as possible first |
| Shortest Path | β Guaranteed (unweighted) | β Not guaranteed |
| Memory | O(V) β can be large for wide graphs | O(V) β proportional to depth |
| Time Complexity | O(V + E) | O(V + E) |
| Typical Uses | Shortest path, nodes within k hops, bipartiteness testing | Cycle detection, topological sort, maze generation |
| Intuition | Ripples on water π | Exploring a maze π |
Choosing Between Them β Read the Problem Structure
BFS when "distance" matters:
- Shortest path (unweighted graphs)
- All nodes within hops
- Bipartiteness testing (can we 2-color the graph?)
DFS when "structure" matters:
- Cycle detection (in directed graphs, did we encounter a vertex currently on the recursion stack? In undirected graphs, did we encounter a visited vertex other than the parent?)
- Topological sort (reverse finish times)
- Connected/strongly connected components
- Backtracking (puzzles, constraint satisfaction)
If you want to spread outward nearest-first, use BFS; if you want to follow one path to the end, use DFS.
Thought Experiment 2: Hitting BFS's Limitation
BFS finds shortest paths in unweighted graphs. What about this problem?
"What's the fastest route from Tokyo to Osaka?"
Each road has a different travel time (weight). Suppose a direct road from Tokyo to Osaka takes 5 hours, while going TokyoβNagoyaβOsaka over two roads takes 4 hours in total.
BFS minimizes the number of edges (hops), so it picks the 1-edge direct route (5 hours). Because it ignores edge weights, it misses the 4-hour route over two edges. When edge costs aren't uniform, BFS can't guarantee the shortest distance.
So we look for a way to make BFS handle weights.
The Dijkstra Insight β Adapting BFS for Weights
Step 1: Re-analyze What BFS Was Guaranteeing
Recall why BFS works:
- Finalize distance-0 vertices (just the source)
- Finalize distance-1 vertices (all neighbors of the source)
- Finalize distance-2 vertices (undiscovered neighbors of distance-1 vertices)
- β¦
The queue's FIFO order guaranteed that smaller distances were finalized first.
In weighted graphs, being a neighbor doesn't mean being close. In the earlier example, the direct TokyoβOsaka edge (5 hours) is longer than the 2-edge path through Nagoya (4 hours in total).
Step 2: "What If I Sort the Queue by Distance?"
So we order the queue by distance instead of insertion order, replacing the FIFO queue with a priority queue.
- BFS: queue (FIFO) β discovered earlier = fewer edges
- Dijkstra: priority queue β smaller total distance = less total weight
Dijkstra's algorithm is BFS with its queue replaced by a priority queue.
Step 3: "The Minimum-Distance Vertex Is Safe to Finalize"
Here is why the distance of the vertex at the front of the priority queue can be finalized:
The vertex at the front of the priority queue cannot be reached by any shorter path. Why? All unprocessed vertices have distance β₯ that of , and since edge weights are non-negative, routing through them cannot reduce the distance.
This argument holds only when edge weights are non-negative. With negative edges, a detour through a "farther" vertex can reduce the total distance, and the argument breaks. For negative edges, use an algorithm such as Bellman-Ford.
Step 4: The "Relaxation" Operation
When we finalize vertex and look at a neighbor , we update 's distance if going through is shorter than what we currently have for . This operation is called relaxation.
"Relaxation" is a historical term, usually explained as coming from the constraint . What the operation actually does is lower a distance estimate (an upper bound) that starts at each time a better path is found.
Putting the Reasoning Into Code
import heapq
def dijkstra(graph, start):
# graph: { node: [(neighbor, weight), ...] }
dist = {node: float('inf') for node in graph} # Start at infinity
prev = {node: None for node in graph}
dist[start] = 0 # Source is 0
pq = [(0, start)] # Priority queue (replaces BFS's queue)
while pq:
d, u = heapq.heappop(pq) # Extract minimum distance (replaces popleft)
if d > dist[u]: # Skip stale entries
continue
for v, weight in graph[u]:
new_dist = dist[u] + weight
if new_dist < dist[v]: # Relaxation: better path found
dist[v] = new_dist
prev[v] = u
heapq.heappush(pq, (new_dist, v))
return dist, prevThe structure is nearly the same as the BFS code. Three things changed:
- Queue β priority queue (extract by distance, not insertion order)
visitedβdisttable (track distances, not just visited/unvisited)- Relaxation check added (
new_dist < dist[v])
Step Through It
The visualizer below shows:
- A distance that was already set getting improved through a different path (that's relaxation)
- The minimum-distance vertex always being selected and finalized
- The
prevcolumn ending up as the shortest path tree
Dijkstra's Algorithm Visualizer
Step through Dijkstra's algorithm on a weighted directed graph.
Start Dijkstra's algorithm from vertex A. Initialize distance to A as 0, all others as β. Insert A(dist=0) into the priority queue.
Dijkstra's Complexity
It depends on the data structure:
- Binary heap: . The most common choice
- Fibonacci heap: . The smallest theoretical bound of the three, but complex to implement
- Array (naive loop): . Faster than a binary heap on dense graphs, where the number of edges approaches
Tracing the Evolution of the Three Algorithms
Starting point: "I want to visit every vertex in a graph without missing any"
Insight 1 β visited: Cycles cause infinite loops, so we need a visited record
The fork β "In what order?":
- "Nearby places first" β FIFO queue β BFS
- "One path all the way to the end" β LIFO stack / recursion β DFS
Evolution β "What if edges have weights?":
- Replace BFS's queue with a priority queue β Dijkstra
- Key insight: "the minimum-distance vertex is safe to finalize" + relaxation
DFS came from a different question than BFS (explore every path), and Dijkstra came from BFS's inability to handle weights. Both began by identifying what about the previous approach didn't fit a new question or condition.
Applications: How the Insights Transfer
Graph Traversal Applications
GPS navigation, game AI pathfinding
BFS / Dijkstra / A*Deadlock detection, dependency cycle checking
DFSBuild systems, compilation order
DFSSocial network clusters, network partitioning
BFS / DFSNetwork design, minimum-cost connectivity
BFS / DFS + Kruskal/PrimMatching problems, scheduling
BFSTopological Sort β A Byproduct of DFS
When a build system has "libA depends on libB" and "libB depends on libC," determining build order is topological sort.
For a DAG, the reverse of DFS finish order gives the build order. With edges directed from dependency to dependent, the libraries form "libC β libB β libA," and the reverse finish order gives the build order C β B β A. If a cycle is found, no topological order exists.
def topological_sort(graph):
visited = set()
result = []
def dfs(node):
visited.add(node)
for neighbor in graph[node]:
if neighbor not in visited:
dfs(neighbor)
result.append(node) # Add on completion
for node in graph:
if node not in visited:
dfs(node)
return result[::-1] # Reverse = topological orderBipartiteness Testing β Exploiting BFS's Layer Structure
The problem is to decide whether a graph's vertices can be colored with 2 colors so that no adjacent vertices share a color. Using BFS's level-by-level processing, alternate colors between even and odd levels:
def is_bipartite(graph):
color = {}
for start in graph:
if start in color:
continue
queue = deque([start])
color[start] = 0
while queue:
node = queue.popleft()
for neighbor in graph[node]:
if neighbor not in color:
color[neighbor] = 1 - color[node]
queue.append(neighbor)
elif color[neighbor] == color[node]:
return False # Same-color neighbors = not bipartite
return TrueComplexity Summary
Complexity Comparison
| Algorithm | Time | Space | Note |
|---|---|---|---|
| BFS | O(V + E) | O(V) | Processes each vertex and adjacency-list entry once |
| DFS | O(V + E) | O(V) | Recursion depth up to V |
| Dijkstra (binary heap) | O((V + E) log V) | O(V) | Each heap operation costs O(log V) |
| Dijkstra (Fibonacci heap) | O(V log V + E) | O(V) | Smaller theoretical bound than the binary heap, but complex to implement |
| Bellman-Ford | O(VE) | O(V) | Handles negative edges; V-1 relaxation rounds |
Conclusion β The Steps That Led to All Three Algorithms
All three algorithms came from the same steps:
- Define the problem: Decide what you want to find
- Try the naive approach: Start with the simplest method and see where it fails
- Identify the bottleneck: Looping on cycles β visited; visiting order β choice of data structure
- Solve with data structures: Whether you use a queue, a stack, or a priority queue determines the strategy
- Verify correctness: Check why it works and under what conditions it breaks (Dijkstra and negative edges)
Use a queue to process nearest-first, a stack to go deep first, and a priority queue to account for weights. BFS, DFS, and Dijkstra each follow from one of these. For a problem that isn't in any textbook, following the same five steps gives you a good idea of which data structure to reach for.