Jc-alt logo
jc

LeetCode: Trees I BST

LeetCode: Trees I BST
37 min read
data structures and algorithms

Binary Search Tree Intro

What is a Binary Search Tree

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

A binary search tree is a type of tree that follows these rules:

  • each node contains a unique key
  • all nodes in the left subtree of a node contain values strictly less than the nodes value
  • all nodes in the right subtree of a node contain values strictly greater than the nodes value

BST Diagram (DFS In Order: Left -> Root -> Right)

        4
      /   \
     2     5
    / \
   1   3

left subtree of 4:  {1, 2, 3}  all < 4
right subtree of 4: {5}        all > 4

DFS in-order visit: 12345

108. Convert Sorted Array to Binary Search Tree ::1:: - Easy

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

Intro

Given an integer array nums where the elements are sorted in ascending order, convert it to a height balanced binary search tree.

Example InputOutput
nums = [-10,-3,0,5,9][0,-3,9,-10,null,5]
nums = [1,3][3,1]

Constraints:

1 ≤ nums.length ≤ 10^4

-10^4 ≤ nums[i] ≤ 10^4

nums is sorted in strictly increasing order

Abstraction

Given a sorted array, convert it into a bst. Use in order traversal.

Pseudocode

Solution 1: [DFS] Recursive Divide and Conquer Pick Middle as Root - Array/DFS Build Balanced BST Top Down

    def sortedArrayToBST(self, nums: List[int]) -> Optional[TreeNode]:

        # Key Insight:
        # - array is already sorted, so an in order traversal of the BST
        #   we build must reproduce this exact array
        # - to guarantee the tree is HEIGHT BALANCED (required by the problem),
        #   always pick the MIDDLE element of the current range as the root
        #   - this splits remaining elements as evenly as possible into
        #     left/right subtrees every time, keeping height O(log n)
        # - elements left of the middle (smaller values) become the left
        #   subtree, elements right of the middle (larger values) become
        #   the right subtree

        # nums = [1, 2, 3, 4, 5], mid index = 2 (value 3)
        #
        #             3
        #           /   \
        #          1     5
        #           \   /
        #            2 4  <- wait, corrected below
        #
        #   left range:  [1, 2]   -> mid index 0 (value 1), 2 becomes right child
        #   right range: [4, 5]   -> mid index 0 (value 4), 5 becomes right child
        #
        #             3
        #           /   \
        #          1     4
        #           \     \
        #            2     5

        def buildBST(left, right):

            # Empty Range:
            #   - no elements available, no node to build
            if left > right:
                return None

            # Pick Middle as Root:
            #   - guarantees balanced split of remaining elements
            mid = (left + right) // 2

            root = TreeNode(nums[mid])

            # Elements left of mid form the left subtree
            root.left = buildBST(left, mid - 1)

            # Elements right of mid form the right subtree
            root.right = buildBST(mid + 1, right)

            return root

        # Start search across the full array
        newBst = buildBST(0, len(nums) - 1)
        
        # overall: tc O(n)
        # overall: sc O(n)
        return newBst

98. Validate Binary Search Tree ::2:: - Medium

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

Intro

Given the root of a binary tree, determine if it is a valid binary search tree (BST). The left subtree of a node contains only nodes with keys strictly less than the node's key. The right subtree of a node contains only nodes with keys strictly greater than the node's key. Both the left and right subtrees must also be binary search trees.

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

Constraints:

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

-231 ≤ Node.val ≤ 231-1

Abstraction

With initial boundaries of -inf and inf, continue to tighten boundaries as we encounter new nodes.

Node.left = [sameLeft, node.val] Node.right = [node.val, sameRight]

If a node ever breaks these boundaries then entire tree is invalid

Pseudocode

Sol 1: DFS Pre Order Recursive Passing Range Limits
1. dfs(node, low, high):
   a. if not node: return True
   b. if not (low < node.val < high): return False
   c. left_valid = dfs(node.left, low, node.val)
   d. right_valid = dfs(node.right, node.val, high)
   e. return left_valid and right_valid
2. return dfs(root, -inf, inf)

Sol 2: BFS Pre Order Iterative Passing Queuing Range Limits
1. queue = deque([(root, -inf, inf)])
2. while queue:
   a. (node, low, high) = queue.popleft()
   b. if not node: continue
   c. if not (low < node.val < high): Return False
   d. queue.append((node.left, low, node.val))
   e. queue.append((node.right, node.val, high))
3. return True

Solution 1: [DFS] DFS Pre Order Recursive Passing Range Limits - Tree/DFS Pre order Traversal

    def isValidBST(self, root: Optional[TreeNode]) -> bool:
        
        # BST Property: (left -> root -> right)
        #   - start with lax boundaries [-inf, inf]
        #   - keep tightening bounds as we encounter nodes
        #   - Node.left =  [sameLeft, node.val]
        #   - Node.right = [node.val, sameRight]

        def dfs(node, low, high):

            # Empty Case:
            # leaf will always be valid
            if node == None:
                return True

            # Validate Node:
            #   - if node touches or is outside boundary, tree is invalid
            if node.val <= low:
                return False
            elif high <= node.val:
                return False

            # Tighten bounds for left subtree
            #   - [sameLeft, node.val]
            leftSubtreeValid = dfs(node.left, low, node.val)
            
            # Tighten bounds for right subtree
            #   - [node.val, sameRight]
            rightSubtreeValid = dfs(node.right, node.val, high)

            # Return if nodes subtrees are also valid
            return leftSubtreeValid and rightSubtreeValid

        # Start with infinite bounds
        treeValid = dfs(root, float('-inf'), float('inf'))

        # overall: tc O(n)
        # overall: sc O(h) for balanced / O(n) for skewed trees
        return treeValid

