Jc-alt logo
jc

LeetCode: Trees I DFS

LeetCode: Trees I DFS
62 min read
data structures and algorithms

DFS Intro

What is DFS

Trees are hierarchical data structures representing relationships between entities, often in a parent-child format.

Depth First Search is a way of traversing those trees.

Depth First Search Order Diagrams

Pre Order (Root -> Left -> Right)

        4
      /   \
     2     5
    / \
   1   3

DFS visit order: 42135
(go as deep as possible before backtracking)

In Order (Left -> Root -> Right)

        4
      /   \
     2     5
    / \
   1   3

DFS visit order: 12345
(visit left subtree, then root, then right subtree)

Post Order (Left -> Right -> Root)

        4
      /   \
     2     5
    / \
   1   3

DFS visit order: 13254
(visit left subtree, then right subtree, then root)

DFS Specific Tree Problems

DFS commits to a path and follows it to completion. Good for problems that depend on fully exploring a path before evaluating, such as situations that the left and right subtrees must be processed before the root node.

Backtracking usually needs a single current path for undoing, which is why is uses DFS mostly. BFS has multiple paths at the same time which makes it harder for backtracking.

94. Binary Tree Inorder Traversal ::3:: - Easy

Topics: Tree Traversal, Stack, Tree, Depth First Search, Binary Tree

Intro

Given the root of a binary tree, return the preorder traversal of its nodes' values. Follow up: Recursive solution is trivial, could you do it iteratively?

Example InputOutput
root = [1,null,2,3][1,2,3]
root = [1,2,3,4,5,null,8,null,null,6,7,9][1,2,4,5,6,7,3,8,9]
root = [][]
root = [1][1]

Constraints:

The number of nodes in the tree is in the range [0, 100]

-100 ≤ Node.val ≤ 100

Abstraction

Traverse a binary tree by Inorder (left -> root -> right).

Pseudocode

Sol 1: Recursive In Order Traversal
1. dfs(node):
   a. if not node: return
   b. dfs(node.left)
   c. res.append(node.val)
   d. dfs(node.right)
2. dfs(root)
3. Return res

Sol 2: Iterative DFS In Order Traversal With Stack
1. (node = root)
2. stack = []
3. While node or stack:
   a. while node:
        stack.append(node)
        node = node.left
   b. node = stack.pop()
   c. res.append(node.val)
   d. node = node.right
4. Return res

Solution 1: [DFS] Recursive In Order Traversal - Tree/DFS Post Order Recursive Two Sided Bottom Up

    def inorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
                
        # Inorder Traversal: (left -> root -> right)
        # Recursion stack will serve as queue to explore the most recent node
        #   - In order requires to explore left first, recurse as far left as possible
        #   - Finished exploring left, 'Visit' node
        #   - Step into right node
        #   - Repeat
        
        res = []
        
        def dfs(node):

            # Base case: 
            # reached a leaf's child 'None'
            if not node:
                return
            
            # In order requires to explore left first, 
            # so push as many left nodes to stack for this root
            dfs(node.left)

            # Recurse until exhausted
            
            # Finished exploring left, 'Visit' node
            res.append(node.val)
            
            # Step into right node
            dfs(node.right)

            # Repeat
        
        # Start DFS from root
        dfs(root)
        
        # overall: tc O(n)
        # overall: sc O(log n) for balanced / O(n) for skewed trees
        return res

Solution 2: [DFS] Iterative DFS In Order Traversal With Stack - Tree/DFS Post Order Recursive Two Sided Bottom Up

    def inorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
                
        # Inorder Traversal: (left -> root -> right) using a Stack
        # Stack will serve as queue to explore the most recent node
        #   - In order requires to explore left first, so push as many left nodes to stack for this root
        #   - Finished exploring left, 'Visit' node
        #   - Step into right node
        #   - Repeat
        
        # Initial Helper Root Node
        # We need a helper root node unlike the other 2 iterative solutions
        # because Inorder requires us to go left first,
        # which requires a node, but since we can't push anything
        # to the stack without modifying the Inorder order,
        # we need a helper
        # sc: O(1)
        node = root
        
        res = []

        # Iterative Node Stack
        # sc: O(n)
        stack = []

        # Traverse until
        #   - curr node is a a leaf child 'None'
        #   - there are no nodes in stack queue to traverse
        while node or stack:
            
            # In order requires to explore left first, 
            # so push as many left nodes to stack for this root
            while node:
                stack.append(node)
                node = node.left
            
            # Reached a 'None' child leaf, 
            # we've reached as far left as we can go

            # Pop the leftmost node at top of queue
            node = stack.pop()
            
            # 'Visit' node
            res.append(node.val)
            
            # Step into right node
            node = node.right

            # Repeat
        
        # overall: tc O(n)
        # overall: sc O(log n) for balanced / O(n) for skewed trees
        return res

144. Binary Tree Preorder Traversal ::2:: - Easy

Topics: Tree Traversal, Stack, Tree, Depth First Search, Binary Tree

Intro

Given the root of a binary tree, return the preorder traversal of its nodes' values.

Example InputOutput
root = [1,null,2,3][1,2,3]
root = [1,2,3,4,5,null,8,null,null,6,7,9][1,2,4,5,6,7,3,8,9]
root = [][]
root = [1][1]

Constraints:

The number of nodes in the tree is in the range [0, 100]

-100 ≤ Node.val ≤ 100

Abstraction

Traverse a binary tree by Preorder (root -> left -> right).

Pseudocode

Sol 1: Recursive Pre Order Traversal
1. dfs(node):
   a. if not node: return
   b. res.append(node.val)
   c. dfs(node.left)
   d. dfs(node.right)
2. dfs(root)
3. Return res

Sol 2: Iterative Pre Order Traversal With Stack
1. if not root: return []
2. stack = [root]
3. While stack:
   a. node = stack.pop()
   b. res.append(node.val)
   c. if node.right: stack.append(node.right)
   d. if node.left: stack.append(node.left)
4. Return res

Solution 1: [DFS] Recursive Pre Order Traversal - Tree/DFS Post Order Recursive Two Sided Bottom Up

    def preorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
                
        # Recursive Preorder Traversal: (root -> left -> right)
        # Recursion stack will serve as queue to explore the most recent node
        #   - In order requires to explore root first, 'Visit' node
        #   - Recurse into left node
        #   - start over
        #   - Run out of left nodes, recurse to right node
        #   - start over
        
        res = []
        
        def dfs(node: Optional[TreeNode]):
            
            # Base case: 
            # reached a leaf's child 'None'
            if not node:
                return
            
            # In order requires to explore root first, 'Visit' node
            res.append(node.val)
            
            # Recurse into left node
            dfs(node.left)

            # Recurse until exhausted
            
            # Finished exploring left, now explore right
            dfs(node.right)

            # Repeat
        
        # Start DFS from root
        dfs(root)
        
        # overall: tc O(n)
        # overall: sc O(log n) for balanced / O(n) for skewed trees
        return res
    class Solution {
    public:
        vector<int> preorderTraversal(TreeNode* root) {

            // Recursive Preorder Traversal: (root -> left -> right)
            // Recursion stack will serve as queue to explore the most recent node
            //   - In order requires to explore root first, 'Visit' node
            //   - Recurse into left node
            //   - start over
            //   - Run out of left nodes, recurse to right node
            //   - start over

            vector<int> res;

            dfs(root, res);

            // overall: tc O(n)
            // overall: sc O(log n) for balanced / O(n) for skewed trees
            return res;
        }

    private:
        void dfs(TreeNode* node, vector<int>& res) {

            // Base case:
            // reached a leaf's child 'nullptr'
            if (node == nullptr) {
                return;
            }

            // In order requires to explore root first, 'Visit' node
            res.push_back(node->val);

            // Recurse into left node
            dfs(node->left, res);

            // Recurse until exhausted

            // Finished exploring left, now explore right
            dfs(node->right, res);

            // Repeat
        }
    };

Solution 2: [DFS] Iterative Pre Order Traversal With Stack - Tree/DFS Post Order Recursive Two Sided Bottom Up

    def preorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
                
        # Iterative Preorder Traversal: (root -> left -> right) using a Stack
        # Stack will serve as queue to explore the most recent node
        #   - In order requires to explore left first, so push as many left nodes to stack
        #   - Pop from stack, 'Visit' node
        #   -
        #   - Pop node from stack, visit it
        #   - Push right child first, then left child so left is processed first
        
        # Early Exit:
        # tree is empty, return empty array
        if not root:
            return []
        
        res = []

        # Iterative Node Stack
        # sc: O(n)
        stack = [root]
        
        # Traverse until
        #   - There are no nodes in stack queue to traverse
        while stack:

            # In order requires to explore root first, 'Visit' node
            node = stack.pop()            
            res.append(node.val)
            
            # Queue RIGHT before LEFT so LEFT is popped first (LIFO)

            # Eventually, we will repeat on right
            if node.right:
                stack.append(node.right)

            # Eventually, we will iterate until exhausted
            if node.left:
                stack.append(node.left)
        
        # overall: tc O(n)
        # overall: sc O(log n) for balanced / O(n) for skewed trees
        return res

