Trees but... arrays?!
When working with JavaScript, I’ve often seen the error JavaScript Heap Out of Memory Error
. I’ve never known what a heap is.
In short, it’s a tree. Kind of.
In previous part, we’ve had binary search tree - that is, a binary tree that is optimized for searching.
Now, notice how I’ve emphasized the binary tree. Because BST is just a form of binary tree. Heap is another kind.
A binary tree is in essence very simple:
So, if we would have a completely balanced tree, we could have something like:
You may see where I’m going with this. Binary tree has levels of power of 2. If a binary tree has 10 levels and is balanced, it’ll have a maximum of 2^10 nodes (2^0, 2^1, 2^2, … 2^10)
Now, another term for this is complete binary tree.
In other words, if we go by leaves, we’ll see that there is no missing value. In the incomplete tree, we have a missing leaf 3.
How does this help us heaps and why should we care? Well, let’s investigate!
As the subtitle suggests, we’re not gonna use trees as we used to with BST. Instead, we’ll use an array. And here comes the important bit:
Heaps must be complete binary trees
Now, consider you have the following array: [1,2,3,4,5,6]
If you look at it, you could try to put it into a tree:
And you would be right! That’s exactly what a heap is. The only difference is that we don’t do links between nodes, and we effectively go by levels (note the number 4 is a child of 2)
So, let’s create a simple heap:
class Heap {
data = [];
getRootNode = () => this.data.at(0)
getLastNode = () => this.data.at(-1)
}
Now, that’s a simple heap. It’s just a wrapper around array. But how does this help us?
Well, consider the array above:
How do we do that? Well, we could increment it. But what if we want to go left even more? Think about the indices in the array
[1,2,3,4,5,6,7,8,9]
Now, we’re getting somewhere. Notice the pattern in there. Now, look at the question above and consider:
With the indices it’s a little harder to see, but we see the binary part. To get the left child, we just do index * 2 + 1
!
In fact, we can traverse the tree with simple multiplication! The same goes for right child - instead of 1
, we add 2
:
Note that the above is moving only left/right. If we wanted to go left/right/left, we’d need the addition to be 1, 2, 1
respectively.
And finally, how do we get the parent? Well, let’s take a look at the above example:
Well, we’re again doubling - except backwards! We’re dividing by 2! If we divide 11 by 2, we get 5.5. Cut off the remainder and we have it!
But wait, there’s one more trick here. Consideer the following heap: [0,1,2,3,4,5,6,7,8,9,10,11,12]
. It’ll look like:
If you take a look, it still works for 11. However, it doesn’t work for 10
. If you Math.floor(10 / 2)
, you will still get 5.
But as seen above, it’s actually 4. We need to have subtract one in here. The final function is:
So, the function here is Math.floor((index - 1) / 2)
So, let’s continue with our Heap!
class Heap {
data = [];
getRootNode = () => this.data.at(0)
getLastNode = () => this.data.at(-1)
getLeftChildIndex = (parentIndex) => (parentIndex * 2) + 1;
getRightChildIndex = (parentIndex) => (parentIndex * 2) + 2;
getParentIndex = (childIndex) => Math.floor(childIndex - 1) / 2
}
And that’s it. We’ve created iur heap processing. But we still can’t insert or delete, can we?
Well, we’re not done yet. The problem is - insertion and deletion depends on the type of heap.
Now, you can easily view this as a sorted array:
To insert and delete, we’ll use a technique called trickling. Because of this, we’ll allow insertion and deletion to be O(log N) rather than O(N) with regular arrays.
When we insert something in the table, we’re working cleverly with the indices. Consider the following heap:
2,3,4,5,6,7,8,9,10
Now, this can we written also as:
But wait. There’s an issue here. Because the definition of min-heap is that the node is lower than its children. That’s an invalid min-heap! While I call it a heap, we can’t use it for this example.
Now that is important because of the insertion and deletion! Consider the following image:
If we insert at the end the value 19, we don’t need to move it anywhere. But if we insert a value 5, we need to move it to the top and swap them all.
So, to trickling:
So, we’ll trickle the node to the top! What do I mean by that? Well, let’s take a look:
Let’s quickly create a min heap insertion:
class MinHeap extends Heap {
insert(value) {
this.data.push(value);
let index = this.data.length - 1;
let parentIndex = this.getParentIndex(index);
while (this.data[parentIndex] > this.data[index]) {
const temp = this.data[index];
this.data[index] = this.data[parentIndex];
this.data[parentIndex] = temp;
index = parentIndex;
parentIndex = this.getParentIndex(index);
}
}
}
Now, for max heap, it’s gonna be pretty much the same. The condition is going to be different:
class MaxHeap extends Heap {
insert(value) {
this.data.push(value);
let index = this.data.length - 1;
let parentIndex = this.getParentIndex(index);
while (this.data[parentIndex] < this.data[index]) {
const temp = this.data[index];
this.data[index] = this.data[parentIndex];
this.data[parentIndex] = temp;
index = parentIndex;
parentIndex = this.getParentIndex(index);
}
}
}
If you look closely, both codes are exactly the same, with the condition being swapped.
And that’s it for insertion!
Now, deletion is a little weird. To understand deletion, let’s talk a little about the use cases. Heaps are often used in priority queues. What that means is consider you have 80 tasks to do. Each has a priority. I can use a max heap for that:
So, when deleting from a heap, we’ll always remove from the top. We won’t be removing individual branches.
Now, you could see this as removing from top of array, and that would be simple. But with heaps, we’re looking for a fast solution. So, what we’ll do is - we will replace the top element, but not remove it.
How will we do that? Well, remember trickling? We were moving upwards. We’ll do exactly the same in here.
Now, there’s one more step in here. We need to consider both parts of the node and moved that one up.
So, let’s quickly do that for min-heap:
class MinHeap extends Heap {
delete() {
const lastValue = this.data.pop();
let index = 0;
this.data[index] = lastValue // replace the first item, effectively removing it
let leftChildIndex = this.getLeftChildIndex(index);
let rightChildIndex = this.getRightChildIndex(index);
while (
this.data[leftChildIndex] < this.data[index] ||
this.data[rightChildIndex] < this.data[index]
) {
const temp = this.data[index];
const lesserNodeIndex = this.data[leftChildIndex] < (this.data[rightChildIndex] || Infinity)
? this.data[leftChildIndex]
: this.data[rightChildIndex]
this.data[index] = this.data[lesserNodeIndex]
this.data[lesserNodeIndex] = temp;
leftChildIndex = this.getLeftChildIndex(index);
rightChildIndex = this.getRightChildIndex(index);
}
}
}
And that’s pretty much it! We:
Again, for max-heap, it’s exactly the same thing. The only difference is the condition!
class MinHeap extends Heap {
delete() {
const lastValue = this.data.pop();
let index = 0;
this.data[index] = lastValue;
let leftChildIndex = this.getLeftChildIndex(index);
let rightChildIndex = this.getRightChildIndex(index);
while (
this.data[leftChildIndex] > this.data[index] || // swapped condition
this.data[rightChildIndex] > this.data[index] // swapped condition
) {
const temp = this.data[index];
const lesserNodeIndex = this.data[leftChildIndex] > (this.data[rightChildIndex] || -Infinity) // - Infinity
? this.data[leftChildIndex]
: this.data[rightChildIndex]
this.data[index] = this.data[lesserNodeIndex]
this.data[lesserNodeIndex] = temp;
leftChildIndex = this.getLeftChildIndex(index);
rightChildIndex = this.getRightChildIndex(index);
}
}
}
Heaps are very effective priority queues and in this part, we’ve investigated 2 of them:
Thanks to the binary tree, we could keep the O(log N) properties of insertion and deletion within an array by clever index play