Solution 2: [BFS] BFS Pre Order Iterative Passing Queuing Range Limits - Tree/DFS Pre order Traversal

    def isValidBST(self, root: Optional[TreeNode]) -> bool:

        # BST Property: (left -> root -> right)
        #   - start with lax boundaries [-inf, inf]
        #   - keep tightening bounds as we encounter nodes
        #   - Node.left =  [sameLeft, node.val]
        #   - Node.right = [node.val, sameRight]

        # Iterate queue
        queue = deque([(root, float('-inf'), float('inf'))])

        while queue:

            # Grab current node boundaries
            node, low, high = queue.popleft()

            # Empty Case:
            # leaf is always valid
            if node == None:
                continue

            # Validate Node:
            #   - if node touches or is outside boundary, tree is invalid
            if node.val <= low:
                return False
            elif high <= node.val:
                return False

            # Pass down new boundaries to left subtree
            #   - [sameLeft, node.val]
            queue.append((node.left, low, node.val))

            # Pass down new boundaries to right subtree
            #   - [node.val, sameRight]
            queue.append((node.right, node.val, high))

        # No subtree returned invalid, entire tree is valid

        # overall: tc O(n)
        # overall: sc O(h) for balanced / O(n) for skewed trees
        return True

669. Trim a Binary Search Tree ::1:: - Medium

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

Intro