145. Binary Tree Postorder Traversal ::2:: - Easy

Topics: Tree Traversal, Stack, Tree, Depth First Search, Binary Tree

Intro

Given the root of a binary tree, return the postorder traversal of its nodes' values. Follow up: Recursive solution is trivial, could you do it iteratively?

Example InputOutput
root = [1,null,2,3][3,2,1]
root = [1,2,3,4,5,null,8,null,null,6,7,9][4,6,7,5,2,9,8,3,1]
root = [][]
root = [1][1]

Constraints:

The number of nodes in the tree is in the range [0, 100]

-100 ≤ Node.val ≤ 100

Abstraction

Traverse a binary tree by Postorder (left -> right -> root).

Pseudocode

Sol 1: Recursive Post Order Traversal
1. dfs(node):
   a. if not node: return
   b. dfs(node.left)
   c. dfs(node.right)
   d. res.append(node.val)
2. dfs(root)
3. Return res

Sol 2: Iterative Post Order Traversal With Stack
1. if not root: return []
2. stack = [root]
3. While stack:
   a. node = stack.pop()
   b. res.append(node.val)
   c. if node.left: stack.append(node.left)
   d. if node.right: stack.append(node.right)
4. res.reverse()
5. Return res

Solution 1: [DFS] Recursive Post Order Traversal - Tree/DFS Post Order Recursive Two Sided Bottom Up

    def postorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
        
        # Postorder Traversal: (left -> right -> root)
        # Recursion stack will serve as queue to explore the most recent node
        #   - Post order requires to explore left first, recurse as far left as possible
        #   - Go up recursive calls, visit node
        #   - start over
        #   - Run out of left nodes, recurse to the right node
        #   - start over
        #   - Run out of right nodes, 'Visit' root node
        #   - start over
        
        res = []
        
        def dfs(node: Optional[TreeNode]):

            # Base case: 
            # reached a leaf's child 'None'
            if not node:
                return
            
            # In order requires to explore left first, 
            # so push as many left nodes to stack for this root
            dfs(node.left)
            
            # Finished exploring left, now explore right
            dfs(node.right)
            
            # 'Visit' node
            res.append(node.val)
        
        # Start DFS from root
        dfs(root)
        
        # overall: tc O(n)
        # overall: sc O(log n) for balanced / O(n) for skewed trees
        return res

Solution 2: [DFS] Iterative Post Order Traversal With Stack - Tree/DFS Post Order Recursive Two Sided Bottom Up

    def postorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
        
        # Post Traversal: (left -> right -> root) using a Stack
        # Stack will serve as queue to explore the most recent node
        #   - Post order requires to explore left first, so push as many left nodes to stack for this root
        #   - Use a modified preorder: root -> right -> left
        #   - Reverse the result at the end to get correct postorder
        
        # Early Exit:
        # tree is empty, return empty array
        if not root:
            return []
        
        res = []
        
        # Iterative Node Stack
        # sc: O(n)
        stack = [root]
        
        # Traverse until
        #   - There are no nodes in stack queue to traverse
        while stack:

            # 'Visit' node
            node = stack.pop()
            res.append(node.val)
            
            # Postorder: push LEFT first so RIGHT is processed first (LIFO)
            if node.left:
                stack.append(node.left)
            if node.right:
                stack.append(node.right)
        
        # Puts this on stack:       root -> right -> left
        # When popped off stack is: left -> right -> root
        res.reverse()
        
        # overall: tc O(n)
        # overall: sc O(h + n), O(h) for stack, O(n) for result array
        return res

543. Diameter of Binary Tree ::2:: - Easy

Topics: Tree Structure Analysis, Tree, Depth First Search, Binary Tree

Intro

Given the root of a binary tree, return the length of the diameter of the tree. The diameter of a binary tree is the length of the longest path between any two nodes in a tree. This path may or may not pass through the root. The length of a path between two nodes is represented by the number of edges between them.

Example InputOutput
root = [1,2,3,4,5]3
root = [1,2]1

Constraints:

The number of nodes in the tree is in the range [0, 104]

-100 ≤ Node.val ≤ 100

Abstraction

Find the diameter of a binary tree. At each node, sum the diameter of the left and right subtree by connecting them at the root to compare it to the global max, and continue to pass up the larger of the two sides up to the root.

Pseudocode

Sol 1: Recursive DFS Post Order Nonlocal CurrMax Pass Up
1. (maxWidth = 0)
2. dfs(node):
   a. if not node: return 0
   b. leftWidth = dfs(node.left)
   c. rightWidth = dfs(node.right)
   d. checkTreeAtNode = leftWidth + rightWidth
   e. maxWidth = max(maxWidth, checkTreeAtNode)
   f. Return 1 + max(leftWidth, rightWidth)
3. dfs(root)
4. Return maxWidth

Sol 2: Recursive DFS Post Order Tuple Pass Up (Width, MaxDiameter)
1. dfs(node):
   a. if not node: return (0, 0)
   b. (leftWidth, leftMaxDiameter) = dfs(node.left)
   c. (rightWidth, rightMaxDiameter) = dfs(node.right)
   d. connectedTree = leftWidth + rightWidth
   e. rootMaxDiameter = max(connectedTree, leftMaxDiameter, rightMaxDiameter)
   f. rootWidth = 1 + max(leftWidth, rightWidth)
   g. Return (rootWidth, rootMaxDiameter)
2. Return dfs(root)[1]

Solution 1: [DFS] Recursive DFS Post Order Nonlocal CurrMax Pass Up - Tree/DFS Post Order Recursive Two Sided Bottom Up

    def diameterOfBinaryTree(self, root: Optional[TreeNode]) -> int:
        
        # Tree Widths:
        # Compare the max width of each subtree while traversing the tree
        # (in, pre, and post should all work for this problem)
        # while adding +1 for each depth level while traversing

        # Postorder Traversal: (left -> right -> root)
        #   - Explore left: get width of tree
        #   - Explore right: get width of tree
        #   - Process root: connect left and right subtrees via curr node by adding widths
        #   - Compare to global max
        #   - Create length for curr node by grabbing max between left and right

        # Global max 
        maxWidth = 0
        
        def dfs(node):

            # nonlocal says maxWidth refers to the variable in the outer enclosing scope, 
            # don't create a new local one
            # res.append doesn't reassign the variable, 
            # it mutates the object it points to, so the preorder traversal
            # doesn't need nonlocal
            nonlocal maxWidth

            # Base case: 
            # reached a leaf's child 'None', width value of a leaf is 0
            if not node:
                return 0
            
            # Explore left
            leftWidth = dfs(node.left)

            # Explore right
            rightWidth = dfs(node.right)
            
            # Process root: connect left and right subtrees via curr node by adding widths
            checkTreeAtNode = leftWidth + rightWidth

            # Check maxWidth
            # nonlocal avoids this creating a new local variable within inner curr scope
            maxWidth = max(maxWidth, checkTreeAtNode)

            # Pick a side to continuing adding to length
            rootWidth = 1 + max(leftWidth, rightWidth)
            return rootWidth
        
        # Start at root
        dfs(root)

        # overall: tc O(n)
        # overall: sc O(log n) for balanced / O(n) for skewed trees
        return maxWidth
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
DFS TraversalO(n)O(log n) / O(n)Visit every node onceRecursion call stack
Per Node WorkO(1)O(1)maxWidth comparison and additionSingle nonlocal write
OverallO(n)O(log n) / O(n)Visit every node onceBalanced vs skewed trees

Solution 2: [DFS] Recursive DFS Post Order Tuple Pass Up (Tree Max Diameter, Tree Length) - Tree/DFS Post Order Recursive Two Sided Bottom Up

    def diameterOfBinaryTree(self, root: Optional[TreeNode]) -> int:
        
        # Tree Widths:
        # Compare the max width of each subtree while traversing the tree
        # while adding +1 for each depth level while traversing

        # Postorder Traversal: (left -> right -> root)
        #   - Explore left: get width and max diameter of subtree
        #   - Explore right: get width and max diameter of subtree
        #   - Process root: connect left and right subtrees via curr node by adding widths
        #   - Compare connected width to left and right max diameters
        #   - Create length for curr node by grabbing max between left and right

        def dfs(node):

            # Base case:
            # reached a leaf's child 'None', width and max diameter of a leaf is 0
            if not node:
                return (0, 0)
            
            # Explore left
            (leftWidth, leftMaxDiameter) = dfs(node.left)

            # Explore right
            (rightWidth, rightMaxDiameter) = dfs(node.right)
            
            # Process root: connect left and right subtrees via curr node by adding widths
            connectedTree = leftWidth + rightWidth
            
            # Check max diameter against connected width and subtree max diameters
            rootMaxDiameter = max(connectedTree, leftMaxDiameter, rightMaxDiameter)
            
            # Create length for curr node by grabbing max between left and right
            rootWidth = 1 + max(leftWidth, rightWidth)
            
            # Pass curr node width and max diameter up recursion calls
            return (rootWidth, rootMaxDiameter)

        # Start at root
        res = dfs(root)[1]

        # overall: tc O(n)
        # overall: sc O(log n) for balanced / O(n) for skewed trees
        return res
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

