Maximum Depth of Binary Tree#


Question Number
Difficulty Tag Tag Tag Tag

Problem#

Given the root of a binary tree, return its maximum depth.

A binary tree’s maximum depth is the number of nodes along the longest path from the root node down to the farthest leaf node.

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 maximum depth of the tree is \(3\) because the longest path from the root node down to the farthest leaf node is \(3\). One possible path is \(1 \rightarrow 2 \rightarrow 4\).

Intuition#

Think in the perspective of a node in a tree, what do you want to know from your children? You want to know the maximum depth of your children’s subtrees. What do you want to tell your parents? You want to tell your parents the maximum depth of your subtree. So the recursion is pretty straightforward:

  1. Parents ask children: Tell me the maximum depth of your subtree! I do not care how you get it, just tell me the maximum depth of your subtree. This means finding the longest path from you (my child) to any leaf in your subtree (child’s children).

  2. Children answer parents: the longest path from me to any leaf in my children is the maximum of the maximum depth of my left subtree and the maximum depth of my right subtree plus one.

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.

Solution (Bottom-up Recursive Postorder Traversal)#

Intuition#

The provided solution is a bottom-up approach, often referred to as postorder because you compute the depth for the children first (i.e., the left and right subtrees), and then compute the depth for the current node based on its children.

The recursive call self.maxDepth(root.left) is made first, computing the depth of the entire left subtree before moving on to the right subtree with self.maxDepth(root.right). Finally, the maximum depth of the two is determined and incremented by one to account for the current node (root), which makes this a bottom-up computation. Think of our postorder traversal, indeed we visit the two children of a node before visiting the node itself. In here, we are computing the depth of the two children before computing the depth of the node itself.

Top-down approaches, on the other hand, often involve passing down values from the root to the leaves. For this particular problem (computing tree depth), both approaches will work.

Let’s discuss the base case, the return value, and the state of the recursive function.

Base Case#

Remark 89 (The base case)

The base case is when the node is None, in which case the depth is 0. This makes sense because the depth of a tree with no nodes is 0.

Return Value (Passing Values Up From Child To Parent)#

Remark 90 (The return statement’s role)

Firstly, be very clear the return statement’s role in the recursive function. When the return statement is returned at a particular node, it is returning the maximum depth of the subtree rooted at that node.

For instance, in the running example above, consider the node with value 2, when it finishes executing the left subtree of 2, it returns 1 which is the maximum depth of the left subtree rooted at 2 (tree 4). Similarly, when it finishes executing the right subtree (tree 5) of 2, it returns 1 which is the maximum depth of the right subtree rooted at 2. Finally, when it finishes executing the subtree rooted at 2, it returns 2 which is the maximum depth of the subtree rooted at 1.

States (Passing Values Down From Parent To Child)#

Remark 91 (The state of the recursive function)

There isn’t a state that’s explicitly passed down from parent to child. This is because the function does not need any additional information from the parent to perform its computation at the child.

However, the top-down approach does require passing down the current depth from the parent to the child, which we will see later.

Visualization#

The below visual is courtesy of the answer here.

Each execution context of a function creates a stackframe. You could imagine the stack frames as boxes, where each new box is placed on top of a previous box (stacking them). Names like root have a scope and lifetime that is limited to the “box” they are defined in.

In the following visualisation, execution goes from top to bottom, and smaller boxes are placed on top of larger boxes, so it is like looking at the stack from “above”:

