1

I have a question in finding the all paths for a sum. The question is:

Given a binary tree and a number ‘S’, find all paths from root-to-leaf such that the sum of all the node values of each path equals ‘S’.

My approach with recursion is:

def all_sum_path(root, target):
    result = []
    find_sum_path(root, target, result, [])
    return result

def find_sum_path(root, target, result, new_path):
    if not root:
        return None
    new_path.append(root.value)
    diff = target - root.value
    if not root.left and not root.right and diff == 0:
        # copy the value of the list rather than a reference
        result.append(list(new_path))
    if root.left:
        return find_sum_path(root.left, diff, result, new_path)
    if root.right:
        return find_sum_path(root.right, diff, result, new_path)
    del new_path[-1]


class TreeNode():
    def __init__(self, _value):
        self.value = _value
        self.left, self.right, self.next = None, None, None

def main():
    root = TreeNode(1)
    root.left = TreeNode(7)
    root.right = TreeNode(9)
    root.left.left = TreeNode(4)
    root.left.right = TreeNode(5)
    root.right.left = TreeNode(2)
    root.right.right = TreeNode(7)

    print(all_sum_path(root, 12))

    root = TreeNode(12)
    root.left = TreeNode(7)
    root.right = TreeNode(1)
    root.left.left = TreeNode(4)
    root.right.left = TreeNode(10)
    root.right.right = TreeNode(5)

    print(all_sum_path(root, 23))

main()

and the output is:

[[1, 7, 4]]
[[12, 7, 4]]

Process finished with exit code 0

However, the correct approach should be:

def all_sum_path(root, target):
    result = []
    find_sum_path(root, target, result, [])
    return result

def find_sum_path(root, target, result, new_path):
    if not root:
        return None
    new_path.append(root.value)
    diff = target - root.value
    if not root.left and not root.right and diff == 0:
        # copy the value of the list rather than a reference
        result.append(list(new_path))
    if root.left:
        find_sum_path(root.left, diff, result, new_path)
    if root.right:
        find_sum_path(root.right, diff, result, new_path)
    del new_path[-1]


class TreeNode():
    def __init__(self, _value):
        self.value = _value
        self.left, self.right, self.next = None, None, None

def main():
    root = TreeNode(1)
    root.left = TreeNode(7)
    root.right = TreeNode(9)
    root.left.left = TreeNode(4)
    root.left.right = TreeNode(5)
    root.right.left = TreeNode(2)
    root.right.right = TreeNode(7)

    print(all_sum_path(root, 12))

    root = TreeNode(12)
    root.left = TreeNode(7)
    root.right = TreeNode(1)
    root.left.left = TreeNode(4)
    root.right.left = TreeNode(10)
    root.right.right = TreeNode(5)

    print(all_sum_path(root, 23))

main()

With output:

[[1, 7, 4], [1, 9, 2]]
[[12, 7, 4], [12, 1, 10]]

Process finished with exit code 0

I have a some questions here:

  1. Why don't we need a return in the recursion statement? I am also interested in how the return statement reduced the output to only one?

  2. Why don't we need the result = find_sum_path(root, target, result, [])? Then what is the logic behind to update the results?

  3. I am not sure why the time complexity is O(N^2)?

The time complexity of the above algorithm is O(N^2), where ‘N’ is the total number of nodes in the tree. This is due to the fact that we traverse each node once (which will take O(N)), and for every leaf node we might have to store its path which will take O(N).

Thanks for your help in advance.

Qiang Super
  • 323
  • 1
  • 11

2 Answers2

1

Why don't we need a return in the recursion statement?

Why don't we need the result = find_sum_path(root, target, result, [])? Then what is the logic behind to update the results?

The result list (and also the new_path list) is being passed through the recursion stacks by reference (or rather by assignment, see what does it mean by 'passed by assignment'?) which means the result variable always points to the same location in your memory as it was initialized to in all_sum_path (as long as it is not re-assigned) and you are able to mutate it in place as needed.

