Deep Dive into Data structures using Javascript - Binary Search Tree
Anatomy of a Binary Search Tree, Use cases, in-depth explanation of implementations using both iterative and recursive approaches.
What is a Binary Search Tree?
Binary Search Tree (BST as short) is a variation of Binary tree data structure with specific restrictions that allows efficient insertion, deletion and lookup of items. Being a descendant of Binary Tree, it comes with each node having at most two children. Specific restriction is the left child node has a value less than its parent node, while the right child node has a value greater than its parent node.
The tone of this article is assuming you are at least familiar with the concept of Tree data structure and Binary Tree. If that's not the case or you need a quick refreshment, I'd suggest you start from the "Introduction to Trees" article by following the link below, then come back and continue here later:
Deep dive into data structures using Javascript - Introduction to Trees
Anatomy of a Binary Search Tree
A Binary Search Tree consists of connected nodes that resemble a hierarchy, where each node has at most two children. The tree starts with a root node, and every node in the tree descends from the root node. There can be only one root in the tree.
Each node in the BST consists of 3 properties:
value: Holds the value / data for the node.
left: Holds a reference (pointer) to the left child node.
right: Holds a reference (pointer) to the right child node.
As mentioned earlier, unique part of Binary Search Tree is the left child node is always having less and the right child node is always having greater value than their parent node. Which also means whenever you add or remove elements, this is something you have to take into account to keep the order of BST staying relevant overall the tree - because this rule applies to every single node inside the BST.
When to use Binary Search Tree
Binary search trees (BSTs) are useful in situations where fast search, insertion, and deletion operations are needed. They are particularly useful when the elements being stored are already sorted or when the elements are constantly changing.
Some real-world examples are:
Storing and quickly searching through large amounts of data, such as a database index or financial data like stock prices.
Dictionary or spell checker, where words are stored in a BST and can be quickly searched for and inserted.
Autocomplete feature, where suggestions are stored in a BST and can be quickly searched for as the user types.
Data compression, most frequently occurring elements in the data are represented by the higher levels of the tree and the less frequently occurring elements are represented by the lower levels. This allows for more efficient storage of the data, as the elements that appear more frequently in the data take up less space in the tree.
In general, Binary Search Trees are best suited when the data is static and we want to achieve a good balance between search and insert/delete operations.
What does make Binary Search Tree efficient? Let's see it by taking a look at the Big O of common operations in Binary Search Tree:
Big O Notation for Binary Search Trees
As you see, we have 2 different time complexity tables above. Let's start with what do we mean by "Balanced" and "Unbalanced" before we move onto the use cases:
Balanced vs Unbalanced Binary Search Tree
In simple terms, Balanced Binary Search Tree is a tree where the height of left and right subtrees is almost same, and Unbalanced Binary Search Tree is a tree where the height of left and right subtrees is not same, which results in poor performance when searching, inserting, or deleting nodes.
If you take a look at the visual below, you'll see an Unbalanced Binary Search Tree starts to look very similar to a Linked List. And that's the reason why the time complexities on an Unbalanced version is O(n) while the Balanced version have the O(log n):
When does a Binary Search Tree becomes unbalanced? If the items are inserted in an order that creates this skewed / unbalanced order. For example, adding items in an ascending or descending order is a common reason. There are a couple ways to avoid this:
Using self balanced BST variations like AVL or Red-Black Tree.
Sort the elements in a balanced order before inserting into the BST.
Rebalance the BST periodically using a method.
Although AVL or Red-Black Tree implementations of BST adds more complexity to the code, but they provide guaranteed logarithmic O(log n) time complexity for the operations. But for now, we will only focus on the foundational building blocks in this article.
Binary Search Tree implementation in Javascript
We will be using ES6 Classes to build our Binary Search Tree. In general, implementing non-linear data structures are quite complex - but no worries: we will first go through a couple of steps in detail until we understand the key concepts.
Build a class for the Tree Node
The main building block of a BST is the Node element. We can use a class for it, so we can reuse it whenever we need to create a new Node:
class Node {
constructor(value) {
this.value = value
this.left = null
this.right = null
}
}
The Node class has a constructor that takes in a value as a parameter, and initializes the left and right properties to null. We can use these pointers later on to manage referencing other nodes or nullifying them whenever it is needed.
Build a class for the Binary Search Tree
Now that we have our Node class ready, we can build a class for our Binary Search Tree. We know there should be a “root” property in the class as a reference to the root node, which we can place in the constructor:
class BinarySearchTree {
constructor() {
this.root = null
}
}
Understanding Iterative vs Recursive Approaches in Binary Search Tree methods
Before we start with method implementations, I want to point out the importance of understanding the difference between iterative and recursive approaches when working with Binary Search Trees. Both approaches have their own benefits and trade-offs, and it's beneficial to understand which one to use in different scenarios.
Iterative Approach
An iterative approach uses a loop to traverse through the tree and perform operations. It is generally more efficient in terms of memory usage as it does not require the use of a call stack (as it avoids the overhead of the function call stack). However, it can be more difficult to read and understand for some people and can make the code less readable.
Recursive Approach
On the other hand, a recursive approach uses function calls to traverse through the tree. This approach is generally simpler to implement, as it follows the natural recursion of the tree structure (side note: Trees are sometimes also referred as recursive data structures). However, it can lead to stack overflow errors when dealing with large trees, and can be less efficient in terms of memory usage.
When to use Iterative or Recursive Approach
When working with Binary Search Trees, it's important to consider the size of the tree and the complexity of the operations you'll be performing. For small trees or simple operations, the recursive approach may be more appropriate. However, for large trees or complex operations, the iterative approach may be more efficient and less prone to errors. It's worth noting that JavaScript has a call stack size limit depending on the environment, which means that a recursive function can only be called until it hits the limit before a stack overflow error occurs. Therefore, it is important to keep in mind the size of the tree and the complexity of the operation when deciding which approach to use.
In this article, I will be providing implementation examples for both iterative and recursive approaches for the most common Binary Search Tree operations such as insert, search and delete. This will help you to understand the pros and cons of each approach, and help you make informed decisions when working with BSTs in your own projects.
Binary Search Tree methods
Now that we have the base building blocks and also understanding of the differences between Iterative and Recursive approaches, we can extend the BinarySearchTree class by introducing methods. Here is the list of methods we are going to implement:
insert(value, isRecursive(optional))
- insert a new value into the BST. "isRecursive" parameter is optional, and if set to true, the method will use the "_insertRecursive" helper method to insert the value into the tree. If not provided or set to false, the "_insertIterative" helper method will be used._insertIterative(value)
- helper method that is used to insert a new value into the BST iteratively._insertRecursive(value)
- helper method that is used to insert a new value into the BST recursively.find(value, isRecursive(optional))
- find an element that matches the given value in the BST. "isRecursive" parameter is optional, and if set to true, the method will use the "_findRecursive" helper method to find the value. If not provided or set to false, the "_findIterative" helper method will be used._findIterative(value)
- helper method that is used to find an element that matches the given value in the BST iteratively._findRecursive(value)
- helper method that is used to find an element that matches the given value in the BST recursively.delete(value, isRecursive(optional))
- remove a node that matches the given value from the BST. "isRecursive" parameter is optional, and if set to true, the method will use the "_deleteRecursive" helper method to remove the node. If not provided or set to false, the "_deleteIterative" helper method will be used._deleteIterative(value)
- helper method that is used to remove a node that matches the given value from the BST iteratively._deleteRecursive(value)
- helper method that is used to remove a node that matches the given value from the BST recursively.showAsLevelOrdered
- display the BST as a level-order multi-dimensional array, uses "level order traversal" under the hood. The final output array will represent the tree in level-order, with each sub-array representing a level of the tree.
Now you might be asking, what does "Level order traversal" means that I mentioned in showAsLevelOrdered
description? It is a subject related to Tree Traversals which needs it's own article. To keep things simple I won’t be diving deep into Tree traversal details here - but rather give a quick overview about it.
Traversals are a way to traverse through all the nodes in a tree data structure, visiting each node in a specific order. "Level order traversal" is one of them, which is also known as "Breadth-first traversal". With level order, nodes are visited level by level, starting from the root. This type of traversal is useful for displaying the tree in a readable format, as it shows the tree's structure clearly. Here is a rough sketch of how the output looks like:
One thing I'd like to mention is, it's completely normal to not fully understand Binary Search Trees at first glance. It's quite common whenever you are dealing with any non-linear data structure. The rule of thumb is, if you give enough time it will all make sense!
I hope this article helped you to understand what Binary Search Tree is and how it works! I’d like to encourage you to experiment with the implementation below in your favorite code editor. When you are experimenting with Binary Search Tree, I highly recommend using the Visualgo on the side. It has a great visualition that can show you how everything works visually.
Here is the link to Binary Search Tree visualization on Visualgo: https://visualgo.net/en/bst
Thanks for reading!
Implementation of Binary Search Tree (BST) in Javascript
class Node {
constructor(value) {
this.value = value
this.left = null
this.right = null
}
}
class BinarySearchTree {
constructor() {
// initialize the root
this.root = null
}
// handles "insertion" with both variations: iterative and recursive
// "isRecursive" is an optional parameter, default handler is iterative
insert(value, isRecursive) {
if (!isRecursive) return this._insertIterative(value)
return this._insertRecursive(value)
}
// handles "find" with both variations: iterative and recursive
// "isRecursive" is an optional parameter, default handler is iterative
find(value, isRecursive) {
if (!isRecursive) return this._findIterative(value)
return this._findRecursive(value)
}
// handles "delete" with both variations: iterative and recursive
// "isRecursive" is an optional parameter, default handler is iterative
delete(value, isRecursive) {
if (!isRecursive) return this._deleteIterative(value)
return this._deleteRecursive(value)
}
// Iterative insertion
_insertIterative(value) {
// Create a new node with the value to be inserted
const newNode = new Node(value)
// If the tree is empty, make the new node as the root and early exit
if (this.root === null) {
return this.root = newNode
}
// Iterative insert method to be called if the root is not empty:
const iterativeInsert = (currentNode, value) => {
while (true) {
// If the value already exists in the tree,
// throw an error or silently fail:
if (value === currentNode.value) {
// OPTIONAL: replace the below line with return
// if you want it to throw an error on duplicates
// throw new Error("Duplicate value not allowed")
return
}
// Check if the input value is less than the current node's value
if (value < currentNode.value) {
// If the left child is null, insert the new node as the left child
if (currentNode.left === null) {
return currentNode.left = newNode
}
// If the left child is not null, move the current node to the left child
currentNode = currentNode.left
} else {
// Check if the input value is greater than
// or equal to the current node's value
if (currentNode.right === null) {
// If the right child is null, insert the new node as the right child
return currentNode.right = newNode
} else {
// If the right child is not null, move the current node to the right child
currentNode = currentNode.right
}
}
}
}
// Call the inner method with root node
// as the starting point, and value as target:
iterativeInsert(this.root, value)
}
// Recursive insertion
_insertRecursive(value) {
// Create a new node with the value to be inserted
const newNode = new Node(value)
// if the tree is empty, insert the new node as the root and early exit
if (this.root === null) {
return this.root = newNode
}
// Recursive insert method to be called if the root is not empty:
const recursiveInsert = (currentNode, value) => {
// If the value already exists in the tree, throw an error or silently fail:
if (value === currentNode.value) {
// OPTIONAL: replace the below line with return
// if you want it to throw an error on duplicates
// throw new Error("Duplicate value not allowed")
return
}
// if the value is greater than the current node's value, go right
if (value > currentNode.value) {
// if there is no right child, insert the new node here
if (currentNode.right === null) {
currentNode.right = newNode
return
} else {
// otherwise, recursively call insert on the right child
recursiveInsert(currentNode.right, value)
}
} else {
// if the value is less than the current node's value, go left
if (currentNode.left === null) {
currentNode.left = newNode
return
} else {
// otherwise, recursively call insert on the left child
recursiveInsert(currentNode.left, value)
}
}
}
// Call the inner method with root node
// as the starting point, and value as target:
recursiveInsert(this.root, value)
}
// Iterative search
_findIterative(value, currentNode = this.root) {
// Start at the root of the tree (or a passed in node)
while (currentNode) {
// Check if the current node's value is equal to the search value
if (currentNode.value === value) {
return currentNode.value; // If found, return the value
}
// If the search value is less than the current node's value,
// move to the left child
if (value < currentNode.value) {
currentNode = currentNode.left;
} else {
// If the search value is greater than the current node's value,
// move to the right child
currentNode = currentNode.right;
}
}
// If the value is not found, return null
return console.log('Given value not found in the tree:', value)
}
// Recursive search
_findRecursive(value, currentNode = this.root) {
// base case: if current node is null, value is not in the tree
if (currentNode === null) {
return console.log('Given value not found in the tree:', value)
}
// if current node value is equal to the given value, return value
if (currentNode.value === value) {
return currentNode.value
}
// if given value is less than current node value, search left subtree
if (value < currentNode.value) {
return this._findRecursive(value, currentNode.left)
}
// if given value is greater than current node value, search right subtree
if (value > currentNode.value) {
return this._findRecursive(value, currentNode.right)
}
}
// Iterative delete
_deleteIterative(value) {
// Start at the root
let currentNode = this.root
// At this point parentNode is null, since root has no parent
let parentNode = null
// STEP 1 - Find the targetNode and it's parentNode to be deleted:
while (currentNode !== null && currentNode.value !== value) {
/*
Here, the currentNode is set as the parentNode
before moving to the left or right child node
depending on the value being searched for.
This is so that the parentNode variable always
holds the parent of the currentNode.
When the loop exits, the currentNode variable holds the target node
that needs to be deleted and "parentNode" variable holds its parent.
*/
parentNode = currentNode
// if the value is less than the current node's value, move to the left child
if (value < currentNode.value) {
currentNode = currentNode.left
// if the value is greater than the current node's value, move to the right child
} else {
currentNode = currentNode.right
}
}
// STEP 2 - If the currentNode is null, the value was not found in the tree
if (currentNode === null) {
console.log('Given value not found in the tree:', value)
return
}
// STEP 3 - Handle the 3 deletion cases:
// leaf node (node with 0 children),
// node with 1 children, node with 2 children
// CASE 1: LEAF NODE
// If the current node has no children, it is a leaf node.
if (currentNode.left === null && currentNode.right === null) {
/*
In this case, if the parent node is null
(which means the current node is the root),
the root is set to null.
*/
if (parentNode === null) {
this.root = null
} else {
/*
If the parent node is not null, the left or right reference
of the parent node is set to null, depending on
if the current node is the left or right child of the parent node.
*/
if (parentNode.left === currentNode) {
parentNode.left = null
} else {
parentNode.right = null
}
}
}
// CASE 2: NODE WITH SINGLE CHILD
// If the current node has 1 children at the left or right side
// ONLY HAS CHILDREN AT RIGHT SIDE, LEFT IS NULL
else if (currentNode.left === null) {
/*
In this case, if the parent node is null
(which means the current node is the root),
the root is set to the current node's right child.
*/
if (parentNode === null) {
this.root = currentNode.right
} else {
/*
If the parent node is not null, the left or right
reference of the parent node is set to
the current node's right child, depending on
if the current node is the left or right child of the parent node.
*/
if (parentNode.left === currentNode) {
parentNode.left = currentNode.right
} else {
parentNode.right = currentNode.right
}
}
// ONLY HAS CHILDREN AT LEFT SIDE, RIGHT IS NULL
/*
Same as the above block of code, but the children at the opposite side
*/
} else if (currentNode.right === null) {
if (parentNode === null) {
this.root = currentNode.left
} else {
if (parentNode.left === currentNode) {
parentNode.left = currentNode.left
} else {
parentNode.right = currentNode.left
}
}
}
// CASE 3: NODE WITH TWO CHILDREN
// If the current node has 2 children (left & right)
else {
/*
If the current node has two children,
we need to find the in-order successor of the current node.
You can go 2 ways to find the inorder successor: either smallest value
in right subtree, or largest element in left subtree. In this method
we will use the first: finding the smallest value in right subtree.
The in-order successor is the node with the smallest value
that is greater than the current node's value.
We start by initializing the inorderSuccessor variable to the
current node's right child and inorderSuccessorParent to the current node.
Then, we iterate through the right child's left subtree
until we find the node with the smallest value.
*/
let inorderSuccessor = currentNode.right
let inorderSuccessorParent = currentNode
while (inorderSuccessor.left !== null) {
inorderSuccessorParent = inorderSuccessor
inorderSuccessor = inorderSuccessor.left
}
currentNode.value = inorderSuccessor.value
/*
Once we find the in-order successor,
we replace the current node's value with the in-order successor's value.
Then, we remove the in-order successor from the tree
by setting its parent's left or right pointer to its right child.
(since the in-order successor is guaranteed to have
no left children, we can simply remove it by linking
its parent to its right child).
Finally, the method returns and the tree is updated with
the desired value removed and its in-order successor taking its place.
*/
if (inorderSuccessorParent === currentNode) {
inorderSuccessorParent.right = inorderSuccessor.right
} else {
inorderSuccessorParent.left = inorderSuccessor.right
}
}
}
// Recursive delete
_deleteRecursive(value) {
// Recursive delete method to be called
const recursiveDelete = (currentNode, value) => {
// Base case: if the node is null, the value is not in the tree
if (currentNode === null) {
console.log('Given value not found in the tree:', value)
return
}
// if the value is less than the node's value,
// then it lies in left subtree
if (value < currentNode.value) {
currentNode.left = recursiveDelete(currentNode.left, value)
}
// if the value is more than the node's value,
// then it lies in right subtree
else if (value > currentNode.value) {
currentNode.right = recursiveDelete(currentNode.right, value)
}
// if value is same as node's value, then this is the node
// to be deleted
else {
// node with only one child or no child
if (currentNode.left === null) {
return currentNode.right
} else if (currentNode.right === null) {
return currentNode.left
}
// node with two children
// get the inorder successor (smallest in the right subtree)
let inorderSuccessor = currentNode.right
while (inorderSuccessor.left !== null) {
inorderSuccessor = inorderSuccessor.left
}
// copy the inorder successor's content to this node
currentNode.value = inorderSuccessor.value
// delete the inorder successor
currentNode.right = recursiveDelete(currentNode.right, inorderSuccessor.value)
}
return currentNode
}
/*
And as usual, we always start the traversal from
the top of tree "this.root". recursiveDelete helper method
will return the complete updated tree after removal,
so we simply point the root to the updated tree:
*/
this.root = recursiveDelete(this.root, value)
}
// Displays an array that will represent the tree
// in level-order, with each sub-array representing a level of the tree.
showAsLevelOrdered() {
// Create an empty array to store the traversed nodes
const temp = []
// Create an array to keep track of the current level of nodes
const queue = []
// If the tree has a root, add it to the queue
if (this.root) {
queue.push(this.root)
}
// Keep traversing the tree while there are nodes in the queue
while (queue.length) {
// Create an array to store the nodes of the current level
const subTemp = []
// Store the number of nodes in the current level
const len = queue.length
// Iterate through the current level of nodes
for (let i = 0; i < len; i += 1) {
// Dequeue the first node in the queue
const node = queue.shift()
// Push the node's value to the subTemp array
subTemp.push(node.value)
// If the node has a left child, add it to the queue
if (node.left) {
queue.push(node.left)
}
// If the node has a right child, add it to the queue
if (node.right) {
queue.push(node.right)
}
}
// Push the subTemp array to the temp array
temp.push(subTemp)
}
// Return the final temp array
return temp
}
}