┌─maxDepth─────────────────────────────────┐
│ root is TreeNode(1)                      │
│ ┌─dfs_postorder(root)──────────────────┐ │
│ │ node is TreeNode(1)                  │ │
│ │ ┌─dfs_postorder(node.left)─────────┐ │ │
│ │ │ node is TreeNode(2)              │ │ │
│ │ │ ┌─dfs_postorder(node.left)─────┐ │ │ │
│ │ │ │ node is TreeNode(4)          │ │ │ │
│ │ │ │ ┌─dfs_postorder(node.left)─┐ │ │ │ │
│ │ │ │ │ node is None             │ │ │ │ │
│ │ │ │ │ return 0                 │ │ │ │ │
│ │ │ │ └──────────────────────────┘ │ │ │ │
│ │ │ │ ┌─dfs_postorder(node.right)┐ │ │ │ │
│ │ │ │ │ node is None             │ │ │ │ │
│ │ │ │ │ return 0                 │ │ │ │ │
│ │ │ │ └──────────────────────────┘ │ │ │ │
│ │ │ │ return max(0, 0) + 1  # 1    │ │ │ │
│ │ │ └──────────────────────────────┘ │ │ │
│ │ │ ┌─dfs_postorder(node.right)────┐ │ │ │
│ │ │ │ node is TreeNode(5)          │ │ │ │
│ │ │ │ ┌─dfs_postorder(node.left)─┐ │ │ │ │
│ │ │ │ │ node is None             │ │ │ │ │
│ │ │ │ │ return 0                 │ │ │ │ │
│ │ │ │ └──────────────────────────┘ │ │ │ │
│ │ │ │ ┌─dfs_postorder(node.right)┐ │ │ │ │
│ │ │ │ │ node is None             │ │ │ │ │
│ │ │ │ │ return 0                 │ │ │ │ │
│ │ │ │ └──────────────────────────┘ │ │ │ │
│ │ │ │ return max(0, 0) + 1  # 1    │ │ │ │
│ │ │ └──────────────────────────────┘ │ │ │
│ │ │ return max(1, 1) + 1  # 2        │ │ │
│ │ └──────────────────────────────────┘ │ │
│ │ ┌─dfs_postorder(node.right)────────┐ │ │
│ │ │ node is TreeNode(3)              │ │ │
│ │ │ ┌─dfs_postorder(node.left)─────┐ │ │ │
│ │ │ │ node is None                 │ │ │ │
│ │ │ │ return 0                     │ │ │ │
│ │ │ └──────────────────────────────┘ │ │ │
│ │ │ ┌─dfs_postorder(node.right)────┐ │ │ │
│ │ │ │ node is None                 │ │ │ │
│ │ │ │ return 0                     │ │ │ │
│ │ │ └──────────────────────────────┘ │ │ │
│ │ │ return max(0, 0) + 1  # 1        │ │ │
│ │ └──────────────────────────────────┘ │ │
│ │ return max(2, 1) + 1  # 3            │ │
│ └──────────────────────────────────────┘ │
│ return 3                                 │
└──────────────────────────────────────────┘

What is not shown here is the return address, which is also part of a stack frame: when an inner function call returns, the calling function context (preserved in a stack frame) has a trace of where to resume its execution.

When this recursion is “emulated” with an explicit stack and no recursive calls, it is not uncommon to replace this idea of a “return address” with the “next task to do”, which is visiting the right child. So that is where the idea comes from to first push the right child (as a deferred task) and then the left child (as the immediate task).

Algorithm#

Pseudocode#

Algorithm 24 (Pseudocode)

Algorithm: maxDepth(node)

Input: node (root of the binary tree)

Output: max_depth (maximum depth of the binary tree)

BEGIN maxDepth(node)
    IF node is None THEN
        RETURN 0
    ELSE
        left_depth ← maxDepth(node.left)
        right_depth ← maxDepth(node.right)
        max_depth ← 1 + max(left_depth, right_depth)
    RETURN max_depth
END
CALL maxDepth with the root node as input.

Mathematical Representation#

Algorithm 25 (Mathematical Representation)

Define the depth function \(f\) for a node \(v\) in the binary tree as follows:

\[\begin{split} f(v) = \begin{cases} 0 & \text{if } v = \emptyset \\ 1 + \max\bigg\{f\Big(\ell(v)\Big), f\Big(r(v)\Big)\bigg\} & \text{otherwise} \end{cases} \end{split}\]

where

  • \(\emptyset\) is the empty tree, or None in Python

  • \(v\) is a node in the tree

  • \(\ell(v)\) is the left child of \(v\)

  • \(r(v)\) is the right child of \(v\)

This means that \(f(v)\) is 0 if \(v\) is \(\emptyset\) (the base case), and otherwise it’s 1 plus the maximum of \(f\) applied to the left and right children of \(v\). This mirrors the recursive logic of the pseudocode maxDepth.

For any binary tree rooted at \(r\) with left subtree \(l\) and right subtree \(r\), the maximum depth of the tree can be found by computing \(f(r)\).

Note that the depth of any tree with a single node (a leaf) is 1, as

