Binary Tree Paths#
Problem#
A clear and concise statement of the problem.
Example#
1tree_values = [1, 2, 4, None, None, 5, None, None, 3, None, None]
2root = build_binary_tree_from_list_preorder(tree_values)
3print_binary_tree(root, node_info=lambda n: (str(n.value), n.left, n.right), is_top=True)
1
/ \
2 3
/ \
4 5
Then the paths should be:
["1->2->4", "1->2->5", "1->3"]
Intuition#
Brief explanation of the initial thought process for solving the problem.
Assumptions#
List of any assumptions made in the problem solving process.
Constraints#
What are the Constraints for?#
Explanation of the constraints and their impact on the problem and solution.
Test Cases#
Set of test cases for validating the solution.
Edge Cases#
Discussion of any potential edge cases in the problem.
Walkthrough / Whiteboarding#
Detailed walkthrough of the problem-solving process.
Theoretical Best Time Complexity#
Discussion of the theoretical best time complexity for this problem.
Theoretical Best Space Complexity#
Discussion of the theoretical best space complexity for this problem.
Space-Time Tradeoff#
Analysis of the tradeoff between space and time complexity for the problem.
Wrong Solution 1#
This is wrong because:
Mutation of
local_pathis not undone when backtracking.Returning
GLOBAL_PATHSis awkward.
To correct it, simply replace local_path with local_path.copy() in each
recursive call.
1class Solution:
2 def binaryTreePaths(self, root: Optional[BinaryTreeNode]) -> List[str]:
3
4 GLOBAL_PATHS: List[str] = []
5
6 def dfs(
7 root: Optional[BinaryTreeNode], local_path: List[str]
8 ) -> Union[Literal[None], Literal[not None]]:
9 if not root:
10 return []
11
12 local_path.append(str(root.val))
13
14 left: Union[Literal[None], Literal[not None]] = dfs(root.left, local_path)
15 right: Union[Literal[None], Literal[not None]] = dfs(root.right, local_path)
16
17 if left == [] and right == []:
18 joined: str = "->".join(local_path)
19 GLOBAL_PATHS.append(joined)
20
21 return GLOBAL_PATHS
22
23 return dfs(root, local_path=[])
Wrong Solution 2#
1class Solution:
2 def binaryTreePaths(self, root: Optional[BinaryTreeNode]) -> List[str]:
3
4 GLOBAL_PATHS: List[str] = []
5
6 def dfs(
7 root: Optional[BinaryTreeNode], local_path: List[str]
8 ) -> Union[Literal[None], Literal[not None]]:
9 if not root:
10 return None
11
12 local_path.append(str(root.val))
13
14 left: Union[Literal[None], Literal[not None]] = dfs(
15 root.left, local_path.copy()
16 )
17 right: Union[Literal[None], Literal[not None]] = dfs(
18 root.right, local_path.copy()
19 )
20
21 if left is None and right is None:
22 joined: str = "->".join(local_path)
23 GLOBAL_PATHS.append(joined)
24 return None
25
26 dfs(root, local_path=[])
27 return GLOBAL_PATHS
is wrong while
1class Solution:
2 def binaryTreePaths(self, root: Optional[BinaryTreeNode]) -> List[str]:
3
4 GLOBAL_PATHS: List[str] = []
5
6 def dfs(
7 root: Optional[BinaryTreeNode], local_path: List[str]
8 ) -> Union[Literal[None], Literal[not None]]:
9 if not root:
10 return []
11
12 local_path.append(str(root.val))
13
14 left: Union[Literal[None], Literal[not None]] = dfs(
15 root.left, local_path.copy()
16 )
17 right: Union[Literal[None], Literal[not None]] = dfs(
18 root.right, local_path.copy()
19 )
20
21 if left == [] and right == []:
22 joined: str = "->".join(local_path)
23 GLOBAL_PATHS.append(joined)
24 return None
25
26 dfs(root, local_path=[])
27 return GLOBAL_PATHS
works. We just changed return None to return [] in the base case. How come?
When you return [] instead of None, and you check for
if left == [] and right == [], the logic behaves differently.
In the code where you use [], the condition if left == [] and right == [] is
true only when both the left and right children of the node are None. This is
because the function returns [] only when it reaches a None node, so both
left and right have to be None for the condition to be true.
When you replace the [] with None, the condition
if left is None and right is None is true not only when both the left and
right children are None, but also when the left or right child is an actual
leaf node itself (with no children). The None return value no longer uniquely
identifies a None child; it also identifies a leaf node’s left and right
children.
In other words, you returned
Nonewhen you reached a leaf node,line 24is where you reached a leaf node, and because of this, consider the tree in preorder1-2-None-5-None-None-3-None-None. When you reach the leaf node5, indeed you return bothleftandrightasNone, but you also returnNonefor the parent node2as well. This is becauseleftandrightare bothNone(because they are leaf nodes), so the conditionif left is None and right is Noneis true, and you returnNonefor the parent node2. This is why you get1->2as a path in the output. This will cascade up the tree, and even your root node1will returnNoneas well.
Solution (Top-down Recursive Preorder Traversal)#
Intuition#
The idea is use preorder traversal to traverse the tree, and keep track of
whether the current node is a leaf node or not. If it is a leaf node, then its
left and right children are None, and we can add the current path to the
global list of paths.
Consider our earlier example:
1
/ \
2 3
/ \
4 5
Now just by eyeballing, if we traverse till the leaf node 4, then we can see
that both its left and right children are None, so the call stack could look
like this when it encounter the left node of 4:
1 -> 2 -> 4 -> None
and when you call dfs on the node None, you hit the base case, and return
None. This None is returned to the parent node 4’s left call. Now
consider the right node of 4, the call stack could look like this:
1 -> 2 -> 4 -> None
and similarly, when you call dfs on the node None, you hit the base case,
and return None. This None is returned to the parent node 4’s right
call.
So since both the left and right children of the node 4 are None, the
condition if left is None and right is None is true, and so we can add the
current path to the global list of paths. The current path is 1->2->4.
But the problem here is the call for node 4 ended, so we return something, and
like the previous wrong solution, if you just return None as the final return,
then once 4 is popped from the stack, the parent node 2’s left (4) will be
assigned None. This is not what we want. We want to return something that is
not None because for node 2, we know that there is already a left child so
it should never fulfill the condition if left is None and right is None.
Visualization#
Visual representation of the problem and solution (if applicable).
Algorithm#
Decision Space \(\mathcal{X}\): In the context of this code, \(\mathcal{X}\) represents the set of all possible paths from the root to any node in the binary tree. Each node’s value is a decision or a choice that is made as the DFS progresses.
Constraints \(\mathcal{C}\): The constraints in this problem are implicit and involve following the binary tree’s structure. You only move from a node to its left or right child, and you stop and record the path when you reach a leaf node (a node with no left or right child).
Solution Space \(\mathcal{S}\): \(\mathcal{S}\) contains all feasible solutions that meet the constraints. In this problem, \(\mathcal{S}\) is the set of all paths from the root to the leaf nodes.
Partial Solution: A partial sequence of choices, which, in this case, is represented by the
local_pathlist. It stores the path from the root to the current node being visited.Complete Solution: A sequence of choices that completely covers the solution space, which is the path from the root to a leaf node.
Backtracking: This is demonstrated by the line
local_path.pop(), which removes the last element from thelocal_pathlist when a leaf node is reached or when backtracking from a subtree. By doing this, the algorithm effectively reverts to the previous state and is ready to explore a different path.
Pseudocode#
Detailed description of the algorithm used to solve the problem.
Mathematical Representation#
Math formulation
Correctness#
Prove the correctness of the algorithm
Claim#
Statement claiming the correctness of the algorithm.
Proof#
Proof showing the correctness of the algorithm.
Implementation#
We first show a raw, not so optimized implementation of the algorithm.
1class Solution:
2 def binaryTreePaths(self, root: Optional[BinaryTreeNode]) -> List[str]:
3
4 GLOBAL_PATHS: List[str] = []
5
6 def dfs(
7 root: Optional[BinaryTreeNode], local_path: List[str]
8 ) -> Union[Literal[None], Literal[not None]]:
9 if not root:
10 return None
11
12 local_path.append(str(root.val))
13
14 left: Union[Literal[None], Literal[not None]] = dfs(
15 root.left, local_path.copy()
16 )
17 right: Union[Literal[None], Literal[not None]] = dfs(
18 root.right, local_path.copy()
19 )
20
21 if left is None and right is None:
22 joined: str = "->".join(local_path)
23 GLOBAL_PATHS.append(joined)
24
25 return not None
26
27 dfs(root, local_path=[])
28 return GLOBAL_PATHS
Check for Completion: The code checks for a leaf node (a complete solution) by the condition
if left is None and right is None:and then processes the path by joining and appending it toGLOBAL_PATHS.Extend the Partial Solution: For each node, the code appends the value to the
local_pathand recursively calls thedfsfunction on the left and right children. This effectively explores all possible choices in the decision space.Prune: There is no explicit pruning in this code since the binary tree’s structure naturally guides the search, but one could argue that returning early when
not rootacts as a form of pruning by avoiding unnecessary exploration.Backtrack: As mentioned, the line
local_path.pop()is where the backtracking occurs, resetting the state to explore other paths.
By mapping the code to the formal definitions, readers can see how the abstract concepts are practically implemented in this specific problem. The systematic exploration of the binary tree using DFS, combined with backtracking to revert the state and explore different paths, aligns with the general principles of backtracking algorithms.
Tests#
Set of tests for validating the algorithm.
Time Complexity#
Analysis of the time complexity of the solution.
Space Complexity#
The local_path.copy() method is used to create a copy of the path list when
making the recursive calls. This ensure that the modifications to the path list
in one branch of the recursion do not affect the path list in other branches.
Since the implementation create a new list for each node in the tree, the space complexity is \(\mathcal{O}(n \times d)\), where \(n\) is the number of nodes in the tree, and \(d\) is the maximum depth of the tree. In a balanced binary tree, this would be \(\mathcal{O}(n \log n)\), and in a skewed tree (where every parent has only one child), it would be \(\mathcal{O}(n^2)\).
Input Space Complexity#
Analysis of the space complexity of the input.
Auxiliary Space Complexity#
Analysis of the space complexity excluding the input and output space.
Total Space Complexity#
Analysis of the total space complexity of the solution.
Solution (Top-down Recursive Preorder Traversal with Backtracking)#
Intuition#
As we have seen in the previous solution, the local_path.copy() method is used
to create a copy of the path list when making the recursive calls. This ensure
that the modifications to the path list in one branch of the recursion do not
affect the path list in other branches.
But this adds to the space complexity of the solution. We can avoid this by using backtracking. We can remove the last element from the path list when we are done processing the current node, and this will ensure that the path list remains the same when we make the recursive calls.
In a way, we can visualize as it follows:
1->2->4
is a valid path, and once we finish traversing the left subtree, we remove the last element from the path list, and we get:
1->2
and when we traverse the right subtree, we get:
1->2->5
and once we finish traversing the right subtree, we remove the last element from the path list, and we get:
1->2
and since 2 does not fulfill the condition
if left is None and right is None, we do not add it to the global list of
paths. But we still need to pop the last element from the path list, and we get:
1
and when we traverse the right subtree, we get:
1->3
Visualization#
Visual representation of the problem and solution (if applicable).
Algorithm#
Pseudocode#
Detailed description of the algorithm used to solve the problem.
Mathematical Representation#
Math formulation
Correctness#
Prove the correctness of the algorithm
Claim#
Statement claiming the correctness of the algorithm.
Proof#
Proof showing the correctness of the algorithm.
Implementation#
1class Solution:
2 def binaryTreePaths(self, root: Optional[BinaryTreeNode]) -> List[str]:
3
4 GLOBAL_PATHS: List[str] = []
5
6 def dfs(
7 root: Optional[BinaryTreeNode], local_path: List[str]
8 ) -> Union[Literal[None], Literal[not None]]:
9 if not root:
10 return None
11
12 local_path.append(str(root.val))
13
14 left: Union[Literal[None], Literal[not None]] = dfs(root.left, local_path)
15 right: Union[Literal[None], Literal[not None]] = dfs(root.right, local_path)
16
17 if left is None and right is None:
18 joined: str = "->".join(local_path)
19 GLOBAL_PATHS.append(joined)
20 local_path.pop()
21
22 return not None
23
24 dfs(root, local_path=[])
25 return GLOBAL_PATHS
Tests#
Set of tests for validating the algorithm.
Time Complexity#
Analysis of the time complexity of the solution.
Space Complexity#
Input Space Complexity#
Analysis of the space complexity of the input.
Auxiliary Space Complexity#
Analysis of the space complexity excluding the input and output space.
Total Space Complexity#
Analysis of the total space complexity of the solution.
References and Further Readings#
Any useful references or resources for further reading.