Introduction to Breadth First Search #
Breadth-first search (BFS) is a fundamental graph and tree traversal algorithm that explores nodes level by level, systematically visiting all neighbors at the current depth before moving to nodes at the next depth level. Unlike depth-first search which plunges deeply into one path, BFS spreads outward like ripples on water, ensuring that nodes closer to the starting point are always explored before more distant ones.
This horizontal exploration pattern makes BFS the algorithm of choice for finding shortest paths in unweighted graphs, analyzing social network connections, web crawling with bounded depth, and any scenario where level-order traversal or minimum distance calculations are required. The systematic nature of BFS guarantees that when you reach a target node, you’ve found it via the shortest possible path.
BFS is particularly powerful because it provides optimal solutions for many problems. When searching for a path between two nodes in an unweighted graph, BFS will always find the shortest path first. This optimality guarantee, combined with its intuitive level-by-level exploration pattern, makes BFS an essential tool in every programmer’s algorithmic toolkit.
Understanding the Queue Data Structure #
The queue is the heart of breadth-first search. While DFS uses a stack (Last In, First Out), BFS relies on a queue (First In, First Out) to maintain the order of node exploration. This FIFO behavior ensures that nodes are processed in the exact order they were discovered, creating the characteristic level-by-level traversal pattern.
In JavaScript, we can use arrays to implement a queue efficiently:
// Queue operations using array
const queue = [];
queue.push(item); // Enqueue: add to end
const item = queue.shift(); // Dequeue: remove from front
While using shift()
on arrays has O(n) time complexity in JavaScript, for most practical purposes and educational examples, this implementation is sufficient. For production systems with very large datasets, you might consider implementing a proper queue with linked lists or using a dedicated queue library for O(1) dequeue operations.
The BFS Algorithm Strategy #
To successfully implement breadth-first search, understanding its operational strategy is crucial:
Start at the Root or Source Node: Begin traversal at a designated starting point - the root node for trees, or any chosen starting node for graphs.
Explore Layer by Layer: Visit all nodes at the current depth level before moving to the next level deeper. This ensures a complete horizontal sweep at each level.
Use a Queue for Order: Maintain a queue of nodes to visit. The FIFO nature of the queue guarantees that nodes are processed in the order they were discovered, maintaining the level-by-level progression.
Mark Visited Nodes: Especially important for graphs, track which nodes have been visited to prevent cycles, infinite loops, and redundant processing.
Process Each Level Completely: Before moving to depth level n+1, ensure all nodes at depth level n have been explored and their children added to the queue.
Track Distance or Level: Optionally maintain information about each node’s distance from the source or its level in the tree, which is useful for shortest path algorithms.
Tree Structure Foundation #
Before implementing BFS, let’s establish the tree node structure we’ll work with:
function Node(value) {
this.value = value;
this.left = null;
this.right = null;
}
This simple binary tree node contains a value and pointers to left and right children. BFS will traverse this structure level by level, visiting all nodes at depth 0, then all nodes at depth 1, then depth 2, and so on.
Implementing BFS for Binary Trees #
Let’s explore how to implement breadth-first search for binary trees using a queue-based approach.
Basic BFS Implementation in JavaScript #
Here’s a clear, well-commented implementation:
function BFS(root) {
// Handle empty tree edge case
if (!root) {
return;
}
// Initialize queue with root node
const queue = [root];
// Continue while there are nodes to process
while (queue.length > 0) {
// Dequeue: remove and get first node in queue
const current = queue.shift();
// Process current node
console.log(current.value);
// Enqueue left child if it exists
if (current.left) {
queue.push(current.left);
}
// Enqueue right child if it exists
if (current.right) {
queue.push(current.right);
}
}
}
Understanding BFS Execution Flow #
Let’s walk through how this algorithm processes a tree:
Initialization: We start by creating a queue containing only the root node. At this point, the root represents level 0.
First Iteration: We dequeue the root node, process it (print its value), and enqueue its children (if they exist). Now the queue contains all nodes at level 1.
Subsequent Iterations: For each node we dequeue from level n, we process it and enqueue its children at level n+1. Because we use a queue, all nodes at level n will be dequeued and processed before any nodes at level n+1.
Level Completion: The FIFO nature of the queue ensures that we completely finish one level before starting the next. This is the key distinction from DFS.
Termination: The algorithm continues until the queue is empty, meaning all reachable nodes have been visited.
Example Traversal #
Consider this binary tree:
1
/ \
2 3
/ \ \
4 5 6
BFS will visit nodes in this order: 1, 2, 3, 4, 5, 6
- Level 0: Visit 1, enqueue [2, 3]
- Level 1: Visit 2, enqueue [3, 4, 5]; Visit 3, enqueue [4, 5, 6]
- Level 2: Visit 4, Visit 5, Visit 6
- Queue is empty, traversal complete
Level-Order Traversal with Level Tracking #
Often, we need to know which level each node belongs to. Here’s an enhanced version that tracks levels:
function BFSWithLevels(root) {
if (!root) {
return;
}
const queue = [{node: root, level: 0}];
while (queue.length > 0) {
const {node: current, level} = queue.shift();
// Process node with level information
console.log(`Level ${level}: ${current.value}`);
// Add children with incremented level
if (current.left) {
queue.push({node: current.left, level: level + 1});
}
if (current.right) {
queue.push({node: current.right, level: level + 1});
}
}
}
This implementation wraps each node with its level information, allowing you to perform level-specific operations or return results grouped by level.
Level-Order Traversal Returning Levels as Arrays #
A common interview question asks for level-order traversal that returns each level as a separate array:
function BFSLevelArrays(root) {
if (!root) {
return [];
}
const result = [];
const queue = [root];
while (queue.length > 0) {
const levelSize = queue.length;
const currentLevel = [];
// Process all nodes at current level
for (let i = 0; i < levelSize; i++) {
const current = queue.shift();
currentLevel.push(current.value);
// Add next level nodes to queue
if (current.left) {
queue.push(current.left);
}
if (current.right) {
queue.push(current.right);
}
}
result.push(currentLevel);
}
return result;
}
The key insight here is capturing queue.length
before processing each level. This tells us exactly how many nodes are at the current level, allowing us to process them as a batch and group them in the result array.
For the example tree above, this would return: [[1], [2, 3], [4, 5, 6]]
BFS for Graph Traversal #
Graphs introduce additional complexity because they can contain cycles. We must track visited nodes to avoid infinite loops:
function BFSGraph(startNode) {
// Handle null starting node
if (!startNode) {
return;
}
const visited = new Set();
const queue = [startNode];
visited.add(startNode);
while (queue.length > 0) {
const current = queue.shift();
// Process current node
console.log(current.value);
// Explore all neighbors
for (const neighbor of current.neighbors) {
// Only visit unvisited neighbors
if (!visited.has(neighbor)) {
visited.add(neighbor);
queue.push(neighbor);
}
}
}
}
Critical Note: We mark nodes as visited when we enqueue them, not when we dequeue them. This prevents the same node from being added to the queue multiple times, which would waste memory and processing time.
Finding Shortest Path with BFS #
One of BFS’s most powerful applications is finding the shortest path between two nodes in an unweighted graph:
function shortestPath(startNode, targetNode) {
if (startNode === targetNode) {
return [startNode];
}
const visited = new Set();
const queue = [{node: startNode, path: [startNode]}];
visited.add(startNode);
while (queue.length > 0) {
const {node: current, path} = queue.shift();
// Explore neighbors
for (const neighbor of current.neighbors) {
if (neighbor === targetNode) {
// Found target! Return the path
return [...path, neighbor];
}
if (!visited.has(neighbor)) {
visited.add(neighbor);
queue.push({
node: neighbor,
path: [...path, neighbor]
});
}
}
}
// No path exists
return null;
}
This implementation maintains the path to each node as we explore. Because BFS explores nodes in order of increasing distance, the first time we reach the target node, we’ve found the shortest path.
Optimized Shortest Path with Parent Tracking #
To save memory, instead of storing the entire path with each node, we can track parent relationships and reconstruct the path at the end:
function shortestPathOptimized(startNode, targetNode) {
if (startNode === targetNode) {
return [startNode];
}
const visited = new Set();
const parent = new Map();
const queue = [startNode];
visited.add(startNode);
parent.set(startNode, null);
while (queue.length > 0) {
const current = queue.shift();
for (const neighbor of current.neighbors) {
if (!visited.has(neighbor)) {
visited.add(neighbor);
parent.set(neighbor, current);
queue.push(neighbor);
// Check if we reached target
if (neighbor === targetNode) {
// Reconstruct path from parent pointers
const path = [];
let node = targetNode;
while (node !== null) {
path.unshift(node);
node = parent.get(node);
}
return path;
}
}
}
}
return null; // No path exists
}
This approach uses O(n) space for parent tracking instead of potentially O(n²) for storing paths with every node.
Finding Distance Between Nodes #
BFS naturally calculates the shortest distance (number of edges) from a source node to all reachable nodes:
function findDistances(startNode) {
const distances = new Map();
const queue = [startNode];
distances.set(startNode, 0);
while (queue.length > 0) {
const current = queue.shift();
const currentDistance = distances.get(current);
for (const neighbor of current.neighbors) {
if (!distances.has(neighbor)) {
distances.set(neighbor, currentDistance + 1);
queue.push(neighbor);
}
}
}
return distances;
}
This returns a map where each key is a node and each value is its distance from the start node.
BFS vs DFS: Comprehensive Comparison #
Understanding when to use BFS versus DFS is crucial for algorithm design.
Data Structure Difference #
- BFS uses a Queue (FIFO): Nodes are processed in the order they’re discovered, creating level-by-level exploration
- DFS uses a Stack (LIFO): Recently discovered nodes are processed first, creating deep-path exploration
Traversal Pattern #
- BFS: Explores horizontally, visiting all nodes at distance k before any nodes at distance k+1
- DFS: Explores vertically, going as deep as possible down one path before backtracking
Shortest Path #
- BFS: Guarantees shortest path in unweighted graphs (first path found is shortest)
- DFS: Does not guarantee shortest path (may find longer paths first)
Memory Usage #
- BFS: O(w) where w is maximum width - stores entire levels, can be memory-intensive for wide trees
- DFS: O(h) where h is height - stores only current path, better for wide but shallow structures
Use Cases for BFS #
- Shortest Path Problems: Finding minimum distance in unweighted graphs
- Level-Order Traversal: Processing trees level by level
- Social Networks: Finding closest connections, “friends of friends” analysis
- Web Crawlers: Exploring websites with bounded depth
- Broadcasting: Propagating messages to nearby nodes first
- Puzzle Solving: Finding minimum moves to reach goal state
- Network Routing: Finding shortest routes in computer networks
- Peer-to-Peer Networks: Discovering nearby peers
- GPS Navigation: Finding shortest routes with uniform edge weights
- Garbage Collection: Marking reachable objects from roots
Use Cases for DFS #
- Pathfinding: When any path will do (not necessarily shortest)
- Cycle Detection: Finding cycles in directed/undirected graphs
- Topological Sorting: Ordering tasks with dependencies
- Maze Solving: Exploring possibilities with backtracking
- Connected Components: Finding isolated subgraphs
- Sudoku/Puzzle Solvers: Backtracking algorithms
- Expression Parsing: Evaluating syntax trees
Time Complexity Comparison #
Both algorithms have the same time complexity:
- Graphs: O(V + E) where V is vertices and E is edges
- Trees: O(n) where n is number of nodes
However, BFS typically finds solutions faster when they’re close to the source, while DFS may find them faster when they’re deep in the tree.
When to Choose BFS #
Choose BFS when:
- You need the shortest path in an unweighted graph
- The target is likely close to the source
- You need to process nodes level by level
- You’re analyzing social network proximity
- Memory is not severely constrained and the tree/graph is not extremely wide
When to Choose DFS #
Choose DFS when:
- Any path will suffice (not specifically shortest)
- The tree is very wide but not deep
- You need to explore all possibilities with backtracking
- You’re detecting cycles or doing topological sorting
- Memory is constrained (tree is wide)
Advanced BFS Applications #
Bi-directional BFS #
For finding paths between two specific nodes, bi-directional BFS can be much faster:
function bidirectionalBFS(startNode, targetNode) {
if (startNode === targetNode) {
return [startNode];
}
const visitedFromStart = new Map([[startNode, null]]);
const visitedFromTarget = new Map([[targetNode, null]]);
const queueStart = [startNode];
const queueTarget = [targetNode];
while (queueStart.length > 0 || queueTarget.length > 0) {
// Expand from start
if (queueStart.length > 0) {
const current = queueStart.shift();
for (const neighbor of current.neighbors) {
if (visitedFromTarget.has(neighbor)) {
// Found intersection! Reconstruct path
return reconstructPath(visitedFromStart, visitedFromTarget, neighbor);
}
if (!visitedFromStart.has(neighbor)) {
visitedFromStart.set(neighbor, current);
queueStart.push(neighbor);
}
}
}
// Expand from target
if (queueTarget.length > 0) {
const current = queueTarget.shift();
for (const neighbor of current.neighbors) {
if (visitedFromStart.has(neighbor)) {
// Found intersection! Reconstruct path
return reconstructPath(visitedFromStart, visitedFromTarget, neighbor);
}
if (!visitedFromTarget.has(neighbor)) {
visitedFromTarget.set(neighbor, current);
queueTarget.push(neighbor);
}
}
}
}
return null; // No path exists
}
function reconstructPath(visitedFromStart, visitedFromTarget, meetingNode) {
// Reconstruct path from start to meeting point
const pathFromStart = [];
let node = meetingNode;
while (node !== null) {
pathFromStart.unshift(node);
node = visitedFromStart.get(node);
}
// Reconstruct path from meeting point to target
const pathToTarget = [];
node = visitedFromTarget.get(meetingNode);
while (node !== null) {
pathToTarget.push(node);
node = visitedFromTarget.get(node);
}
return [...pathFromStart, ...pathToTarget];
}
Bi-directional BFS explores from both ends simultaneously, potentially reducing search space significantly when a path exists.
Weighted Graphs: Dijkstra’s Algorithm #
For weighted graphs, BFS doesn’t guarantee shortest paths. Use Dijkstra’s algorithm instead:
function dijkstra(startNode, targetNode) {
const distances = new Map([[startNode, 0]]);
const previous = new Map();
const unvisited = new Set([startNode]);
// Add all neighbors to unvisited
for (const node of getAllNodes()) {
if (node !== startNode) {
distances.set(node, Infinity);
unvisited.add(node);
}
}
while (unvisited.size > 0) {
// Find unvisited node with minimum distance
let current = null;
let minDistance = Infinity;
for (const node of unvisited) {
if (distances.get(node) < minDistance) {
minDistance = distances.get(node);
current = node;
}
}
if (current === targetNode) {
break; // Found shortest path to target
}
unvisited.delete(current);
// Update distances to neighbors
for (const {neighbor, weight} of current.edges) {
const newDistance = distances.get(current) + weight;
if (newDistance < distances.get(neighbor)) {
distances.set(neighbor, newDistance);
previous.set(neighbor, current);
}
}
}
// Reconstruct path
const path = [];
let node = targetNode;
while (node !== undefined) {
path.unshift(node);
node = previous.get(node);
}
return {path, distance: distances.get(targetNode)};
}
Multi-Source BFS #
Sometimes we need to find distances from multiple source nodes simultaneously:
function multiSourceBFS(sourceNodes) {
const distances = new Map();
const queue = [];
// Initialize with all source nodes at distance 0
for (const source of sourceNodes) {
distances.set(source, 0);
queue.push(source);
}
while (queue.length > 0) {
const current = queue.shift();
const currentDistance = distances.get(current);
for (const neighbor of current.neighbors) {
if (!distances.has(neighbor)) {
distances.set(neighbor, currentDistance + 1);
queue.push(neighbor);
}
}
}
return distances;
}
This is useful for problems like finding the nearest exit in a maze with multiple exits, or analyzing propagation from multiple starting points.
Time and Space Complexity Analysis #
Time Complexity #
For Graphs:
- O(V + E) where V is the number of vertices and E is the number of edges
- We visit each vertex once: O(V)
- For each vertex, we examine all its edges: O(E)
- Combined: O(V + E)
For Trees:
- O(n) where n is the number of nodes
- Each node is visited exactly once
- For a binary tree with n nodes, there are n-1 edges
Best Case: O(1) if the target is the root or starting node
Worst Case: O(V + E) when we must explore the entire graph
Space Complexity #
Queue Space:
- Worst case O(V): The queue might contain all nodes at the widest level
- For a complete binary tree, the maximum width is roughly n/2 at the bottom level
- O(w) where w is the maximum width of the tree
Visited Set:
- O(V): We track all visited nodes to prevent cycles
Total Space Complexity:
- O(V) for the queue and visited set combined
Memory Considerations:
- BFS uses more memory than DFS for deep, narrow structures
- BFS can be memory-intensive for very wide trees or graphs
- For a complete binary tree of height h: max width = 2^h
Common BFS Interview Problems #
1. Binary Tree Right Side View #
Find what you would see if you looked at the tree from the right side:
function rightSideView(root) {
if (!root) return [];
const result = [];
const queue = [root];
while (queue.length > 0) {
const levelSize = queue.length;
for (let i = 0; i < levelSize; i++) {
const current = queue.shift();
// Last node in this level is visible from right
if (i === levelSize - 1) {
result.push(current.value);
}
if (current.left) queue.push(current.left);
if (current.right) queue.push(current.right);
}
}
return result;
}
2. Minimum Depth of Binary Tree #
Find the minimum depth (shortest path to a leaf):
function minDepth(root) {
if (!root) return 0;
const queue = [{node: root, depth: 1}];
while (queue.length > 0) {
const {node: current, depth} = queue.shift();
// Found a leaf node
if (!current.left && !current.right) {
return depth;
}
if (current.left) {
queue.push({node: current.left, depth: depth + 1});
}
if (current.right) {
queue.push({node: current.right, depth: depth + 1});
}
}
return 0;
}
3. Word Ladder Problem #
Find the shortest transformation sequence from one word to another:
function wordLadder(beginWord, endWord, wordList) {
const wordSet = new Set(wordList);
if (!wordSet.has(endWord)) return 0;
const queue = [{word: beginWord, length: 1}];
const visited = new Set([beginWord]);
while (queue.length > 0) {
const {word: current, length} = queue.shift();
if (current === endWord) {
return length;
}
// Try all possible one-letter changes
for (let i = 0; i < current.length; i++) {
for (let c = 97; c <= 122; c++) { // 'a' to 'z'
const newWord = current.slice(0, i) + String.fromCharCode(c) + current.slice(i + 1);
if (wordSet.has(newWord) && !visited.has(newWord)) {
visited.add(newWord);
queue.push({word: newWord, length: length + 1});
}
}
}
}
return 0; // No transformation exists
}
4. Rotten Oranges #
Find minimum time for all oranges to rot when adjacent oranges rot every minute:
function orangesRotting(grid) {
const rows = grid.length;
const cols = grid[0].length;
const queue = [];
let freshCount = 0;
// Find all initially rotten oranges and count fresh ones
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (grid[r][c] === 2) {
queue.push({row: r, col: c, time: 0});
} else if (grid[r][c] === 1) {
freshCount++;
}
}
}
if (freshCount === 0) return 0;
const directions = [[0, 1], [1, 0], [0, -1], [-1, 0]];
let maxTime = 0;
while (queue.length > 0) {
const {row, col, time} = queue.shift();
maxTime = time;
// Spread rot to adjacent oranges
for (const [dr, dc] of directions) {
const newRow = row + dr;
const newCol = col + dc;
if (newRow >= 0 && newRow < rows &&
newCol >= 0 && newCol < cols &&
grid[newRow][newCol] === 1) {
grid[newRow][newCol] = 2;
freshCount--;
queue.push({row: newRow, col: newCol, time: time + 1});
}
}
}
return freshCount === 0 ? maxTime : -1;
}
Practical Tips for Implementing BFS #
Always Check for Null/Empty Input: Handle edge cases at the beginning of your function.
Mark Visited When Enqueueing: This prevents nodes from being added to the queue multiple times, saving memory and preventing duplicate processing.
Use Appropriate Data Structures: While arrays work for learning, consider using actual queue implementations for production code handling large datasets.
Track Level/Distance When Needed: Many problems require knowing how far from the source each node is. Track this information as you traverse.
Consider Space Complexity: BFS can use significant memory for wide structures. If memory is constrained, consider whether DFS might be more appropriate.
Batch Process Levels: When you need to process each level as a unit, capture the queue size before processing that level.
Use Sets for Fast Lookups: When tracking visited nodes, use a Set rather than an array for O(1) lookup time.
Early Termination: If searching for a specific node, return immediately when found rather than continuing traversal.
Conclusion #
Breadth-first search is a powerful, versatile algorithm that should be in every programmer’s toolbox. Its level-by-level exploration pattern makes it ideal for shortest path problems, social network analysis, level-order traversal, and countless other applications where proximity or minimum distance matters.
The key to mastering BFS is understanding that the queue’s FIFO behavior naturally creates the level-by-level exploration pattern. By maintaining this queue and a visited set (for graphs), you can systematically explore any tree or graph structure while guaranteeing that you find optimal solutions for unweighted path problems.
While BFS uses more memory than DFS for deep structures, its guarantee of finding shortest paths in unweighted graphs makes it indispensable for many real-world applications. Practice implementing BFS in various contexts, understand when to choose it over DFS, and you’ll find it becomes second nature when approaching graph and tree problems.
Whether you’re building a web crawler, implementing a game AI, analyzing social networks, or solving coding interview problems, BFS provides a systematic, optimal approach to exploring connected data structures. Master this algorithm, and you’ll have a powerful tool that opens doors to solving complex computational problems efficiently and elegantly.