\[ f(v) = 1 + \max\left(f(\emptyset), f(\emptyset)\right) = 1. \]

So the maximum depth of a tree can be found by applying this recursive function to the root node, which will in turn apply it to all of its descendants. The depth of the entire tree is then given by the return value of the function when applied to the root node.

Example#

If we were to represent the recursive calls to maxDepth as a mathematical function \(f\), where \(f(v)\) represents the maximum depth of the subtree rooted at node \(v\), then the recursion for the given tree would look like this:

\[\begin{split} \begin{aligned} f(1) &= 1 + \max(f(2), f(3)) \\ &= 1 + \max(1 + \max(f(4), f(5)), f(3)) \\ &= 1 + \max(1 + \max(1, 1), f(3)) \\ &= 1 + \max(2, f(3)) \\ &= 1 + \max(2, 1) \\ &= 1 + 2 \\ &= 3 \end{aligned} \end{split}\]

For node 1 (root):

\[ f(1) = 1 + \max(f(2), f(3)) \]

For node 2:

\[ f(2) = 1 + \max(f(4), f(5)) \]

The leaves, nodes 4 and 5, have no children, so their depth is 1:

\[ f(4) = f(5) = 1 + \max(f(\emptyset), f(\emptyset)) = 1 \]

because by definition, \(f(\emptyset) = 0\) as specified by the base case.

For node 3:

\[ f(3) = 1 \]

So you can see that \(f(v)\) depends on the maximum of \(f\) applied to its children, plus 1 for the current node. This matches the Python code: maxDepth of a node is 1 (for the node itself) plus the maximum of the maxDepth of its children.

The final depth of the tree will be calculated as the value of \(f(1)\), which depends on \(f(2)\) and \(f(3)\), and so on.

So the recursive calls for each node are basically asking the left and right children for their maximum depth, and then returning the maximum of the two plus one for the current node.

Claim#

Claim the algorithm is correct.

Proof#

Prove the correctness of the algorithm.

Implementation#

 1class Solution:
 2    def maxDepth(self, root: BinaryTreeNode) -> Union[int, Literal[0]]:
 3        if not root:
 4            return 0
 5
 6        left_depth = self.maxDepth(root.left)
 7        right_depth = self.maxDepth(root.right)
 8        return max(left_depth, right_depth) + 1
 9
10    def maxDepth_no_max(self, root: BinaryTreeNode) -> Union[int, Literal[0]]:
11        if not root:
12            return 0
13
14        left_depth = self.maxDepth(root.left)
15        right_depth = self.maxDepth(root.right)
16
17        if left_depth > right_depth:
18            max_depth = left_depth + 1
19        else:
20            max_depth = right_depth + 1
21        return max_depth
22
23    def maxDepth_helper(self, root: BinaryTreeNode) -> Union[int, Literal[0]]:
24        def dfs_postorder(root: BinaryTreeNode) -> Union[int, Literal[0]]:
25            if not root:
26                return 0
27
28            left_depth = dfs_postorder(root.left)
29            right_depth = dfs_postorder(root.right)
30            return max(left_depth, right_depth) + 1
31        return dfs_postorder(root)

Now the third solution involves adding a helper function dfs_postorder to perform the recursive computation. This is a common pattern in recursive solutions, where you have a recursive function that takes in some arguments and a helper function that does the actual recursion. In this context, the helper function makes clear to me the following:

  1. The helper is a postorder depth-first search traversal of the tree.

  2. When you call maxDepth_helper, you know that the return is dfs_postorder of the root node, which is max(left_depth, right_depth) + 1. It helps me understand the code better.

Tests#

1assert Solution().maxDepth(root) == 3
2assert Solution().maxDepth_no_max(root) == 3
3assert Solution().maxDepth_helper(root) == 3

Time Complexity#

TODO.

Space Complexity#

TODO.

From Recursive to Iterative to Recursive#

To visualize the stack calls, we can think of it this way:

maxDepth(1)
    maxDepth(2)
        maxDepth(4)
            maxDepth(None)
            maxDepth(None)
        maxDepth(5)
            maxDepth(None)
            maxDepth(None)
    maxDepth(3)
        maxDepth(None)
        maxDepth(None)

so translating this to stack calls, we have:

[1]
[1, 2]
[1, 2, 4]
[1, 2, 4, None]
[1, 2, 4, None, None]
[1, 2, 4, None]
[1, 2, 5]
[1, 2, 5, None]
[1, 2, 5, None, None]
[1, 2, 5, None]
[1, 2]
[1, 2, None]
[1, 2, None, None]
[1, 2, None]
[1, 2]
[1]
[1, 3]
[1, 3, None]
[1, 3, None, None]
[1, 3, None]
[1]
[]
 1from enum import Enum
 2
 3class State(Enum):
 4    PROCESS_LEFT = 1
 5    PROCESS_RIGHT = 2
 6    UPDATE_MAX_DEPTH = 3
 7
 8def maxDepth(root):
 9    if not root:
10        return 0
11
12    stack = [(root, State.PROCESS_LEFT, 1)]
13    max_depth = 0
14
15    while stack:
16        node, state, depth = stack.pop()
17
18        if node is None:
19            continue
20
21        if state == State.PROCESS_LEFT:
22            stack.append((node, State.PROCESS_RIGHT, depth))
23            if node.left:
24                stack.append((node.left, State.PROCESS_LEFT, depth + 1))
25        elif state == State.PROCESS_RIGHT:
26            stack.append((node, State.UPDATE_MAX_DEPTH, depth))
27            if node.right:
28                stack.append((node.right, State.PROCESS_LEFT, depth + 1))
29        else:
30            max_depth = max(max_depth, depth)
31
32    return max_depth
1assert maxDepth(root) == 3

Let’s go through the process using the same binary tree we’ve been using for examples:

    1
   / \
  2   3
 / \
4   5

Let’s trace the function and call A our State.PROCESS_LEFT, B our State.PROCESS_RIGHT, and C our State.UPDATE_MAX_DEPTH.

Step 0:

  • stack = [(root, 'A', 1)] (root is node 1)

  • max_depth = 0

Step 1:

  • Pop (node 1, ‘A’, 1) from stack.

  • node is not None and marker is ‘A’, so add (node 1, ‘B’, 1) and (node 2, ‘A’, 2) to stack.

  • stack = [(node 1, 'B', 1), (node 2, 'A', 2)]

  • max_depth remains 0.

Step 2:

  • Pop (node 2, ‘A’, 2) from stack.

  • node is not None and marker is ‘A’, so add (node 2, ‘B’, 2) and (node 4, ‘A’, 3) to stack.

  • stack = [(node 1, 'B', 1), (node 2, 'B', 2), (node 4, 'A', 3)]

  • max_depth remains 0.

Step 3:

  • Pop (node 4, ‘A’, 3) from stack.

  • node is not None and marker is ‘A’, so add (node 4, ‘B’, 3) to stack. (node 4 doesn’t have children, so no additional entries)

  • stack = [(node 1, 'B', 1), (node 2, 'B', 2), (node 4, 'B', 3)]

  • max_depth remains 0.

Step 4:

  • Pop (node 4, ‘B’, 3) from stack.

  • node is not None and marker is ‘B’, so add (node 4, ‘C’, 3) to stack. (node 4 doesn’t have a right child, so no additional entries)

  • stack = [(node 1, 'B', 1), (node 2, 'B', 2), (node 4, 'C', 3)]

  • max_depth remains 0.

Step 5:

  • Pop (node 4, ‘C’, 3) from stack.

  • node is not None and marker is ‘C’, so max_depth is updated to max(max_depth, depth) = max(0, 3) = 3.

  • stack = [(node 1, 'B', 1), (node 2, 'B', 2)]

  • max_depth is now 3.

Step 6:

  • Pop (node 2, ‘B’, 2) from stack.

  • node is not None and marker is ‘B’, so add (node 2, ‘C’, 2) and (node 5, ‘A’, 3) to stack.

  • stack = [(node 1, 'B', 1), (node 2, 'C', 2), (node 5, 'A', 3)]

  • max_depth remains 3.

Step 7:

  • Pop (node 5, ‘A’, 3) from stack.

  • node is not None and marker is ‘A’, so add (node 5, ‘B’, 3) to stack. (node 5 doesn’t have children, so no additional entries)

  • stack = [(node 1, 'B', 1), (node 2, 'C', 2), (node 5, 'B', 3)]

  • max_depth remains 3.

