Binary Search in Trees!
We’re now moving to trees where recursion will really shine. So, let’s talk a bit about trees before we dive deep into it.
In arrays, I’ve shown a basic binary search, always halving the array, allowing for O(log N) complexity in search.
Binary search trees make use of that. You could say they’re a special case of linked list. Where linked list had a following single node, binary search trees have 2 following nodes
So, how can a basic binary search look like? Well, in code, it’d look something like this:
const bst = {
root: {
value: 10,
leftNode: {
value: 5,
},
rightNode: {
value: 15
}
}
}
In essence, it’s a simple tree.
That’s pretty much all the rules. But there are some limiting factors
Consider that you have a tree of 3 levels. We’d have a total of 7 nodes (1 + 2 + 4)
If you were to search in this tree, for any value, it’d take you 3 steps (the amount of levels).
But would it? Sometimes, your tree can be a little weird. Imagine that for some reason, all values are greater than the root node. In such a case, the amount of levels would be 7, as well as the amount of nodes. See following image:
That would be unfortunate for multiple reasons:
Some more terminology includes predecessor and successor
With binary trees, these are fairly easy to find
And finally, traversing a tree. To go through a tree, we have 2 options:
We’ll implement them both eventually, but that’s enough of theory for now.
Before going deep into building a BST, let’s recap the benefits of BST:
With those concepts above, we’re getting to the gist of it. Order. If we rely on sorted data, it’s worht creating a data structure that is fast for addition and removal of data, as well as searching. Binary Search Tree is exactly that.
In worst case (completely unbalanced tree), binary search tree is just a doubly linked list.
And finally, an interesting fact I found while searching online. Morse code can be written in binary search tree!
So, let’s deep dive to BST operations. We have:
So, let’s start with defining a node:
class TreeNode {
leftChild;
rightChild;
value;
constructor(value, left, right) {
this.value = value;
this.leftChild = left;
this.rightChild = right
}
}
A simple tree node has basically 3 references:
Now, when we go inserting a node, we need to search for it first. Kind of.
Each node takes care about the insertion for its own. Since we have a lot of nodes, we’ll move deeper down:
insert(value) {
if (this.value === value) {
console.error("Value already exists");
return null;
}
if (value < this.value) {
if (this.leftChild) {
this.leftChild.insert(value);
}
else {
this.leftChild = new TreeNode(value)
}
}
if (value > this.value) {
if (this.rightChild) {
this.rightChild.insert(value);
}
else {
this.rightChild = new TreeNode(value)
}
}
}
The above code is fairly simple as it’s a bunch of if statements:
Now, to try this out, let’s traverse it. Traversing is basically going through the entire tree. Traversing is simple - we just move to next nodes:
traverse() {
this.leftChild?.traverse()
this.rightChild?.traverse()
}
Now, consider what you want to do with the value you’re traversing. Consider that you have the following tree:
What if you put a print call on the first line, last line, or in between? Well, let’s take a look:
traverse() {
console.log(this.value); // 25, 15, 10, 20, 35, 30, 40
this.leftChild?.traverse()
console.log(this.value); // 10, 15, 20, 25, 30, 35, 40
this.rightChild?.traverse()
console.log(this.value); // 10, 20, 15, 30, 40, 35, 25
}
If you compare the logs in there, you can see that:
This is better visible in the following example:
Let’s continue with finding a value, because this is where it really shines. This is very similar to insertion:
findNodeWith(value) {
if (this.value === value) return this;
if (value < this.value) return this.leftChild.findNodeWith(value)
if (value > this.value) return this.rightChild.findNodeWith(value)
return null;
}
The above function is a simple search
The most problematic part of binary search trees is deletion. As we’ve seen, insertion is easy:
So, how do we delete a node? Well, here come the successor and predecessor nodes. First, let’s start with searching the node to delete:
delete(valueToDelete) {
if (valueToDelete > this.value) this.rightChild = this.rightChild?.delete(valueToDelete)
if (valueToDelete < this.value) this.leftChild = this.leftChild?.delete(valueToDelete)
if (valueToDelete === this.value) {
if (!this.leftChild) return this.rightChild;
if (!this.rightChild) return this.leftChild;
this.rightChild = this.rightChild.lift(this)
}
return this;
}
Now, that’s basically it for the delete itself. When we delete a node, we just find the node to delete and return it. The problem comes with replacing the node.
Now, if your IQ is on par with a the drawing sticks in a box of crayons, you may figure out what’s next
Why with the successor? Well, consider the following:
So, what happens if you delete the number 30? What node takes its place?
Well, the last point doesn’t exactly stand. Because we will use it as the next node. In the above example, we’ll replace the node 30 with node 31. Let’s see what happens:
The binary tree is still intact. By replacing the node with successor, we’ve kept the order correct! We also need to relink existing links
So, let’s define ourselves a lift function!
lift(parentNodeToDelete) {
if (this.leftChild) {
this.leftChild = this.leftChild?.lift(parentNodeToDelete);
return this;
}
parentNodeToDelete.value = this.value;
return this.rightChild;
}
I invite you to try to draw the above code on a piece of paper, but basically:
right
node, therefore to find the successor, we move left (until there’s no higher left value).The entire code will look something like this:
class TreeNode {
leftChild;
rightChild;
value;
constructor(value, left, right) {
this.value = value;
this.leftChild = left;
this.rightChild = right;
}
insert(value) {
if (this.value === value) {
console.error("Value already exists");
return null;
}
if (value < this.value) {
if (this.leftChild) {
this.leftChild.insert(value);
} else {
this.leftChild = new TreeNode(value);
}
}
if (value > this.value) {
if (this.rightChild) {
this.rightChild.insert(value);
} else {
this.rightChild = new TreeNode(value);
}
}
}
traverse() {
console.log(this.value);
this.leftChild?.traverse();
this.rightChild?.traverse();
}
findNodeWith(value) {
if (this.value === value) return this;
if (value < this.value) return this.leftChild.findNodeWith(value);
if (value > this.value) return this.rightChild.findNodeWith(value);
return null;
}
delete(valueToDelete) {
if (valueToDelete > this.value)
this.rightChild = this.rightChild?.delete(valueToDelete);
if (valueToDelete < this.value)
this.leftChild = this.leftChild?.delete(valueToDelete);
if (valueToDelete === this.value) {
console.log(this.value)
if (!this.leftChild) return this.rightChild;
if (!this.rightChild) return this.leftChild;
console.log(this.rightChild.value)
this.rightChild = this.rightChild.lift(this);
}
return this;
}
lift(parentNodeToDelete) {
if (this.leftChild) {
this.leftChild = this.leftChild?.lift(parentNodeToDelete);
return this;
}
parentNodeToDelete.value = this.value;
return this.rightChild;
}
}
Binary trees are extremely useful in computer science. A lot of programmes run them under the hood because of how fast the insertion, deletion and search is.
That being said, it can’t be used everywhere. We need to keep in mind the spacial complexity.