Posted in Data Structures/ Leetcode, Trees

Part 1: Tree Concepts – Four Different Tree Traversals

Binary Tree

Pre-order: root, left, right – F, B, A, D, C, E, G, I, H

Recursive Implementation:

The recursive implementation is quite simple and intuitive. We process the root and then its left and right children until there are no nodes left, recursively.

def preorderTraversal(self, root: TreeNode) -> List[int]:
    
    nodes = []

    def preorder(root):
        if root:
            nodes.append(root.val)
            preorder(root.left)
            preorder(root.right)

    preorder(root)
    return nodes

Iterative Implementation:

What happens beneath recursion is the use of stack space to store nodes. In the iterative implementation, we can explicitly mimic the use of stack. Note that we need to process the root, the left child and then the right child. In stacks, the rule is Last In First Out (LIFO) or First In Last Out (FILO). This means, to process the left node before the right node in pre-order, we have to add the right node to the stack before adding the left node. See the code below.

def preorderTraversal(self, root: TreeNode) -> List[int]:
    if not root:
        return []

    nodes, stack = [], [root]

    while stack:
        curr = stack.pop()
        nodes.append(curr.val)

        if curr.right:
            stack.append(curr.right)
        if curr.left:
            stack.append(curr.left)

    return nodes

In-order: left, root, right – A, B, C, D, E, F, G, H, I

Recursive Implementation:

This is very similar to pre-order traversal except that we process the left node before the root and then the right child.

def inorderTraversal(self, root: TreeNode) -> List[int]:

    nodes = []

    def inorder(root):
        if root:
            inorder(root.left)
            nodes.append(root.val)
            inorder(root.right)

    inorder(root)
    return nodes

Iterative Implementation:

The iterative implementation of in-order traversal is a little tricky. In in-order, we have to process the left node first and then the root followed by the right child. For this, we have to get to the left most node in the tree first which is the leftmost leaf node if it is present.

  • Do this by using a pointer called curr which points to the root at first.
  • Traverse the left subtree while adding nodes to the stack, setting curr = curr.left until there are no more nodes left which means until curr is NULL. This will capture the entire left subtree of a node.
  • Once curr is NULL, start traversing the right subtree. Before traversing the right subtree, pop the stack and add the top most element to the nodes list.
  • To traverse the right subtree, set curr = curr.right

Basically we keep going as left as possible from a given node until there are no more nodes left. We then pop the stack, add to the results and start traversing the right subtree of the node that we just processed. We keep repeating this process as long as curr is not NULL or the stack is not empty. I recommend that you hand simulate a simple example to understand how the algorithm works.

def inorderTraversal(self, root: TreeNode) -> List[int]:

    if not root:
        return []

    nodes, stack = [], []
    curr = root

    while curr or stack:
        while curr:
            stack.append(curr)
            curr = curr.left
        curr = stack.pop()
        nodes.append(curr.val)
        curr = curr.right
    return nodes

Post-order: left, right, root – A, C, E, D, B, H, I, G, F

Recursive Implementation:

In post order, we process the left and right child before the root node, recursively.

def postorderTraversal(self, root: TreeNode) -> List[int]:

    nodes = []

    def postorder(root):
        if root:
            postorder(root.left)
            postorder(root.right)
            nodes.append(root.val)

    postorder(root)
    return nodes

Iterative Implementation:

In the post-order traversal implementation, we will take a simple approach where our stack contains the reverse of post order sequence. Once we are done processing, we will return the results by reversing the stack.

Note that in post-order, we need to return left, right and then root. The reverse of this is root, right and then left. This is the order in which we will be processing the nodes.

  • To begin with, add the root to the stack.
  • Pop the stack and add the top most element to nodes list.
  • Add the left child of this node and then the right child to the stack. This will make sure that we pop and process the right child first and then the left child.
  • Notice that the order of processing/ popping is root, right and then the left child which is the reverse of post-order traversal.
  • Upon processing the whole tree, return the reverse of nodes list.
def postorderTraversal(self, root: TreeNode) -> List[int]:

    if not root:
        return []

    stack = [root]
    nodes = []

    while stack:
        node = stack.pop()
        nodes.append(node.val)

        if node.left:
            stack.append(node.left)
        if node.right:
            stack.append(node.right)

    return nodes[::-1]

Level-order: F, B, G, A, D, I, C, E, H

Level-order traversal is also known as Breadth First Search of a tree. For this we can use a queue usually but the code below uses a simple Python List to mimic the same functionality. Here level indicates the height of the tree. We go from root to the leaves level by level.

  • Add root to a list/ queue called level.
  • Add all the nodes at the top most level to the nodes list.
  • Create a temp list and add the left and right child of each node in this level from left to right.
  • Set level to this temp list to process the next level.
  • Continue processing each level as long as there are nodes left.

Implementation:

def levelOrder(self, root: TreeNode) -> List[List[int]]:

        if not root:
            return []

        level = [root]
        result = []

        while level:
            result.append([node.val for node in level])

            temp = []
            for node in level:
                if node.left:
                    temp.append(node.left)
                if node.right:
                    temp.append(node.right)

            level = [node for node in temp]

        return result

Time Complexity: The processing time for different traversals is O(N) where N is the number of nodes in the tree and we have to process all N nodes.

Space Complexity: O(N) for the use of stack/ queue space where N is the number of nodes in the tree.

Hope you enjoyed learning the implementations for different types of tree traversals.

Posted in Binary Search Trees, Data Structures/ Leetcode, June Leetcoding Challenge, Trees

Search in a Binary Search Tree

We are given the root of a BST and a target value to search for. If the target node exists, return the subtree rooted at that node, else return NULL.

Example: Value to search for = 2

        4
/ \
2 7
/ \
1 3

Output:

      2     
/ \
1 3

Solution:

Binary Search Trees (BST) have this incredible property where an inorder traversal yields nodes in sorted order. This is because each node’s value is greater than all the values in its left subtree and lesser than all the values in its right subtree.

Armed with this knowledge, what we essentially do is to search for the given value in a sorted list. What is the most efficient way to search in a sorted list? Yes, binary search, where at each step we try to cut down our search space by half. In the case of a BST, this equals eliminating either the left or the right subtree of a node from our search space. Now this works well in a balanced BST where the number of nodes in the left and right subtrees are fairly equal. When the tree is skewed to the left or to the right, it’s the same as doing a linear search.

Our search algorithm is as follows:

  • Start at the root node. If the root = target, return root.
  • If root < target, then the target might be in the right subtree. We then recursively search the right subtree only.
  • If root > target, then the target might be in the left subtree. We then recursively search the left subtree only.
  • If the value wasn’t found, return NULL.

Time Complexity: O(log N) in a balanced BST but O(N) in a skewed tree where N is the number of nodes in the tree.

Space Complexity: O(log N) for the use of recursive stack space. O(N) at the worst case when the tree is skewed.

Code:

def searchBST(self, root: TreeNode, val: int) -> TreeNode:
    def searchHelper(root, val):
        if not root:
            return None
        if root.val == val:
            return root
        if root.val < val:
            return searchHelper(root.right, val)
        else:
            return searchHelper(root.left, val)
    return searchHelper(root, val)

Hope you enjoyed this simple but delightful problem! Have a nice day!

Posted in Data Structures/ Leetcode, June Leetcoding Challenge, Trees

[Problem Solving] Invert a Binary Tree

We are given a binary tree as shown below.

Invert a binary tree

Let’s observe the given tree and compare it with the output. What do we notice?

The left child of a node becomes the right child and vice versa. So we start at the root node and invert the left and right children.

     1
   /   \
  2     3

becomes

     1
   /   \
  3     2

We then invert the left and right sub-trees starting at each of those children. This leads to a naturally recursive approach.

     1
   /   \
  3     2
 / \   / \
6   7 4   5

becomes

     1
   /   \
  3     2
 / \   / \
7   6 5   4

An intuitive way to solve most tree problems is to use recursion. Now we have to decide a few things before coding.

Base case:

Here, the base case would be a NULL node. We simply return at that point since there are no nodes left to invert.

Recursive strategy:

Our recursive strategy involves the following steps.

  • Invert the left and right child of current node
  • Recursive call to invert the left sub-tree starting at the left child
  • Recursive call to invert the right sub-tree starting at the right child

Time Complexity: We will end up visiting each node in the tree, so the time complexity will be O(N) where N is the number of nodes in the tree.

Space Complexity: In recursion problems, an internal stack is used to temporarily hold elements, so the space complexity will be O(H) where H is the height of the tree. This is because as we recurse over left and right sub-trees, the function calls will go as deep as the tree height. In the worst case, H -> N, so the overall space complexity will be O(N) where N is the number of nodes in the tree.

Code for the above solution:

class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

def invertTree(self, root: TreeNode) -> TreeNode:

    def invertTreeHelper(root):

        if not root:
            return

        root.left, root.right = root.right, root.left
        invertTreeHelper(root.left)
        invertTreeHelper(root.right)

    invertTreeHelper(root)
    return root

Testing:

These are some of the cases that are good to test for tree problems.

  • Given test case: [1,2,3,4,5,6,7]
  • Empty tree: []
  • No children: [1]
  • No right child: [1, 2]
  • Right skewed tree: [1,null,2,null,3,null,4,null,5,null,6,null,7]
  • Left skewed tree: [1,2,null,3,null,4,null,5,null,6,null,7]

Want to try to solve this problem? https://leetcode.com/problems/invert-binary-tree/

Hope you learnt how to invert a binary tree. Have a great day!