All Posts

BFS — Breadth-First Search: Level-by-Level Graph Exploration

BFS explores graphs level by level using a queue — the key to shortest paths and level-order problems.

Abstract AlgorithmsAbstract Algorithms
··17 min read
Share
Share on X / Twitter
Share on LinkedIn
Copy link

TLDR: BFS explores a graph level by level using a FIFO queue, guaranteeing the shortest path in unweighted graphs. Recognize BFS problems by keywords: "shortest path," "minimum steps," or "level order." Time: O(V + E). Space: O(V). Mark nodes visited on enqueue — never on dequeue.


📖 Why GPS, LinkedIn, and npm All Think in Waves

GPS navigation finds the shortest route. LinkedIn shows you second-degree connections. npm resolves the order in which to install packages. These three systems look nothing alike — but they all solve the same underlying problem: find all reachable nodes from a starting point, in the order they appear, closest first.

That is BFS in one sentence: explore every node at distance k before exploring any node at distance k + 1.

The intuition is a stone dropped in a pond. Ripples spread outward in concentric circles — every point at radius 1 is reached before any point at radius 2. BFS is that ripple, applied to graphs.

BFS is uniquely suited to these problems because the queue data structure enforces discovery order. Every time you process a node, you enqueue its neighbors. Since the queue is FIFO, neighbors discovered earlier — meaning neighbors of closer nodes — are always dequeued before neighbors of farther nodes. The distance property is not something you calculate; it falls out of the queue structure for free.

Real SystemBFS RoleWhat "Level" Means
GPS / Google MapsShortest path in road networkRoad hops from source
LinkedIn "2nd Connections"Breadth-limited graph scanDegrees of separation
npm / MavenDependency resolution orderDependency depth
Web crawlerLevel-order URL discoveryClick distance from seed page

BFS shines in two specific situations: when you need the shortest unweighted path, or when the problem asks you to process nodes level by level. Outside these two cases, DFS is often simpler and cheaper.


🔍 The Queue Contract: How BFS Explores One Level at a Time

BFS's core invariant is: every node at level k is processed before any node at level k + 1.

This is enforced by the data structure itself — a FIFO queue (ArrayDeque in Java). Nodes are enqueued when first discovered and dequeued when processed. Because the queue is first-in-first-out, nodes that were discovered earlier (closer to the source) are always processed first.

The three rules of BFS — no exceptions:

  1. Enqueue the starting node and mark it visited.
  2. Dequeue a node, process it, and enqueue all unvisited neighbors, marking each visited immediately on enqueue (not on dequeue — this is the most common BFS bug).
  3. Repeat until the queue is empty.

The "mark visited on enqueue" rule is critical. If you delay marking until dequeue time, the same node can be enqueued multiple times — once by each of its already-discovered neighbors. In a dense graph this turns O(V + E) traversal into O(V × E), completely breaking the algorithm's complexity guarantees.

The mental model: the queue at any moment holds the frontier — all nodes that have been discovered but not yet expanded. When you process a frontier node, you push the next frontier out by one more level.


⚙️ The BFS Template in Java: Queue, Visited Set, and the Level Loop

This template handles any graph BFS. Internalize it once and adapt it to every problem variant:

import java.util.*;

public class BFSTemplate {

    // Generic BFS on an adjacency-list graph
    public void bfs(Map<Integer, List<Integer>> graph, int start) {
        Queue<Integer> queue = new ArrayDeque<>();
        Set<Integer> visited = new HashSet<>();

        queue.offer(start);
        visited.add(start);            // Mark on ENQUEUE

        while (!queue.isEmpty()) {
            int node = queue.poll();
            System.out.println("Visiting: " + node);

            for (int neighbor : graph.getOrDefault(node, Collections.emptyList())) {
                if (!visited.contains(neighbor)) {
                    visited.add(neighbor); // Mark on ENQUEUE
                    queue.offer(neighbor);
                }
            }
        }
    }