Step 8:

  • Pop (node 5, ‘B’, 3) from stack.

  • node is not None and marker is ‘B’, so add (node 5, ‘C’, 3) to stack. (node 5 doesn’t have a right child, so no additional entries)

  • stack = [(node 1, 'B', 1), (node 2, 'C', 2), (node 5, 'C', 3)]

  • max_depth remains 3.

Step 9:

  • Pop (node 5, ‘C’, 3) from stack.

  • node is not None and marker is ‘C’, so max_depth is updated to max(max_depth, depth) = max(3, 3) = 3.

  • stack = [(node 1, 'B', 1), (node 2, 'C', 2)]

  • max_depth remains 3.

Step 10:

  • Pop (node 2, ‘C’, 2) from stack.

  • node is not None and marker is ‘C’, so max_depth remains max(max_depth, depth) = max(3, 2) = 3.

  • stack = [(node 1, 'B', 1)]

  • max_depth remains 3.

Step 11:

  • Pop (node 1, ‘B’, 1) from stack.

  • node is not None and marker is ‘B’, so add (node 1, ‘C’, 1) and (node 3, ‘A’, 2) to stack.

  • stack = [(node 1, 'C', 1), (node 3, 'A', 2)]

  • max_depth remains 3.

Step 12:

  • Pop (node 3, ‘A’, 2) from stack.

  • node is not None and marker is ‘A’, so add (node 3, ‘B’, 2) to stack. (node 3 doesn’t have children, so no additional entries)

  • stack = [(node 1, 'C', 1), (node 3, 'B', 2)]

  • max_depth remains 3.

Step 13:

  • Pop (node 3, ‘B’, 2) from stack.

  • node is not None and marker is ‘B’, so add (node 3, ‘C’, 2) to stack. (node 3 doesn’t have a right child, so no additional entries)

  • stack = [(node 1, 'C', 1), (node 3, 'C', 2)]

  • max_depth remains 3.

Step 14:

  • Pop (node 3, ‘C’, 2) from stack.

  • node is not None and marker is ‘C’, so max_depth remains max(max_depth, depth) = max(3, 2) = 3.

  • stack = [(node 1, 'C', 1)]

  • max_depth remains 3.

Step 15:

  • Pop (node 1, ‘C’, 1) from stack.

  • node is not None and marker is ‘C’, so max_depth remains max(max_depth, depth) = max(3, 1) = 3.

  • stack is now empty.

  • max_depth remains 3.

Finally, the function will return max_depth, which is 3, as the maximum depth of the binary tree.

Final:

  • The stack is finally empty.

  • max_depth is returned, which is 3. This is the maximum depth of the binary tree.

In this way, the iterative depth-first search traversal using a stack successfully finds the maximum depth of the binary tree. It does so by emulating the recursive depth-first search process using the stack and a marker to keep track of where in the process it is for each node.

Deprecated#

