2023-08-08 16:09:42 -07:00
..
2023-08-03 15:37:11 -07:00
2023-08-08 15:41:34 -07:00
2023-08-08 15:46:10 -07:00
2023-08-08 16:05:32 -07:00
2023-08-08 15:35:17 -07:00
2023-08-08 14:17:34 -07:00
2023-08-08 14:55:51 -07:00
2023-08-08 15:56:48 -07:00
2023-08-08 14:37:44 -07:00
2023-08-08 13:49:24 -07:00
2023-08-08 14:27:07 -07:00
2023-08-08 13:52:20 -07:00
2023-08-08 14:32:28 -07:00
2023-08-08 14:21:03 -07:00
2023-08-08 13:39:07 -07:00
2023-08-08 16:09:42 -07:00

trees


  • a tree is a widely used abstract data type that represents a hierarchical structure with a set of connected nodes.

  • each node in the tree can be connected to many children, but must be connect to exactly one parent (except for the root node).

  • a tree is an undirected and connected acyclic graph and there are no cycle or loops.



binary trees


  • binary trees are trees that have each up to 2 children.

  • access, search, remove, insert are all O(log(N). space complexity of traversing balanced trees is O(h) where h is the height of the tree (while very skewed trees will be O(N).

  • the width is the number of nodes in a level.

  • the degree is the nunber of children of a node.

  • a complete tree is a tree on which every level is fully filled (except perhaps for the last).

  • a perfect tree is both full and complete (it must have exactly 2**k - 1 nodes, where k is the number of levels).



full trees


  • a full binary tree has each node with either zero or two children (and no node has only one child).

def is_full(node) -> bool:
	if node is None:
		return True
	return bool(node.right and node.left) and is_full(node.right) and is_full(node.left)


is leaf?


  • a node is called leaf if it has no children.

def is_leaf(node):
	return bool(not node.right and not node.left)


depth of a binary tree


  • the depth (or level) of node is the number of edges from the tree's root node until the node.

def max_depth(root) -> int:
        
        if root is None:
            return 0
        
        return max(max_depth(root.left) + 1, max_depth(root.right)  + 1)


height of a tree


  • the height of a node is the number of edges on the longest path between that node and a leaf.

  • the height of tree is the height of its root node, or the depth of its deepest node.


def height(root):
      
      if root is none:
            return 0
            
      return 1 + max(height(root.left), height(root.right))


balanced trees


  • a balanced tree is a binary tree in which the left and right subtrees of every node differ in height by no more than 1.

def height(root):
    if root is None:
      return -1
      
    return 1 + max(height(root.left), height(root.right))

def is_balanced(root):
    if root is None:
      return True

    return abs(height(root.left) - height(root.right)) < 2 and \
            is_balanced(root.left) and is_balanced(root.right)


tree traversal: breath-first search (level-order)


  • give you all elements in order with time O(log(N). used to traverse a tree by level.

  • iterative solutions use a queue for traversal or find the shortest path from the root node to the target node:

    • in the first round, we process the root node.
    • in the second round, we process the nodes next to the root node. - in the third round, we process the nodes which are two steps from the root node, etc. - newly-added nodes will not be traversed immediately but will be processed in the next round.
    • if node X is added to the kth round queue, the shortest path between the root node and X is exactly k.
    • the processing order of the nodes in the exact same order as how they were added to the queue (which is FIFO).

def bfs_iterative(root):

        result = []
        queue = collections.deque([root])
        
        while queue:
    
            node = queue.popleft()
                
            if node:
                result.append(node.val)
                queue.append(node.left)
                queue.append(node.right)
        
        return result




  • deep-first search (DFS) can also be used to find the path from the root node to the target node if you want to visit every node and/or search the deepest paths firsts.

  • recursion solutions are easier to implement; however, if the recursion depth is too high, stack overflow might occur. in that case, you might use BFS instead or implement DFS using an explicit stack (i.e., use a while loop and a stack structure to simulate the system call stack).

  • overall, we only trace back and try another path after we reach the deepest node. as a result, the first path you find in DFS is not always the shortest path:

    • we first push the root node to the stack, then we try the first neighbor and push its node to the stack, etc.
    • when we reach the deepest node, we need to trace back.
    • when we track back, we pop the deepest node from the stack, which is actually the last node pushed to the stack.
    • the processing order of the nodes is exactly the opposite order of how they are added to the stack.


in-order


  • left -> node -> right

  • in a bst, in-order traversal will be sorted in the ascending order (therefore, it's the most frequently used method).

  • converting a sorted array to a bst with inorder has no unique solution (in another hadnd, both preorder and postorder are unique identifiers of a bst).


def inorder(root):
        if root is None:
            return []
	return inorder(root.left) + [root.val] + inorder(root.right)

def inorder_iterative(root) -> list:
        
        result = []
        stack = []
        node = root
        
        while stack or node:

            if node:
		stack.append(node)
                node = node.left
            else: 
                node = stack.pop()
                result.append(node.val)
                node = node.right
            
        return result

  • we can also build an interator:

class BST_Iterator:

    def __init__(self, root):
        self.stack = []
        self.left_inorder(root)
        
    def left_inorder(self, root):
        while root:
            self.stack.append(root)
            root = root.left

    def next(self) -> int:
        top_node = self.stack.pop()
        if top_node.right:
            self.left_inorder(top_node.right)
        return top_node.val
        
    def has_next(self) -> bool:
        return len(self.stack) > 0


pre-order


  • node -> left -> right

  • top-down (parameters are passed down to children), so deserialize with a queue.


def preorder_recursive(root):
        if root is None:
            return []
	return [root.val] + preorder(root.left) + preorder(root.right)


def preorder_iterative(root) -> list:
        
        result = []
        stack = [root]
        
        while stack:
            
            node = stack.pop()
            
            if node:
                result.append(node.val) 
                stack.append(node.right) # not the order (stacks are fifo)
                stack.append(node.left)
            
        return result


post-order


  • left -> right -> node

  • bottom-up solution.

  • deletion process is always post-order: when you delete a node, you will delete its left child and its right child before you delete the node itself.

  • post-order can be used in mathematical expressions as it's easier to write a program to parse a post-order expression. using a stack, each time when you meet an operator, you can just pop 2 elements from the stack, calculate the result and push the result back into the stack.


def postorder(root):
        if root is None:
            return []
	return postorder(root.left) + postorder(root.right) + [root.val]


def postorder_iterative(root) -> list:
    stack, result = [], []
    node = root
    
    while node or stack:

            while node:
                if node.right:
                    stack.append(node.right)
                stack.append(node)
                node = node.left
            
            node = stack.pop()
            
            if stack and node.right == stack[-1]:
                stack[-1] = node
                node = node.right

            else:
                result.append(node.val)
                node = None
                
    return result


is same tree?


def is_same_trees(p, q):

        if not p and not q:
            return True
        
        if (not p and q) or (not q and p):
            return False
        
        if p.val != q.val:
            return False
        
        return is_same_trees(p.right, q.right) and is_same_trees(p.left, q.left)


is symmetric?


def is_symmetric(root) -> bool:
        
        stack = [(root, root)]
        
        while stack:
            
            node1, node2 = stack.pop()
            
            if (not node1 and node2) or (not node2 and node1):
                return False
            
            if node1 and node2:

		if node1.val != node2.val:
                     return False

		stack.append([node1.left, node2.right])
		stack.append([node1.right, node2.left])
        
        return True


def is_symmetric_recursive(root) -> bool:
            
        def helper(node1, node2):
                if (not node1 and node2) or \
                   (not node2 and node1) or \
                   (node1 and node2 and node1.val != node2.val):
                        return False
                
                if (not node1 and not node2):
                        return True
                
                return helper(node1.left, node2.right) and helper(node2.left, node1.right)
            
        return helper(root.left, root.right)


lowest common ancestor


def lowest_common_ancestor(root, p, q):

    stack = [root]
    parent = {root: None}
    
    while p not in parent or q not in parent:

        node = stack.pop()
        if node:
            parent[node.left] = node
            parent[node.right] = node
            stack.append(node.left)
            stack.append(node.right)

    ancestors = set()
    while p:
        ancestors.add(p)
        p = parent[p]

    while q not in ancestors:
        q = parent[q]
            
    return q


has path sum?


def has_path_sum(root, target_sum) -> bool:
    
        def transverse(node, sum_here=0):
            
            if not node:
                return sum_here == target_sum
            
            sum_here += node.val
            
            if not node.left:
                return transverse(node.right, sum_here)
            if not node.right:
                return transverse(node.left, sum_here)
            else:   
                return transverse(node.left, sum_here) or transverse(node.right, sum_here)
    
        if not root:
            return False
        
        return transverse(root)


build tree from inorder with preorder or postorder


  • building with preorder:

def build_tree(preorder, inorder) -> Optional[Node]:
        
        def helper(left, right, index_map):
            
            if left > right:
                return None
            
            root = Node(preorder.pop(0)) # this order change from postorder
            index_here = index_map[root.val]

            root.left = helper(left, index_here - 1, index_map) # this order change from postorder
            root.right = helper(index_here + 1, right, index_map)
            
            return root
        
        index_map = {value: i for i, value in enumerate(inorder)}
        
        return helper(0, len(inorder) - 1, index_map)

  • build with postorder:

def build_tree(left, right, index_map):
            
        if left > right:
                return None
            
        root = Node(postorder.pop())  # this order change from preorder
        index_here = index_map[root.val]

        root.right = build_tree(index_here + 1, right, index_map) # this order change from preorder
        root.left = build_tree(left, index_here - 1, index_map)
            
        return root


def build_tree(inorder, postorder) -> Optional[Node]:

        index_map = {val: i for i, value in enumerate(inorder)}
            
        return fill_tree(0, len(inorder) - 1, index_map)


return number of unival subtrees


  • a unival subtree means all nodes of the subtree have the same value

def count_unival(root) -> int:
        
        global count = 0
        
        def dfs(node):
            if node is None:
                return True
            
            if dfs(node.left) and dfs(node.right):
                if (node.left and node.left.val != node.val) or \
                    (node.right and node.right.val != node.val):
                         return False
                self.count += 1
                return True
            
            return False

        dfs(root)
        return count


successors and precessors



def successor(root):
  
  root = root.right
  while root.left:
      root = root.left
      
  return root


def predecessor(root):
  
  root = root.left
  while root.right:
      root = root.right
  
  return root


binary search trees


  • binary search tree are binary trees where all nodes on the left are smaller than the root, which is smaller than all nodes on the right.

  • if a bst is balanced, it guarantees O(log(N)) for insert and search (as we keep the tree's height as h = log(N)).

  • common types of balanced trees are red-black and avl.



insert a node


  • the main strategy is to find out a proper leaf position for the target and then insert the node as a leaf (therefore, insertion will begin as a search).

  • the time complexity is O(h) where h is a tree height. that results in O(log(N)) in the average case, and O(N) worst case.


def bst_insert_iterative(root, val):

  node = root
  while node:
    
    if val > node.val:
        if not node.right:
          node.right = Node(val)
          break
        else:
          node = node.right
    
    else:
        if not node.left:
          node.left = Node(val)
          break
        else:
          node = node.left
        
  return root


def bst_insert_recursive(root, val):
        
    if root is None:
        return Node(val)

    if val > root.val:
        root.right = self.bst_insert_recursive(root.right, val)
        
    else:
        root.left = self.bst_insert_recursive(root.left, val)
        
    return root


delete a node


  • deletion is a more complicated operation, and there are several strategies.

  • one of them is to replace the target node with a proper child: - if the target node has no child (it's a leaf): simply remove the node - if the target node has one child, use the child to replace the node - if the target node has two child, replace the node with its in-order successor or predecessor node and delete the node

  • similar to the recursion solution of the search operation, the time complexity is O(h) in the worst case.

  • according to the depth of recursion, the space complexity is also O(h) in the worst case. we can also represent the complexity using the total number of nodes N.

  • the time complexity and space complexity will be O(log(N)) in the best case but O(N) in the worse case.


def successor(root):
   
    root = root.right
    while root.left:
         root = root.left
    return root.val


def predecessor(root):
  
    root = root.left
    while root.right:
         root = root.right
    return root.val


def delete_node(root, key):
        
    if root is None:
         return root

    if key > root.val:
         root.right = delete_node(root.right, key)
      
    elif key < root.val:
            root.left = delete_node(root.left, key)
      
    else:
        if not (root.left or root.right):
            root = None
          
        elif root.right:
            root.val = successor(root)
            root.right = delete_node(root.right, root.val)
          
        else:
            root.val = predecessor(root)
            root.left = delete_node(root.left, root.val)
        
    return root


search for a value


  • for the recursive solution, in the worst case, the depth of the recursion is equal to the height of the tree. therefore, the time complexity would be O(h). the space complexity is also O(h).

  • for an iterative solution, the time complexity is equal to the loop time which is also O(h), while the space complexity is O(1).


def search_bst_recursive(root, val):
        
        if root is None or root.val == val:
            return root
        if val > root.val:
            return search_bst_recursive(root.right, val) 
        else:
            return search_bst_recursive(root.left, val) 

        
def search_bst_iterative(root, val):
        
        while root:
            
            if root.val == val:
                break
            if root.val < val:
                root = root.right
            else:
                root = root.left
        
        return root


find successor of two nodes inorder


def find_successor(node1, node2):

    successor = None

    while node1:

        if node1.val <= node2.val:
            node1 = node1.right
        else:
            successor = node1
            node1 = node1.left

    return successor


convert sorted array to bst


  • note that there is no unique solution.

def convert_sorted_array_to_bst(nums):

      def helper(left, right):
        
            if left > right:
                return None

            p = (left + right) // 2

            root = Node(nums[p])
            root.left = helper(left, p - 1)
            root.right = helper(p + 1, right)

            return root
        
      return helper(0, len(nums) - 1)


lowest common ancestor for a bst


def lowest_common_ancestor(root, p, q):

        node, result = root, root
        
        while node:
            
            result = node
            
            if node.val > p.val and node.val > q.val:
                node = node.left
            elif node.val < p.val and node.val < q.val:
                node = node.right
            else:
                break
        
        return result


checking if bst is valid


def is_valid_bst_iterative(root):
        
        queue = deque((root, float(-inf), float(inf)))
        
        while queue:
          
            node, min_val, max_val = queue.popleft()
          
            if node:
                if min_val >= node.val or node.val >= max_val:
                    return False
                if node.left:
                    queue.append((node.left, min_val, node.val))
                if node.right:
                    queue.append((node.right, node.val, max_val))
        
        return True


def is_valid_bst_recursive(root, min_val=float(-inf), max_val=float(inf)):
            
        if root is None:
            return True
        
        return (min_val < root.val < max_val) and \
                  is_valid_bst_recursive(root.left, min_val, root.val) and \
                  is_valid_bst_recursive(root.right, root.val, max_val)
    
    
def is_valid_bst_inorder(root):
        
        def inorder(node):
            if node is None:
                return True
            
            inorder(node.left)
            stack.append(node.val)
            inorder(node.right)
            
        stack = []
        inorder(root)
  
        for i in range(1, len(stack)):
            if queue[i] <= queue[i - 1]:
                return False
            
        return True