Master Breadth-First Search completely — the level-by-level exploration strategy, complete implementation with queue, BFS tree and shortest path guarantee, detecting bipartite graphs, multi-source BFS, 0-1 BFS, BFS on grids, and all classic BFS interview problems.
What Is BFS?
Breadth-First Search (BFS) is a graph traversal algorithm that explores all vertices at the current "level" (distance from start) before moving to vertices at the next level. It uses a queue (FIFO) to maintain the order of exploration.
Intuition: Imagine dropping a stone in water. The ripples spread outward in concentric circles — all points at distance 1 first, then distance 2, then 3... BFS works the same way.
| Graph | BFS from vertex 0: |
| 0—1—4 Level 0 | {0} |
| | | Level 1 | {1, 3} (neighbors of 0) |
| 2—3 Level 2 | {4, 2} (neighbors of 1 and 3) |
| Level 3 | {} (no new neighbors) |
| BFS order: 0 | 1 → 3 → 4 → 2 |
BFS Step-by-Step Trace
| Graph | 0—1—2—3 (path graph) |
| Step 1 | queue=[0], visited={0} |
| Neighbors of 0 | 1, 4 — add to queue |
| Step 2 | Dequeue 1 |
| Neighbors of 1 | 0(visited), 2 |
| Step 3 | Dequeue 4 |
| Neighbors of 4 | 0(visited), 3 |
| Step 4 | Dequeue 2 |
| Neighbors | 1(visited), 3(visited) |
| Step 5 | Dequeue 3 |
| Neighbors | all visited |
| Result: 0 | 1→4→2→3 |
BFS for Shortest Path (Unweighted Graph)
Key property: In an unweighted graph, BFS guarantees the SHORTEST path (fewest edges) from source to any reachable vertex. This is because BFS explores nodes level by level — it reaches every node via the minimum number of hops.
def bfs_shortest_path(graph, source, target):
"""
Find shortest path (min edges) from source to target.
Returns the path as a list, or None if unreachable.
Time: O(V + E), Space: O(V)
"""
if source == target:
return [source]
visited = {source}
queue = deque([(source, [source])]) # (current_node, path_to_node)
while queue:
node, path = queue.popleft()
for neighbor in graph[node]:
if neighbor == target:
return path + [neighbor] # Found! Return the complete path
if neighbor not in visited:
visited.add(neighbor)
queue.append((neighbor, path + [neighbor]))
return None # Target not reachable
graph = {0:[1,2], 1:[0,3], 2:[0,3], 3:[1,2,4], 4:[3]}
print(bfs_shortest_path(graph, 0, 4)) # [0, 1, 3, 4] or [0, 2, 3, 4]
BFS with Distance Array (More Efficient)
def bfs_distances(graph, source):
"""
Find shortest distances from source to all reachable vertices.
Avoids storing full paths — much more memory efficient.
"""
dist = {source: 0}
queue = deque([source])
while queue:
node = queue.popleft()
for neighbor in graph[node]:
if neighbor not in dist:
dist[neighbor] = dist[node] + 1
queue.append(neighbor)
return dist
graph = {0:[1,2], 1:[0,3], 2:[0,3], 3:[1,2,4], 4:[3]}
print(bfs_distances(graph, 0))
# {0:0, 1:1, 2:1, 3:2, 4:3}
Level-by-Level BFS
When you need to process all nodes at one distance before the next:
def bfs_level_order(graph, start):
"""
BFS that groups nodes by level.
Used in: level-order tree traversal, finding eccentricity.
"""
visited = {start}
current_level = [start]
levels = []
while current_level:
levels.append(list(current_level))
next_level = []
for node in current_level:
for neighbor in graph[node]:
if neighbor not in visited:
visited.add(neighbor)
next_level.append(neighbor)
current_level = next_level
return levels
graph = {0:[1,2], 1:[3,4], 2:[5], 3:[], 4:[], 5:[]}
print(bfs_level_order(graph, 0))
# [[0], [1,2], [3,4,5]]
BFS on 2D Grid
Grids are graphs where each cell is a vertex and edges go to adjacent cells:
def bfs_grid(grid, start_r, start_c, target_r, target_c):
"""
Find shortest path in 2D grid.
grid[r][c] = 0 means passable, 1 means blocked.
"""
m, n = len(grid), len(grid[0])
if grid[start_r][start_c] == 1 or grid[target_r][target_c] == 1:
return -1
queue = deque([(start_r, start_c, 0)]) # (row, col, distance)
visited = {(start_r, start_c)}
while queue:
r, c, dist = queue.popleft()
if r == target_r and c == target_c:
return dist
for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)]: # 4 directions
nr, nc = r+dr, c+dc
if (0 <= nr < m and 0 <= nc < n and
grid[nr][nc] == 0 and (nr,nc) not in visited):
visited.add((nr, nc))
queue.append((nr, nc, dist+1))
return -1 # No path exists
grid = [
[0,0,0,0],
[0,1,1,0],
[0,0,0,0]
]
print(bfs_grid(grid, 0, 0, 2, 3)) # 5
Multi-Source BFS
When you have multiple starting points simultaneously:
def multi_source_bfs(grid, sources):
"""
Start BFS from all source cells simultaneously.
Finds distance from nearest source for each cell.
Used in: walls and gates, rotten oranges.
"""
m, n = len(grid), len(grid[0])
dist = [[float('inf')]*n for _ in range(m)]
queue = deque()
for r, c in sources:
dist[r][c] = 0
queue.append((r, c))
while queue:
r, c = queue.popleft()
for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)]:
nr, nc = r+dr, c+dc
if 0 <= nr < m and 0 <= nc < n and dist[nr][nc] == float('inf'):
dist[nr][nc] = dist[r][c] + 1
queue.append((nr, nc))
return dist
BFS for Bipartite Check
A graph is bipartite if vertices can be colored with 2 colors such that no two adjacent vertices share the same color.
def is_bipartite(graph):
"""
Check if graph is bipartite using 2-coloring via BFS.
A graph is bipartite iff it has no odd-length cycles.
Time: O(V + E)
"""
color = {}
for start in graph:
if start in color:
continue # Already colored this component
color[start] = 0
queue = deque([start])
while queue:
node = queue.popleft()
for neighbor in graph[node]:
if neighbor not in color:
color[neighbor] = 1 - color[node] # Opposite color
queue.append(neighbor)
elif color[neighbor] == color[node]:
return False # Same color adjacent vertex → not bipartite
return True
# Bipartite (no odd cycles)
g1 = {0:[1,3], 1:[0,2], 2:[1,3], 3:[0,2]}
print(is_bipartite(g1)) # True
# Not bipartite (triangle = 3-cycle = odd)
g2 = {0:[1,2], 1:[0,2], 2:[0,1]}
print(is_bipartite(g2)) # False
0-1 BFS — For Graphs with Edge Weights 0 or 1
When edges have weight 0 or 1 (not general), a deque achieves O(V+E) instead of Dijkstra's O((V+E) log V):
def bfs_01(graph, source, n):
"""
0-1 BFS: edge weights are 0 or 1.
Weight-0 edges: prepend (front of deque)
Weight-1 edges: append (back of deque)
Time: O(V + E)
"""
dist = [float('inf')] * n
dist[source] = 0
dq = deque([source])
while dq:
node = dq.popleft()
for neighbor, weight in graph[node]:
new_dist = dist[node] + weight
if new_dist < dist[neighbor]:
dist[neighbor] = new_dist
if weight == 0:
dq.appendleft(neighbor) # High priority
else:
dq.append(neighbor) # Lower priority
return dist
Connected Components
def count_components(graph, n):
"""Count connected components using BFS."""
visited = set()
components = 0
for start in range(n):
if start not in visited:
components += 1
queue = deque([start])
visited.add(start)
while queue:
node = queue.popleft()
for neighbor in graph.get(node, []):
if neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)
return components
Complexity
| Aspect | Value | Notes |
|---|
| Time | O(V + E) | Visit each vertex once, each edge once |
| Space | O(V) | Queue holds at most V vertices |
| Shortest path guarantee | ✅ Yes | For unweighted graphs only |
| Completeness | ✅ Yes | Finds all reachable vertices |
Summary
BFS uses a queue and explores level by level:
- Start at source, mark visited, add to queue
- Dequeue a vertex, process it, add unvisited neighbors to queue
- Repeat until queue is empty
Key properties:
- Finds shortest path (min edges) in unweighted graphs
- Explores in order of distance from source
- O(V+E) time with adjacency list
- Natural for level-order traversal, multi-source, bipartite checking
Use BFS when: "Minimum steps," "shortest path in unweighted graph," "level by level processing," "find nearest X."
*Next Lesson: DFS Algorithm*