Hide code cell source

  1import time
  2from typing import Literal, Union
  3
  4from rich.pretty import pprint
  5
  6from omnivault.dsa.trees.binary import BinaryTreeNode
  7from omnivault.dsa.trees.utils import build_binary_tree_from_list_preorder
  8from omnivault.dsa.trees.utils import print_binary_tree
  9
 10tree_values = [1, 2, 4, None, None, 5, None, None, 3, None, None]
 11root = build_binary_tree_from_list_preorder(tree_values)
 12
 13
 14class Solution:
 15    def maxDepth_stack(self, root: BinaryTreeNode) -> Union[int, Literal[0]]:
 16        if not root:
 17            return 0
 18
 19        stack = [(1, root)]  # The stack holds tuples of a node and its depth
 20        max_depth = 0
 21        count = 0
 22        while stack:
 23            count += 1
 24
 25            # Print the current state of the stack before popping
 26            print(f"\n\nIteration: {count}")
 27            pprint(
 28                f"Stack before popping: {[(node.value if node else None, depth) for depth, node in stack]}"
 29            )
 30
 31            # Pop a node from the stack and print it
 32            depth, node = stack.pop()
 33            pprint(f"Popped node: {(node.value if node else None, depth)}")
 34
 35            # Print the state of the stack after popping
 36            pprint(
 37                f"Stack after popping: {[(node.value if node else None, depth) for depth, node in stack]}"
 38            )
 39
 40            if node:  # If the node is not None
 41                # Update max_depth if current depth is greater
 42                max_depth = max(max_depth, depth)
 43                pprint(f"Updated max depth: {max_depth}")
 44
 45                # Add the right child and its depth to the stack
 46                stack.append((depth + 1, node.right))
 47
 48                # Add the left child and its depth to the stack
 49                stack.append((depth + 1, node.left))
 50
 51                # Print the state of the stack after adding the children
 52                pprint(
 53                    f"Stack after adding children: {[(node.value if node else None, depth) for depth, node in stack]}"
 54                )
 55
 56        return max_depth
 57
 58
 59class Solution2:
 60    def maxDepth(self, root) -> int:
 61        if root is None:
 62            return 0
 63
 64        node_stack = [(root, "process")]
 65        depth_stack = []
 66
 67        count = 0
 68        while node_stack:
 69
 70            node, action = node_stack.pop()
 71
 72            if action == "process":
 73                node_stack.append((node, "post_process"))
 74
 75                if node.right:
 76                    node_stack.append((node.right, "process"))
 77                if node.left:
 78                    node_stack.append((node.left, "process"))
 79                depth_stack.append(1)
 80            else:  # action == 'post_process'
 81                if node.left:
 82                    depth_stack[-1] = max(depth_stack[-1], 1 + depth_stack.pop())
 83                if node.right:
 84                    depth_stack[-1] = max(depth_stack[-1], 1 + depth_stack.pop())
 85            if count == 0:
 86                pprint(f"{node.value}: {depth_stack}")
 87                pprint([(node.value, action) for node, action in node_stack])
 88
 89                time.sleep(10000)
 90            count += 1
 91        return depth_stack[0]
 92
 93
 94class TreeNode:
 95    def __init__(self, x):
 96        self.val = x
 97        self.left = None
 98        self.right = None
 99
100
101# fmt: off
102from enum import Enum
103
104class State(Enum):
105    PROCESS_LEFT = 1
106    PROCESS_RIGHT = 2
107    UPDATE_MAX_DEPTH = 3
108
109def maxDepth(root):
110    if not root:
111        return 0
112
113    stack = [(root, State.PROCESS_LEFT, 1)]
114    max_depth = 0
115    iteration = 1
116
117    while stack:
118        print(f"\n\nIteration: {iteration}")
119        pprint(f"Stack before= {[(n.value if n else None, s.name, d) for n, s, d in stack]}")
120
121        node, state, depth = stack.pop()
122
123        if node is None:
124            continue
125
126        if state == State.PROCESS_LEFT:
127            stack.append((node, State.PROCESS_RIGHT, depth))
128            if node.left:
129                stack.append((node.left, State.PROCESS_LEFT, depth + 1))
130        elif state == State.PROCESS_RIGHT:
131            stack.append((node, State.UPDATE_MAX_DEPTH, depth))
132            if node.right:
133                stack.append((node.right, State.PROCESS_LEFT, depth + 1))
134        else:
135            max_depth = max(max_depth, depth)
136
137        print("Stack after:", [(n.value if n else None, s.name, d) for n, s, d in stack])
138        iteration += 1
139
140    return max_depth
141
142
143
144# Construct the tree for testing
145print(maxDepth(root))  # Output: 3
146
147
148# print(Solution().maxDepth_stack(root))
149# print(Solution2().maxDepth(root))

Iteration: 1
"Stack before= [(1, 'PROCESS_LEFT', 1)]"
Stack after:
[(1, 'PROCESS_RIGHT', 1), (2, 'PROCESS_LEFT', 2)]

Iteration: 2
"Stack before= [(1, 'PROCESS_RIGHT', 1), (2, 'PROCESS_LEFT', 2)]"
Stack after:
[(1, 'PROCESS_RIGHT', 1), (2, 'PROCESS_RIGHT', 2), (4, 'PROCESS_LEFT', 3)]

Iteration: 3
"Stack before= [(1, 'PROCESS_RIGHT', 1), (2, 'PROCESS_RIGHT', 2), (4, 'PROCESS_LEFT', 3)]"
Stack after:
[(1, 'PROCESS_RIGHT', 1), (2, 'PROCESS_RIGHT', 2), (4, 'PROCESS_RIGHT', 3)]

