Tree level order: keep the next frontier separate
Constructed practice problem; no company attribution. Prerequisites: node identity; this page introduces binary trees and traversal depth.
Candidate brief
A dependency inspector displays a binary tree one depth at a time, left child before right child. Return one list of values per depth. Equal labels may identify different nodes. How will you avoid mixing children discovered during a level into that same level?
Write this:
@dataclass(eq=False)
class Node:
value: object
left: "Node | None" = None
right: "Node | None" = None
def tree_level_order(root):
...
| Contract | Decision |
|---|---|
| Input | Node root or None; each node has opaque value and left/right child references |
| Output | Lists of values by depth, in left-to-right order; root is depth 0 |
| Boundaries | Empty gives []; duplicate values remain separate entries; no mutation |
| Invalid input | Malformed child links, cycles, or a child shared by multiple parents raise ValueError |
| Excluded | DAG traversal and concurrent topology changes; output stores value references |
Optional refresher · the underlying tool
Breadth-first traversal uses a FIFO queue so every node at depth d is visited before nodes at d+1. Freeze the current queue length before processing one level:
from collections import deque
queue = deque([root])
level_size = len(queue) # children appended later belong to next level
For root 1 with children 2 and 3, return [[1], [2,3]]. An empty tree returns [], not a list containing an empty level.
A design choice worth saying aloud
The queue contains nodes still to be visited, not completed values. Capture level_size before appending children: without that boundary, the first pass swallows the next level. If the input graph can share a node or contain a cycle, define whether traversal is a tree-only contract or add identity-based visited tracking.
What the interviewer expects
The interviewer gives you the scenario and the contract above. Explain what a successful call returns, walk through one example below, and name what your state means before choosing a data structure.
Done means: Lists of values by depth, in left-to-right order; root is depth 0.
Now predict each output before looking at the reference; invalid input should leave any existing state unchanged unless the contract says otherwise.
Test-case scenarios to settle before coding
01 · Representative
- Input / starting state
- root a; children b,c; b has d
- Expected result
[[a],[b,c],[d]]by value
What it is testing: Queue boundaries preserve levels.
02 · Empty
- Input / starting state
None- Expected result
[]
What it is testing: No empty level is emitted.
03 · Singleton
- Input / starting state
- one node
- Expected result
- one one-element level
What it is testing: Depth zero is represented.
04 · Duplicate values
- Input / starting state
- distinct nodes with equal values
- Expected result
- both values appear
What it is testing: Topology, not a value set, controls visitation.
05 · Shared child
- Input / starting state
- left and right reference same node
- Expected result
ValueError
What it is testing: The input must be a tree, not a DAG.
06 · Cycle/malformed
- Input / starting state
- child returns to ancestor or invalid object
- Expected result
ValueError; no mutation
What it is testing: Traversal must terminate safely.
For each case, show which branch or state change produces that result.
A root a, children b, c, and b.right = d gives
tree_level_order(a) == [["a"], ["b", "c"], ["d"]].
Two distinct children both valued "x" produce ["x", "x"].
Pointing both root children at the same node raises ValueError.
Before opening the explanation, restate the contract, trace the smallest useful example, implement a baseline, and identify the repeated work. Then implement your improvement independently and derive tests from the contract. Say what your state means before saying which data structure stores it.
Worked lesson, changed requirements, and reference
Define a tree before choosing a traversal
A binary tree has at most two ordered children per node, one parent per nonroot node, and no cycles. Depth counts edges from the root. Node identity determines whether a node repeats; equal values do not. A simple baseline first finds tree height, then walks from the root separately for each depth. It revisits upper nodes and can take O(nh) time, including O(n²) on a chain of height n. Retaining the next frontier avoids those repeated walks.
Use a first-in, first-out queue, which removes the oldest enqueued node. At the start of an outer iteration, the queue contains exactly one level in left-to-right order. Snapshot its current length and pop exactly that many nodes, appending their values to the current output list and enqueueing left then right children. New children remain queued for the next iteration; looping until the queue is empty inside a level would mix every depth together.
| Level start queue | Frozen pop count | Values emitted | Queue afterward |
|---|---|---|---|
| a | 1 | [a] |
b, c |
| b, c | 2 | [b, c] |
d |
| d | 1 | [d] |
empty |
Every node is enqueued once, and every child is one edge deeper than its parent,
so the invariant advances to the next level. The reference uses deque.popleft;
removing index zero from a Python list repeatedly shifts its remaining elements.
It also records node identities when enqueuing and rejects repeats, covering
both cycles and shared-child DAGs. Total time is O(n). The BFS frontier costs
O(w), where w is maximum level width (the queue may contain parts of two levels,
still O(w)); structural validation adds O(n) auxiliary identity storage. Returned
values and nested lists cost O(n) output. For a guaranteed valid tree, omitting
the seen set recovers O(w) auxiliary space but weakens the checked contract.
Follow-up 1: return zigzag levels
Predict which ordering must change. Keep traversal order ordinary so child discovery remains simple; reverse the values of every odd-numbered output level.
| Depth | Queue traversal order | Display order |
|---|---|---|
| 0 | a | a |
| 1 | b, c | c, b |
| 2 | d | d |
Do not reverse child enqueue order opportunistically: it changes later frontiers and is harder to prove. Reversing complete level lists adds O(n) total work.
Follow-up 2: the input becomes a DAG
The original API rejects this shape. A changed contract could emit each identity once at its shortest distance from root, marking visited on enqueue; BFS still establishes shortest unweighted distance. Alternatively, displaying every path can duplicate c and may have exponentially many entries. Explicit cycle policy is then necessary. A senior candidate names frontier and identity invariants and handles a deep chain iteratively. A lead candidate chooses whether the UI shows unique entities or containment paths and budgets output size accordingly.
Run and check
From the repository root:
cd curriculum/01-code/02-data-structures-algorithms/problems/17-tree-level-order
python -m unittest -v test_solution.py
Reference implementation (download file, source below) · Contract and oracle tests (download file, source below). Read the tests after your attempt. A green reference suite verifies the supplied implementation; it does not demonstrate independent transfer. Reimplement one follow-up with the reference closed and explain which old invariant no longer holds.
from dataclasses import dataclass
@dataclass(eq=False)
class Node:
value: object
left: "Node | None" = None
right: "Node | None" = None
from collections import deque
def tree_level_order(root):
if root is None:
return []
if not isinstance(root, Node):
raise ValueError("root must be Node or None")
queue = deque([root])
visited_nodes = {root}
result = []
while queue:
level = []
for _ in range(len(queue)):
node = queue.popleft()
level.append(node.value)
for child in (node.left, node.right):
if child is None:
continue
if not isinstance(child, Node) or child in visited_nodes:
raise ValueError("expected a tree without malformed or repeated nodes")
visited_nodes.add(child)
queue.append(child)
result.append(level)
return result
import random
import unittest
from solution import Node, tree_level_order
class LevelTests(unittest.TestCase):
def test_examples_depth_and_identity(self):
root = Node("a", Node("b", right=Node("d")), Node("c"))
self.assertEqual(tree_level_order(root), [["a"], ["b", "c"], ["d"]])
self.assertEqual(tree_level_order(Node(0, Node("x"), Node("x"))), [[0], ["x", "x"]])
self.assertEqual(tree_level_order(None), [])
deep = None
for i in reversed(range(3000)):
deep = Node(i, right=deep)
self.assertEqual(tree_level_order(deep), [[i] for i in range(3000)])
def test_reject_graphs_and_malformed_links(self):
child = Node(1)
for root in [42, Node(0, left=42), Node(0, child, child)]:
with self.assertRaises(ValueError):
tree_level_order(root)
root = Node(0)
root.left = root
with self.assertRaises(ValueError):
tree_level_order(root)
self.assertIs(root.left, root)
def test_dfs_depth_oracle(self):
rng = random.Random(117)
def build(depth):
if depth == 0 or rng.random() < .25:
return None
return Node(rng.randrange(4), build(depth - 1), build(depth - 1))
def visit(node, depth, levels):
if node is None:
return
if depth == len(levels):
levels.append([])
levels[depth].append(node.value)
visit(node.left, depth + 1, levels)
visit(node.right, depth + 1, levels)
for _ in range(200):
root = build(6)
expected = []
visit(root, 0, expected)
self.assertEqual(tree_level_order(root), expected)
if __name__ == "__main__":
unittest.main()