DSA sheet · Trees · DFS pattern

Path Sum II

Path Sum (Problem 15) only asked whether a good root-to-leaf path exists. Path Sum II asks for all of them, written out as lists of values. To write a path out, we have to remember the nodes we walked through. Walking back up the tree means we must also forget them again. That "remember on the way down, forget on the way back up" step is called backtracking, and it is the real lesson of this video. The teacher finds the need for it during her dry run, so we will too.

Every problem below follows the same order:
① the question in simple words → ② what the constraints tell us → ③ intuition → ④ building the conditions from examples → ⑤ approach steps → ⑥ code → ⑦ code line by line → ⑧ dry run → ⑨ complexity & remember

Part 0 · Before starting

From Path Sum (Problem 15), the teacher says it's a must-know

One Python fact you need for backtracking

When you pass a list into a function, Python does not copy it. The function gets the very same list. If any call appends to it or pops from it, every call sees the change, because there is only one list.

one list, many names
def add_seven(lst):
    lst.append(7)

path = [5, 4]
add_seven(path)
print(path)          # [5, 4, 7]  the caller's list changed too

saved = path         # NOT a copy, just a second name for the same list
path.pop()
print(saved)         # [5, 4]     saved changed as well!

snapshot = list(path)   # a real copy (path[:] also works)
path.pop()
print(snapshot)      # [5, 4]     the copy is safe
print(path)          # [5]

Keep this in mind. Both bugs in Part B come from this one fact.

Part A · Path Sum II with DFS + backtracking

LeetCode 113

1The question in simple words

Given the root of a binary tree and targetSum, return every root-to-leaf path whose values add up to targetSum. Each path is a list of node values from root to leaf. The answer is a list of lists.

             5
            / \
           4   8
          /   / \
        11   13  4
        / \     / \
       7   2   5   1          targetSum = 22

This tree has 5 root-to-leaf paths (one per leaf):

pathsum= 22?
5 → 4 → 11 → 727no
5 → 4 → 11 → 222yes
5 → 8 → 1326no
5 → 8 → 4 → 522yes
5 → 8 → 4 → 118no

Answer: [[5, 4, 11, 2], [5, 8, 4, 5]]. If no path works, return an empty list [].

2What the constraints tell us

Doubt: what if the tree is not balanced?
→ The "about 13 levels" argument only works for a balanced tree. In a skewed tree, one path can hold all 5000 nodes: 5000 × 1000 = 5 × 10⁶. That still fits easily in an int (about 2 × 10⁹), so the conclusion "no overflow" holds anyway. (In the video she says "5,000" once where she means the max value 1000. The value limit is 1000.)

3Intuition: a trail of footprints

Imagine walking down the tree and leaving a footprint on each node you step on. Your footprints, read from the top, are your current path.

That last step is backtracking: undo your own change before you return, so the caller finds things exactly as they were.

4Building the logic from the example

The teacher builds the code in small steps. She writes what she understands first, then dry-runs it, and lets the dry run show her what's missing.

Step 1: we need an outer list and an inner list

Step 2: base case → just return

If the node is None, there's nothing to add and nothing to check. Return. Here the function doesn't give back True/False. It fills ans as it goes, so a plain return is enough.

Step 3: step onto the node → add it to the path

After the None check, put the node's value into path. At 5 with an empty path, path becomes [5].

Step 4: at a leaf, check the target

The teacher reminds us of the two ways from Path Sum. Running sum: 5, then 9, then 20, then 27 at 7 or 22 at 2, compared with the target at each leaf. Shrinking target: 22 → 17 → 13 → 2, and at the leaf compare what's left with the leaf's value. She uses the shrinking target.

Why check only at a leaf? Because a path has to end at a leaf to count. At 7 we need 2 but have 7, so not valid. At 2 we need 2 and have 2, so it's valid. Then we add path into ans. More precisely, we add a copy of it. She writes "new list of path" (see Part B, Bug 2, for why).

Step 5: go left and right with a smaller target, and the same path

5 is not a leaf, so call the left child and then the right child. What do we send?

Step 6: "Is it over?" Not yet: the dry run shows the missing line