    // Level-aware BFS — when you need to know which level each node belongs to
    public void bfsLevelOrder(Map<Integer, List<Integer>> graph, int start) {
        Queue<Integer> queue = new ArrayDeque<>();
        Set<Integer> visited = new HashSet<>();

        queue.offer(start);
        visited.add(start);
        int level = 0;

        while (!queue.isEmpty()) {
            int size = queue.size();   // Snapshot: exactly the current level's count
            for (int i = 0; i < size; i++) {
                int node = queue.poll();
                System.out.println("Level " + level + ": node " + node);
                for (int neighbor : graph.getOrDefault(node, Collections.emptyList())) {
                    if (!visited.contains(neighbor)) {
                        visited.add(neighbor);
                        queue.offer(neighbor);
                    }
                }
            }
            level++;
        }
    }
}

The int size = queue.size() snapshot is the standard level-boundary technique. You capture the queue size before expanding, so the inner for loop processes exactly the nodes in the current level — then the next while iteration starts the next level. Using a null sentinel instead of this snapshot is error-prone; the snapshot approach is the industry standard.


🧠 Deep Dive: BFS Memory Layout and Performance Characteristics

BFS Internals: Queue State at Each Expansion Step

At each step, the queue holds the entire frontier — all nodes discovered but not yet expanded. Here is the queue state for a BFS from node A on the graph A→B, A→C, B→D, B→E, C→F:

StepNode DequeuedQueue After ExpansionNewly Visited
Start[A]{A}
1A[B, C]{B, C}
2B[C, D, E]{D, E}
3C[D, E, F]{F}
4D[E, F]
5E[F]
6F[]

The frontier grows as wide as the widest level. That is the root cause of BFS's space cost: in a balanced binary tree with n nodes, the bottom level has n/2 nodes — the queue peaks at O(n/2) = O(n). In a star graph (one center connected to n−1 leaves), the queue holds n−1 nodes at level 1.

Performance Analysis: O(V + E) Time and O(V) Space

Time Complexity: O(V + E)

Each vertex is enqueued and dequeued exactly once — O(V) total dequeue operations. Each edge is examined exactly once, when its source vertex is dequeued and its neighbors are inspected — O(E) total neighbor checks. Combined: O(V + E).

Space Complexity: O(V)

The visited set holds at most V nodes. The queue holds at most the width of the widest level, which in the worst case is V − 1. Dominant term: O(V).

Where it gets slow: BFS is memory-bound in wide graphs. A celebrity node in a social network with 100M followers requires holding 100M nodes in memory at level 1 alone. Production systems handle this with distributed BFS (Apache Giraph, Spark GraphX), depth-limited BFS, or bidirectional BFS — which reduces frontier size by expanding from both source and target simultaneously.


📊 Visualizing BFS Wave Expansion on a Six-Node Graph

BFS from node 1 expands outward in three waves. Each colour represents one level of discovery:

graph TD
    N1["1 ◉ Level 0 — Start"]
    N2["2 — Level 1"]
    N3["3 — Level 1"]
    N4["4 — Level 2"]
    N5["5 — Level 2"]
    N6["6 — Level 2"]

    N1 --> N2
    N1 --> N3
    N2 --> N4
    N2 --> N5
    N3 --> N6

    style N1 fill:#4CAF50,color:#fff,stroke:#388E3C
    style N2 fill:#2196F3,color:#fff,stroke:#1565C0
    style N3 fill:#2196F3,color:#fff,stroke:#1565C0
    style N4 fill:#FF9800,color:#fff,stroke:#E65100
    style N5 fill:#FF9800,color:#fff,stroke:#E65100
    style N6 fill:#FF9800,color:#fff,stroke:#E65100

Wave 0 (green): Node 1 — source, enqueued at the start.
Wave 1 (blue): Nodes 2, 3 — all nodes exactly 1 hop from source.
Wave 2 (orange): Nodes 4, 5, 6 — all nodes exactly 2 hops from source.

After processing node 1, the queue holds [2, 3]. After processing node 2, the queue holds [3, 4, 5]. The queue always contains a mix of at most two consecutive levels — the current level being drained and the next level being filled.


🌍 Where BFS Shows Up in Production Systems

Shortest-path routing in navigation: Google Maps models road intersections as nodes and road segments as edges. BFS on an unweighted graph gives the minimum number of road segments between two points. For weighted distances (where roads have different lengths), Dijkstra's algorithm is used — but it is structurally identical to BFS with a priority queue substituted for the FIFO queue.