110. Balanced Binary Tree ::1:: - Easy

Topics: Tree Structure Analysis, Tree, Depth First Search, Binary Tree

Intro

Given a binary tree, determine if it is height-balanced. A height-balanced binary tree is a binary tree in which the depth of the two subtrees of every node never differs by more than one.

Example InputOutput
root = [3,9,20,null,null,15,7]true
root = [1,2,2,3,3,null,null,4,4]false
root = []true

Constraints:

The number of nodes in the tree is in the range [0, 5000]

-104 ≤ Node.val ≤ 104

Abstraction

Determine if a binary tree is height balanced. A height-balanced binary tree is a binary tree in which the depth of the two subtrees of every node never differs by more than one.

At each node, compare the left and right subtree heights to ensure the difference is never greater than 1, if so throw and error.

Pseudocode

Sol 1: Post Order DFS Recursive Exception Throw
1. dfs(node):
   a. if node is None: return 0
   b. leftHeight = dfs(node.left)
   c. rightHeight = dfs(node.right)
   d. if abs(leftHeight - rightHeight) > 1:
        raise ValueError
   e. Return 1 + max(leftHeight, rightHeight)
2. try:
     dfs(root)
     Return True
3. except ValueError:
     Return False

Solution 1: [DFS] Post Order DFS Recursive Exception Throw - Tree/DFS Post Order Recursive Two Sided Bottom Up

    def isBalanced(self, root: Optional[TreeNode]) -> bool:
       
        # Note:
        # DFS post order: left -> right -> root
        # 1. Process left -> right -> :
        # 2. Process -> root : validate if balanced, raise exception if imbalanced
        # Results: detect imbalance, short-circuit on first imbalance found

        def dfs(node):

            # Base case: 
            # Reached leaf node, height of 0
            if node == None:
                return 0
            
            # Grab left height
            leftHeight = dfs(node.left)

            # Grab right height
            rightHeight = dfs(node.right)
            
            # Check:
            # Unbalanced node has left and right heights that differ by more than 1,
            # if node is unbalanced, raise exception
            if abs(leftHeight - rightHeight) > 1:
                raise ValueError("unbalanced")
            
            # Node is balanced, 
            # grab height and pass up
            rootHeight = 1 + max(leftHeight, rightHeight)
            return rootHeight
        
        # Catch exception
        try:
            dfs(root)
            return True
        except ValueError:
            return False

        # overall: tc O(n)
        # overall: sc O(log n) for balanced / O(n) for skewed trees
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

572. Subtree of Another Tree ::2:: - Easy

Topics: Tree Structure Analysis, Tree, Depth First Search, String Matching, Binary Tree, Hash Function, Hashing Data Structure

Intro

Given the roots of two binary trees root and subRoot, return true if there is a subtree of root with the same structure and node values of subRoot and false otherwise. A subtree of a binary tree tree is a tree that consists of a node in tree and all of this node's descendants. The tree tree could also be considered as a subtree of itself.

Example InputOutput
root = [3,4,5,1,2], subRoot = [4,1,2]true
root = [3,4,5,1,2,null,null,null,null,0], subRoot = [4,1,2]false

Constraints:

The number of nodes in the root tree is in the range [1, 2000].

The number of nodes in the subRoot tree is in the range [1, 1000].

-104 ≤ root.val ≤ 104

-104 ≤ subRoot.val ≤ 104

Abstraction

Determine if binary tree 2 is a subtree of binary tree 1.

Traverse both trees simultaneously. Make a hash representation of the tree 1 and their subtrees, and compare the full hash of tree 2 against all possibilities.

Pseudocode

Sol 1: Pre Order DFS Node By Node Recursive Comparison
1. isSameTree(s, t):
   a. if s == None and t == None: return True
   b. if s == None or t == None: return False
   c. if s.val != t.val: return False
   d. Return isSameTree(s.left, t.left) and isSameTree(s.right, t.right)
2. if subRoot == None: return True
3. if root == None: return False
4. if isSameTree(root, subRoot): return True
5. Return isSubtree(root.left, subRoot) or isSubtree(root.right, subRoot)

Sol 2: Post Order Merkle Tree Via Subtree Hash Fingerprinting
1. rootHashes = set()
2. merkleHash(node, addToRootHash):
   a. if node == None: return "#"
   b. leftHash = merkleHash(node.left, addToRootHash)
   c. rightHash = merkleHash(node.right, addToRootHash)
   d. nodeSignature = f"{leftHash},{node.val},{rightHash}"
   e. nodeHash = hash(nodeSignature)
   f. if addToRootHash: rootHashes.add(nodeHash)
   g. Return nodeHash
3. merkleHash(root, True)
4. subRootHash = merkleHash(subRoot, False)
5. Return subRootHash in rootHashes

Solution 1: [DFS] Pre Order DFS Node By Node Recursive Comparison [SC Opt] - Tree/DFS Pre Order Recursive One Sided Top Down

    def isSubtree(self, root: Optional[TreeNode], subRoot: Optional[TreeNode]) -> bool:
        
        # Note:
        # DFS pre order: root -> left -> right
        # 1. Process root -> : 
        #    validate subtree matches
        # 2. Process -> left -> right :
        # Result: validate if subtree is subtree of tree

        # Recursive abstraction call to validate if same tree
        def isSameTree(s, t):

            # Pre Order: Validate that root nodes match

            # 2 Match Cases:
            #   - both are laves
            #   - values match

            # 2 Failure Cases:
            #   - only one is a leaf
            #   - values do not match
            
            if s == None and t == None:
                return True
            if s == None or t == None:
                return False
            if s.val != t.val:
                return False
            
            # Match:
            # Validate the rest of children to ensure a full match
            return isSameTree(s.left, t.left) and isSameTree(s.right, t.right)
        
        # Empty check:
        # An empty subtree matches any tree
        if subRoot == None:
            return True

        # Empty check:
        # Nothing can match an empty root tree
        if root == None:
            return False
        
        # Full Match:
        # Check if trees are a complete match starting at root of original
        if isSameTree(root, subRoot):
            return True
        
        # Subtree Match:
        # Check if trees are a complete match at some inner subtree of original
        return self.isSubtree(root.left, subRoot) or self.isSubtree(root.right, subRoot)  

        # overall: tc O(n * m)
        # overall: sc O(h1 + h2)
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Solution 2: [DFS] Post Order Merkle Tree Via Subtree Hash Fingerprinting [TC Opt] - Tree/DFS Pre Order Recursive One Sided Top Down

    def isSubtree(self, root: Optional[TreeNode], subRoot: Optional[TreeNode]) -> bool:
        
        # Note:
        # DFS post order: left -> right -> root
        # 1. Process -> left -> right :
        #    hash children first (bottom up)
        # 2. Process root -> :
        #    combine children hashes into node hash (Merkle style)
        # Result: if subRoot hash appears anywhere in root's hash set, its a subtree

        import hashlib

        # Hash set to store all subtree hashes in root,
        # will be used to compare subtree vs merkle tree of original full tree
        # sc: O(n)
        rootHashes = set()

        # Recursive abstraction call to hash a subtree bottom up
        # tc: O(n)
        # sc: O(n)
        def merkleHash(node, addToRootHash):

            # Post Order: Hash children before hashing root

            # Base case:
            # Null node gets a consistent null sentinel hash
            if node == None:
                return "#"
            
            # Post Order: Hash children first (bottom up)
            leftHash  = merkleHash(node.left, addToRootHash)
            rightHash = merkleHash(node.right, addToRootHash)

            # Combine children hashes + node value into single hash
            # Delimiters prevent value collision (e.g. val 1,2 vs val 12)
            nodeSignature = f"{leftHash},{node.val},{rightHash}"
            nodeHash = hashlib.md5(nodeSignature.encode()).hexdigest()

            # This subtree's hash belongs to original root tree
            if addToRootHash:
                rootHashes.add(nodeHash)

            return nodeHash
        
        # Build all subtree hashes for root (bottom up)
        merkleHash(root, True)

        # Build subRoot hash using same hashing logic
        subRootHash = merkleHash(subRoot, False)

        # Check if subRoot fingerprint exists anywhere in root
        res = subRootHash in rootHashes

        # overall: tc O(n + m)
        # overall: sc O(n + m)
        return res
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