The teacher asks if the code is complete, says not quite, and dry-runs to find out why. Follow the path list:

  1. At 5 → path [5]. At 4 → [5, 4]. At 11 → [5, 4, 11]. At 7 → [5, 4, 11, 7].
  2. 7 is a leaf, 2 ≠ 7, so nothing is saved. 7's children are None, so both calls return at once.
  3. Now the call for 7 ends and we go back to 11, which next calls its right child 2.
  4. Problem: path is still [5, 4, 11, 7]. Nobody removed the 7! When 2 is added, path becomes [5, 4, 11, 7, 2]. That isn't a real path, because 7 and 2 are siblings, not parent and child.

The fix: when a node's call is completely finished (after both children are done), remove that node from the path. In Python that's path.pop(), which removes the last item. (In Java she writes path.remove(path.size() - 1), which does the same thing.)

The backtracking linepath.pop() goes at the very end of the function, after both recursive calls. Then, whenever a call returns, path looks exactly the same as when that call started.
Doubt 1: why must the pop come after both recursive calls, not straight after the leaf check?
→ While we explore a node's children, that node is still part of the path. When we're at 11 and go to its left child 7 and then its right child 2, both paths must start with 5, 4, 11. If we popped 11 before calling the children, they would get [5, 4] and build [5, 4, 7] and [5, 4, 2], which are wrong. So the node stays in the path for its whole visit, and is removed only when it's done with both children.
Doubt 2: we saved [5, 4, 11, 2] because it was valid. Why do we still pop the 2?
→ We already saved a copy in ans. That copy is a separate list, so popping from path doesn't touch it. And we still have to clean up: the search continues to other paths (next up is 5 → 8 → …), and those must not carry an old 2. Every node gets popped, good path or not.
Doubt 3: what about the None calls? Do they pop something too?
→ No. A None call returns before it appends anything. Nothing added, so nothing to remove. Each real node appends exactly once and pops exactly once, which keeps the list balanced.
Doubt 4: in Path Sum (Problem 15) we passed only a number down, and nobody popped anything. Why is that different?
→ A number like targetSum − root.val is a new value for each call. The parent's own number never changes, so there's nothing to undo. (The teacher showed this: back at 11, it still remembered its target 13.) But path is one shared list that every call changes. Shared things you change on the way down must be un-changed on the way up. That's why only the list needs backtracking.

5Approach steps

  1. Make the outer list ans = []. Call dfs(root, targetSum, []). Return ans.
  2. In dfs: node is None → return.
  3. Choose: append the node's value to path.
  4. If it's a leaf and target == node.val → append a copy of path to ans.
  5. Explore: dfs(left, target − val, path), then dfs(right, target − val, path).
  6. Un-choose (backtrack): path.pop().

Choose → explore → un-choose is the general shape of backtracking. You'll see it again in subsets, permutations and many graph problems.

6Code (Python)

