Imagine drawing a tree on a whiteboard and reading it out loud one row at a time: first the root, then everything one step below it, then everything two steps below it, and so on. That row-by-row reading is a level order traversal (also called a breadth-first traversal). You'll implement binaryTreeLevelOrderTraversal(root), which takes the root of a binary tree and returns its values grouped into one array per level, top to bottom, and left to right within each level.
This shows up any time depth means something: rendering a comment thread by reply-depth, laying out an org chart rank by rank, or computing a tree's "right-side view." The grouping by level is the whole point — a flat list of values would lose which row each value sat on.
// A node in the binary tree. Either child may be null.
type TreeNode = {
val: number;
left: TreeNode | null;
right: TreeNode | null;
};
// root: the top node of the tree, or null for an empty tree.
// returns: an array of levels; each level is an array of that row's
// node values, ordered left to right.
function binaryTreeLevelOrderTraversal(root: TreeNode | null): number[][];
A three-level tree. The root 3 is level 0; its children 9 and 20 are level 1; 20's children 15 and 7 are level 2:
// 3
// / \
// 9 20
// / \
// 15 7
const root = {
val: 3,
left: { val: 9, left: null, right: null },
right: {
val: 20,
left: { val: 15, left: null, right: null },
right: { val: 7, left: null, right: null },
},
};
binaryTreeLevelOrderTraversal(root);
// => [[3], [9, 20], [15, 7]]
A single node is a tree with exactly one level:
binaryTreeLevelOrderTraversal({ val: 1, left: null, right: null });
// => [[1]]
root is null, return [] (an empty array, not [[]]).val of each node, not the node objects themselves.left or right may be null independently; a null child contributes nothing to the next level (no placeholder, no gap).val; return each occurrence.You'll read a binary tree row by row — root first, then its children, then their children — and return each row as its own array of values.
You have a binary tree and you want to read it the way you'd read a page: top row, then the next row, then the row below that, each row left to right. That's a level order traversal. The output is one array per row, so a tree three rows tall returns three inner arrays.
The grouping is the whole job. If you only needed every value in top-to-bottom order, a flat list would do. But "which row was each value on" carries real meaning: the root of a comment thread versus its replies, the CEO versus their reports, the deepest visible row in a UI. Lose the row boundaries and you've answered a different, weaker question.
Walk the tree one full row at a time. To do that you need a waiting line — a queue — of nodes you've discovered but not yet looked at. You take nodes off the front; when you look at a node, you put its children on the back. Because children always go behind everything already waiting, the queue naturally hands nodes back to you in top-to-bottom, left-to-right order.
The one subtlety: a plain queue walk visits nodes in the right order but doesn't tell you where one row ends and the next begins. The fix is a single number — a snapshot of the queue's length taken at the start of each row. We'll build up to why that number is exactly what we need.
The instinct is right — use a queue, walk breadth-first. Here's the most direct version:
function flatBfs(root) {
const values = [];
if (root === null) return values;
const queue = [root];
while (queue.length > 0) {
const node = queue.shift();
values.push(node.val);
if (node.left !== null) queue.push(node.left);
if (node.right !== null) queue.push(node.right);
}
return values;
}
Run it on the tree above. It returns [3, 9, 20, 15, 7] — every value, in perfect top-to-bottom, left-to-right order. The traversal order is correct. But the return type is wrong: we were asked for [[3], [9, 20], [15, 7]], and this hands back a single flat list. Nothing in the loop ever decided "the row ends here." The information about which values shared a row is simply gone.
You might next reach for recursion to "fix" the grouping — but it's easy to get the grouping wrong there too. A common buggy shape appends each node's children as a fresh group, keyed off the parent instead of the depth:
function recurseWrong(root) {
const result = [];
function visit(node) {
if (node === null) return;
const row = [];
if (node.left !== null) row.push(node.left.val);
if (node.right !== null) row.push(node.right.val);
if (row.length > 0) result.push(row); // ← groups by PARENT, not by depth
visit(node.left);
visit(node.right);
}
result.push([root.val]);
visit(root);
return result;
}
On the same tree this returns [[3], [9, 20], [15, 7]] — and it looks like it works. But it groups siblings under one parent, not all nodes at one depth. The moment two different parents have children at the same depth, it splits them into separate rows. On the tree 1 → (2 → 4), (3 → 5) it returns [[1], [2, 3], [4], [5]] instead of the correct [[1], [2, 3], [4, 5]]: nodes 4 and 5 are both at depth 2, but they have different parents, so this code files them under separate rows. The depth-first walk also visits the entire left subtree before the right, so even the order across a row can come out wrong. Grouping by "who's your parent" is not the same as grouping by "how deep are you."
Keep the queue from the first attempt. Add one number: before draining a row, record how many nodes are waiting. That count is the size of the current row, because at that instant the queue holds exactly the nodes of one level and nothing else.
function binaryTreeLevelOrderTraversal(root) {
const result = [];
if (root === null) return result; // empty tree → no levels at all
const queue = [root]; // holds the nodes we still need to visit, in arrival order
while (queue.length > 0) {
// Snapshot the count NOW, before we enqueue any children below.
// Everything currently in the queue is exactly one level.
const levelSize = queue.length;
const level = [];
// Drain precisely `levelSize` nodes — this level and nothing more.
for (let i = 0; i < levelSize; i++) {
const node = queue.shift(); // take from the front
level.push(node.val); // record the value, not the node
// Push children onto the BACK. They belong to the next level and are
// not counted by `levelSize`, which we froze above.
if (node.left !== null) queue.push(node.left);
if (node.right !== null) queue.push(node.right);
}
result.push(level);
}
return result;
}
module.exports = { binaryTreeLevelOrderTraversal };
The shift from the naive version is small but decisive. Take the non-obvious pieces in turn.
Why levelSize is read before the inner loop, not inside it. When the outer while checks the queue at the top of a row, the queue holds precisely the nodes of that row — their children haven't been pushed yet. Capturing queue.length at that moment freezes the row's size. The inner for then runs exactly that many times. If you instead looped while (queue.length > 0) on the inner loop, you'd never stop: every node you process pushes children, the queue never empties mid-row, and you'd drain the whole tree into one array — straight back to the flat-BFS bug.
Why children go on the back while we take from the front. A queue is first-in, first-out. The current row's nodes are at the front; their children, pushed to the back, sit behind any nodes still waiting. So a node's children are always served after every node already in line and in the left-to-right order we pushed them. That ordering is what makes the next snapshot describe the next full row.
Why we push node.val, not node. The contract asks for values grouped by level, not the node objects. Pushing node.val builds arrays of numbers directly; forgetting the .val returns arrays of { val, left, right } objects, which fails the "values, not nodes" requirement.
Why the explicit !== null checks on each child. A missing child is null. Pushing null onto the queue would later blow up at node.val (reading .val of null throws) and would also corrupt the count for the next row. Guarding each child keeps nulls out of the queue entirely, so the snapshot only ever counts real nodes.
The empty-tree guard. If root is null, the queue would start as [null], the loop would dequeue it, and node.val would throw. Returning result (an empty array) up front matches the contract: an empty tree has zero levels, so the answer is [], not [[]].
Why we use a fresh level array each iteration. Each pass of the while builds one row and pushes it into result. Allocating a new level per row keeps rows independent; reusing one array across rows would have every entry in result point at the same (final) contents.
Let's run the three-level tree from the prompt end to end, watching the queue and the snapshot at the start of each row.
// 3
// / \
// 9 20
// / \
// 15 7
init queue = [3] result = []
── row A ──
levelSize = 1 (snapshot the front: just node 3)
shift 3 level = [3]; push 3's children 9, 20
queue = [9, 20]
push row result = [[3]]
── row B ──
levelSize = 2 (snapshot: nodes 9 and 20 are this row)
shift 9 level = [9]; 9 has no children
shift 20 level = [9,20]; push 20's children 15, 7
queue = [15, 7]
push row result = [[3], [9, 20]]
── row C ──
levelSize = 2 (snapshot: nodes 15 and 7 are this row)
shift 15 level = [15]; 15 has no children
shift 7 level = [15,7]; 7 has no children
queue = []
push row result = [[3], [9, 20], [15, 7]]
queue empty → return [[3], [9, 20], [15, 7]]
The pivotal moment is the start of row B. The queue holds [9, 20]; we snapshot levelSize = 2 before touching anything. As we drain those two, node 20 pushes 15 and 7 onto the back — but the snapshot was already 2, so the inner loop stops after two shifts and 15/7 are correctly held over for row C. Had we read queue.length after pushing 20's children, it would read 3, and the row boundary would land in the wrong place.
node.val straight into one flat array returns [3, 9, 20, 15, 7] — correct order, wrong shape. The snapshot is the only thing that closes each row. Without it you've solved a different problem (flatten the tree breadth-first), not this one.queue.length after enqueuing children. The count must be taken at the top of the row, before any child is pushed. Read it mid-drain and it includes nodes from the next level, so your row sizes drift and values land in the wrong inner array. Freeze levelSize first; never re-read queue.length inside the inner loop.null children onto the queue. If you skip the !== null checks and push both children unconditionally, nulls enter the queue. They inflate the next levelSize, and dequeuing one then doing node.val throws Cannot read properties of null. Guard each child so only real nodes ever enter the line.root === null must return [] before the loop. Skip the guard and the queue starts as [null]; the first node.val throws. Note the contract wants [], not [[]] — zero levels, not one empty level.for (let i = 0; i < levelSize; i++) against the captured size, not the live queue.length. If you write the inner loop as for (let i = 0; i < queue.length; i++) while also pushing children, the bound grows under you and you walk past the row boundary. Loop against the snapshot, not the changing length.node rather than node.val yields arrays of { val, left, right } objects. The output must be plain numbers per level — push .val.depth argument, and append each value to result[depth] (creating the inner array the first time you reach a new depth): function visit(node, depth) { if (!node) return; if (result.length === depth) result.push([]); result[depth].push(node.val); visit(node.left, depth + 1); visit(node.right, depth + 1); }. The key is that the depth index — not the parent — decides the row, which is exactly what the buggy recursion above got wrong. Left-to-right order falls out because you always recurse left before right.unshift into level (or reverse it before pushing). The traversal is unchanged — only how you lay each row into its array flips.levels.map(row => row[row.length - 1]). Computing level order first makes it a one-liner; this is a direct payoff of keeping the rows grouped instead of flattened.[[15, 7], [9, 20], [3]]) by reversing result at the end, or by unshift-ing each completed row to the front instead of pushing to the back. Same traversal, reversed assembly.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Imagine drawing a tree on a whiteboard and reading it out loud one row at a time: first the root, then everything one step below it, then everything two steps below it, and so on. That row-by-row reading is a level order traversal (also called a breadth-first traversal). You'll implement binaryTreeLevelOrderTraversal(root), which takes the root of a binary tree and returns its values grouped into one array per level, top to bottom, and left to right within each level.
This shows up any time depth means something: rendering a comment thread by reply-depth, laying out an org chart rank by rank, or computing a tree's "right-side view." The grouping by level is the whole point — a flat list of values would lose which row each value sat on.
// A node in the binary tree. Either child may be null.
type TreeNode = {
val: number;
left: TreeNode | null;
right: TreeNode | null;
};
// root: the top node of the tree, or null for an empty tree.
// returns: an array of levels; each level is an array of that row's
// node values, ordered left to right.
function binaryTreeLevelOrderTraversal(root: TreeNode | null): number[][];
A three-level tree. The root 3 is level 0; its children 9 and 20 are level 1; 20's children 15 and 7 are level 2:
// 3
// / \
// 9 20
// / \
// 15 7
const root = {
val: 3,
left: { val: 9, left: null, right: null },
right: {
val: 20,
left: { val: 15, left: null, right: null },
right: { val: 7, left: null, right: null },
},
};
binaryTreeLevelOrderTraversal(root);
// => [[3], [9, 20], [15, 7]]
A single node is a tree with exactly one level:
binaryTreeLevelOrderTraversal({ val: 1, left: null, right: null });
// => [[1]]
root is null, return [] (an empty array, not [[]]).val of each node, not the node objects themselves.left or right may be null independently; a null child contributes nothing to the next level (no placeholder, no gap).val; return each occurrence.You'll read a binary tree row by row — root first, then its children, then their children — and return each row as its own array of values.
You have a binary tree and you want to read it the way you'd read a page: top row, then the next row, then the row below that, each row left to right. That's a level order traversal. The output is one array per row, so a tree three rows tall returns three inner arrays.
The grouping is the whole job. If you only needed every value in top-to-bottom order, a flat list would do. But "which row was each value on" carries real meaning: the root of a comment thread versus its replies, the CEO versus their reports, the deepest visible row in a UI. Lose the row boundaries and you've answered a different, weaker question.
Walk the tree one full row at a time. To do that you need a waiting line — a queue — of nodes you've discovered but not yet looked at. You take nodes off the front; when you look at a node, you put its children on the back. Because children always go behind everything already waiting, the queue naturally hands nodes back to you in top-to-bottom, left-to-right order.
The one subtlety: a plain queue walk visits nodes in the right order but doesn't tell you where one row ends and the next begins. The fix is a single number — a snapshot of the queue's length taken at the start of each row. We'll build up to why that number is exactly what we need.
The instinct is right — use a queue, walk breadth-first. Here's the most direct version:
function flatBfs(root) {
const values = [];
if (root === null) return values;
const queue = [root];
while (queue.length > 0) {
const node = queue.shift();
values.push(node.val);
if (node.left !== null) queue.push(node.left);
if (node.right !== null) queue.push(node.right);
}
return values;
}
Run it on the tree above. It returns [3, 9, 20, 15, 7] — every value, in perfect top-to-bottom, left-to-right order. The traversal order is correct. But the return type is wrong: we were asked for [[3], [9, 20], [15, 7]], and this hands back a single flat list. Nothing in the loop ever decided "the row ends here." The information about which values shared a row is simply gone.
You might next reach for recursion to "fix" the grouping — but it's easy to get the grouping wrong there too. A common buggy shape appends each node's children as a fresh group, keyed off the parent instead of the depth:
function recurseWrong(root) {
const result = [];
function visit(node) {
if (node === null) return;
const row = [];
if (node.left !== null) row.push(node.left.val);
if (node.right !== null) row.push(node.right.val);
if (row.length > 0) result.push(row); // ← groups by PARENT, not by depth
visit(node.left);
visit(node.right);
}
result.push([root.val]);
visit(root);
return result;
}
On the same tree this returns [[3], [9, 20], [15, 7]] — and it looks like it works. But it groups siblings under one parent, not all nodes at one depth. The moment two different parents have children at the same depth, it splits them into separate rows. On the tree 1 → (2 → 4), (3 → 5) it returns [[1], [2, 3], [4], [5]] instead of the correct [[1], [2, 3], [4, 5]]: nodes 4 and 5 are both at depth 2, but they have different parents, so this code files them under separate rows. The depth-first walk also visits the entire left subtree before the right, so even the order across a row can come out wrong. Grouping by "who's your parent" is not the same as grouping by "how deep are you."
Keep the queue from the first attempt. Add one number: before draining a row, record how many nodes are waiting. That count is the size of the current row, because at that instant the queue holds exactly the nodes of one level and nothing else.
function binaryTreeLevelOrderTraversal(root) {
const result = [];
if (root === null) return result; // empty tree → no levels at all
const queue = [root]; // holds the nodes we still need to visit, in arrival order
while (queue.length > 0) {
// Snapshot the count NOW, before we enqueue any children below.
// Everything currently in the queue is exactly one level.
const levelSize = queue.length;
const level = [];
// Drain precisely `levelSize` nodes — this level and nothing more.
for (let i = 0; i < levelSize; i++) {
const node = queue.shift(); // take from the front
level.push(node.val); // record the value, not the node
// Push children onto the BACK. They belong to the next level and are
// not counted by `levelSize`, which we froze above.
if (node.left !== null) queue.push(node.left);
if (node.right !== null) queue.push(node.right);
}
result.push(level);
}
return result;
}
module.exports = { binaryTreeLevelOrderTraversal };
The shift from the naive version is small but decisive. Take the non-obvious pieces in turn.
Why levelSize is read before the inner loop, not inside it. When the outer while checks the queue at the top of a row, the queue holds precisely the nodes of that row — their children haven't been pushed yet. Capturing queue.length at that moment freezes the row's size. The inner for then runs exactly that many times. If you instead looped while (queue.length > 0) on the inner loop, you'd never stop: every node you process pushes children, the queue never empties mid-row, and you'd drain the whole tree into one array — straight back to the flat-BFS bug.
Why children go on the back while we take from the front. A queue is first-in, first-out. The current row's nodes are at the front; their children, pushed to the back, sit behind any nodes still waiting. So a node's children are always served after every node already in line and in the left-to-right order we pushed them. That ordering is what makes the next snapshot describe the next full row.
Why we push node.val, not node. The contract asks for values grouped by level, not the node objects. Pushing node.val builds arrays of numbers directly; forgetting the .val returns arrays of { val, left, right } objects, which fails the "values, not nodes" requirement.
Why the explicit !== null checks on each child. A missing child is null. Pushing null onto the queue would later blow up at node.val (reading .val of null throws) and would also corrupt the count for the next row. Guarding each child keeps nulls out of the queue entirely, so the snapshot only ever counts real nodes.
The empty-tree guard. If root is null, the queue would start as [null], the loop would dequeue it, and node.val would throw. Returning result (an empty array) up front matches the contract: an empty tree has zero levels, so the answer is [], not [[]].
Why we use a fresh level array each iteration. Each pass of the while builds one row and pushes it into result. Allocating a new level per row keeps rows independent; reusing one array across rows would have every entry in result point at the same (final) contents.
Let's run the three-level tree from the prompt end to end, watching the queue and the snapshot at the start of each row.
// 3
// / \
// 9 20
// / \
// 15 7
init queue = [3] result = []
── row A ──
levelSize = 1 (snapshot the front: just node 3)
shift 3 level = [3]; push 3's children 9, 20
queue = [9, 20]
push row result = [[3]]
── row B ──
levelSize = 2 (snapshot: nodes 9 and 20 are this row)
shift 9 level = [9]; 9 has no children
shift 20 level = [9,20]; push 20's children 15, 7
queue = [15, 7]
push row result = [[3], [9, 20]]
── row C ──
levelSize = 2 (snapshot: nodes 15 and 7 are this row)
shift 15 level = [15]; 15 has no children
shift 7 level = [15,7]; 7 has no children
queue = []
push row result = [[3], [9, 20], [15, 7]]
queue empty → return [[3], [9, 20], [15, 7]]
The pivotal moment is the start of row B. The queue holds [9, 20]; we snapshot levelSize = 2 before touching anything. As we drain those two, node 20 pushes 15 and 7 onto the back — but the snapshot was already 2, so the inner loop stops after two shifts and 15/7 are correctly held over for row C. Had we read queue.length after pushing 20's children, it would read 3, and the row boundary would land in the wrong place.
node.val straight into one flat array returns [3, 9, 20, 15, 7] — correct order, wrong shape. The snapshot is the only thing that closes each row. Without it you've solved a different problem (flatten the tree breadth-first), not this one.queue.length after enqueuing children. The count must be taken at the top of the row, before any child is pushed. Read it mid-drain and it includes nodes from the next level, so your row sizes drift and values land in the wrong inner array. Freeze levelSize first; never re-read queue.length inside the inner loop.null children onto the queue. If you skip the !== null checks and push both children unconditionally, nulls enter the queue. They inflate the next levelSize, and dequeuing one then doing node.val throws Cannot read properties of null. Guard each child so only real nodes ever enter the line.root === null must return [] before the loop. Skip the guard and the queue starts as [null]; the first node.val throws. Note the contract wants [], not [[]] — zero levels, not one empty level.for (let i = 0; i < levelSize; i++) against the captured size, not the live queue.length. If you write the inner loop as for (let i = 0; i < queue.length; i++) while also pushing children, the bound grows under you and you walk past the row boundary. Loop against the snapshot, not the changing length.node rather than node.val yields arrays of { val, left, right } objects. The output must be plain numbers per level — push .val.depth argument, and append each value to result[depth] (creating the inner array the first time you reach a new depth): function visit(node, depth) { if (!node) return; if (result.length === depth) result.push([]); result[depth].push(node.val); visit(node.left, depth + 1); visit(node.right, depth + 1); }. The key is that the depth index — not the parent — decides the row, which is exactly what the buggy recursion above got wrong. Left-to-right order falls out because you always recurse left before right.unshift into level (or reverse it before pushing). The traversal is unchanged — only how you lay each row into its array flips.levels.map(row => row[row.length - 1]). Computing level order first makes it a one-liner; this is a direct payoff of keeping the rows grouped instead of flattened.[[15, 7], [9, 20], [3]]) by reversing result at the end, or by unshift-ing each completed row to the front instead of pushing to the back. Same traversal, reversed assembly.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.