105. Construct Binary Tree from Pre Order and In Order Traversal ::1:: - Medium

Topics: Tree Traversal, Array, Hash Table, Depth First Search, Divide and Conquer, Tree, Binary Tree

Intro

Given two integer arrays pre order and in order where pre order is the pre order traversal of a binary tree and inorder is the inorder traversal of the same tree, construct and return the binary tree.

Example InputOutput
preorder = [3,9,20,15,7], inorder = [9,3,15,20,7][3,9,20,null,null,15,7]
preorder = [-1], inorder = [-1][-1]

Constraints:

1 ≤ preorder.length ≤ 3000

inorder.length == preorder.length

-3000 ≤ preorder[i], inorder[i] ≤ 3000

Each value or inorder also appears in preorder.

preorder is guaranteed to be the preorder traversal of the tree.

inorder is guaranteed to be the inorder traversal of the tree.

Abstraction

Given a pre and in order traversal with no null markers, construct the original tree.

The problem gives 2 lists because there are no null markers in either of the lists. Null markers solve "where does each subtree end". Pre/post order solve "where does the root sit" for free. In order with a root location solve "everything to the left and right belong to the corresponding subtrees".

Since we are given no null markers, we need either pre/post order + in order.

Pseudocode

Sol 1: DFS Pre Order Recursive In Order Hash Map
1. inOrderIndexMapping = {val: idx for idx, val in enumerate(inorder)}
2. (preIndex = 0)
3. preAndInArrayToTree(left, right):
   a. if left > right: return None
   b. rootVal = preorder[preIndex]
   c. root = TreeNode(rootVal)
   d. rootIndex = inOrderIndexMapping[rootVal]
   e. preIndex += 1
   f. root.left = preAndInArrayToTree(left, rootIndex - 1)
   g. root.right = preAndInArrayToTree(rootIndex + 1, right)
   h. Return root
4. Return preAndInArrayToTree(0, len(inorder)-1)

Solution 1: DFS Pre Order Recursive In Order Hash Map - Tree/DFS Pre order Traversal

    def buildTree(self, preorder: List[int], inorder: List[int]) -> Optional[TreeNode]:
            
        # Pre Order + In Order Array (no null markers):

        # To reconstruct the tree we need to solve these two requirements:
        #   - "where does the root sit"
        #   - "where does each subtree end" (i.e. the left/right split)

        # Pre Order + In Order or Post Order + In Order Covers Both:
        #   - Pre/post order solve "where does the root sit" for free,
        #     root is always first (pre-order) or always last (post-order),
        #     a fixed position regardless of tree shape.
        #   - In order + known root, the root being provided from Pre/post order, solves 
        #     "everything to the left of root belongs to the left subtree and vice versa for right" 
        #   - (So for this problem), Pre/post order + in order is fully self-sufficient as we get
        #     a fixed root position through pre order and explicit subtree boundaries through in order

        #   - (for contrast) Null markers solve "where does each subtree end", 
        #     an explicit sentinel marks every missing child, so the exact size of every
        #     subtree is recoverable from the token stream itself.


        # Note:
        # We are given 2 arrays, pre order and in order, which are traversals of the same tree.
        # The pre order array will pointer will represent the root of the tree
        # The in order will represent the left and right subtrees of that root
        
        #         1
        #        / \
        #       2   3
        #      / \
        #     4   5

        # preorder (root -> left -> right):  [1, 2, 4, 5, 3]
        # inorder  (left -> root -> right):  [4, 2, 5, 1, 3]

        # 1. Grab root value from pre order pointer
        # 2. Since the values are unique, we can use root value to find root index in in order array
        # 3. The root index in the in order array will split the in order array into left and right subtrees
        # 4. We will then recursively build the left and right subtrees using the left and right sections of the in order array
        # 5. Move the pre order pointer to the next root which because (root -> left -> right),
        #    will be the left and right subtrees we just marked into sections

        # Recursive abstraction to build tree from pre and in order arrays
        # left and right are relative to the in order array, 
        # and represent the bounds of the current subtree we are building
        def preAndInArrayToTree(left, right):

            # Pre Order:
            # Holds the current root of the current tree we are building 
            nonlocal preIndex

            # Base case: 
            # no elements remain for in order, return leaf
            if left > right:
                return None

            # 1. Grab root value from pre order pointer
            rootVal = preorder[preIndex]
            root = TreeNode(rootVal)

            # 2. Since the values are unique, we can use root value to find root index in in order array
            rootIndex = inOrderIndexMapping[rootVal]

            # 3. The root index in the in order array will split the in order array into left and right subtrees

            # [left ... root ... right]  =>

            # [left ... root - 1, root, root + 1 ... right]
            #   | left subtree |   r     | right subtree |
            
            # Left subtree gets the left section: [left ... root - 1]
            leftSubtreeLeftNode = left
            leftSubtreeRightNode = rootIndex - 1

            # Right subtree gets the right section: [root + 1 ... right]
            rightSubtreeLeftNode = rootIndex + 1
            rightSubtreeRightNode = right

            # Iterate to next root from pre order (root -> left -> right),
            # so the left and right subtrees we just marked are about to be built
            preIndex += 1

            # 4. We will then recursively build the left and right subtrees using the left and right sections of the in order array

            # Recurse to assign the left and right subtree range
            root.left = preAndInArrayToTree(leftSubtreeLeftNode, leftSubtreeRightNode)
            root.right = preAndInArrayToTree(rightSubtreeLeftNode, rightSubtreeRightNode)

            # 5. Move the pre order pointer to the next root
            return root


        # PreRootIndex: 
        #   - root of tree we currently building
        #   - trees will be build in order of pre order (root -> left -> right) 
        
        # Left/Right InOrderTreeBounds: 
        #   - left/right bounds of tree we are currently building
        #   - bounds will be in order of in order (left -> root -> right)

        # InOrderHashMap:
        #   - translates root from PreOrder be relative to InOrder, (left -> root -> right)

        # Tree Root Building Tracker:
        preIndex = 0

        # Left/Right Bounds Building Tracker:
        leftTreeBounds = 0 
        rightTreeBounds = len(inorder)-1

        # LookUp for (value -> index) for In Order Array
        inOrderIndexMapping = {}
        for idx, val in enumerate(inorder):
            inOrderIndexMapping[val] = idx

        # Start building tree from root
        fullTree = preAndInArrayToTree(leftTreeBounds, rightTreeBounds)

        # overall: tc O(n)
        # overall: sc O(n)
        return fullTree
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

337. House Robber III ::1:: - Medium

Topics: Tree Structure Analysis, Dynamic Programming, Tree, Depth First Search, Binary Tree

Intro

The thief has found himself a new place for his thievery again. There is only one entrance to this area, called root. Besides the root, each house has one and only one parent house. After a tour, the smart thief realized that all houses in this place form a binary tree. It will automatically contact the police if two directly-linked houses were broken into on the same night. Given the root of the binary tree, return the maximum amount of money the thief can rob without alerting the police.

Example InputOutput
root = [3,2,3,null,3,null,1]7
root = [3,4,5,1,3,null,1]9

Constraints:

1 ≤ preorder.length ≤ 3000

0 ≤ Node.val ≤ 10^4

Abstraction

For a binary tree with values, determine at each node, if you should grab the node value and skip both the children, or skip the node value and grab either of the children.

Pseudocode

Sol 1: Recursive Post Order DP
1. dfs(node):
   a. if not node: return (0, 0)
   b. (leftRob, leftSkip) = dfs(node.left)
   c. (rightRob, rightSkip) = dfs(node.right)
   d. robNode = node.val + leftSkip + rightSkip
   e. skipNode = max(leftRob, leftSkip) + max(rightRob, rightSkip)
   f. Return (robNode, skipNode)
2. (rootRob, rootSkip) = dfs(root)
3. Return max(rootRob, rootSkip)