Path Sum II with DFS + backtracking (the teacher's way)
class Solution:
    def pathSum(self, root, targetSum):
        self.ans = []                         # outer list: every good path
        self.dfs(root, targetSum, [])         # path starts empty
        return self.ans

    def dfs(self, root, targetSum, path):
        if root is None:                      # nothing here, nothing to undo
            return
        path.append(root.val)                 # CHOOSE: step onto this node
        if root.left is None and root.right is None:      # leaf?
            if targetSum == root.val:                     # sum is exactly right
                self.ans.append(list(path))               # save a COPY
        self.dfs(root.left, targetSum - root.val, path)   # EXPLORE left
        self.dfs(root.right, targetSum - root.val, path)  # EXPLORE right
        path.pop()                            # UN-CHOOSE: step back off this node

The teacher also says you could carry a running sum instead (add each value, compare with targetSum at the leaf). Then you don't shrink the target. The backtracking part stays exactly the same:

Path Sum II with a running sum (same backtracking)
class Solution:
    def pathSum(self, root, targetSum):
        self.ans = []
        self.dfs(root, 0, targetSum, [])
        return self.ans

    def dfs(self, root, s, targetSum, path):
        if root is None:
            return
        s += root.val                         # s is a number: no undo needed
        path.append(root.val)                 # path is shared: undo below
        if root.left is None and root.right is None and s == targetSum:
            self.ans.append(list(path))
        self.dfs(root.left, s, targetSum, path)
        self.dfs(root.right, s, targetSum, path)
        path.pop()

7Code line by line

linewhat it means
self.ans = []The outer list, made once per question. (Made inside pathSum so that a second test case doesn't see old answers.)
self.dfs(root, targetSum, [])Start at the root with the full target and an empty path.
if root is None: returnBase case. We return before touching path, so there's nothing to clean up.
path.append(root.val)This node is now on the current path.
if root.left is None and root.right is None:Only a leaf can end a path.
if targetSum == root.val:This leaf uses up exactly what's left of the target.
self.ans.append(list(path))Save a snapshot. list(path) makes a new list with the same values, so later pops can't change it.
self.dfs(root.left, targetSum - root.val, path)Try every path through the left child. It needs less (we've spent root.val). It shares the same path.
self.dfs(root.right, targetSum - root.val, path)Then through the right child. When this starts, path is back to what it was after we appended root.val, because the left call cleaned up after itself.
path.pop()Backtrack: remove this node's value before going back to the parent. Now the parent sees path exactly as before.

8Dry run: follow the path list and the stack

Each call is (node, target it received). Watch the path column: it grows by one on the way down and shrinks by one on the way back up.

#what happenspath afterwardsans
1call (5, 22): append 5. Not a leaf → go left with 17[5][]
2call (4, 17): append 4. Not a leaf → go left with 13[5, 4][]
3call (11, 13): append 11. Not a leaf → go left with 2[5, 4, 11][]
4call (7, 2): append 7. Leaf, 2 ≠ 7 → not saved. Children None → return[5, 4, 11, 7][]
57 is done → pop 7 → back to 11[5, 4, 11][]
6call (2, 2): append 2. Leaf, 2 == 2 → save a copy[5, 4, 11, 2][[5,4,11,2]]
72 is done → pop 2 (the saved copy stays) → back to 11[5, 4, 11][[5,4,11,2]]
811 has finished both children → pop 11 → back to 4[5, 4]same
94's right is None (returns at once) → 4 done → pop 4 → back to 5[5]same
105 goes right: call (8, 17): append 8. Not a leaf → left with 9[5, 8]same
11call (13, 9): append 13. Leaf, 9 ≠ 13 → not saved[5, 8, 13]same
1213 done → pop 13 → back to 8[5, 8]same
13call (4, 9): append 4. Not a leaf → left with 5[5, 8, 4]same
14call (5, 5): append 5. Leaf, 5 == 5 → save a copy[5, 8, 4, 5][[5,4,11,2], [5,8,4,5]]
15leaf 5 done → pop 5 → back to 4[5, 8, 4]same
16call (1, 5): append 1. Leaf, 5 ≠ 1 → not saved[5, 8, 4, 1]same
171 done → pop 1; 4 done → pop 4; 8 done → pop 8; 5 done → pop 5[][[5,4,11,2], [5,8,4,5]]

The stack is empty, path is back to [], and ans holds both good paths. Final answer: [[5, 4, 11, 2], [5, 8, 4, 5]] ✓

row 4 · path [5,4,11,7]
(5,22)(4,17)(11,13)(7,2) ✗
row 6 · path [5,4,11,2]
(5,22)(4,17)(11,13)(2,2) ✓ save
row 14 · path [5,8,4,5]
(5,22)(8,17)(4,9)(5,5) ✓ save

See the pattern: the stack height and the path length always move together. Every call on the stack has exactly one value in the path. That's the backtracking promise: each call cleans up its own footprint.

9Complexity & remember

Doubt: is the time really just O(n)? Saving a path copies it.
→ Visiting the nodes is O(n), as she says. On top of that, each saved path costs a copy as long as the path, up to h. So the strict bound is O(n + (good paths) × h). In the worst case that's around O(n·h), which is why LeetCode's own write-up says O(n²) worst case. For n = 5000 this is still fast. In an interview, say "O(n) visits plus the cost of copying each answer path".
Remember Path Sum II None → return · append · leaf and target == val → save list(path) · recurse left, right with target − val · pop. Every append has its own pop.

Part B · Backtracking up close: the two bugs to avoid

The two most common Path Sum II bugs come straight from the "one shared list" fact in Part 0. Seeing their actual output makes the fixes stick.

1Bug 1: forgetting path.pop()

wrong: no backtracking
class SolutionNoPop:
    def pathSum(self, root, targetSum):
        self.ans = []
        self.dfs(root, targetSum, [])
        return self.ans

    def dfs(self, root, targetSum, path):
        if root is None:
            return
        path.append(root.val)
        if root.left is None and root.right is None and targetSum == root.val:
            self.ans.append(list(path))
        self.dfs(root.left, targetSum - root.val, path)
        self.dfs(root.right, targetSum - root.val, path)
        # missing: path.pop()

The targets are still right (they're plain numbers), so it still finds the right leaves. But the saved lists are garbage, because nothing is ever rubbed out:

good leaf reachedwhat path holds by then
leaf 2 (5→4→11→2)[5, 4, 11, 7, 2]: the dead-end 7 is still there
leaf 5 (5→8→4→5)[5, 4, 11, 7, 2, 8, 13, 4, 5]: the whole left side is still there

Output: [[5,4,11,7,2], [5,4,11,7,2,8,13,4,5]] ✗. This is exactly what the teacher spotted in her dry run.

2Bug 2: saving path itself instead of a copy

wrong: no copy
class SolutionNoCopy:
    def pathSum(self, root, targetSum):
        self.ans = []
        self.dfs(root, targetSum, [])
        return self.ans

    def dfs(self, root, targetSum, path):
        if root is None:
            return
        path.append(root.val)
        if root.left is None and root.right is None and targetSum == root.val:
            self.ans.append(path)          # BUG: same list, not a snapshot
        self.dfs(root.left, targetSum - root.val, path)
        self.dfs(root.right, targetSum - root.val, path)
        path.pop()

Here ans doesn't store two paths. It stores two names for the one shared list. The backtracking pops keep changing that list, and at the very end it's empty. So both entries show the empty list.

Output: [[], []] ✗. The teacher's "new list of path" is the fix: list(path) (or path[:], or path.copy()).

3The picture to keep in your head

                       path over time
  go down to 5     →  [5]
  go down to 4     →  [5, 4]
  go down to 11    →  [5, 4, 11]
  go down to 7     →  [5, 4, 11, 7]      leaf, wrong sum
  back up from 7   →  [5, 4, 11]         pop
  go down to 2     →  [5, 4, 11, 2]      leaf, right sum → photo (copy)
  back up from 2   →  [5, 4, 11]         pop
  back up from 11  →  [5, 4]             pop
  back up from 4   →  [5]                pop
  go down to 8     →  [5, 8]             ... and so on

The path list is used like a stack: push on the way down, pop on the way up. It always mirrors the call stack.

Doubt: could I avoid backtracking by passing path + [root.val] (a brand-new list) to each child?
→ Yes, that also gives the right answer, because then no list is shared, just like the number target. But every call now builds a fresh list as long as the path, which costs more time and memory. The append/pop version reuses one list. That's why interviewers expect the backtracking version, and it's the one the teacher teaches.

Part C · Revision page

Path Sum (15)Path Sum II (16)
asksdoes a good path exist?list all good paths
returnsTrue / Falselist of lists
base caseNone → FalseNone → return (nothing)
combine childrenleft or right (stop early)call both, always (we need every path)
carries downtarget (a number)target (a number) + path (a shared list)
backtrackingnot neededpath.pop() at the end
time / spaceO(n) / O(h)O(n) visits + copies / O(h)
thing passed downshared between calls?needs undo?
targetSum - root.val (int)no, each call gets its own valueno
path (list)yes, one list for all callsyes: pop
ans (list)yes, but we want it to keep everythingno (store copies in it)
If you remember only 5 lines 1. Outer list ans, inner list path, target shrinks by root.val.
2. None → return. Then append the node.
3. Leaf and target == val → ans.append(list(path)), a copy.
4. Recurse left, then right, passing the same path.
5. pop at the very end. Every append gets its own pop.
Mistakes to avoid ✗ forgetting path.pop() (paths keep old nodes)
✗ ans.append(path) without copying (answer becomes [[], []])
✗ popping before the recursive calls (children lose their parent)
✗ return-ing early at a good leaf without popping (path stays dirty)
✗ checking the sum at a non-leaf node
✗ keeping ans as a class-level list that never gets reset
test it yourself (paste under any of the solutions above)
root = TreeNode(5,
    TreeNode(4, TreeNode(11, TreeNode(7), TreeNode(2))),
    TreeNode(8, TreeNode(13), TreeNode(4, TreeNode(5), TreeNode(1))))

s = Solution()
print(s.pathSum(root, 22))     # [[5, 4, 11, 2], [5, 8, 4, 5]]
print(s.pathSum(root, 26))     # [[5, 8, 13]]
print(s.pathSum(root, 100))    # []
print(s.pathSum(None, 0))      # []

Based on this video: Path Sum II | DFS & Backtracking