cf.completefrontend
TREES & TRIES · CORE

Root-to-leaf paths

Walk down a tree while keeping the path from the root to the current node, and act on it whenever you reach a leaf.

Time O(n * h)Space O(h)8 practice problemsStep-through picture

The idea

A root-to-leaf path is the list of nodes you pass going from the root down to a leaf, a node with no children. A tree has exactly one such path per leaf. Breadcrumbs in a menu and file paths in a folder tree are root-to-leaf paths.

To list them all, walk the tree depth-first and keep the current path in an array called path. When you step into a node, add it to the end. When you finish with it, remove it. This undo step is called backtracking: it puts path back the way the parent had it, so the parent can try its next child.

At a leaf, path holds exactly the route from the root to that leaf, so you can save a copy, check its sum, or build a string from it.

An everyday example

Writing down the trail on a hike

You hike a trail system where every fork splits into smaller trails. You write each landmark on a notepad as you pass it. When a trail ends, you photograph your notepad: that photo is one complete route. Then you walk back to the last fork, crossing off the landmarks you are leaving, and try the other trail.

How the example maps to the code
In the exampleIn the code
The notepadpath
Writing down a landmarkpath.push(node.val)
A trail that endsnode.left === null && node.right === null
Photographing the notepadpaths.push([...path])
Crossing off a landmark when you walk backpath.pop()

From the slow way to root-to-leaf paths

  1. A slow way to list every root-to-leaf path: first find every leaf, then for each leaf search the tree again for the route that reaches it. With many leaves, that's many full searches of up to n nodes each, O(n²) in total.
  2. But a depth-first walk already passes along every route. While the walk is standing on a node, the calls waiting on the call stack are exactly that node's ancestors, in order from the root. The route is already there; we only need to write it down.
  3. Keep it in one shared array, path. Entering a node pushes its value, so path grows as the walk goes down. Leaving a node must undo that push, so that the parent sees its own route again before it walks into its next child. This push-then-pop pattern is called backtracking.
  4. When the walk stands on a leaf, path is a complete route from the root, so save a copy of it, because the array itself keeps changing. One DFS with a shared array, a push on entry, a copy at each leaf, and a pop on exit: that's root-to-leaf path listing.

How it works

  1. Keep paths for the answers and one shared path for the current route.
  2. In walk(node), stop at null.
  3. Add the node: path.push(node.val).
  4. If the node is a leaf, save a copy of path into paths.
  5. Otherwise walk into node.left and then node.right.
  6. Before returning, remove the node again with path.pop(), so the parent's path is unchanged.

Watch it run

This picture runs the code below on a real input. Press Step to move one step at a time, or Play to watch. The highlighted line is the one that just ran. Change the input to see other cases.

Loading the picture…
See every step at onceThe same run, laid out side by side
Drawing every step…

The code

Point at a note to light up the lines it explains.

  1. function rootToLeafPaths(root) {
  2. const paths = [];
  3. const path = [];
  4. function walk(node) {
  5. if (node === null) return;
  6. path.push(node.val);
  7. if (node.left === null && node.right === null) {
  8. paths.push([...path]);
  9. } else {
  10. walk(node.left);
  11. walk(node.right);
  12. }
  13. path.pop();
  14. }
  15. walk(root);
  16. return paths;
  17. }

How fast it is

TimeO(n * h)
Extra memoryO(h)

The walk visits each node once. Saving a path copies up to h values, once per leaf. The shared path and the call stack hold at most one route, not counting the answer.

  • Best caseO(n)

    A chain: there is only one leaf, so one copy of length n is made, plus the n visits.

  • Average caseO(n log n)

    A balanced tree: about n / 2 leaves, each copied with a route of about log₂ n values.

  • Worst caseO(n · h)

    A chain of n / 2 nodes where each node also has a leaf hanging off its side. The routes have lengths 2, 3, 4, and so on, so the copies add up to about n² / 8 values.

Common mistakes

  • Saving path itself instead of a copy. Every saved route is the same array, which is empty at the end. Save [...path].
  • Forgetting path.pop(). Nodes from finished branches stay in the route and later paths come out too long.
  • Treating a node with one child as a leaf. A leaf has no children at all; check both left and right.
  • Saving at null instead of at a leaf. A node with one child would then be saved as if a path ended there.