Solution 1: [DFS] Recursive Post Order DP - Tree/DFS Pre order Traversal

    def rob(self, root: Optional[TreeNode]) -> int:
                
        # House Tree Structure:
        # Each house is a node in a binary tree.
        # If we rob a node, we cannot rob its direct children.
        
        # Ex:
        #        5
        #       / \
        #      4   7
        #       \   \
        #        2   8
        
        # Key Idea (Tree DP):
        # At each node, we must decide between:
        #
        #   1. Rob this node
        #      -> Cannot rob left or right child
        #
        #   2. Skip this node
        #      -> We are allowed to either rob or skip children, 
        #         so we grab the max between two options
        #
        # Each node needs to return two states
        #   - rob
        #   - skip
        

        # Post Order traversal: (left -> right -> root)
        # Grab best choice from children to calculate best choice for root

        def dfs(node):
            
            # Base case: 
            # A leaf node has no value, 
            # mark both the rob and skip values as 0
            if not node:
                return (0, 0)
            
            # Post Order traversal
            # grab value for rob and skip from children
            leftRob, leftSkip = dfs(node.left)
            rightRob, rightSkip = dfs(node.right)
            
            # DP:
            # Need to generate the values for rob/skip for children of this node

            # Rob node and skip children
            robNode = node.val + leftSkip + rightSkip
            
            # Skip node and max between rob or skip children 
            skipNode = max(leftRob, leftSkip) + max(rightRob, rightSkip)
            
            # Return rob and skip values for node
            return (robNode, skipNode)
        
        
        # Grab the rob/skip values for the root node
        (rootRob, rootSkip) = dfs(root)
        
        # overall: tc O(n)
        # overall: sc O(log(n)) for balanced / O(n) for skewed trees
        return max(rootRob, rootSkip)
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

1325. Delete Leaves With a Given Value ::1:: - Medium

Topics: Tree Structure Analysis, Dynamic Programming, Tree, Depth First Search, Binary Tree

Intro

Given a binary tree root and an integer target, delete all the leaf nodes with value target. Note that once you delete a leaf node with value target, if its parent node becomes a leaf node and has the value target, it should also be deleted (you need to continue doing that until you cannot).

Example InputOutput
root = [1,2,3,2,null,2,4], target = 2[1,null,3,null,4]
root = [1,3,3,3,2], target = 3[1,3,null,null,2]
root = [1,2,null,2,null,2], target = 2[1]

Constraints:

The number of nodes in the tree is in the range [1, 3000]

1 ≤ Node.val, target ≤ 1000

Abstraction

Given a binary tree, remove all leaves that match the target value, including nodes that become leaves after their children have been removed.

Pseudocode

Sol 1: Post Order Recursive Prune Upwards While Empty
1. if not root: return None
2. root.left = removeLeafNodes(root.left, target)
3. root.right = removeLeafNodes(root.right, target)
4. if root.left == None and root.right == None and root.val == target:
    Return None
5. Return root

Solution 1: [DFS] Post Order Recursive Prune Upwards While Empty - Tree/DFS Pre order Traversal

    def removeLeafNodes(self, root: Optional[TreeNode], target: int) -> Optional[TreeNode]:
        
        # We only want remove LEAF nodes with value == target

        # Post Order (left -> right -> root):
        # Remove children first and determine if root will become a leaf
        
        # Base case:
        # Nothing to remove
        if not root:
            return None
        
        # Remove target nodes from left and right subtrees
        root.left = self.removeLeafNodes(root.left, target)        
        root.right = self.removeLeafNodes(root.right, target)
        
        # Remove current node if:
        #   - left and right are None, (current node is a leaf)
        #   - Node has the target value 
        if root.left == None and root.right == None and root.val == target:
            return None
        
        # overall: tc O(n)
        # overall: sc O(log n) for balanced / O(n) for skewed trees
        return root
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

366. Find Leaves of Binary Tree ::1:: - Medium

Topics: Tree Structure Analysis, Tree, Depth First Search, Binary Tree

Intro

You are given the root of a binary tree. Your task is to collect the nodes of the tree in a specific way that simulates repeatedly removing leaf nodes. The process works as follows: First, identify and collect all current leaf nodes (nodes with no children) into a group. Remove these leaf nodes from the tree. After removal, some nodes that previously had children may now become new leaf nodes. Repeat steps 1-2, collecting each new set of leaf nodes into separate groups. Continue this process until the entire tree is empty The result should be a list of lists, where each inner list contains the values of nodes that were removed together in the same iteration.

Example InputOutput
root = [1, 2, 3, 4, 5][[4, 5, 3], [2], [1]]

Constraints:

The number of nodes in the tree is in the range [1, 100].

-100 ≤ Node.val ≤ 100

Abstraction

Given a binary tree, find the node to leaf height for each node, which is the height distance from itself to the farthest leaf in its left or right subtree. Then group nodes by that height.

Equivalent to removing all leaves layer by layer and grouping as we go.

Pseudocode

Sol 1: DFS Post Order Single Pass Height Bucketing
1. res = []
2. dfs(node):
   a. if not node: return -1
   b. leftHeight = dfs(node.left)
   c. rightHeight = dfs(node.right)
   d. height = 1 + max(leftHeight, rightHeight)
   e. if height == len(res): res.append([])
   f. res[height].append(node.val)
   g. Return height
3. dfs(root)
4. Return res

Solution 1: [DFS] DFS Post Order Single Pass Height Bucketing - Tree/DFS Post Order Traversal

    def findLeaves(self, root: Optional[TreeNode]) -> List[List[int]]:
        
        # DFS Post Order Height Bucketing

        # Track nodes height during DFS iteration
        # and bucket node's value into result[height] bucket as we go

        # Height buckets
        # sc: O(n)
        res = []

        def dfs(node) -> int:

            # Base Case:
            # a null child has no height, 
            # pass back -1 so that the parent leaf is assigned height 0
            if not node:
                return -1

            # Height of children
            leftHeight = dfs(node.left)
            rightHeight = dfs(node.right)

            # Compute Height:
            # 1 + the larger distance to a leaf between the two subtrees
            height = 1 + max(leftHeight, rightHeight)

            # Bucket Placement:
            # ensure height has a bucket before appending
            if height == len(result):
                res.append([])
            res[height].append(node.val)

            # Return heigh to root node 
            return height

        # Start height bucketing on root
        dfs(root)

        # overall: tc O(n)
        # overall: sc O(n)
        return res

124. Binary Tree Maximum Path Sum ::1:: - Hard

Topics: Tree Subsection Splitting, Dynamic Programming, Tree, Depth First Search, Binary Tree

Intro

A path in a binary tree is a sequence of nodes where each pair of adjacent nodes in the sequence has an edge connecting them. A node can only appear in the sequence at most once. Note that the path does not need to pass through the root. The path sum of a path is the sum of the node's values in the path. Given the root of a binary tree, return the maximum path sum of any non-empty path.

Example InputOutput
root = [1,2,3]6
root = [-10,9,20,null,null,15,7]42

Constraints:

The number of nodes in the tree is in the range [1, 3 * 104]

-1000 ≤ Node.val ≤ 1000

Abstraction

Given a tree, find the path that produces the max sum.

Each node has either positive or negative value. We will use the negative values to split the tree into tree sections of only positive sections.

Pseudocode

Sol 1: Post Order Negative Path 0 Flattening Reset Separating Positive Subtrees
1. (maxSum = -inf)
2. dfs(node):
   a. if not node: return 0
   b. leftGain = max(dfs(node.left), 0)
   c. rightGain = max(dfs(node.right), 0)
   d. currTree = node.val + leftGain + rightGain
   e. maxSum = max(maxSum, currTree)
   f. Return node.val + max(leftGain, rightGain)
3. dfs(root)
4. Return maxSum

Solution 1: [Dynamic Programming] [DFS] Post Order Negative Path 0 Flattening Reset Separating Positive Subtrees - Tree/DFS Pre order Traversal

    def maxPathSum(self, root: Optional[TreeNode]) -> int:
        
        # Negative Path 0 Flattening Reset Separating Positive Subtrees:
        #  - We have negative values in the tree,
        #    so we treat negative values are reset points for our sum paths.
        #    Clamp to 0 resets the path sum, 
        #    which breaks the tree into sections of only positive sums,
        #    so maxSum becomes the max positive path

        # Ex:
        #        1
        #       / \
        #     -2   5
        #    /  \   \
        #   6    2   8
        #    \
        #     3

        # Positive Splits:
        #
        # [1, 5, 8], [2], [6, 3]
        
        # Postorder Traversal (DFS): (left -> right -> root)
        #   - Need to process left and right subtrees 
        #     before to determine where the positive tree paths are

        # Dynamic Programming:
        #   - DP state: dp[node] = max downward path sum starting at node,
        #     extending into ONE child branch (a path can only pass through
        #     a node once, so it can't fork into both children and still
        #     extend upward to the parent)
        #   - Transition: dp[node] = node.val + max(0, dp[left], dp[right])
        #   - Base case: dp[None] = 0
        #   - Each dp[node] is computed exactly once (postorder = correct
        #     evaluation order, since dp[node] needs dp[left]/dp[right] first)
        #     and reused by its parent — this is memoization via recursion,
        #     no need for an explicit memo table since the tree structure
        #     itself guarantees each subproblem is visited once
        #   - maxSum is NOT dp[root] — it's a separate global answer that
        #     checks, at every node, the path that uses BOTH branches
        #     (node as the "peak"), since dp[node] alone only tracks what
        #     can be extended upward

        # Global max path sum across all nodes
        maxSum = float('-inf')

        def dfs(node):
            nonlocal maxSum

            # Base case:
            # reached a leaf's child 'None'
            if not node:
                return 0

            # Clamp negative paths to 0, so we only consider positive paths
            leftGain = max(dfs(node.left), 0)
            rightGain = max(dfs(node.right), 0)

            # Connect left and right paths through current node
            currTree = node.val + leftGain + rightGain

            # Check global max
            maxSum = max(maxSum, currTree)

            # Continue passing up better branch upwards
            currMax = node.val + max(leftGain, rightGain)
            return currMax

        dfs(root)

        # overall: tc O(n)
        # overall: sc O(log n) for balanced / O(n) for skewed trees
        return maxSum
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