I am also interested in how the return statement reduced the output to only one?

When you use return in your solution, you are completely giving up on exploring right subtrees of a node when the left subtree is done.

if root.left: 
    return find_sum_path(root.left, diff, result, new_path)
# -- unreachable code if `root.left` is not `None` --
if root.right:
    return find_sum_path(root.right, diff, result, new_path)

I am not sure why the time complexity is O(N^2)?

if not root.left and not root.right and diff == 0:
    # copy the value of the list rather than a reference
    result.append(list(new_path))

This part of the code is making a full copy of the new_path to append it to result. Take the case of a binary tree which is somewhere between highly imbalanced and completely balanced, all nodes have values 0 and S is also 0. In such case, you'll make L (number of leaf nodes) copies of new_path with each containing up to H elements (height of the tree) so O(L * H) ~ O(N^2)

So the worst-case possible time complexity is certainly not linear O(N) but not completely O(N^2) either.

chiragjn
  • 764
  • 11
  • 31
  • Thanks for your help. I still have some questions, and hopefully you can explain more. 1. As the results is a reference in the find_sum_path, therefore, it will get updated in the function find_sum_path. That is why I don't need the return. 2. For the return, why will I give up the right sub-tree if return is used? 3. Why the copy of a new_path will take O(H)? And what is S in the 'S is also 0'? – Qiang Super Jan 11 '21 at 18:37
  • Sorry, S is the target sum. – Qiang Super Jan 11 '21 at 18:58
  • 2. If some node has a left child, you are doing a `return find_sum_path(root.left, diff, result, new_path)`, the code simply can't go to right child of that node, I would encourage you to dry run it or attach a debugger and check 3. When you reach a leaf node and your condition is satisfied, you want to make a full clone of the path collected in `new_path` so far because new_path is only a temporary buffer to add and remove nodes during traversal. In the worst case, `new_path` can contain the longest path in the tree of len H i.e. height of the tree – chiragjn Jan 11 '21 at 19:22
  • Thanks for your explanation. The `return` will simply terminate the execution of my current function, leading the missing of another sub tree. I am still a little bit confusing since the complexity of cloning the `new_path` will take O(H). Or can we say the complexity of cloning is O(N)? Thanks. – Qiang Super Jan 11 '21 at 22:14
1

First off, I want to say you're very close to solving the problem and you've done a terrific job. Recursion is a functional heritage and so using it with functional style yields the best results. This means avoiding things like mutations, variable reassignments, and other side effects. This can eliminate many sources of bugs (and headaches)!

To spruce up your program, we can start by modifying TreeNode to accept both left and right parameters at time of construction -

class TreeNode():
  def __init__(self, value, left=None, right=None):
    self.value = value
    self.left = left
    self.right = right

Now we can define two trees, t1 and t2. Notice we don't reassign root -

def main():
  t1 = TreeNode \
    ( 1
    , TreeNode(7, TreeNode(4), TreeNode(5))
    , TreeNode(9, TreeNode(2), TreeNode(7))
    )
    
  t2 = TreeNode \
    ( 12
    , TreeNode(7, TreeNode(4), None)
    , TreeNode(1, TreeNode(10), TreeNode(5))
    )

  print(all_sum_path(t1, 12))
  print(all_sum_path(t2, 23))

main()

The expected output is -

[[1, 7, 4], [1, 9, 2]]
[[12, 7, 4], [12, 1, 10]]

Finally we implement find_sum. We can use mathematical induction to write a simple case analysis for our function -

  1. if the input tree t is empty, return the empty result
  2. (inductive) t is not empty. if t.value matches the target, q, a solution has been found; add t.value to the current path and yield
  3. (inductive) t is not empty and t.value does not match the target q. add t.value to the current path and new sub-problem, next_q; solve the sub-problem on t.left and t.right branches -