Social graph traversal — LinkedIn "People You May Know": LinkedIn's graph has 950M+ nodes. Finding 2nd-degree connections is a depth-2 BFS from a user node. At this scale, the graph is sharded across machines, and BFS runs in parallel using a superstep model: one BFS level per superstep, with message passing between shards for cross-shard edges. Apache Giraph and Spark GraphX implement this model.

Dependency resolution — Maven/npm/Gradle: Package A depends on B and C; B depends on D. To build A, you must build dependencies before dependents. BFS (or topological sort, which is BFS on a DAG via Kahn's algorithm) gives the correct build order. A cycle in the dependency graph — easily detected during BFS — signals a build error.

Garbage collection — JVM mark phase: The JVM's garbage collector treats live object references as edges and objects as nodes. The mark phase is essentially a BFS or DFS from GC roots (stack frames, static fields), marking every reachable object as live. BFS is preferred in some collectors because its bounded frontier size is more predictable in memory-constrained environments.


⚖️ When BFS Becomes Expensive and When It Beats DFS

BFS and DFS are complementary tools. Choosing the wrong one wastes memory or produces wrong results.

BFS beats DFS when:

  • You need the shortest path in an unweighted graph — DFS cannot guarantee shortest path.
  • The solution is close to the source — BFS finds it with minimal exploration.
  • You need level-order processing — BFS produces this naturally.

DFS beats BFS when:

  • The graph is wide (high branching factor) — the BFS queue grows to the width of the widest level, potentially exhausting memory.
  • You need to find any path (not shortest) — DFS finds a path in O(depth) memory vs O(width) for BFS.
  • You are searching a deep, narrow tree — DFS uses stack space proportional to depth; BFS would hold the entire bottom level in memory.

Common failure mode — the delayed visited mark: Marking a node visited on dequeue instead of enqueue. In a dense graph where node X is reachable from 50 neighbors, X gets enqueued 50 times, each enqueue triggers its own sub-traversal, and complexity explodes. Always mark on enqueue.

Mitigation: Code review checklist: find every queue.offer(node) call and verify visited.add(node) appears directly before or after it, in the same block.


🧭 BFS vs DFS: Choosing the Right Graph Traversal

SituationUse BFSUse DFS
Need shortest path (unweighted graph)✅ BFS guarantees it❌ DFS cannot guarantee it
Need level-by-level tree traversal✅ Natural fit❌ Requires extra bookkeeping
Problem says "minimum steps / hops"✅ One BFS level = one step❌ Not applicable
Problem says "all paths" or "exhaustive"❌ Not its strength✅ DFS + Backtracking
Graph is extremely wide (social networks)⚠️ Watch memory✅ Better memory for wide graphs
Cycle detection in directed graph⚠️ Possible but verbose✅ 3-color DFS is simpler

The one-line decision rule: Use BFS when the problem contains "shortest," "minimum steps," or "level order." Use DFS for everything else.


🧪 Four Classic BFS Problems with Full Java Solutions

Example 1: Binary Tree Level Order Traversal (LeetCode 102)

Return each level's values as a separate list. The key: queue.size() snapshot captures one level.

import java.util.*;

public class LevelOrderTraversal {

    // TreeNode definition assumed: int val; TreeNode left; TreeNode right;
    public List<List<Integer>> levelOrder(TreeNode root) {
        List<List<Integer>> result = new ArrayList<>();
        if (root == null) return result;

        Queue<TreeNode> queue = new ArrayDeque<>();
        queue.offer(root);

        while (!queue.isEmpty()) {
            int size = queue.size();
            List<Integer> level = new ArrayList<>();
            for (int i = 0; i < size; i++) {
                TreeNode node = queue.poll();
                level.add(node.val);
                if (node.left != null)  queue.offer(node.left);
                if (node.right != null) queue.offer(node.right);
            }
            result.add(level);
        }
        return result;
    }
}

Example 2: Binary Tree Zigzag Level Order (LeetCode 103)

Same BFS skeleton; a boolean flag reverses insertion direction each level. Using a LinkedList lets us prepend in O(1) for right-to-left levels.

public List<List<Integer>> zigzagLevelOrder(TreeNode root) {
    List<List<Integer>> result = new ArrayList<>();
    if (root == null) return result;

    Queue<TreeNode> queue = new ArrayDeque<>();
    queue.offer(root);
    boolean leftToRight = true;

    while (!queue.isEmpty()) {
        int size = queue.size();
        LinkedList<Integer> level = new LinkedList<>();
        for (int i = 0; i < size; i++) {
            TreeNode node = queue.poll();
            if (leftToRight) level.addLast(node.val);
            else             level.addFirst(node.val); // O(1) prepend
            if (node.left != null)  queue.offer(node.left);
            if (node.right != null) queue.offer(node.right);
        }
        result.add(level);
        leftToRight = !leftToRight;
    }
    return result;
}

Example 3: Number of Islands (LeetCode 200) — BFS Version

BFS floods each island by "sinking" visited land cells in-place, avoiding a separate visited set.

public int numIslands(char[][] grid) {
    int islands = 0;
    int rows = grid.length, cols = grid[0].length;
    int[][] dirs = {{0,1},{0,-1},{1,0},{-1,0}};

    for (int r = 0; r < rows; r++) {
        for (int c = 0; c < cols; c++) {
            if (grid[r][c] == '1') {
                islands++;
                Queue<int[]> queue = new ArrayDeque<>();
                queue.offer(new int[]{r, c});
                grid[r][c] = '0';   // Sink = mark visited on enqueue
                while (!queue.isEmpty()) {
                    int[] cell = queue.poll();
                    for (int[] d : dirs) {
                        int nr = cell[0] + d[0], nc = cell[1] + d[1];
                        if (nr >= 0 && nr < rows && nc >= 0 && nc < cols
                                && grid[nr][nc] == '1') {
                            grid[nr][nc] = '0';
                            queue.offer(new int[]{nr, nc});
                        }
                    }
                }
            }
        }
    }
    return islands;
}

Example 4: Word Ladder — Shortest Transformation Sequence (LeetCode 127)

BFS on an implicit graph where nodes are words and edges connect words differing by one character. Removing words from the set replaces a visited set — same O(1) lookup, one less allocation.

public int ladderLength(String beginWord, String endWord, List<String> wordList) {
    Set<String> wordSet = new HashSet<>(wordList);
    if (!wordSet.contains(endWord)) return 0;

    Queue<String> queue = new ArrayDeque<>();
    queue.offer(beginWord);
    int steps = 1;

    while (!queue.isEmpty()) {
        int size = queue.size();
        for (int i = 0; i < size; i++) {
            String word = queue.poll();
            char[] chars = word.toCharArray();
            for (int j = 0; j < chars.length; j++) {
                char original = chars[j];
                for (char ch = 'a'; ch <= 'z'; ch++) {
                    if (ch == original) continue;
                    chars[j] = ch;
                    String next = new String(chars);
                    if (next.equals(endWord)) return steps + 1;
                    if (wordSet.contains(next)) {
                        wordSet.remove(next);  // Remove acts as visited mark
                        queue.offer(next);
                    }
                }
                chars[j] = original;
            }
        }
        steps++;
    }
    return 0;
}

🛠️ Neo4j Shortest Path: BFS as a First-Class Graph Database Operation

Neo4j is the leading open-source graph database. Its Graph Data Science (GDS) library exposes BFS-powered shortest path algorithms directly through the Cypher query language:

// Find shortest path between Alice and Bob in the social graph
MATCH (a:Person {name: "Alice"}), (b:Person {name: "Bob"})
CALL gds.shortestPath.dijkstra.stream('socialGraph', {
    sourceNode: id(a),
    targetNode: id(b),
    relationshipWeightProperty: 'strength'
})
YIELD totalCost, path
RETURN [node IN nodes(path) | node.name] AS route, totalCost

For unweighted graphs, Neo4j uses bidirectional BFS — expanding simultaneously from source and target, meeting in the middle. This halves the average frontier size: for a 6-degrees-of-separation network, the search touches roughly the square root of the nodes that unidirectional BFS would reach.

Internally, Neo4j's GDS Java implementation uses an ArrayDeque as the BFS frontier and a BitSet (not a HashSet) for the visited markers. For billion-node graphs, the BitSet uses ~120 MB to track 10^9 boolean flags — a HashSet would need over 30 GB for the same data. The algorithm is structurally identical to the template above; the optimization is purely in the visited data structure.

For a full deep-dive on Neo4j's graph traversal architecture and memory model, see Neo4j's open-source GDS repository at github.com/neo4j/graph-data-science.


📚 Lessons Learned: Five BFS Pitfalls Every Engineer Hits Once

  1. Mark visited at enqueue, not dequeue. This is the most common BFS correctness bug. Marking at dequeue allows the same node to be enqueued multiple times, turning O(V + E) into O(V × E) in dense graphs. Every queue.offer(node) call must be preceded by visited.add(node).

  2. Use int size = queue.size() to capture level boundaries. This snapshot taken before the inner loop ensures the inner for processes exactly the current level. The alternative — a null sentinel between levels — is error-prone and harder to read.

  3. Sink grid cells instead of allocating a visited set. For Number of Islands and similar 2D-grid BFS problems, modifying grid[r][c] = '0' in-place is O(1) per cell and avoids a separate O(n × m) HashSet. Only do this if the problem allows grid mutation (most do).

  4. BFS does not give shortest-weight path. BFS minimizes hops, not total weight. If edge weights differ, Dijkstra (BFS with a PriorityQueue) or Bellman-Ford is required. Confusing unweighted BFS with Dijkstra is a common interview mistake.

  5. Bidirectional BFS halves search space for point-to-point queries. When both source and target are known in advance, expanding from both ends simultaneously reduces the explored frontier from O(b^d) to O(b^(d/2)), where b is the branching factor and d is the distance. Implement this only when BFS performance is a bottleneck; the standard single-source BFS is correct for all cases.


📌 Summary: BFS in Five Key Points

  • BFS explores a graph level by level, using a FIFO queue to guarantee that nodes closer to the source are always processed before nodes farther away.
  • Shortest path in unweighted graphs is BFS's superpower. DFS cannot guarantee shortest path because it may find a longer path before the shorter one.
  • The level-aware template (int size = queue.size() snapshot) is the workhorse for level-order tree problems, minimum-step grid problems, and multi-source BFS.
  • Time O(V + E), Space O(V) — space is dominated by the widest level's node count in the queue.
  • Mark visited on enqueue, never on dequeue — this single discipline prevents the most common BFS correctness and performance bug.

The BFS decision rule in one line: use BFS when the problem asks for "minimum steps," "shortest path," or "level order." Default to DFS for everything else.


📝 Practice Quiz

  1. What data structure does BFS use to maintain the traversal frontier, and why does it guarantee shortest-path discovery?

    • A) Stack (LIFO) — processes the deepest node first
    • B) Queue (FIFO) — processes nodes in discovery order, closest first
    • C) Min-Heap — processes the lowest-cost node first Correct Answer: B
  2. You are given an m × n grid of cells (0 = blocked, 1 = open) and must find the minimum number of steps from the top-left to the bottom-right corner. Which algorithm and template pattern should you use?

    • A) DFS with backtracking and a min-distance tracker
    • B) BFS with int size = queue.size() level tracking, incrementing steps per level
    • C) Binary search on step count with a DFS feasibility check Correct Answer: B
  3. In the BFS Number of Islands solution, cells are marked visited by setting grid[r][c] = '0' at enqueue time. What would happen if you moved this assignment to just before queue.poll() instead?

    • A) Nothing changes — the result is identical
    • B) The same cell could be enqueued multiple times by different neighbors, inflating the island count and degrading performance
    • C) Java would throw a ConcurrentModificationException Correct Answer: B
  4. (Open-ended challenge) Standard BFS guarantees the shortest path in an unweighted graph. Suppose every edge has weight 1 except one edge with weight 3. Would standard BFS still find the minimum-weight path? Describe how you would adapt the algorithm to handle arbitrary non-negative integer weights, and analyze how the data structure change affects time complexity.


Abstract Algorithms

Written by

Abstract Algorithms

@abstractalgorithms