Loading content...
In the previous page, we mastered the fundamental level-order traversal algorithm. That algorithm produces a flat sequence of nodes in level order, but many problems require more: they need to know where one level ends and the next begins.
Consider these common problems:
[[1], [2, 3], [4, 5, 6, 7]]All of these require level-aware processing—knowing not just the order of nodes, but which nodes belong to the same level. This page teaches you the essential technique for tracking level boundaries during BFS.
By the end of this page, you will understand multiple techniques for tracking level boundaries, implement the standard level-by-level BFS pattern, solve classic level-based problems with confidence, and recognize when level-aware versus basic BFS is needed.
Our basic level-order traversal visits nodes in the correct order, but it loses level information. The queue treats all nodes uniformly—we can't tell from the queue alone which nodes belong to which level.
The Core Challenge:
When we're midway through processing, the queue contains nodes from (at most) two consecutive levels: the remainder of the current level and the children we've already enqueued from the next level. We need a way to distinguish between them.
Tree: 1 / \ 2 3 / \ / \ 4 5 6 7 Basic BFS Output: [1, 2, 3, 4, 5, 6, 7] ← Flat listDesired Output: [[1], [2, 3], [4, 5, 6, 7]] ← Grouped by level During traversal, queue states blend levels: After dequeuing 2: Queue = [3, 4, 5] ↑ level 1 ↑↑ level 2 Without extra tracking, how do we know 3 is the last level-1 node?When do we "close" the current level and start a new one? This is the level boundary problem.Why Does This Matter?
Many tree problems are inherently level-based. Some examples:
Level Order as 2D Array: The classic LeetCode problem asks for List<List<Integer>>, not a flat list.
Level Statistics: Finding max/min/sum/average per level requires knowing which nodes belong together.
Level-End Operations: Right side view, left side view, zigzag traversal all need to identify level boundaries.
Tree Visualization: Pretty-printing a tree requires knowing where to insert line breaks.
Level-Based Decisions: Some algorithms make decisions at level transitions (e.g., doubling some parameter each level).
The technique we'll learn is fundamental to all of these.
An important observation: the queue never contains nodes from more than two consecutive levels. When processing level k, the queue starts with all level k nodes. As we dequeue and process them, we add level k+1 children. At any moment, we have remaining level k nodes followed by level k+1 nodes. This property is key to our solutions.
The most elegant solution to the level boundary problem is the level size technique. The key insight is simple but powerful:
At the start of processing any level, the queue contains exactly all nodes of that level and nothing else.
Therefore, if we capture the queue's size at that moment, we know exactly how many nodes belong to the current level. We process exactly that many nodes, and when done, we've completed the level.
123456789101112131415161718192021222324
LEVEL-ORDER-BY-LEVEL(root): 1. If root is null, return empty result 2. Create empty queue Q and empty result list R 3. Enqueue root into Q 4. While Q is not empty: a. levelSize ← current size of Q // CRITICAL: Capture size b. Create empty levelNodes list c. For i from 1 to levelSize: // Process exactly levelSize nodes i. Dequeue front node as 'current' ii. Add current to levelNodes iii. Enqueue left child (if exists) iv. Enqueue right child (if exists) d. Add levelNodes to R // Complete level 5. Return R KEY INSIGHT:- At step 4a, Q contains ONLY current-level nodes- By processing exactly levelSize nodes, we process exactly one level- After the inner loop, Q contains ONLY next-level nodes- The outer loop naturally iterates once per levelTree: 1 / \ 2 3 / \ \ 4 5 6 TRACE: Iteration 1 (Level 0): Queue at start: [1] levelSize = 1 Process 1 node: - Dequeue 1, add to level, enqueue 2, 3 levelNodes = [1] Queue after: [2, 3] Result: [[1]] Iteration 2 (Level 1): Queue at start: [2, 3] levelSize = 2 Process 2 nodes: - Dequeue 2, add to level, enqueue 4, 5 - Dequeue 3, add to level, enqueue 6 levelNodes = [2, 3] Queue after: [4, 5, 6] Result: [[1], [2, 3]] Iteration 3 (Level 2): Queue at start: [4, 5, 6] levelSize = 3 Process 3 nodes: - Dequeue 4, add to level (no children) - Dequeue 5, add to level (no children) - Dequeue 6, add to level (no children) levelNodes = [4, 5, 6] Queue after: [] Result: [[1], [2, 3], [4, 5, 6]] Queue empty, done!Final Result: [[1], [2, 3], [4, 5, 6]]Why This Works:
The technique exploits a crucial invariant: at the start of each outer loop iteration, the queue contains exactly all nodes of the upcoming level.
Initially true: We start with just the root, which is the only node at level 0.
Preserved by iteration: When we process all levelSize nodes of level k, we:
Result: Each outer loop iteration processes exactly one complete level.
This is the backbone of level-aware BFS algorithms.
You MUST capture the queue size BEFORE the inner loop begins. A common bug is checking queue.length in the loop condition itself, which changes as you enqueue children. Always use: levelSize = queue.length followed by for (let i = 0; i < levelSize; i++).
Let's implement the level-by-level BFS pattern in multiple languages, emphasizing the key structural elements.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
class TreeNode { val: number; left: TreeNode | null; right: TreeNode | null; constructor(val: number = 0, left: TreeNode | null = null, right: TreeNode | null = null) { this.val = val; this.left = left; this.right = right; }} /** * Performs level-order traversal returning nodes grouped by level. * * @param root - The root of the binary tree * @returns 2D array where result[i] contains all nodes at level i * * Time Complexity: O(n) * Space Complexity: O(n) for result + O(w) for queue */function levelOrder(root: TreeNode | null): number[][] { if (root === null) { return []; } const result: number[][] = []; const queue: TreeNode[] = [root]; while (queue.length > 0) { // CRITICAL: Capture size at the START of level processing const levelSize = queue.length; const currentLevel: number[] = []; // Process exactly levelSize nodes (one complete level) for (let i = 0; i < levelSize; i++) { const node = queue.shift()!; currentLevel.push(node.val); // Enqueue children for next level if (node.left) queue.push(node.left); if (node.right) queue.push(node.right); } // Add completed level to result result.push(currentLevel); } return result;} // Example:// Tree: 1// / \// 2 3// / \ \// 4 5 6//// Output: [[1], [2, 3], [4, 5, 6]]12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
from collections import dequefrom typing import Optional, List class TreeNode: def __init__(self, val: int = 0, left: 'TreeNode' = None, right: 'TreeNode' = None): self.val = val self.left = left self.right = right def level_order(root: Optional[TreeNode]) -> List[List[int]]: """ Performs level-order traversal returning nodes grouped by level. Args: root: The root of the binary tree Returns: 2D list where result[i] contains all node values at level i Time Complexity: O(n) Space Complexity: O(n) for result + O(w) for queue """ if not root: return [] result = [] queue = deque([root]) while queue: # Capture size before processing level_size = len(queue) current_level = [] # Process exactly level_size nodes for _ in range(level_size): node = queue.popleft() current_level.append(node.val) if node.left: queue.append(node.left) if node.right: queue.append(node.right) result.append(current_level) return result # Example:# level_order(root) returns [[1], [2, 3], [4, 5, 6]]12345678910111213141516171819202122232425262728293031323334353637383940414243444546
import java.util.*; public class Solution { /** * Performs level-order traversal returning nodes grouped by level. * * @param root The root of the binary tree * @return 2D list where result.get(i) contains all nodes at level i * * Time Complexity: O(n) * Space Complexity: O(n) for result + O(w) for queue */ public List<List<Integer>> levelOrder(TreeNode root) { List<List<Integer>> result = new ArrayList<>(); if (root == null) { return result; } Queue<TreeNode> queue = new LinkedList<>(); queue.offer(root); while (!queue.isEmpty()) { // Capture size before processing int levelSize = queue.size(); List<Integer> currentLevel = new ArrayList<>(levelSize); // Process exactly levelSize nodes for (int i = 0; i < levelSize; i++) { TreeNode node = queue.poll(); currentLevel.add(node.val); if (node.left != null) { queue.offer(node.left); } if (node.right != null) { queue.offer(node.right); } } result.add(currentLevel); } return result; }}This pattern is so common that you should recognize it instantly: outer while loop for levels, inner for loop processing exactly levelSize nodes, with size captured before the inner loop. When you see a tree problem requiring level grouping, this structure should come to mind immediately.
While the level size technique is the most elegant, there are other valid approaches. Understanding alternatives deepens comprehension and provides options for different scenarios.
123456789101112131415161718192021222324252627282930
/** * Alternative: Store level information alongside each node. * Useful when you need level info for individual node processing. */function levelOrderWithDepth(root: TreeNode | null): number[][] { if (!root) return []; const result: number[][] = []; // Store [node, level] pairs const queue: [TreeNode, number][] = [[root, 0]]; while (queue.length > 0) { const [node, level] = queue.shift()!; // Ensure result has an array for this level if (result.length === level) { result.push([]); } result[level].push(node.val); // Enqueue children with incremented level if (node.left) queue.push([node.left, level + 1]); if (node.right) queue.push([node.right, level + 1]); } return result;} // Pros: Explicit level info for each node, flexible// Cons: Extra memory for storing levels, more complex queue items1234567891011121314151617181920212223242526272829303132333435
/** * Alternative: Use null markers between levels. * Less common, but conceptually interesting. */function levelOrderWithMarker(root: TreeNode | null): number[][] { if (!root) return []; const result: number[][] = []; const queue: (TreeNode | null)[] = [root, null]; // null marks end of level let currentLevel: number[] = []; while (queue.length > 0) { const node = queue.shift()!; if (node === null) { // End of current level result.push(currentLevel); currentLevel = []; // If more nodes exist, add marker for next level if (queue.length > 0) { queue.push(null); } } else { currentLevel.push(node.val); if (node.left) queue.push(node.left); if (node.right) queue.push(node.right); } } return result;} // Pros: Clear level separation logic// Cons: Adds complexity, null handling required123456789101112131415161718192021222324252627282930313233
/** * Alternative: Alternate between two queues. * Current level in one, next level in the other. */function levelOrderTwoQueues(root: TreeNode | null): number[][] { if (!root) return []; const result: number[][] = []; let currentQueue: TreeNode[] = [root]; let nextQueue: TreeNode[] = []; while (currentQueue.length > 0) { const currentLevel: number[] = []; // Process all nodes in current queue for (const node of currentQueue) { currentLevel.push(node.val); if (node.left) nextQueue.push(node.left); if (node.right) nextQueue.push(node.right); } result.push(currentLevel); // Swap queues currentQueue = nextQueue; nextQueue = []; } return result;} // Pros: Very clear level separation, easy to reason about// Cons: Uses more memory (two queue structures)| Approach | Memory Overhead | Code Complexity | Best Use Case |
|---|---|---|---|
| Level Size (standard) | None | Low | Most problems, clean code |
| Store Level with Node | O(n) extra | Medium | Need level for each node's processing |
| Sentinel Markers | O(h) markers | Medium | When natural to think in terms of markers |
| Two Queues | O(w) extra | Low | When clarity is paramount |
The level size technique (standard approach) should be your default. It's memory-efficient, widely recognized, and suffices for most problems. Use alternatives when they offer specific advantages for your use case.
Now that we have the level-by-level technique, let's see how it elegantly solves a variety of classic problems. Each solution builds directly on our standard pattern.
1234567891011121314151617181920212223242526272829303132333435363738
/** * Find the maximum value at each level of the tree. * * Example: * 1 * / \ * 3 2 * / \ \ * 5 3 9 * * Output: [1, 3, 9] * Level 0: max(1) = 1 * Level 1: max(3, 2) = 3 * Level 2: max(5, 3, 9) = 9 */function largestValues(root: TreeNode | null): number[] { if (!root) return []; const result: number[] = []; const queue: TreeNode[] = [root]; while (queue.length > 0) { const levelSize = queue.length; let levelMax = -Infinity; for (let i = 0; i < levelSize; i++) { const node = queue.shift()!; levelMax = Math.max(levelMax, node.val); if (node.left) queue.push(node.left); if (node.right) queue.push(node.right); } result.push(levelMax); } return result;}1234567891011121314151617181920212223242526272829303132333435363738
/** * Calculate the average value of nodes at each level. * * Example: * 3 * / \ * 9 20 * / \ * 15 7 * * Output: [3.0, 14.5, 11.0] * Level 0: 3/1 = 3.0 * Level 1: (9+20)/2 = 14.5 * Level 2: (15+7)/2 = 11.0 */function averageOfLevels(root: TreeNode | null): number[] { if (!root) return []; const result: number[] = []; const queue: TreeNode[] = [root]; while (queue.length > 0) { const levelSize = queue.length; let levelSum = 0; for (let i = 0; i < levelSize; i++) { const node = queue.shift()!; levelSum += node.val; if (node.left) queue.push(node.left); if (node.right) queue.push(node.right); } result.push(levelSum / levelSize); } return result;}123456789101112131415161718192021222324252627282930313233343536373839
/** * Return values visible from the right side of the tree. * (The last node at each level when viewed left-to-right) * * Example: * 1 <- see 1 * / \ * 2 3 <- see 3 * \ \ * 5 4 <- see 4 * * Output: [1, 3, 4] */function rightSideView(root: TreeNode | null): number[] { if (!root) return []; const result: number[] = []; const queue: TreeNode[] = [root]; while (queue.length > 0) { const levelSize = queue.length; for (let i = 0; i < levelSize; i++) { const node = queue.shift()!; // Last node in this level = rightmost visible node if (i === levelSize - 1) { result.push(node.val); } if (node.left) queue.push(node.left); if (node.right) queue.push(node.right); } } return result;} // Variant: Left Side View - take first node (i === 0) instead123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
/** * Traverse tree level by level, alternating direction. * Even levels: left to right * Odd levels: right to left * * Example: * 3 * / \ * 9 20 * / \ * 15 7 * * Output: [[3], [20, 9], [15, 7]] * Level 0 (L→R): [3] * Level 1 (R→L): [20, 9] * Level 2 (L→R): [15, 7] */function zigzagLevelOrder(root: TreeNode | null): number[][] { if (!root) return []; const result: number[][] = []; const queue: TreeNode[] = [root]; let leftToRight = true; while (queue.length > 0) { const levelSize = queue.length; const currentLevel: number[] = []; for (let i = 0; i < levelSize; i++) { const node = queue.shift()!; // Add to front or back based on direction if (leftToRight) { currentLevel.push(node.val); } else { currentLevel.unshift(node.val); } if (node.left) queue.push(node.left); if (node.right) queue.push(node.right); } result.push(currentLevel); leftToRight = !leftToRight; // Alternate direction } return result;}Notice how every solution uses the same structure: outer while loop, levelSize capture, inner for loop. The only variation is what we do with the nodes: take max, compute sum, check position, or reverse order. Master the structure, and these variations become trivial.
Some problems require knowing not just the level, but also the position of each node within its level. This is especially useful for width calculations and positional problems.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
/** * Find the maximum width of the tree. * Width of a level = rightmost position - leftmost position + 1 * (Including null nodes between existing nodes) * * Example: * 1 * / \ * 3 2 * / \ \ * 5 3 9 * * Width at level 0: 1 (just node 1) * Width at level 1: 2 (nodes 3 and 2) * Width at level 2: 4 (positions: 5 at 0, 3 at 1, null at 2, 9 at 3) * Maximum width: 4 */function widthOfBinaryTree(root: TreeNode | null): number { if (!root) return 0; let maxWidth = 0; // Queue stores [node, position] where position is column index const queue: [TreeNode, bigint][] = [[root, 0n]]; while (queue.length > 0) { const levelSize = queue.length; const firstPos = queue[0][1]; // Position of leftmost node let lastPos = firstPos; for (let i = 0; i < levelSize; i++) { const [node, pos] = queue.shift()!; lastPos = pos; // For a node at position p: // Left child is at 2p, right child is at 2p + 1 if (node.left) { queue.push([node.left, pos * 2n]); } if (node.right) { queue.push([node.right, pos * 2n + 1n]); } } // Width of this level const width = Number(lastPos - firstPos) + 1; maxWidth = Math.max(maxWidth, width); } return maxWidth;} // Note: Using BigInt because positions can grow exponentially// (2^depth), causing overflow for deep trees with number type.Position Indexing for Binary Trees:
When assigning positions to nodes in a binary tree, we use a scheme borrowed from array-based heap representation:
p is at position 2p (or 2p+1)p is at position 2p+1 (or 2p+2)This indexing has a beautiful property: the difference between rightmost and leftmost positions at any level gives the width, including gaps from missing nodes.
Why BigInt?
In a tree of height h, positions at the last level can be as large as 2^h. For h = 64, this exceeds JavaScript's safe integer limit. Using BigInt prevents overflow errors for very deep trees.
In languages without arbitrary-precision integers, you may need to handle overflow carefully. An alternative is to normalize positions at each level by subtracting the first position, keeping numbers manageable. The width calculation only needs relative positions, not absolute ones.
While level-order traversal is naturally iterative (using a queue), it's possible to achieve level-grouped output using recursion with DFS. This is sometimes useful when the rest of your code is recursive or when you want to avoid explicit queue management.
12345678910111213141516171819202122232425262728293031323334
/** * Level order traversal using recursive DFS. * * Key insight: Pass depth as parameter, use it to place nodes * in the correct level of the result. */function levelOrderDFS(root: TreeNode | null): number[][] { const result: number[][] = []; function dfs(node: TreeNode | null, depth: number): void { if (node === null) return; // Create array for this level if it doesn't exist if (result.length === depth) { result.push([]); } // Add current node to its level result[depth].push(node.val); // Recurse on children with incremented depth dfs(node.left, depth + 1); dfs(node.right, depth + 1); } dfs(root, 0); return result;} // Why does this work?// - We visit left subtree before right (preorder)// - Within each level, left children are visited before right// - Depth parameter correctly identifies each node's level// - Result: nodes are grouped by level, left-to-right within levelWhen to Use Recursive Approach:
Combining with other DFS operations: If you're already doing DFS for other reasons (like path calculations), adding level tracking is cheap.
Space considerations: For very wide trees, DFS uses O(height) space vs BFS's O(width). This can be significant.
Existing recursive codebase: When consistency with surrounding recursive code matters.
Important Distinction:
The DFS approach produces the same grouped output as BFS, but nodes are not visited in level order during execution. With BFS, when you process level k, no level k+1 nodes have been touched yet. With DFS, you dive deep first. The result grouping is the same, but the visit sequence differs.
If you only need the final level-grouped output, both approaches work. If the algorithm requires processing entire levels before moving on (e.g., connecting siblings), BFS is required. The recursive approach cannot provide true level-by-level processing during execution.
Let's consolidate the techniques for level-aware tree processing.
queue.length at the start of each level, then process exactly that many nodes.What's Next:
We've mastered how to traverse and process trees level by level. But why is level-order traversal so important beyond just grouping nodes? The answer lies in its deep connection to shortest paths.
In the next page, we'll explore how BFS's level-order property guarantees shortest paths in unweighted structures—a fundamental insight that extends far beyond trees into graph algorithms.
You now have mastery over level-aware BFS traversal. You can group nodes by level, perform level-based computations, and recognize when this technique applies. The level size pattern should feel automatic—capture size, process that many, repeat.