Lecture 18: Their Bark is Worse Than Their Byte: Part III

Notice that for each node, its left child node is less than it while its right child is greater.

Binary Search Trees - Operations

Now, let’s consider some operations on a binary search tree:

Example

Insert 2 more values into the above tree and explain how you did so: add 7, 24.

Binary Search Trees - Implementation

Well, we’ve seen them in action… now let’s see how to build a BST!

Unlike the preceding tree implementations (which were not governed by any rules regarding the semantics of parent-children relationships), we might now create a public BST class instead of a BSTNode, since it would be problematic for a user to modify our BST structure.

We’ll start by scaffolding the BSTNode and then work on some methods for the BST itself.

Example

Implement a BST of ints using the scaffolding below (for simplicity, assume uniqueness of elements)

from typing import Optional
class BSTNode:
    data: int
    left: Optional["BSTNode"]
    right: Optional["BSTNode"]
 
    def __init__(self, data: int):
        self.data = data
        self.left = None
        self.right = None
 
class BST:
    root: Optional[BSTNode]
    size: int
 
    def __init__(self):
        self.root = None
        self.size = 0
 
    def add(self, to_add: int) -> None:
        """
        Adds a new int into the BST by first finding its
        proper location in the existing tree, and then
        creating a new node containing the int at that spot
 
        :param: toAdd Integer value to add at its proper spot in the BST
        """
        #  Case: empty tree
        if self.root is None:
            self.root = BSTNode(to_add)
            self.size += 1
            return
 
        current: BSTNode = self.root
        while current is not None:
            # Case: insertion is less than current
            if to_add < current.data:
                # Case: OK to insert left
                if current.left is None:
                    current.left = BSTNode(to_add)
                    self.size += 1
                    return
                current = current.left
                continue
            # Case: insertion greater than current
            elif to_add > current.data:
                # Case: OK to insert right
                if current.right is None:
                    current.right = BSTNode(to_add)
                    self.size += 1
                    return
                current = current.right
                continue
            return
 
    def contains(self, query: int) -> bool:
        """
        Determines whether or not the given int query exists
        in the BST by performing binary search on the BST
        starting at the root.
 
        :param: query The int with which to perform a membership test.
        :return: Returns whether or not the given query is stored within this BST
        """
        current: Optional[BSTNode] = self.root
        while current is not None:
            # Found the query!
            if current.data == query:
                return True
            # Otherwise, keep looking
            current = current.left if query < current.data else current.right
        return False

We should, of course, test the above:

tree = BST()
tree.add(10)
tree.add(2)
tree.add(12)
tree.add(4)
tree.add(3)
tree.add(11)
print(tree.contains(10)) # true
print(tree.contains(3))  # true
print(tree.contains(14)) # false
print(tree.contains(1))  # false

Example

Add a method def get_sorted_list() -> list[int] to the BST class above that returns a list of Integers composed of the BST’s ints in sorted order.

And there you have it! A nice implementation of a BST.

Balancing Binary Search Trees

Example

Let’s reorder our .add() calls in the test above:

tree.add(2)
tree.add(3)
tree.add(4)
tree.add(10)
tree.add(11)

tree.add(12)

Now that we've done that, let's try to draw out what this tree looks like!

Oh… I think we just made a Linked List…

Let’s formalize this idea:

Well, as it turned out, that worst case insertion was problematic… and the more linear, and less tree-like binary search trees became, the less efficient their search became too!

So the eggheads of yore considered making an algorithm that would keep the tree balanced such that any insertion won’t make the tree become too linear.

Balancing binary search trees is an operation that prevents them from becoming too linear, thus securing the performance guarantees we expect in a BST.

We’ll talk more about how to create Balancing Trees next time!


Autocomplete This!

Do you guys know about Google? It’s this really underground website that lets you search for anything on the web! It also has this cool feature called Autocomplete!

No, we’re not learning how to write Google today… but we will learn a bit about how a primitive version of this autocomplete works.

Note that all of the search suggestions all share the prefix "superc"!

Now, if we stored every possible search result as its own separate string, that would probably be rather inefficient! Considering the topic we’re covering today, you’re probably asking “how the hell does a Tree make that any easier?”

We can exploit the fact that many search terms have the same prefix to find some possible completion for them!

To do this, we’ll use a Data Structure called:

Tries/Prefix Trees

Tries are trees in which keys are typically text with many overlapping prefixes - meaning we can store a large number of those keys efficiently.

Ternary Search Tree:

Ternary Search Trees are an efficient trie implementation in which Nodes store 3 child references with different meanings:

  • char: a singular letter of a word/term
  • word_end: a boolean flagging whether the character is the terminal letter of a word
  • mid: characters that build upon the previous mid node’s prefix
  • left, right: alternatives to the node’s prefix - letters that are less than the node on the left, and greater on the right