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.
Insertion: Explain how to insert a value into a BST; what is the asymptotic runtime complexity of this operation?
Starting at the root, ask if your insertion is less than or greater than the current node, and follow the path that results until the first open spot. Insertion into a BST again has the complexity O(log(n)) (assuming your tree is “balanced”, which we’ll talk about later).
Binary Search: Explain how binary search works on a BST; what is the asymptotic runtime complexity of this operation?
Starting at the root, ask if your query is less than or greater than the current node, and follow the path that results. Binary search on a BST again has the complexity O(log(n)) (assuming your tree is “balanced”, which we’ll talk about later).
Sorting: Explain how you may use a BST to easily return a sorted representation of your data. What is the asymptotic runtime complexity of this operation? (hint: think about traversal methods).
Simply perform an inorder traversal, which we know takes O(n). How elegant!
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 Optionalclass BSTNode: data: int left: Optional["BSTNode"] right: Optional["BSTNode"] def __init__(self, data: int): self.data = data self.left = None self.right = Noneclass 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
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:
Say I'm inserting ints into a binary search tree. What property of this input list of ints will cause insertion to take O(n) time?
When the insertions are done one at a time and arrive in sorted order.
Suppose I do create a binary search tree using the above detrimental insertion ordering. What efficiency guarantee on what operations does my BST lose?
I lose log(n) performance of search and insertion!
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