When to use it

Look for these clues in a problem:

  • The question mentions paths "from the root to a leaf", breadcrumbs, or full file paths.
  • You must find whether some root-to-leaf path has a given sum, or list all that do.
  • Each path forms something, like a number from its digits or a string from its labels.
  • The answer depends on all the ancestors of a node, not only its parent.

Check yourself

Answer each one in your head first, then open it.

Why is [...path] needed at a leaf?

path is one array that keeps changing. Saving it directly saves a reference to it, and every saved entry ends up as the final empty array.

How many routes are saved for a tree with n nodes and k leaves?

Exactly k, one per leaf. Nodes with children never trigger a save.

In the trace, what would paths be if line 13, path.pop(), were removed?

[[3, 8, 6], [3, 8, 6, 1, 2], [3, 8, 6, 1, 2, 9]]. Each route would keep every node visited before it.

Why doesn't a running sum passed as a parameter need to be undone?

Each call gets its own copy of the parameter. When a call returns, the parent's sum was never changed.

Practice problems

Stuck on one? Tuto is in the playground too, and gives you hints, never the answer.

Go further

You don't need these to start practicing. Open one when you want more.

Why it works

The reasoning that makes it always right

The rule each call keeps: when walk(node) starts, path holds the values from the root down to node's parent, in order, and when it returns, path is exactly what it was when it started. For null, nothing is pushed or popped, so the rule holds.

For a real node, line 6 pushes node.val, so path is now the route from the root to node. If node is a leaf, that route is a root-to-leaf path and a copy is saved. If not, each child call starts with the right route and, by the same rule, gives it back unchanged. Then line 13 pops the one value this call pushed, restoring the starting state.

The DFS reaches every leaf exactly once, and at that moment path is that leaf's full route, so each root-to-leaf path is saved once. Nothing else is saved, because only leaves reach line 8. The walk ends like any DFS: each call goes one level deeper, and the depth is finite.

Going deeper

3 more ideas

Why one shared array is enough

Each call pushes exactly one value and pops exactly one value before it returns. So when a call ends, path is exactly what it was when the call started. That means the parent sees its own path again and can walk into its second child with the right route.

Carrying a running value instead

If you only need a total, you don't need the array. Pass the running sum as a parameter: walk(node, sum + node.val). Each call has its own copy of sum, so nothing needs to be undone. The same works for a number made of digits: value * 10 + node.val.

Time and memory

Walking takes O(n). Copying a path at a leaf costs its length, so listing all paths takes up to O(n * h) in total. The shared path and the call stack use O(h) memory, not counting the output.

Work it by hand

Every step in a table, with why

List every route in a 6-node tree with 3 leaves. Node 8 has only a right child, so it shows that only real leaves are saved.

root = [3, 8, 1, null, 6, 2, 9]

StepWhat happensnode.valpathpathsWhy
1push3[3][]Enter the root. 3 has children, so it isn't a leaf.
2push8[3, 8][]Go left to 8. It has a right child only.
3push6[3, 8, 6][]walk(8.left) returned at once on null, so the walk moved on to 8.right, which is 6.
4leaf: save a copy6[3, 8, 6][[3, 8, 6]]6 has no children, so path is a full route and a copy is saved.
5pop6[3, 8][[3, 8, 6]]Leaving 6 removes it, giving 8 its route back.
6pop8[3][[3, 8, 6]]Both sides of 8 are done, so 8 is removed too.
7push1[3, 1][[3, 8, 6]]Back at the root, the walk enters its right child 1.
8push2[3, 1, 2][[3, 8, 6]]1 has a left child 2.
9leaf: save a copy2[3, 1, 2][[3, 8, 6], [3, 1, 2]]2 is a leaf, so its route is saved.
10pop2[3, 1][[3, 8, 6], [3, 1, 2]]Remove 2 before trying 1's right child.
11push9[3, 1, 9][[3, 8, 6], [3, 1, 2]]Because 2 was popped, 9 is added right after 1. Without the pop, this route would read [3, 1, 2, 9].
12leaf: save a copy9[3, 1, 9][[3, 8, 6], [3, 1, 2], [3, 1, 9]]9 is the last leaf.
13pop9[3, 1]3 routesLeave 9.
14pop1[3]3 routesLeave 1.
15pop3[]3 routesLeave the root. path is empty again, exactly as it was before the walk.