Given the root of a binary search tree and the lowest and highest boundaries as low and high, trim the tree so that all its elements lies in [low, high]. Trimming the tree should not change the relative structure of the elements that will remain in the tree (i.e., any node's descendant should remain a descendant). It can be proven that there is a unique answer. Return the root of the trimmed binary search tree. Note that the root may change depending on the given bounds.

Example InputOutput
root = [1,0,2], low = 1, high = 2[1,null,2]
root = [3,0,4,null,2,null,null,1], low = 1, high = 3[3,2,null,1]

Constraints:

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

0 ≤ Node.val ≤ 10^4

The value of each node in the tree is unique

root is guaranteed to be a valid binary search tree

0 ≤ low ≤ high ≤ 10^4

Abstraction

Given a new required boundaries [low, high], traverse tree while removing all nodes and their subtrees that are not within the boundaries.

Pseudocode

Sol 1: DFS Pruning Optimization
1. if not root: return None
2. if root.val < low:
    return trimBST(root.right, low, high)
3. if root.val > high:
    return trimBST(root.left, low, high)
4. root.left = trimBST(root.left, low, high)
5. root.right = trimBST(root.right, low, high)
6. return root

Solution 1: Pruning Optimization - Tree/DFS Post Order Recursive Two Sided Bottom Up

    def trimBST(self, root: Optional[TreeNode], low: int, high: int) -> Optional[TreeNode]:
        
        # BST Property: (left < root < right)
        #   - if we encounter a node outside the valid boundaries,
        #     we can trim their entire subtree
        
        def dfs(node, low, high):

            # Empty Case:
            # found leaf, nothing to remove
            if node == None:
                return None

            # Node outside left boundaries:
            #   - node and left subtree, both are guaranteed to be out of bounds
            #   - replace node with a trimmed right subtree, may still have in bound nodes
            if node.val < low:
                return dfs(node.right, low, high)

            # Node outside right boundaries:
            #   - node and right subtree, both are guaranteed to be out of bounds
            #   - replace node with a trimmed left subtree, may still have in bound nodes
            if high < node.val:
                return dfs(node.left, low, high)

            # Node is within both boundaries
            #   - trim left and right subtrees
            node.left = dfs(node.left, low, high)
            node.right = dfs(node.right, low, high)

            # Tree has been trimmed, return valid node and its subtrees if any
            return node

        # Trim starting at root
        newRoot = dfs(root, low, high)

        # overall: O(n)
        # overall: O(log(n)) for balanced / O(n) for skewed
        return newRoot

235. Lowest Common Ancestor of a Binary Search Tree ::2:: - Medium

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

Intro

Given a binary search tree (BST), find the lowest common ancestor (LCA) node of two given nodes in the BST. According to the definition of LCA on Wikipedia: “The lowest common ancestor is defined between two nodes p and q as the lowest node in T that has both p and q as descendants (where we allow a node to be a descendant of itself).”

Example InputOutput
root = [6,2,8,0,4,7,9,null,null,3,5], p = 2, q = 86
root = [6,2,8,0,4,7,9,null,null,3,5], p = 2, q = 42
root = [2,1], p = 2, q = 12

Constraints:

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

-104 ≤ root.val ≤ 104

All Node.val are unique.

p != q

p and q will exist in the BST.

Abstraction

Given 2 values, find the split point in a bst, where the 2 values end up in different subtrees. The split point is the lowest common ancestor.

Pseudocode

Sol 1: Recursive BST Traversal
1. if p.val < root.val and q.val < root.val:
    return lowestCommonAncestor(root.left, p, q)
2. elif p.val > root.val and q.val > root.val:
    return lowestCommonAncestor(root.right, p, q)
3. else:
    return root

Sol 2: Iterative BST Traversal
1. node = root
2. while node:
   a. if p.val < node.val and q.val < node.val:
        node = node.left
   b. elif p.val > node.val and q.val > node.val:
        node = node.right
   c. else:
        return node

Solution 1: [BST] Recursive Find First Split Point - Tree/BST Guided Recursive Traversal

    def lowestCommonAncestor(self, root: TreeNode, p: TreeNode, q: TreeNode) -> TreeNode:
    

        def dfs(node, p, q):

            # Lowest Common Ancestor:
            # First node to contain p and q in left and right subtree

            # BST Property: (left < node < right)
            #   - the first time that p and q split into different subtrees,
            #       that node will be the lowest common ancestor

            # Both vals are smaller:
            #   - both vals live in the left subtree, explore
            if p.val < node.val and q.val < node.val:
                return dfs(node.left, p, q)

            # Both vals are larger:
            #   - both vals live in the right subtree, explore
            elif p.val > node.val and q.val > node.val:
                return dfs(node.right, p, q)

            # Vals Split Between Subtrees:
            #   - one val is larger than curr, one val is smaller
            #   - curr node is the lowest common ancestor
            return node

        lca = dfs(root, p, q)

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

Solution 2: [BST] Iterative Find First Split Point - Tree/BST Guided Iterative Traversal

    def lowestCommonAncestor(self, root: TreeNode, p: TreeNode, q: TreeNode) -> TreeNode:
        
        # Lowest Common Ancestor:
        # First node to contain p and q in left and right subtree

        # BST Property:
        # - given that (left < root < right)
        # - the first time that p and q split,
        #   is the node that contains both of them 

        curr = root

        # Traverse until LCA is found
        while curr:

            # Both vals are smaller:
            #   - both vals live in the left subtree, explore
            if p.val < curr.val and q.val < curr.val:
                curr = curr.left

            # Both vals are larger:
            #   - both vals live in the right subtree, explore
            elif p.val > curr.val and q.val > curr.val:
                curr = curr.right

            # One val is larger, one val is smaller:
            #   - vals are split between the different subtrees
            #   - curr node is the lowest common ancestor
            else:
                break

        lca = curr

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

701. Insert into a Binary Search Tree ::2:: - Medium

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

Intro

You are given the root node of a binary search tree (BST) and a value to insert into the tree. Return the root node of the BST after the insertion. It is guaranteed that the new value does not exist in the original BST. Notice that there may exist multiple valid ways for the insertion, as long as the tree remains a BST after insertion. You can return any of them.

Example InputOutput
root = [4,2,7,1,3], val = 5[4,2,7,1,3,5]
root = [40,20,60,10,30,50,70], val = 25[40,20,60,10,30,50,70,null,null,25]
root = [4,2,7,1,3,null,null,null,null,null,null], val = 5[4,2,7,1,3,5]

Constraints:

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

-108 ≤ Node.val ≤ 108

All Node.val are unique.

-1010 ≤ val ≤ 108

It's guaranteed that val does not exist in the original BST.

Abstraction

Given a val, insert it into a valid place in the bst. Since there are no restrictions, adding it as a leaf is the easiest way.

Pseudocode

Sol 1: Recursive Traversal
1. if root == None:
    return TreeNode(val)
2. if val < root.val:
    root.left = insertIntoBST(root.left, val)
3. else:
    root.right = insertIntoBST(root.right, val)
4. return root

Sol 2: Iterative Traversal
1. if not root: return TreeNode(val)
2. current = root
3. while True:
   a. if val < current.val:
      if current.left: current = current.left
      else: current.left = TreeNode(val); break
   b. else:
      if current.right: current = current.right
      else: current.right = TreeNode(val); break
4. return root

Solution 1: [BST] Recursive Create New Leaf - Tree/BST Guided Recursive Traversal

    def insertIntoBST(self, root: Optional[TreeNode], val: int) -> Optional[TreeNode]:

        # BST Property: (left < root < right)

        def dfs(node, val):
            
            # Base Case: 
            #   - explored down bst and reached leaf 
            #   - new val will replace this leaf
            if node == None:
                return TreeNode(val)
            
            # Val is lower:
            #   - val lives in left subtree, explore
            if val < node.val:
                node.left = dfs(node.left, val)

            # Val is greater:
            #   - val lives in right subtree, explore
            else:
                node.right = dfs(node.right, val)

            return node
        
        # Insert
        newRoot = dfs(root, val)

        # tc: O(h)
        # sc: O(log n) for balanced / O(n) for unbalanced tree
        return newRoot

Solution 2: [BST] Iterative Create New Leaf - Tree/BST Guided Iterative Traversal

    def insertIntoBST(self, root: Optional[TreeNode], val: int) -> Optional[TreeNode]:
        
        # BST Property: (left < root < right)

        # Empty Case:
        # tree is empty, new node becomes root
        if not root:
            return TreeNode(val)
        
        curr = root
        
        while True:

            # Val is lower:
            #   - val lives in left subtree, explore
            if val < curr.val:
                
                # Left node exists, continue exploring
                if curr.left:
                    curr = curr.left

                # Base Case: 
                #   - explored down bst and reached leaf 
                #   - new val will replace this leaf
                else:
                    curr.left = TreeNode(val)
                    break

            # Val is greater:
            #   - val lives in right subtree, explore
            else:

                # Right node exists, continue exploring
                if curr.right:
                    curr = curr.right
                
                # Base Case: 
                #   - explored down bst and reached leaf 
                #   - new val will replace this leaf
                else:
                    curr.right = TreeNode(val)
                    break
        
        # Return original root to maintain BST structure

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

230. Kth Smallest Element in a BST ::2:: - Medium

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

Intro

Given the root of a binary search tree, and an integer k, return the kth smallest value (1-indexed) of all the values of the nodes in the tree. Follow up: If the BST is modified often (i.e., we can do insert and delete operations) and you need to find the kth smallest frequently, how would you optimize?

Example InputOutput
root = [3,1,4,null,2], k = 11
root = [5,3,6,2,4,null,null,1], k = 33

Constraints:

The number of nodes in the root tree is n

1 ≤ k ≤ n ≤ 104

0 ≤ Node.val ≤ 104

Abstraction

In Order traversal of BST gives a strictly ascending order. Traverse while keeping a counter for nodes encountered, then return kth smallest.

Pseudocode

Sol 1: DFS In Order Recursive Early Stop with Global Element Counter
1. (result = None)
2. inorder(node):
   a. if not node or result is not None: return
   b. inorder(node.left)
   c. k -= 1
   d. if k == 0:
        result = node.val
        return
   e. inorder(node.right)
3. inorder(root)
4. return result

Sol 2: DFS In Order Iterative with Element Counter
1. stack = []
2. while True:
   a. while root:
        stack.append(root)
        root = root.left
   b. root = stack.pop()
   c. k -= 1
   d. if k == 0: Return root.val
   e. root = root.right

Solution 1: [DFS] DFS In Order Recursive Early Stop with Global Element Counter - Tree/DFS Pre order Traversal

    def kthSmallest(self, root: Optional[TreeNode], k: int) -> int:
        
        # BST property:
        # in order traversal (left -> root -> right) visits nodes
        # in strictly ascending order

        # K Nodes Counter:
        # - counts down remaining nodes until kth smallest reached
        kCount = k
        
        def inorder(node):

            nonlocal kCount

            # Empty Case:
            #   - empty subtree, nothing to find here
            if node == None:
                return None
            
            # Find the kth smallest:
            #   - in order iteration gives you the next smallest
            #   - capture the result so it can propagate back up
            leftRes = inorder(node.left)

            # Early Stop:
            #   - answer already found in left subtree, propagate it up
            if leftRes != None:
                return leftRes

            # Decrement node count
            kCount -= 1

            # If reached kth smallest:
            #   - return directly
            if kCount == 0:
                return node.val
            
            # Ran out of smaller left, current node wasn't answer either:
            #   - continue with larger right values
            #   - propagate whatever the right subtree finds (or None)
            return inorder(node.right)

        # Smallest
        kthSmallest = inorder(root)
        
        # Start search from root
        # overall: tc O(h + k) average, O(n) worst case (skewed tree, large k)
        # overall: sc O(h) for balanced / O(n) for skewed trees (recursion stack)
        return kthSmallest

Solution 2: [DFS] DFS In Order Iterative with Element Counter - Tree/DFS Pre order Traversal

    def isValidBST(self, root: Optional[TreeNode]) -> bool:

        # BST property:
        # in order traversal (left -> root -> right) visits nodes
        # in strictly ascending order

        # Iterative stack
        stack = []
        
        while True:

            # Queue as many small nodes as possible
            while root:
                stack.append(root)
                root = root.left
            
            # Queue:
            #   - pop smallest unvisited node
            root = stack.pop()

            # Decrease node counter
            k -= 1

            # If kth node, return
            if k == 0:
                return root.val
            
            # Ran out of smaller left, move to right subtree
            root = root.right

        # Empty Case:
        # k was larger than the number of nodes in the tree

        # overall: tc
        # overall: sc
        return None

99. Recover Binary Search Tree ::2:: - Medium

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

Intro

You are given the root of a binary search tree (BST), where the values of exactly two nodes of the tree were swapped by mistake. Recover the tree without changing its structure. Follow up: A solution using O(n) space is pretty straight-forward. Could you devise a constant O(1) space solution?

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

Constraints:

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

-2^31 ≤ Node.val ≤ 2^31-1

Abstraction

In Order traversal of BST gives a strictly ascending order.

Given a sorted array, if you swap 2 values, this will result in either 1 or 2 violations.

Traversal the bst is then equivalent of traversing a sorted array looking for the 2 elements to swap to fix the 1 or 2 violations.

Pseudocode

  oh! pseudocode hasn't been written yet, try another card! :)

Solution 1: [DFS] Recursive DFS In Order Two Pointer Violation Tracking - Tree/DFS In Order Recursive One Sided Top Down

    def recoverTree(self, root: Optional[TreeNode]) -> None:

        # BST property:
        # in order traversal (left -> root -> right) visits nodes
        # in strictly ascending order

        # 2 Swaps:
        # - will cause either 1 or 2 violations 
        # - a violation being a node that breaks the strictly ascending order
        #   by having value smaller than the node before it

        # ------------------------------------
        # 1 Violation:
        # index:      0   1   2   3   4
        # swapped:    1   2   4   3   5         
        #                         ^    
        #                       (violation, i=3: 4 > 3)     

        # 2 Violations:
        # index:      0   1   2   3   4
        # value:      1   4   3   2   5
        #                     ^   ^
        #                   (violation, i=2: 4 > 3)     
        #                       (violation, i=3, 3 > 2)


        # ------------------------------------
        # Tracking Swaps:

        # 1 Violations (adjacent swap):
        
        # value:   1   2   4   3   5
        #                  ^   ^
        #               prev1 curr1  <- violation #1: 3 < 4
        #                  ^   ^                       prev(4) is 1st swap
        #              first   second                  curr(3) is 2nd swap

        # 2 Violations (non adjacent swap):
        
        # value:   1   4   3   2   5
        #              ^   ^
        #            prev1 curr1        <- violation #1: 3 < 4
        #              ^                                 prev(4) is 1st swap
        #           first
        #                  ^   ^
        #               prev2 curr2     <- violation #2: 2 < 3
        #                      ^                         curr(2) is 2nd swap
        #                     second

        #  First is updated to prev
        #  Second is updated to curr

        # If there is 1 violation:
        #   - first is updated once, ending at prev1
        #   - second is updated once, ending at curr1

        # If there are 2 violations:
        #   - first is updated once, ending at prev1
        #   - second is updated twice, ending at curr2

        # Both cases lead to first and second pointing to the correct values to swap

        # Violation Counter:
        # - tracks how many violations found so far (max 2)
        violations = 0

        # Two values to swap 
        first = None
        second = None

        # Node before violation
        prev = None

        def dfs(curr):

            nonlocal first, second, prev, violations

            # Empty Check / Early Stop:
            #   - empty subtree
            #   - both violations already found, rest of tree is guaranteed sorted
            if not curr or violations == 2:
                return

            # In order traversal
            dfs(curr.left)

            # Early Stop:
            #   - left subtree traversal may have just found the 2nd violation
            #   - stop before doing any more work at this node
            if violations == 2:
                return

            # Strictly ascending broken:
            #   - curr is smaller than prev:
            #   - violation found, track prev and curr in first and second
            if prev and curr.val < prev.val:

                # Updated first to prev1
                if first == None:
                    first = prev

                # Update second to curr1, then to curr2
                second = curr

                # 2nd violation found, no need to go further right
                if violations == 2:
                    return

            # Update prev to curr before we iterate
            prev = curr

            # In order traversal
            dfs(curr.right)

        # Start search at root
        dfs(root)

        # Swap the values to avoid altering tree structure:
        first.val, second.val = second.val, first.val

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

Solution 2: [Morris Traversal] Threaded Tree In Order Two Pointer Violation Tracking [SC Opt] - Tree/Morris In Order Iterative Constant Space

    def recoverTree(self, root: Optional[TreeNode]) -> None:

        # BST property:
        # in order traversal (left -> root -> right) visits nodes
        # in strictly ascending order

        # Morris In Order Traversal: (left -> root -> right), O(1) space
        #   - avoids a recursion stack or an explicit stack 
        #     by temporarily threading the tree, 
        #     by using each node's unused right pointer to point back to its in order successor


        # Two values to swap
        first = second = None

        # Node before violation
        prev = None

        curr = root

        # Violation Counter:
        # - tracks how many violations found so far (max 2)
        violations = 0

        # Each edge is threaded and unthreaded at most once
        while curr:

            # No Left Subtree Exists: (____ -> root -> right)
            #   - in order traversal is simple in this case,
            #     just process root and iterate to the right
            #
            #        4
            #         \
            #          5
            #
            if not curr.left:

                # Process Root by:
                #   - First to prev1 and Second to curr1 to curr2 logic
                if prev and curr.val < prev.val:
                    violations += 1
                    if first == None:
                        first = prev
                    second = curr

                # Iterate prev and curr
                prev = curr
                curr = curr.right

                # Early Stop:
                #   - tree is clean here (no thread was touched), safe to stop
                if violations == 2:
                    break


            # Left Subtree Exists: (left -> root -> right)
            #   - in order traversal is not simple in this case
            #   - find the rightmost in the left subtree,
            #     and thread it to current to allow for in order traversal (right -> root) eventually
            #
            #        4
            #       / \
            #      2   5
            #     / \
            #    1   3 - -
            #             - - (thread) - - > 4
            else:

                # Thread
                #   - grab rightmost of the left subtree
                currLeftSubtreeRightmost = curr.left
                while currLeftSubtreeRightmost.right and currLeftSubtreeRightmost.right != curr:
                    currLeftSubtreeRightmost = currLeftSubtreeRightmost.right

                # Thread has not been placed yet:
                #   - place thread from rightmost of left subtree to the root
                if currLeftSubtreeRightmost.right == None:

                    # Place 
                    currLeftSubtreeRightmost.right = curr

                    # Thread placed, ready to explore curr.left
                    #   - due to in order, eventually, the last node we will hit is
                    #     the rightmost of the left subtree,
                    #     at which point the thread will allow us to return to the root
                    curr = curr.left

                # Thread has already been placed:
                #   - in order has brought us to the root, via the thread
                else:

                    
                    # currLeftSubtreeRightmost.right holds the thread that got us here
                    #   - remove the thread
                    currLeftSubtreeRightmost.right = None

                    # Process Root by:
                    #   - First to prev1 and Second to curr1 to curr2 logic
                    if prev and curr.val < prev.val:
                        violations += 1
                        if first == None:
                            first = prev
                        second = curr

                    # Iterate prev and curr
                    prev = curr
                    curr = curr.right

                    # Early Stop:
                    #   - thread was just removed, tree is clean here, safe to stop
                    if violations == 2:
                        break

        # Swap the values to avoid altering tree structure:
        first.val, second.val = second.val, first.val

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

450. Delete Node in a BST ::2:: - Medium

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

Intro

Given a root node reference of a BST and a key, delete the node with the given key in the BST. Return the root node reference (possibly updated) of the BST. Basically, the deletion can be divided into two stages:

  1. Search for a node to remove.
  2. If the node is found, delete the node. Follow up: Could you solve it with time complexity O(height of tree)?
Example InputOutput
root = [5,3,6,2,4,null,7], key = 3[5,4,6,2,null,null,7]
root = [5,3,6,2,4,null,7], key = 0[5,3,6,2,4,null,7]
root = [], key = 0[]

Constraints:

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

-105 ≤ Node.val ≤ 105

Each node has a unique value

root is a valid binary search tree

-105 ≤ key ≤ 105

Abstraction

Given a target node, remove it from a binary tree if it exists and reorganize binary tree to be valid again.

A target node may have 0, 1, or 2 children. After the target node is remove the children must be reorganized to make the BST valid again.

0 and 1 case are simple. Either delete with no repercussion or replace the deleted node with the 1 child.

2 children will require us to:

  • find the in order successor, the smallest node in the right subtree
  • replace the deleted node with in order successor
  • this will result in 2 in order successors temporarily
  • delete the original in order successor in the right subtree

Pseudocode

Sol 1: Recursive Traversal
1. if root == None: return None
2. if key < root.val:
    root.left = deleteNode(root.left, key)
3. elif key > root.val:
    root.right = deleteNode(root.right, key)
4. else:
   a. if root.left == None and root.right == None:
        Return None
   b. elif root.left == None:
        Return root.right
   c. elif root.right == None:
        Return root.left
   d. else:
        successor = root.right
        while successor.left: successor = successor.left
        root.val = successor.val
        root.right = deleteNode(root.right, successor.val)
5. return root

Sol 2: Iterative Traversal
1. if not root: return None
2. dummyHead = TreeNode(0), dummyHead.left = root
3. parent = dummyHead
4. current = root
5. (is_left_child = True)
6. while current and current.val != key:
   a. parent = current
   b. if key < current.val:
        current = current.left, is_left_child = True
   c. else:
        current = current.right, is_left_child = False
7. if not current: return root
8. if current.left and current.right:
   a. successor_parent = current, successor = current.right
   b. while successor.left: successor_parent = successor, successor = successor.left
   c. current.val = successor.val
   d. parent = successor_parent, current = successor
   e. is_left_child = (successor_parent.left == successor)
9. child = current.left if current.left else current.right
10. if is_left_child: parent.left = child
11. else: parent.right = child
12. return dummyHead.left

Solution 1: [BST] Recursive Traversal - Tree/BST Guided Recursive Traversal

    def deleteNode(self, root: Optional[TreeNode], key: int) -> Optional[TreeNode]:
        
        # BST property: (left < node.val < right)
        
        # BST Deletion:
        
        # 0 or 1 children:
        #   - remove lone node
        #   - replace removed node with child

        # 2 children:
        #       - find in order successor (smallest node in right subtree)
        #       - replace node value with successor
        #       - replacing node temporarily creates 2 successor nodes
        #       - delete original successor node recursively

        def dfs(node, key):

            # Traverse until we find the target node

            # Empty Case: 
            # Target node does not exist
            if node == None:
                return None

            # Target lives in left subtree:
            #    - explore         
            if key < node.val:
                node.left = dfs(node.left, key)
            
            # Target lives in right subtree:
            #   - explore
            elif key > node.val:
                node.right = dfs(node.right, key)
            
            # Found target:
            #   - choose between the 0, 1, or 2 children cases
            else:

                # 0 Children
                #   - remove node
                if node.left == None and node.right == None:
                    return None

                # 1 Child
                #   - replace removed node with child
                elif node.left == None:
                    return node.right

                # 1 Child
                #   - replace removed node with child
                elif node.right == None:
                    return node.left

                # 2 children:
                #       - find in order successor (smallest node in right subtree)
                #       - replace node value with successor
                #       - replacing node temporarily creates 2 successor nodes
                #       - delete original successor node recursively
                else:

                    # Grab right subtree
                    rightSubtree = node.right

                    # Grab smallest node in right subtree
                    #   - traverse left as far as possible
                    smallestNodeInRightSubtree = rightSubtree
                    while smallestNodeInRightSubtree.left:
                        smallestNodeInRightSubtree = smallestNodeInRightSubtree.left
                    
                    # Replace target node with smallest node we just grabbed
                    #   - now there are 2 copies of the smallest node
                    node.val = smallestNodeInRightSubtree.val
                    
                    # Remove original copy of smallest node in right subtree
                    node.right = dfs(rightSubtree, smallestNodeInRightSubtree.val)
                     
            # Original node object either holds:
            #   - some new smallest node
            #   - original value it started with
            return node

        newRoot = dfs(root, key)

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

Solution 2: [BST] Iterative Traversal - Tree/BST Guided Iterative Traversal

    def deleteNode(self, root: Optional[TreeNode], key: int) -> Optional[TreeNode]:

        # BST property: 
        # left < node.val < right
        
        # BST Deletion:
        
        # 0 or 1 children:
        #   - remove lone node
        #   - replace removed node with child

        # 2 children:

        #       a. find in order successor (smallest node in right subtree)
        #       b. replace node value with successor
        #       c. delete successor node iteratively

        # Empty Case: 
        # Target node does not exist
        if root == None:
            return None

        # Dummy head:
        #   - simplifies edge case where root itself is deleted
        #   - dummyHead.left always points to the current tree root
        dummyHead = TreeNode(-1)
        dummyHead.left = root

        # Parent pointer:
        #   - tracks parent of current node so we can relink after deletion
        parentOfCurr = dummyHead

        # Traversal pointer
        curr = root

        # Tracks whether current is the left or right child of parent
        #   - needed to correctly relink after deletion
        #   - helps validate between the 3 cases
        isLeftChild = True

        # Traverse until we find the target node
        while curr and curr.val != key:

            # Target lives in left subtree:
            #    - explore
            if key < curr.val:
                parentOfCurr = curr
                curr = curr.left
                isLeftChild = True

            # Target lives in right subtree:
            #   - explore
            else:
                parentOfCurr = curr
                curr = curr.right
                isLeftChild = False

        # Empty Case:
        # Target node does not exist
        if curr == None:
            return root

        # Found target:
        #   - check the children cases
        #   - replace


        # 2 Children:
        #   - find in order successor (smallest node in right subtree)
        #   - replace node value with successor
        #   - relink pointers so we now target the successor node for removal
        if curr.left and curr.right:

            # Track parent of current smallest for removal
            parentOfSmallestNodeRightSubtree = curr

            # Grab right subtree
            smallestNodeRightSubtree = curr.right

            # Grab the smallest node:
            #   - traverse left as far as possible
            # tc O(log n) for balanced / O(n) for skewed trees
            while smallestNodeRightSubtree.left:

                # Grab parent
                parentOfSmallestNodeRightSubtree = smallestNodeRightSubtree

                # Grab smaller
                smallestNodeRightSubtree = smallestNodeRightSubtree.left

            # Replace target value with smallest node
            curr.val = smallestNodeRightSubtree.val

            # Now there are two copies of the smallest node:
            #   - smallest node will always have 0 or 1 children only
            #   - reset parentOfCurr to parent of smallest
            #   - reset curr to be at smallest
            #   - replace both
            parentOfCurr = parentOfSmallestNodeRightSubtree
            curr = smallestNodeRightSubtree
            isLeftChild = (parentOfSmallestNodeRightSubtree.left == smallestNodeRightSubtree)

        # Curr is either handling the original target,
        # or the smallestNode we just moved

        # 0 or 1 Child:
        if curr.left:

            # Left child exists:
            #   - it's the only child, grab it
            child = curr.left

        else:

            # Right child exists:
            #   - it's the only child, grab it
            child = curr.right

        # Replace parent with child
        if isLeftChild:
            parentOfCurr.left = child
        else:
            parentOfCurr.right = child

        # Dummy head's left holds the head, either:
        #   - the original root
        #   - a new root, if the original root was the node deleted
        newHead = dummyHead.left

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

96. Unique Binary Search Trees ::1:: - Medium

Topics: Tree Structure Analysis, Math, Dynamic Programming, Tree, Binary Search Tree, Binary Tree

Intro

Given an integer n, return the number of structurally unique BST's (binary search trees) which has exactly n nodes of unique values from 1 to n.

Example InputOutput
n = 35
n = 11

Constraints:

1 ≤ n ≤ 19

Abstraction

Find the number of total number of unique arrangements for a bst.

Pseudocode

Solution 1: [DP] Bottom Up Dynamic Programming Catalan Number Recurrence - DP/1D Tabulation

    def numTrees(self, n: int) -> int:

        dp = [0] * (n + 1)

        # Base Case:
        #   - 0 nodes has exactly 1 shape: the empty tree
        dp[0] = 1

        # Pick Tree Size:
        #   - for 1 to n, try every size
        for numNodes in range(1, n+1):

            # Pick Root Node:
            #   - for node 1 to numNodes, pick which one will serve as root
            for rootNode in range(1, numNodes+1):

                # Generate total possible number of trees for this combination of:
                #   - total number of numNodes
                #   - rootNode

                # Count:
                #   - [left ... root ... right]
                #   - left subtree gets [1 ... rootNode-1] or rootNode-1 nodes
                #   - right subtree gets [rootNode+1 ... numNodes] or numNodes-rootNode nodes

                #   - multiply the independently computed counts together:
                #       every possible left shape * every possible right shape

                #   - which means the same as:
                #       total possible combinations for i nodes *
                #       total possible combinations for j nodes

                #   - which is what allows us to use dp
                dp[numNodes] += dp[rootNode - 1] * dp[numNodes - rootNode]

        # Grab combinations
        totalCombinationsForTreeWithNNodes = dp[n]

        # overall: tc O(n^2)
        # overall: sc O(n)
        return totalCombinationsForTreeWithNNodes

95. Unique Binary Search Trees II ::1:: - Medium

Topics: Tree Structure Analysis, Dynamic Programming, Backtracking, Tree, Binary Search Tree, Binary Tree

Intro

Given an integer n, return all the structurally unique BST's (binary search trees), which has exactly n nodes of unique values from 1 to n. Return the answer in any order.

Example InputOutput
n = 3[[1,null,2,null,3],[1,null,3,2],[2,1,3],[3,1,null,null,2],[3,2,null,1]]
n = 1[[1]]

Constraints:

1 ≤ n ≤ 8

Abstraction

Find the number of total number of unique arrangements for a bst. Return the arrangements.

Pseudocode

Solution 1: [DFS] Recursive Divide and Conquer with Range Memoization - Tree/DFS Build Subtrees Top Down

    def generateTrees(self, n: int) -> List[Optional[TreeNode]]:

        # Notes:
        # - for a range of values [left, right], pick each value rootNode in
        #   that range as the root
        # - values smaller than rootNode (left..rootNode-1) form the left
        #   subtree, values larger than rootNode (rootNode+1..right) form the
        #   right subtree
        # - every combination of (a left subtree shape, a right subtree shape)
        #   produces one distinct valid tree rooted at rootNode

        # Count:
        #   - [left ... root ... right]
        #   - left subtree gets [left ... rootNode-1] nodes
        #   - right subtree gets [rootNode+1 ... right] nodes

        #   - pair every possible left shape with every possible right shape:
        #       every possible left shape * every possible right shape

        #   - which means the same as:
        #       total possible combinations for the left range *
        #       total possible combinations for the right range

        #   - which is what allows us to memoize on (left, right)

        # Number of trees:
        #   - (O(Catalan(n) * n) - Catalan(n)) trees are built
        #   - each tree has O(n) nodes to assemble 
        #   - memoization avoids rebuilding identical shaped ranges,
        #     but each distinct (left, right) range still does real work once
        #   - O(Catalan(n) * n) space to store all generated trees dominates the O(n) recursion stack

        # Range [1, 3], root = rootNode = 2
        #
        #        2
        #       / \
        #      1   3
        #
        #   left subtree built from range [1, 1]
        #   right subtree built from range [3, 3]

        # Base Case:
        #   - empty range (left > right) has exactly 1 valid "shape": None
        #   - MUST return [None], not [], so the cartesian product loop below
        #     still runs once (pairs with the other side's real subtrees)

        # Memoization:
        #   - the values within a range (e.g. [3, 5]) are just consecutive
        #     integers, so the SHAPES of trees buildable from any range of
        #     the same length are structurally identical regardless of the
        #     actual starting value
        #   - memoize on (left, right) so we don't rebuild identical-shaped
        #     subtrees for different recursive calls that hit the same range
        memo = {}

        def buildTrees(left, right):

            # Empty Range:
            #   - no values available, only valid tree is an empty one
            if left > right:
                return [None]

            # Already Computed:
            #   - this exact range has been built before, reuse it
            if (left, right) in memo:
                return memo[(left, right)]

            # All Trees Built From This Range:
            allTrees = []

            # Try every value rootNode in [left, right] as the root
            for rootNode in range(left, right + 1):

                # Build every possible left subtree (values left..rootNode-1)
                leftSubtrees = buildTrees(left, rootNode - 1)

                # Build every possible right subtree (values rootNode+1..right)
                rightSubtrees = buildTrees(rootNode + 1, right)

                # Cartesian Product:
                #   - pair every left shape with every right shape,
                #     each pairing is a distinct valid tree rooted at rootNode
                for leftTree in leftSubtrees:
                    for rightTree in rightSubtrees:
                        root = TreeNode(rootNode)
                        root.left = leftTree
                        root.right = rightTree
                        allTrees.append(root)

            # Cache and return
            memo[(left, right)] = allTrees
            return allTrees

        # Start search across the full range of values
        allBstTrees = buildTrees(1, n)

        # overall: tc O(Catalan(n) * n) - Catalan(n) 
        # overall: sc O(Catalan(n) * n) 
        return allBstTrees