Binary Search Tree – Deep Dive

Continuing my interview prep journey i’d like to tackle a specific data structure in the tree family. Tree’s ordinarily are data structures that can have between 0-N children and all generate from a specific root. (Tree’s honestly look more like a trees root structure than the actual tree, but tree sounds better). Trees have a variety of uses and applications (A specific example is the DNS system used for addressing domain names) and are an important data structure to learn.

There are a few subclasses of the tree, but one important one is the binary search tree. A binary search tree is a tree where each child has at most two children. The left child is always less than the parent node and the right child is always greater than the parent node. If you traverse the tree any node within the tree will also be a binary search tree. What makes this structure so powerful is its ability to make a search for a specific value O(log N) time.

Construction

Similar to a linked list, a binary search tree is constructed of nodes that reference one another. The base level is called the root which is contained in the tree code. (The following examples and methods will be done in Python3)

class Node:
    
    def __init__(self, key):
        self.key = key
        self.left = None
        self.right = None

class BinarySearchTree:
   
    def __init__(self):
        self.root = None

These are the bare essentials for the binary search tree. The Node class for holding the node values and the BinarySearchTree class for performing all of the operations of the binary search tree.

Insertion

The first operation we want to perform with a binary search tree is to insert new nodes.

class BinarySearchTree:
 
    def __init__(self):
        self.root

    def insert(self, key):
        if self.root is None:
            self.root = Node(key)
        else:
            current = root
            parent = None

            while True:
                parent = current

                if key < parent.key:
                    current = current.left
                    
                    if current is None:
                        parent.left = Node(key)
                        return
                   
                else:
                    current = current.right
    
                    if current is None:
                        parent.right = Node(key)
                        return
 

What happens in the code. We first check the root to ensure that there is a Node assigned. If there is we can traverse down the tree by comparing the value of our new key against the parent node until we find an open spot.

Search

Search is very simple. You compare the values from left and right until you either find the value or find None.

def search(self, target):
    current = self.root

    while current is not None:
        if current.key == target:
            return True
        else:
            if current.key > target:
                current = current.left
            else:
                current = current.right
    return False

Delete

Deleting is a little more complex depending on which node is deleted.

def delete(self, target):
    if root is None:
        return

    self.root = self.delete_helper(self.root, target)

def getMinimumKey(self, current):
    while current.left:
        current = current.left
    return current

def delete_helper(self, root, target):
    
    parent = None
    current = root

    while current and current.key != target:
        parent = current
        
        if target < current.key:
            current= current.left
        else:
            current = current.right
        
        current is None:
            return root

     # Case 1: No Children
     if current.left is None and current.right is None:
            
         if current != root:
             if parent.left == current:
                 parent.left = None
             else:
                 parent.right = None
         else:
             root = None

     # Case 2: node to be deleted has two children
     elif current.left and current.right:
             
         successor = self.getMinimumKey(current.right)
             
         val = successor.key
             
         self.delete_helper(root, successor.key)

         current.key = val

     # Case 3: node to be deleted has only one child
     else:
           
         if current.left:
             child = current.left
         else:
             child = current.right

         if current != root:
             if current = parent.left:
                 parent.left = child
             else:
                 parent.right = child
         else:
             root = child

      return root

There are three cases to look out for when deleting a node. If the node has no children, the node has one child, and the node has two children. In case 1, where the node has no children such as a leaf node or a root node with no children. We simply set the side that the child is on to None or the root to None if we are deleting a root with no children. If the node has one child we figure out where the child is (left or right) and then we assign that child to the now open position on the current nodes parent to the child, or set the root to the child. For two children it gets slightly more complicated. The child that needs to replace a two child parent is the parents in order successor or the right child’s left most child. I added another helper method that finds this child and then once it is found it will apply the deletion and replacement.

Traversals

Traversals involve how to navigate through the tree. They also come up in interview questions (there is a famous one where you need to build a correct tree with a pre-order and in-order array). There are three types, pre-order, in-order, and post-order. Each of the traversals deals with which node gets processed first when navigating through the tree. All operations are performed in O(N) time (because you need to find every node).

For my design of the traversals I’m focusing on their utility in the testing process. I’ll create an array that will be returned and test that array to ensure the traversal gives the right result.

In Order Traversal:
In order traversal is the easiest to comprehend. When you finish with this traversal it should return the values from least to greatest as it is processing the nodes in order. (For the sake of space, all of these methods are within the BinarySearchTree, but I will show them on their own.)

def in_order_traversal(self):
    traversal_list = []
    return self.in_order_traversal_helper(self.root, traversal_list

def in_order_traversal_helper(self, root, traversal_list):
    if root is None:
        return
    self.in_order_traversal_helper(root.left, traversal_list)
    traversal_list.append(root.key)
    self.in_order_traversal_helper(root.right, traversal_list)
    
    return traversal_list

You’ll notice that in_order_traversal_helper() is just a depth first search that appends the values to an array and returns that array. If I had a tree with 2 as the root, 1 as the left node, and 3 as the right node if I perform an in-order traversal, I will get an array that will look like [1, 2, 3]

Pre-order Traversal
Pre-order traversal processes a node as soon as it is reached and then moves along the left and right sub trees. Since the root is always the first node in the list. Pre-order traversal’s can be useful for constructing a correct tree from an array. Note that the traversals are all very similar, what separates them is the order in processing nodes.

def pre_order_traversal(self):
    traversal_list = []
    return pre_order_traversal_helper(root, traversal_list)

def pre_order_traversal_helper(self, root, traversal_list)
    if root is None:
       return

    traversal_list.append(root.key)
    self.pre_order_traversal_helper(root.left, traversal_list)
    self.pre_order_traversal_helper(root.right, traversal_list)

    return traversal_list

This will add the node to the array as soon as it is reached, and then move on to the left then right sub trees. Given the same tree as before (root=2, left=1, right=3) this will return an array [2, 1, 3]

Post-order traversal
Post order traversal involves going all the way to the furthest leaf within the tree then processing it. The array will be filled with all the leaf nodes starting from left to right, then the parents, all the way until the root.

def post_order_traversal(self):
    traversal_list = []
    return self.post_order_traversal_helper(root, traversal_list)

def post_order_traversal_helper(self, root, traversal_list):
    if root is None:
        return
    
    self.post_order_traversal_helper(root.left, traversal_list)
    self.post_order_traversal_helper(root.right, traversal_list)
    traversal_list.append(root.key)

    return traversal_list

Given the same tree (root=2, left=1, right=3) we get an array returned [1, 3, 2]

Those are the basic operations of a binary search tree. There are more advanced BST’s that also self balance (which I may or may not cover in a future blog post) that cover for disadvantages of the binary search tree (what if all the inserted nodes are on the right or left of the tree?). With the basics of the tree down we can get a better idea of how to navigate BST problems and possible ways to use a BST to solve common problems.

Print Friendly, PDF & Email

Leave a Reply