[[3, 8, 6], [3, 1, 2], [3, 1, 9]], one route per leaf, from left to right. The trace ends when the root's call pops 3: 6 pushes and 6 pops, always in matching pairs.

Compared with other ways

When to pick something else
ApproachTimeExtra memoryChoose it when
Root-to-leaf pathsO(n · h)O(h)You need every route, or every route that passes a check.
Carry a value downO(n)O(h)You only need a number per route, like a sum or a count, so no copies are made.
Search again for each leafO(n²)O(h)Never better. It's the slow start this walk replaces.
BFS with parent linksO(n · h)O(n)The tree is too deep for recursion. Record each node's parent during BFS, then rebuild each route by walking up from its leaf.

Extra memory: O(h): the shared path and the call stack each hold at most one route. The output itself can reach O(n · h) values, as the worst case shows, and isn't counted here.

How the steps grow

1101001k10k100k1M116324864input size n
Steps as the input grows, for Root-to-leaf paths and the other approaches, on a scale where each line up is 10 times more steps.
  • Root-to-leaf paths, Search again for each leaf, BFS with parent links O(n · h)
  • Carry a value down O(n)
About how many steps for bigger inputs
Approachn = 10n = 1,000n = 1,000,000
Root-to-leaf paths1001 million1 trillion
Carry a value down101,0001 million
Search again for each leaf1001 million1 trillion
BFS with parent links1001 million1 trillion

Inputs that need care

5 tricky inputs
An empty tree: root = null
walk(null) returns at once, so paths stays []. An empty tree has no routes.
One node: root = [4]
The root is a leaf, so the answer is [[4]]: one route of length one.
A node with only one child, like 8 in the trace
It isn't a leaf, so nothing is saved there, and the missing side's call returns on null. If you saved a route at every null child instead of at leaves, [3, 8] would be saved from 8's empty left side and [3, 8, 6] would be saved twice.
Saving path instead of [...path]
Every saved entry points to the same array, which is empty when the walk ends, so the answer would be [[], [], []].
Negative values in a target-sum version
You can't stop early when the running sum passes the target, because a later negative value may bring it back down. Walk all the way to the leaf before deciding.

Variations and follow-ups

Twists interviewers add

Routes with a target sum

Carry the running sum as a parameter: walk(node, sum + node.val). At a leaf, save the route only if the sum equals the target. path still needs its push and pop, but the sum doesn't, because each call has its own copy.

Paths that start anywhere

To count downward paths with a given sum that may start below the root, keep a map from running sum to how many times it appears on the current route. At each node, add count.get(sum - target) to the answer, and undo the map change on the way back up, just like the pop.

Routes as strings

For breadcrumbs or file paths, build path.join(" / ") at each leaf. Or pass a string down, as in walk(node, prefix + "/" + node.val). Strings can't be changed in place, so no pop is needed, but each step copies the string.

Without recursion

Keep a stack of [node, routeSoFar] pairs, where each pair has its own route array. It uses more memory, but it handles trees too deep for the call stack.

In an interview

Spot it, explain it, answer follow-ups
  • Spot it from "all paths", "root to leaf", "path sum", "print every route", or an answer that's a list of lists of node values.
  • Say the backtracking rule: "every push has a matching pop before the call returns". Then point at where each one happens.
  • Be exact about what a leaf is: both children are null. Interviewers often use a tree whose root has one child to check this.
  • Expect "what if you only need to know whether some route adds up to the target?". Return a boolean, carry the sum, and stop at the first match: return walk(node.left, s) || walk(node.right, s).

Where frontend code uses it

The same idea in real UI code
  • Breadcrumb navigation shows the root-to-current path in a nested menu or category tree, like Home / Shoes / Running.
  • Form libraries report validation errors with a path through nested data, like address.street, built while walking the data down.
  • A file explorer search shows each matching file with its full path, the labels on the way from the root folder down to it.

Related algorithms