Iteration: 4
"Stack before= [(1, 'PROCESS_RIGHT', 1), (2, 'PROCESS_RIGHT', 2), (4, 'PROCESS_RIGHT', 3)]"
Stack after:
[(1, 'PROCESS_RIGHT', 1), (2, 'PROCESS_RIGHT', 2), (4, 'UPDATE_MAX_DEPTH', 3)]

Iteration: 5
"Stack before= [(1, 'PROCESS_RIGHT', 1), (2, 'PROCESS_RIGHT', 2), (4, 'UPDATE_MAX_DEPTH', 3)]"
Stack after:
[(1, 'PROCESS_RIGHT', 1), (2, 'PROCESS_RIGHT', 2)]

Iteration: 6
"Stack before= [(1, 'PROCESS_RIGHT', 1), (2, 'PROCESS_RIGHT', 2)]"
Stack after:
[(1, 'PROCESS_RIGHT', 1), (2, 'UPDATE_MAX_DEPTH', 2), (5, 'PROCESS_LEFT', 3)]

Iteration: 7
"Stack before= [(1, 'PROCESS_RIGHT', 1), (2, 'UPDATE_MAX_DEPTH', 2), (5, 'PROCESS_LEFT', 3)]"
Stack after:
[(1, 'PROCESS_RIGHT', 1), (2, 'UPDATE_MAX_DEPTH', 2), (5, 'PROCESS_RIGHT', 3)]

Iteration: 8
"Stack before= [(1, 'PROCESS_RIGHT', 1), (2, 'UPDATE_MAX_DEPTH', 2), (5, 'PROCESS_RIGHT', 3)]"
Stack after:
[(1, 'PROCESS_RIGHT', 1), (2, 'UPDATE_MAX_DEPTH', 2), (5, 'UPDATE_MAX_DEPTH', 3)]

Iteration: 9
"Stack before= [(1, 'PROCESS_RIGHT', 1), (2, 'UPDATE_MAX_DEPTH', 2), (5, 'UPDATE_MAX_DEPTH', 3)]"
Stack after:
[(1, 'PROCESS_RIGHT', 1), (2, 'UPDATE_MAX_DEPTH', 2)]

Iteration: 10
"Stack before= [(1, 'PROCESS_RIGHT', 1), (2, 'UPDATE_MAX_DEPTH', 2)]"
Stack after:
[(1, 'PROCESS_RIGHT', 1)]

Iteration: 11
"Stack before= [(1, 'PROCESS_RIGHT', 1)]"
Stack after:
[(1, 'UPDATE_MAX_DEPTH', 1), (3, 'PROCESS_LEFT', 2)]

Iteration: 12
"Stack before= [(1, 'UPDATE_MAX_DEPTH', 1), (3, 'PROCESS_LEFT', 2)]"
Stack after:
[(1, 'UPDATE_MAX_DEPTH', 1), (3, 'PROCESS_RIGHT', 2)]

Iteration: 13
"Stack before= [(1, 'UPDATE_MAX_DEPTH', 1), (3, 'PROCESS_RIGHT', 2)]"
Stack after:
[(1, 'UPDATE_MAX_DEPTH', 1), (3, 'UPDATE_MAX_DEPTH', 2)]

Iteration: 14
"Stack before= [(1, 'UPDATE_MAX_DEPTH', 1), (3, 'UPDATE_MAX_DEPTH', 2)]"
Stack after:
[(1, 'UPDATE_MAX_DEPTH', 1)]

Iteration: 15
"Stack before= [(1, 'UPDATE_MAX_DEPTH', 1)]"
Stack after:
[]
3

Solution (Stack)#

Intuition#

We can convert the recursion process into an iterative one using a stack data structure. A stack data structure adheres to the principle of Last-In-First-Out (LIFO), which means that the most recently added element is the first one to be removed.

In the context of recursion, the function call stack also operates based on the LIFO principle. When a function calls itself recursively, each recursive call (along with any variables local to that call) gets pushed onto a call stack. When a recursive call finishes, it’s popped from the stack, and execution continues in the previous call.

By utilizing a stack data structure, we can simulate this process iteratively, effectively converting a recursive function into an iterative one. We manually push elements onto the stack and pop them off, mirroring the push and pop operations that would be performed automatically by the call stack in a recursive function.

