Why does a naive explicit-stack rewrite lose the work a recursion does after its recursive calls?
answer
- what does a frame remember besides its arguments?
- the parent still owes an addition
- a popped node is never seen again
- you need a second visit, after the children
- tag each entry with enter or exit
basics
~20 sPushing children captures only the descent. A real frame also remembers where to resume, because the parent still owes work once its children finish. Push each node twice with a phase tag so the parent is revisited after its subtree completes.
solid answer
~50 sA call frame is parameters plus locals plus a resume point, and the naive rewrite keeps only the parameters. When a category's recursion sums its own stock and then adds the finished totals of its children, that final addition is the resume point — and "push all children, pop, repeat" throws it away, because a popped node is never revisited. The standard fix is to encode the resume point explicitly: push the node tagged `exit` first, then its children tagged `enter`, so the parent is popped again only after its whole subtree has been processed and its children's results are available. What you have written at that point is a hand-rolled program counter, which is the honest cost of the conversion. The trap is that a single order-independent accumulator — one global total — still comes out right, so the loss shows up only when parents need their children's individual results.
code
pseudocode · 15 linesstack = empty
push(stack, (root, "enter"))
sums = empty map
while stack is not empty:
(node, phase) = pop(stack)
if phase == "enter":
push(stack, (node, "exit"))
for i in length(node.children)-1 downto 0:
push(stack, (node.children[i], "enter"))
else:
s = node.stock
for i in 0..length(node.children)-1:
s = s + sums[node.children[i]]
sums[node] = s
return sums[root]go deeper
Know that a popped node is gone unless you push it again, so any work a parent owes after its children must be scheduled deliberately. The phrase to remember is second visit.
Explain the resume point as the third thing a frame holds, and walk the enter/exit encoding: parent's exit entry pushed first, children pushed after it and in reverse. Say why a single global total still looks correct.
Judge whether the hand-rolled control flow is worth owning: what depth guarantee the data actually gives you, and what test proves the aggregation, not just the sum, survived the rewrite.
Take a position on hand-encoded frames in shared code — where the team may write one, what documentation or invariant it carries, and when reshaping the data to bound depth beats simulating a call stack forever.
## The part of the frame the rewrite drops A frame holds three things: the parameter values for this invocation, its local variables, and a **return address** — where in the body to carry on when the call returns. For a recursion that does nothing after its call, the return address is dead weight. For a recursion that aggregates, it is the whole point. Take a catalogue where every category must end up with its own rolled-up subtotal: a category's total is its own stock plus the totals of each sub-category. The recursion writes itself — recurse on each child, then add the returned values. That addition happens **after** the calls, at the return address. A rewrite that only pushes children models the descent perfectly and models the return not at all: a node is popped once, processed once, and never seen again. There is no moment at which its children are known to be finished. ## Why the loss is easy to miss If the only output is one global total, the descent-only rewrite still gives the right number. Addition into a single accumulator is order-independent and every node is visited exactly once, so the answer matches. Everything that needs the *structure* of the aggregation breaks: - per-category subtotals written back onto each node; - the deepest path in the tree, or the height of each subtree; - a flag like "this category and everything under it is out of stock"; - releasing or unwinding something the descent acquired; - any decision a parent makes conditioned on what its children returned. A passing total is therefore not evidence the rewrite is correct. ## Encoding the resume point The common technique is a phase tag on each stack entry. Popping a node in the `enter` phase means "I have not descended yet": push the same node back tagged `exit`, then push its children tagged `enter`. Because the children land above the parent's `exit` entry, every one of them is fully processed before that entry is popped — and when it is, the post-call work runs with all child results in hand. ``` stack = empty push(stack, (root, "enter")) sums = empty map while stack is not empty: (node, phase) = pop(stack) if phase == "enter": push(stack, (node, "exit")) for i in length(node.children)-1 downto 0: push(stack, (node.children[i], "enter")) else: s = node.stock for i in 0..length(node.children)-1: s = s + sums[node.children[i]] sums[node] = s return sums[root] ``` Each node is pushed twice and popped twice, so the work is still linear in the number of nodes. The tag is doing exactly what the return address did: telling the loop which half of the body to run. Notice the two orderings that both have to be right — the `exit` entry must go on *before* the children, and the children go on in reverse so they are explored in their natural order. ## The alternatives, and what they cost **A separate result stack.** Push results as subtrees complete and have each parent pop as many as it has children. Compact, and completely opaque to the next reader, because the correspondence between nodes and results is implicit in the counts. **An unfinished-children counter.** Give each node a count, decrement it when a child finishes, and run the parent's post-work when it reaches zero. This scales naturally to more than two phases, but it means mutating nodes or maintaining a side table. **Push a resume index instead of a phase.** Rather than two phases, store "I am between child 2 and child 3", which is the general case — it is literally an instruction pointer for the frame, and it is what you end up with when the body interleaves work between the calls rather than only after them. All three are the same admission: to remove the call stack you must reproduce what it stored, and the resume point is the piece people forget. ## When it is worth writing It is worth it when depth is genuinely unbounded by the data and the aggregation must still happen — a supplier-provided catalogue with no depth guarantee, an import path where a single bad tree must not take the process down. It is not worth it for a tree whose depth is bounded by a handful of levels, where you have traded four lines that read like the definition of the problem for twenty lines of hand-managed control flow that the next person has to simulate mentally to trust. "Any recursion converts to a loop" is true; "the loop version is simpler" is the part that is not.
- How many times is each node pushed and popped in the two-phase version, and what does that cost?Twice each — once entering, once exiting — so the total work stays linear in the number of nodes with a constant factor of about two. Peak stack size grows too, since every ancestor's pending exit entry sits below the current path's unexplored siblings, but each entry is a reference plus a small tag.
- The phase tag has two values here. When is two not enough?When the body does work between the recursive calls rather than only after all of them — check a child's result, decide whether to descend into the next. Then you must store a resume index saying which child you were up to, which is exactly the instruction pointer a real frame kept for you.
The descent-only rewrite is a reading list with no bookmarks: you know which chapters to open, but nothing records that you were halfway through the last one.
saying these in an interview costs you the question
- Believes pushing all children reproduces the recursion exactly
- Concludes the rewrite is correct because the global total matches
- Thinks a frame stores only arguments, not a resume point
- Runs the parent's aggregation on the way down instead
- Claims the two-phase version changes the linear time bound