def find_sum (t, q, path = []):
  if not t:
    return                        # (1)
  elif t.value == q:
    yield [*path, t.value]        # (2)
  else:
    next_q = q - t.value          # (3)
    next_path = [*path, t.value]
    yield from find_sum(t.left, next_q, next_path)
    yield from find_sum(t.right, next_q, next_path)

Notice how we don't use mutations like .append above. To compute all paths, we can write all_find_sum as the expansion of find_sum -

def all_sum_path (t, q):
  return list(find_sum(t, q))

And that's it, your program is done :D


If you don't want to use a separate generator find_sum, we can expand the generator in place -

def all_sum_path (t, q, path = []):
  if not t:
    return []
  elif t.value == q:
    return [[*path, t.value]]
  else:
    return \
      [ *all_sum_path(t.left, q - t.value, [*path, t.value])
      , *all_sum_path(t.right, q - t.value, [*path, t.value])
      ]

Notice the distinct similarity between the two variations. Any well-written program can easily be converted between either style.

Mulan
  • 129,518
  • 31
  • 228
  • 259
  • Thanks for your help! Impressive way to learn 1. how to express my tree more efficiently. 2. yield and yield from statement. 3. *. But I am pretty new have additional questions about 2 and 3 question. Especially the 3 question. Would you mind explaining more? – Qiang Super Jan 12 '21 at 01:00
  • Would you mind looking at my another question. I am not sure why I can update the `result` here, but I can't update the`_max` there. Thanks. – Qiang Super Jan 12 '21 at 01:12
  • Why we need the `yield from` than `yield` in the `yield from find_sum(t.left, next_q, next_path)`? I removed the `from` and the results are totally different. – Qiang Super Jan 12 '21 at 01:13
  • Hi Qiang, the difference between `yield` and `yield from` is subtle. `yield` outputs a value from the generator, but `yield from` (often as a recursive expression) yields to another iterator! For example, `yield [1,2,3]` will output `[1,2,3]` but `yield from [1,2,3]` is the same as `yield 1` `yield 2` `yield 3`. – Mulan Jan 12 '21 at 02:34
  • As for `*`, it flattens one level of nesting. For example `[1, *[2, 3], 4]` will return `[1,2,3,4]` and `[*[1,2], *[3,4]]` would return `[1,2,3,4]`. You can spread any iterable with `*`, like `[*"foo", *"bar"]` will return `["f","o","o","b","a","r"]`. All of these can be written _without_ `*` too. `[1] + [2,3] + [4]` returns `[1,2,3,4]`, and `[1,2]+[3,4]` returns `[1,2,3,4]` and `list(iter("foo")) + list(iter("bar"))` returns `["f","o","o","b","a","r"]` – Mulan Jan 12 '21 at 02:40
  • So when we wrote `return [ *alll_sum_path(t.left, ...), *all_sum_path(t.right, ...) ]`, we could have written `all_sum_path(t.left, ...) + all_sum_path(t.right, ...)` to get the same result. I used the `[ *..., *... ]` syntax because it helps communicate that the return value is a list, `[...]`. Whereas `a + b` doesn't show that as clearly. – Mulan Jan 12 '21 at 02:42
  • Thanks for your explanation. I am wondering why don't you use `return` here? – Qiang Super Jan 12 '21 at 14:42
  • I'm happy to continue the discussion. A function that uses `yield` or `yield from` is a generator function. Generator functions can "return" many values using a `yield` expression. The difference is the generator can be resumed after the `yield` and move to the next expression and potentially "return" (yield) more values. When you use actual `return` in a generator, it stops the generator. For all non-generator functions, we see an explicit `return ...` on each code branch. Does that answer your question? – Mulan Jan 12 '21 at 14:54
  • Yes. I get it. The issue in my code is that I used the `return` so that the code stop at only one branch, and won't execute another branch. – Qiang Super Jan 12 '21 at 14:58
  • Thank you for your time and patience. – Qiang Super Jan 12 '21 at 15:21