The idea is to keep the next nodes to visit in a stack. Due to the FILO behavior of stack, one would get the order of visit same as the one in recursion.

Visualization#

TODO.

Algorithm#

Claim#

Proof#

Implementation#

 1class Solution:
 2    def maxDepth_stack(self, root: BinaryTreeNode) -> Union[int, Literal[0]]:
 3        if not root:
 4            return 0
 5
 6        stack = [(1, root)] # The stack holds tuples of a node and its depth
 7        max_depth = 0
 8        while stack:
 9            depth, node = stack.pop()
10            if node: # If the node is not None
11                # Update max_depth if current depth is greater
12                max_depth = max(max_depth, depth)
13
14                # Add the right child and its depth to the stack
15                stack.append((depth + 1, node.right))
16
17                # Add the left child and its depth to the stack
18                stack.append((depth + 1, node.left))
19
20        return max_depth

The order of visit in the iterative solution is the same as the one in recursion#

Earlier, we mentioned that the order of visit in the iterative solution is the same as the one in recursion. Why and how so?

In the context of a depth-first search (DFS) implemented with a stack, the order in which nodes are popped off the stack corresponds to the order in which nodes are “visited” in the traversal.

When you use a stack to implement DFS, you’re essentially treating the top of the stack as your “current” position in the tree. Each time you pop a node off the stack, you “visit” that node. Then you push its children onto the stack, according to your chosen order for visiting children.

The “Last-In, First-Out” (LIFO) property of a stack ensures that the children of the current node (which were the last to be added to the stack) will be the first ones to be popped off and visited. This effectively simulates the behavior of a recursive DFS, where you explore as deeply as possible along each branch before backtracking, thanks to the call stack. In a stack-based DFS, the manual stack replaces the call stack used in recursion.

So yes, in a DFS implemented with a stack, the order in which nodes are popped from the stack is the order in which the nodes are visited.

Why do we push two children onto the stack when the recursive solution only recurses on one child at a time?#

You’re correct that the recursive solution doesn’t explicitly “push” both children onto a stack, but the process is similar. When you make a recursive call, that call and its local context (including parameters and any other local variables) are implicitly added (or “pushed”) onto a system-managed stack known as the call stack.

In a typical depth-first recursion of a binary tree, such as in the maxDepth function, the process can be described like this:

  1. Visit the current node.

  2. Recursively visit the left child.

  3. Once the left recursion finishes (meaning we’ve explored as deep as possible on the left), recursively visit the right child.

During the recursive process, the current execution context (including the current node, its depth, and the yet-to-be-explored right child) is saved on the call stack. This context is then recovered when the left recursive call is done, which allows the algorithm to then explore the right child. In this sense, it’s like we’re “pushing” both children onto the stack, with the left child being immediately explored and the right child being saved for later.

The iterative solution with an explicit stack is doing essentially the same thing, but the process is more explicit. It pushes both children onto the stack, but because of the stack’s LIFO nature, the right child is visited after all nodes in the left subtree have been explored, which aligns with the order of node visits in the recursive version.

So, although the iterative and recursive versions appear quite different in code, the way they explore the tree – and the order in which they visit nodes – is fundamentally the same.

Consequently, right after the first iteration we already have something like:

[1, 3, 2]

One may be confused why we have 3 inside the stack already when we are just supposed to be traversing from 1 to 2 first.

While it’s true that the node 3 is added to the call stack only after all the nodes in the left subtree of 1 have been processed, it’s important to remember that the call stack contains paused execution contexts. So even though maxDepth(3) is not called until after maxDepth(2), maxDepth(1) (and by extension, 3) is still on the call stack during the entire process.

Tests#

1assert Solution().maxDepth_stack(root) == 3

Time Complexity#

Space Complexity#

Solution (Top-down Recursive Preorder Traversal)#

Add depth as an argument to the recursive function.

Intuition#

Visualization#

Algorithm#

Claim#

Proof#

Implementation#

Time Complexity#

Space Complexity#

Tail Recursion#

TODO.

Intuition#

Visualization#

Algorithm#

Claim#

Proof#

Implementation#

Time Complexity#

Space Complexity#

References and Further Readings#