2872. Maximum Number of K-Divisible Components ::1:: - Hard

Topics: Tree Subsection Splitting, Tree, Depth First Search, Greedy

Intro

There is an undirected tree with n nodes labeled from 0 to n - 1. You are given the integer n and a 2D integer array edges of length n - 1, where edges[i] = [ai, bi] indicates that there is an edge between nodes ai and bi in the tree. You are also given a 0-indexed integer array values of length n, where values[i] is the value associated with the ith node, and an integer k. A valid split of the tree is obtained by removing any set of edges, possibly empty, from the tree such that the resulting components all have values that are divisible by k, where the value of a connected component is the sum of the values of its nodes. Return the maximum number of components in any valid split.

Example InputOutput
n = 5, edges = [[0,2],[1,2],[1,3],[2,4]], values = [1,8,1,4,4], k = 62
n = 7, edges = [[0,1],[0,2],[1,3],[1,4],[2,5],[2,6]], values = [3,0,6,1,5,2,1], k = 33

Constraints:

1 ≤ n ≤ 3 * 10^4

edges.length == n - 1

edges[i].length == 2

0 ≤ ai, bi < n

values.length == n

0 ≤ values[i] ≤ 10^9

1 ≤ k ≤ 10^9

Sum of values is divisible by k

The input is generated such that edges represents a valid tree

Abstraction

Given a tree, find the maximum number of edges we can cut so that every resulting piece has a value sum divisible by k.

Each node contributes its value to a running subtree sum. We will use divisibility by k as the split point, so whenever a subtree's sum is a multiple of k, we can cut it away from its parent as its own valid component and continue.

Pseudocode

Sol 1: Greedy Postorder Simulate Tree Cutting By Returning 0
1. graph = adjacency list from edges (undirected)
2. (components = 0)
3. dfs(node, parent):
   a. subtree_sum = values[node]
   b. for each neighbor in graph[node]:
      if neighbor == parent: continue
      subtree_sum += dfs(neighbor, node)
   c. if subtree_sum % k == 0:
        components += 1
        Return 0
   d. Return subtree_sum
4. dfs(0, -1)
5. Return components

Solution 1: [Greedy] [DFS] Greedy Postorder Simulate Tree Cutting By Returning 0 - Tree/DFS Post Order Recursive Bottom Up

    def maxKDivisibleComponents(self, n: int, edges: List[List[int]], values: List[int], k: int) -> int:

        # Greedy Tree Cutting:
        # The challenge is the problem asks for the MAXIMUM number of components,
        # so brute force would be to try every possible set of edge cuts
        # which would lead to O(2^n)

        # Greedy Tree Cutting avoids trying every combination and allows O(n),
        # as we can greedily cut edges bottom up using postorder DFS
 
        # Key Point:
        # When a subtree sum is divisible by k, we can safely cut it from its parent
        # because adding a multiple of k to any sum never changes its remainder

        # Summary:
        # Pass 1 (Postorder):
        #   - accumulate subtree sums bottom up
        #   - cut edge immediately when subtree sum % k == 0

        # Since the original graph is undirected,
        # we need a undirected adjacency list 
        # to represent the connection going both ways for each edge
        # sc: O(n)
        graph = defaultdict(list)
        for u, v in edges:
            graph[u].append(v)
            graph[v].append(u)

        # Total component count
        # sc: O(1)
        self.components = 0

        # ------------------------------
        # Pass 1: Postorder DFS (left- > right -> root)
        # Greedily cut edges bottom up, counting valid components

        # Need to calculate the components for children to determine components for root
        #   - Process all children first, accumulate subtree sum
        #   - Process root: if subtree sum % k == 0, cut edge and count as component
        #   - Returning 0 to parent simulates cutting the edge (excludes from parent sum)
        def dfs(node, parent):

            # Start subtree sum with current node's value
            subtree_sum = values[node]

            # Process left -> right ->
            # accumulate subtree sums from all children
            for leftOrRight in graph[node]:

                # Skip original parent to avoid cycles
                # (in case node is pointing to itself in adjacency list)
                if leftOrRight == parent:
                    continue

                subtree_sum += dfs(leftOrRight, node)

            # Process -> root: check if subtree is divisible by k
            if subtree_sum % k == 0:

                # Greedy: cut immediately at the deepest valid point
                # cutting early is always safe — proof above
                self.components += 1

                # Return 0 to parent to simulate cutting the edge
                return 0

            # Subtree not divisible — pass sum up to parent
            return subtree_sum

        # Pass 1: postorder greedy cut, count valid components
        dfs(0, -1)

        # overall: tc O(n)
        # overall: sc O(n)
        return self.components

663. Equal Tree Partition ::1:: - Medium

Topics: Tree Subsection Splitting, Tree, Depth First Search, Binary Tree

Intro

You are given the root of a binary tree. Your task is to determine if it's possible to split the tree into two separate trees by removing exactly one edge, such that both resulting trees have equal sums of node values. The problem asks you to return true if such a partition exists, and false otherwise.

Example InputOutput
root = [5,10,10,null,null,2,3]true
root = [1,2,10,null,null,2,20]false

Constraints:

The number of nodes in the tree is in the range [1, 10^4].

-10^5 ≤ Node.val ≤ 10^5

Abstraction

Removing any single edge splits the tree into exactly two pieces: the subtree rooted at that edge's child, and everything else (total sum minus that subtree's sum). So the maximum product can be found by computing the sum of every possible subtree via one DFS pass, then for each subtree sum t, checking t * (totalSum - t) and keeping the best result.

Pseudocode

Sol 1: Post Order Subtree Sum Target Match
1. if not root.left and not root.right: return False
2. subtreeSums = []
3. dfs(node):
   a. if not node: return 0
   b. leftSum = dfs(node.left)
   c. rightSum = dfs(node.right)
   d. subtreeSum = node.val + leftSum + rightSum
   e. subtreeSums.append(subtreeSum)
   f. Return subtreeSum
4. totalSum = dfs(root)
5. subtreeSums.pop()
6. if totalSum % 2 != 0: return False
7. target = totalSum // 2
8. Return target in subtreeSums

Solution 1: [DFS] Post Order Subtree Sum Target Match - Tree/DFS Post Order Traversal

    def checkEqualTree(self, root: Optional[TreeNode]) -> bool:
        
        # DFS Post Order Subtree Sum Target Match

        # Removing any single edge splits the tree into exactly two
        # pieces: the subtree rooted at the child side of that edge,
        # and everything else (totalSum minus that subtree's sum). For
        # those two pieces to have equal sums, the subtree's sum must
        # be exactly half of totalSum, which automatically guarantees
        # the remaining piece is the other half.

        # This means a valid partition exists if and only if some
        # subtree sum (other than the root's own sum, since there's no
        # edge above the root to remove) equals totalSum / 2.

        # We can compute every subtree's sum in a single post-order DFS
        # pass (children fully summed before the parent), collecting
        # each one as a candidate. The root's own final sum is excluded
        # afterward, since it represents "the whole tree," not a valid
        # split.

        # Edge Case:
        # a single node has no edges to remove at all
        if not root.left and not root.right:
            return False

        # Track every subtree sum encountered
        # sc: O(n)
        subtreeSums = []

        def dfs(node):

            # Base Case:
            # a null node contributes 0 to its parent's sum
            if not node:
                return 0

            # Process Children First (Post Order):
            # a node's subtree sum depends on both children's sums
            leftSum = dfs(node.left)
            rightSum = dfs(node.right)

            # Compute Subtree Sum:
            subtreeSum = node.val + leftSum + rightSum

            # Record Candidate Split:
            # removing the edge above this node produces this exact split
            subtreeSums.append(subtreeSum)

            return subtreeSum

        # Total sum of the entire tree, also the last value dfs() returns
        # tc: O(n)
        totalSum = dfs(root)

        # Exclude Root's Own Sum:
        # the whole tree's sum is not a valid split, there's no edge above it
        subtreeSums.pop()

        # Target Split Check:
        # a valid partition exists if some subtree equals exactly half
        # of totalSum, since the remaining piece automatically matches
        # tc: O(n)
        if totalSum % 2 != 0:
            return False

        target = totalSum // 2

        # overall: tc O(n), one DFS pass to compute all subtree sums,
        #   one linear pass to check for the target
        # overall: sc O(n), for subtreeSums plus O(h) recursion stack
        #   depth, h = height of tree
        return target in subtreeSums

1339. Maximum Product of Splitted Binary Tree ::1:: - Medium

Topics: Tree Subsection Splitting, Tree, Depth First Search, Binary Tree

Intro

Given the root of a binary tree, split the binary tree into two subtrees by removing one edge such that the product of the sums of the subtrees is maximized. Return the maximum product of the sums of the two subtrees. Since the answer may be too large, return it modulo 109 + 7. Note that you need to maximize the answer before taking the mod and not after taking it.

Example InputOutput
root = [1,2,3,4,5,6]110
root = [1,null,2,3,4,null,null,5,6]90

Constraints:

The number of nodes in the tree is in the range [2, 5 * 10^5]

1 ≤ Node.val ≤ 10^4

Abstraction

Removing any single edge splits the tree into exactly two pieces: the subtree rooted at that edge's child, and everything else (total sum minus that subtree's sum). So the maximum product can be found by computing the sum of every possible subtree via one DFS pass, then for each subtree sum t, checking t * (totalSum - t) and keeping the best result.

Pseudocode

Sol 1: Post Order Subtree Sum Enumeration
1. MOD = 10**9 + 7
2. subtreeSums = []
3. dfs(node):
   a. if not node: return 0
   b. leftSum = dfs(node.left)
   c. rightSum = dfs(node.right)
   d. subtreeSum = node.val + leftSum + rightSum
   e. subtreeSums.append(subtreeSum)
   f. Return subtreeSum
4. totalSum = dfs(root)
5. (maxProduct = 0)
6. for each t in subtreeSums:
    maxProduct = max(maxProduct, t * (totalSum - t))
7. Return maxProduct % MOD

Solution 1: [DFS] Post Order Subtree Sum Enumeration - Tree/DFS Post Order Traversal

    def maxProduct(self, root: Optional[TreeNode]) -> int:
        
        # DFS Post Order Subtree Sum Enumeration

        # Removing any single edge splits the tree into exactly two
        # pieces: the subtree rooted at the child side of that edge,
        # and everything else (the rest of the tree). If a subtree
        # rooted at some node has sum t, then removing the edge above
        # that node always produces a split of (t, totalSum - t).

        # This means every possible split is fully determined by every
        # possible subtree sum in the tree. Rather than simulating each
        # edge removal individually, we can compute every subtree's sum
        # in a single post-order DFS pass (children fully summed before
        # the parent), collecting each one as a candidate split point.

        # Once every subtree sum is known, the answer is just the
        # maximum of t * (totalSum - t) across all collected sums.

        MOD = 10**9 + 7

        # Track every subtree sum encountered
        # sc: O(n)
        subtreeSums = []

        def dfs(node):

            # Base Case:
            # a null node contributes 0 to its parent's sum
            if not node:
                return 0

            # Process Children First (Post Order):
            # a node's subtree sum depends on both children's sums
            leftSum = dfs(node.left)
            rightSum = dfs(node.right)

            # Compute Subtree Sum:
            subtreeSum = node.val + leftSum + rightSum

            # Record Candidate Split:
            # removing the edge above this node produces this exact split
            subtreeSums.append(subtreeSum)

            return subtreeSum

        # Total sum of the entire tree, also the last value dfs() returns
        # tc: O(n)
        totalSum = dfs(root)

        # Maximize Product Across All Candidate Splits:
        # for each subtree sum t, the split is (t, totalSum - t)
        # tc: O(n)
        maxProduct = 0
        for t in subtreeSums:
            maxProduct = max(maxProduct, t * (totalSum - t))

        # overall: tc O(n), one DFS pass to compute all subtree sums,
        #   one linear pass to find the best split
        # overall: sc O(n), for subtreeSums plus O(h) recursion stack
        #   depth, h = height of tree
        res = maxProduct % MOD

        # overall: tc O(n)
        # overall: sc O(n)
        return res

834. Sum of Distances in Tree ::1:: - Hard

Topics: Connected Components, Dynamic Programming, Tree, Depth First Search, Graph Theory, Tree Rerooting

Intro

There is an undirected connected tree with n nodes labeled from 0 to n - 1 and n - 1 edges. You are given the integer n and the array edges where edges[i] = [ai, bi] indicates that there is an edge between nodes ai and bi in the tree. Return an array answer of length n where answer[i] is the sum of the distances between the ith node in the tree and all other nodes.

Example InputOutput
n = 6, edges = [[0,1],[0,2],[2,3],[2,4],[2,5]][8,12,6,10,10,10]
n = 1, edges = [][0]
n = 2, edges = [[1,0]][1,1]

Constraints:

1 ≤ n ≤ 3 * 10^4

edges.length == n - 1

edges[i].length == 2

0 ≤ ai, bi < n

ai != bi

The given input represents a valid tree

Abstraction

Given a tree, which is just a connected component with n-1 edges with no cycles, find the sum of distances from every node to every other node.

Rather than recomputing distances from scratch for each node, we compute the sum once for one root, then reroot and shifting the root one edge at a time and adjusting the previous answer based on how many nodes are now closer or farther.

Since this connected component has no cycles, we do not need to worry about recomputing the sum of distance (a shorter path) for the node that ends up with the cycle

Pseudocode

Sol 1: ReRooting DP On PostOrder Node 0 and PreOrder All Other Nodes
1. tree = undirected adjacency list from edges
2. nodeToAllTotal = [0] * n
3. subtreeSize = [1] * n

4. dfs_postOrder(node, parent):
   a. for each child in tree[node]:
      if child == parent: continue
      dfs_postOrder(child, node)
      subtreeSize[node] += subtreeSize[child]
      nodeToAllTotal[node] += nodeToAllTotal[child] + subtreeSize[child]

5. dfs_preOrder(node, parent):
   a. for each child in tree[node]:
      if child == parent: continue
      nodeToAllTotal[child] = nodeToAllTotal[node] - subtreeSize[child] + (n - subtreeSize[child])
      dfs_preOrder(child, node)

6. dfs_postOrder(0, -1)
7. dfs_preOrder(0, -1)
8. Return nodeToAllTotal

Solution 1: [DFS] ReRooting DP On PostOrder Node 0 and PreOrder All Other Nodes - Tree/DFS Post Order Recursive Two Sided Bottom Up

    def sumOfDistancesInTree(self, n: int, edges: List[List[int]]) -> List[int]:
        
        # Re-rooting DP:
        # A common idea in competitive programming,
        # and somewhat easy to spot as such problems typically ask something like
        # "find some value for each root"

        # Re-rooting DP (Dynamic Programming On Trees):
        # The challenge is the problem asks for answer at EVERY node,
        # pretending as if that node is the root, so brute force would be:
        #   1. pick a node as root
        #   2. run a DFS to compute its answer
        #   3. repeat for the rest n nodes
        # Which would lead to O(n^2)

        # Re-rooting DP avoids recomputing everything from scratch and allows O(n),
        # as we can compute the answer for one root (node 0),
        # then efficiently "move" the root across each edge

        # Key Point:
        # When moving the root from parent -> child:
        #   - child subtree nodes become 1 step closer
        #   - all other nodes become 1 step further
        # so we need to know the size of subtrees to calculate
        # how many nodes are closer and how many are now farther

        # Summary:
        # Using the previously computed DP values, we can derive
        # the child's answer in O(1) instead of running another DFS in O(n)

        # General Re-rooting DP Pattern:
        #   Pass 1 (Postorder):
        #       - gather information from children -> parent

        #   Pass 2 (Preorder):
        #       - Propagate answers from parent -> children

        # Since the original graph is undirected,
        # we need a undirected adjacency list 
        # to represent the connection going both ways for each edge
        # sc: O(n)
        tree = defaultdict(list)
        for u, v in edges:
            tree[u].append(v)
            tree[v].append(u)


        # Sum of distances from node i to all other nodes:
        #   Ex: After our Post Order iteration for node 0, we get
        #       nodeToAllTotal[0] = sum of distances from root node 0 to all other nodes
        # sc: O(n)
        nodeToAllTotal = [0] * n


        # Subtree sizes for the subtree at each node:
        #   Re-rooting requires us to know the subtree sizes so
        #   we can modify the solution for node 0 for all other nodes
        #   while iterating from parent -> child, we must know:
        #       - how many nodes get closer?
        #       - how many nodes get farther?

        # Ex:
        #        0
        #       / \
        #      1   2
        #         / \
        #        3   4
        #
        # count[0] = 5  (entire tree)
        # count[1] = 1  (nodes 1)
        # count[2] = 3  (nodes 2,3,4)
        # count[i] = size of i's subtree

        # sc: O(n)
        subtreeSize = [1] * n


        # ------------------------------
        # Pass 1: Postorder DFS (left -> right -> root)
        #   - Solving for node 0 as the root, 
        #     find the total sum of distances to all nodes
        #   - Calculate the subtree size for the subtree at each node during iteration
        #     to be used during Preorder dfs

        # Process all children first:
        #   - pass up total distance needed
        #   - pass up subtree sizes
        def dfs_postOrder(node, parent):

            # First process children in adjacency matrix
            for leftOrRight in tree[node]:

                # Skip original parent to avoid cycles
                # (in case node is pointing to itself in adjacency matrix)
                if leftOrRight == parent:
                    continue

                # ----------------------------------
                # Generate subtree size for children

                # Process child subtree first,
                # and add children subtree size to parent
                dfs_postOrder(leftOrRight, node)
                subtreeSize[node] += subtreeSize[leftOrRight]


                # ----------------------------------
                # Use subtree size for children to calculate total distance for parent

                # distances from parent node to all nodes in parent's own subtree subtree =
                #   nodeToAllTotal[child]  — distances from child node to all nodes in child's own subtree
                #   + subtreeSize[child]   — every node in child's subtree is 1 edge farther from parent node than from child node
                nodeToAllTotal[node] += nodeToAllTotal[leftOrRight] + subtreeSize[leftOrRight]


        # ------------------------------
        # Pass 2: Preorder DFS (root -> left -> right)
        # Using the answer for node 0, derive the answer for all other nodes

        # We have solved for root already via DP (node 0 -> ith):
        #   - continue to solve downwards
        #   - propagate distances and re-root downward to children
        def dfs_preOrder(node, parent):

            # First process children in adjacency matrix
            for leftOrRight in tree[node]:

                # Skip original parent to avoid cycles
                # (in case node is pointing to itself in adjacency matrix)
                if leftOrRight == parent:
                    continue

                # ----------------------------------
                # Use parent's allNodeDistance to derive child's allNodeDistance

                # distances from child node to all nodes in child's own subtree =
                #   nodeToAllTotal[node]             — parent's distance to all nodes in tree
                #   - subtreeSize[leftOrRight]       — every node in child's subtree is 1 edge closer to child than to parent
                #   + (n - subtreeSize[leftOrRight]) — every node outside child's subtree is 1 edge farther from child than from parent
                nodeToAllTotal[leftOrRight] = nodeToAllTotal[node] - subtreeSize[leftOrRight] + (n - subtreeSize[leftOrRight])

                # ----------------------------------
                # Continue to propagate parent to children allNodeDistance

                # Propagate to leftOrRight's children
                dfs_preOrder(leftOrRight, node)


        # Pass 1: postorder compute subtree sizes and distances from root 0
        dfs_postOrder(0, -1)

        # Pass 2: preorder re-root and propagate distances to all nodes
        dfs_preOrder(0, -1)

        # overall: tc O(n)
        # overall: sc O(n)
        return nodeToAllTotal

427. Construct Quad Tree ::1:: - Medium

Topics: Tree Traversal, Array, Depth First Search, Divide and Conquer, Tree, Matrix, Grid

Intro

Given a n * n matrix grid of 0's and 1's only. We want to represent grid with a Quad-Tree. Return the root of the Quad-Tree representing grid. A Quad-Tree is a tree data structure in which each internal node has exactly four children. Besides, each node has two attributes:

  • val: True if the node represents a grid of 1's or False if the node represents a grid of 0's. Notice that you can assign the val to True or False when isLeaf is False, and both are accepted in the answer.
  • isLeaf: True if the node is a leaf node on the tree or False if the node has four children. class Node public boolean val; public boolean isLeaf; public Node topLeft; public Node topRight; public Node bottomLeft; public Node bottomRight; We can construct a Quad-Tree from a two-dimensional area using the following steps:
  1. If the current grid has the same value (i.e all 1's or all 0's) set isLeaf True and set val to the value of the grid and set the four children to Null and stop. If the current grid has different values, set isLeaf to False and set val to any value and divide the current grid into four sub-grids as shown in the photo. Recurse for each of the children with the proper sub-grid.
Example InputOutput
grid = [[0,1],[1,0]][[0,1],[1,0],[1,1],[1,1],[1,0]]
look at questionlook at question

Constraints:

n == grid.length == grid[i].length

n == 2^x where 0 ≤ x ≤ 6

Abstraction

Given a grid, translate it into a quad tree. A Quad Trees is a data structure that efficiently divides 2D space into smaller regions.

Each cell in the grid is either 0 or 1. We will use value uniformity as the split point, whenever a region is entirely one value it becomes a leaf, whenever a region contains mixed values we quarter it into four sub grids and recurse until every section is uniform and becomes a leaf.

Pseudocode

Sol 1: Quad Tree Construction Split Grid Into 4 Subsections And Turn Leaf When Uniform Values
1. n = len(grid)
2. dfs(r0, c0, size):
   a. if size == 1:
        Return Node(val=grid[r0][c0], isLeaf=True)
   b. half = size // 2
   c. topLeft = dfs(r0, c0, half)
   d. topRight = dfs(r0, c0 + half, half)
   e. bottomLeft = dfs(r0 + half, c0, half)
   f. bottomRight = dfs(r0 + half, c0 + half, half)
   g. subtreeLeaves = all 4 children isLeaf
   h. subtreeValues = all 4 children val equal
   i. if subtreeLeaves and subtreeValues:
        Return Node(val=topLeft.val, isLeaf=True)
   j. Return Node(val=True, isLeaf=False, topLeft, topRight, bottomLeft, bottomRight)
3. Return dfs(0, 0, n)

Solution 1: [DFS] Quad Tree Construction Split Grid Into 4 Subsections And Turn Leaf When Uniform Values - Tree/DFS Pre order Traversal

    def construct(self, grid: List[List[int]]) -> 'Node':

        # DFS Post Order Quad Tree Construction:
        # (top-left -> top-right -> bottom-left -> bottom-right -> root)

        #   - Divide the grid into 4 equal sub-grids
        #   - Process all 4 children first (Post Order), then merge at root
        #   - If all 4 children are leaves with the same value (uniformity):
        #       the root collapses into a single leaf
        #   - If the 4 children don't uniformly match: 
        #       keep an internal node with all 4 children attached

        # Grid size
        # sc: O(1)
        n = len(grid)

        def dfs(r0, c0, size) -> Node:

            # Base Case 1x1 Grid:
            # We've reached a single 1x1 cell grid, 
            # which will always be a leaf since a single cell is by definition uniform.
            # Go back up recursion call to 4x4 cell grid and continue up
            if size == 1:
                return Node(val=bool(grid[r0][c0]), isLeaf=True)

            # 4x4 Cell Grid:
            # We have at least 4 cells,
            # now we need to validate if we have uniform cells or if we need to recurse
            
            # Divide:
            # split the current grid into 4 equal quadrants
            half = size // 2

            # Process Children First (Post Order):
            # recursively build each quadrant before merging at this level
            # tc: O(1) for each call, 
            # tc: O(n^2) recursive cost of the 4 children across recursion tree
            topLeft     = dfs(r0,        c0,        half)
            topRight    = dfs(r0,        c0 + half, half)
            bottomLeft  = dfs(r0 + half, c0,        half)
            bottomRight = dfs(r0 + half, c0 + half, half)

            # Merge Check:
            # root can collapse into a single leaf if:
            #   - all 4 children are leaves
            #   - all 4 leaves hold the same value
            subtreeLeaves = (  
                                topLeft.isLeaf and 
                                topRight.isLeaf and
                                bottomLeft.isLeaf and 
                                bottomRight.isLeaf
                            )
            subtreeValues = (
                                topLeft.val == 
                                topRight.val ==
                                bottomLeft.val == 
                                bottomRight.val
                            )

            # Grid Uniformity:
            # We can collapse into one leaf node
            if subtreeLeaves and subtreeValues:
                return Node(val=topLeft.val, isLeaf=True)

            # Grid Mismatch:
            # Create an internal node with 4 subtrees representing the 4 sub grids
            return Node(
                val=True, isLeaf=False,
                topLeft=topLeft,
                topRight=topRight,
                bottomLeft=bottomLeft,
                bottomRight=bottomRight
            )

        # Initialize at coordinates (0, 0)
        # tc: O(n^2) every cell is visited exactly once at the base case for the value uniformity check O(n^2)
        # sc: O(n^2 + log n) log n for the recursion stack itself + n^2 for the output quad tree
        quadTreeRoot = dfs(0, 0, n)

        # overall: tc O(n^2)
        # overall: sc O(log n)
        return quadTreeRoot
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks