Heap
Last updated
Last updated
The purpose of a heap is to perform extractMin
/extractMax
, deleteMin
/deleteMax
, and peek
.
You cannot search for an element efficiently in a heap, i.e., a heap is NOT a search tree.
We store each node at a position in an array. But the key question is how do we know the structure of the binary tree? (i.e., how do we find a node’s parents and children).
A common implementation is as follows:
If a node is at index of the array, then its left child is at index , its right child is at index , and its parent is at index .
It is easy to visualize it as follows: draw the binary tree - starting from top and moving down, add the nodes at every level in left to right order.
But if you think hard about it, you realise that this only works for a complete binary tree (if nodes to the left of current node on the same level have missing children then the current node cannot have any children). You can represent non-complete binary trees too if you put a null
value at the index for missing nodes.
So, why don’t we use an array representation everywhere? How about an AVL tree? Just insert null
where there is an element missing right? What’s the point in creating so many nodes and having pointers to depict parent-child relations?
Umm.. what about rotations then? Changing the parent and child relations would involving shifting a lot of elements to ensure that our parent-child relationship information is maintained correctly. It would take in the worst case, which, needless to say, is terrible.
A full binary tree of height has nodes. That is, adding a node (doesn’t matter where since there is no space anywhere lol) results in the increase of the height of the tree (that’s why we call it “full” - there is no more space left).
A complete binary tree is a binary tree in which all the levels are completely filled except possibly the lowest one, which is filled from the left. A way to visualise this is to consider the array representation of a complete binary tree - it must not have any null
values between 2 non-null values.
The height of a complete binary tree (and obviously also a full binary tree) is (since it is full binary tree up to height ). In other words, a complete binary tree is balanced.
We shall discuss only about min-heap here. A max-heap is exactly analagous.
Invariant: Formally, for any node u
, u.value <= u.right.value && u.value <= u.left.value
. It is crucial to be able to maintain this invariant (efficiently!) on insertion and deletion.
The procedure is as follows:
Add the element to the end of the array (this maintains the complete binary tree property as there are no gaps) (just regular insert)
If the heap property is violated, i.e., if the newly added node has a value lower than its parent, swap them. (maintain invariant of the min-heap)
Keep performing step 2 until heap property is no longer violated.
This paradigm of insertion/deletion is quite similar to a lot of other data structures involving trees. For example, consider deletion in an AVL tree. Once we delete a node, we travel up to the root, performing rotations as necessary to ensure that our invariant (height-balance) remains true.
Notice that the element bubbles upwards from the leaf to the root (if necessary) when inserted. So, the direction of adjustment is upwards.
In case of our above example, the final heap will look like this:
You are only allowed to delete the root of the min-heap. That’s the whole point of storing the minimum at the root - so that you can pop it out. Heaps are commonly used as implementations of a priority queue and in a priority queue, you want to be able to remove the element with lowest priority (or whatever you specify as the basis for the total order).
The question is: after you delete the root, which node should become the root? A naive answer would be to pick the lower of the two children. But if the left child has lower value, then it no longer remains a complete binary tree. Remember that you need to maintain 2 key properties of a heap: it should be a complete binary tree and the invariant (min-heap property) should be satisfied
Here is the procedure (quite neat!) for deletion
Pop out the root from the heap
Make the last element of the array (the right-most node on the bottom-most level) the root (this preserves complete binary tree property)
Now send the root towards the leaf by swapping with the smaller of the two children if necessary (maintains invariant)
Observe that the direction of adjustment is downwards in this case (opposite to that of insertion) - we push the node from the root to the leaf.
If you want to delete a non-root node, you can use lazy deletion. When a delete operation is performed on a vertex, just flag is at “INVALID
”. Then, if we encounter an INVALID
vertex on an “extractMin
/pop
” call, we just remove them and call extractMin
again (until we get a valid node). This notion of labelling as DELETED
/INVALID
is quite popular (recall that we used a similar idea while deleting during open addressing). It ensures that our structural property remains intact (in this case, complete BT; in case of open addessing, no null
elements between two non-null
elements that were originally mapped to the same bucket, which would lead to keyNotFound()
while searching error even if the key exists).
As an aside, it is super important to be able to spot such similarities in paradigms common tips and tricks for data structures, and use them while designing your own (ingenious and creative) data structures to solve problems. Joining the dots and seeing patterns between related (or even seemingly unrelated) topics is one of the surest ways to gain an insight.
The main purpose of a heap is to be able to get the item with the lowest value efficiently in a dynamic data structure (supports insertion, deletion). If there were no more insertions or deletions that were going to be performed, we could just sort the elements and return the lowest value in order.
But, we can use a heap to sort the elements too. Given an array and an empty heap, just insert all the elements into the heap one by one (or use heapify
if you want it to be slightly faster) and then delete them one by one (delete gives the lowest element). The order in which elements are obtained is the sorted order.
Given an array that represents a complete binary tree (which does not satisfy the max-heap property), we want to create a heap (say, max-heap in this case)
We correct a single violation of the heap property in a subtree’s root at every step. So, heapify takes in an array that is a heap with only a single violation of the max-heap property.
We greedily build a heap using a bottom-up approach. In any complete binary tree, each node in the lowest level of the tree is a heap by itself. In the second lowest level, perform swap if necessary.
Starting from the lowest level and moving upwards (leftwards in the array), ensure that each node is part of a heap.
As the maximum number of swaps needed for each node increases as we go higher up the heap, the number of nodes decreases exponentially. In particular, the number of nodes halves and the number of swaps per node increases by 1 with each level higher. So, the two effects cancel out (neutralize each other).
A more rigorous proof is as follows:
So, the total amount of work in the for loop can be summed as:
Derivation for sum of the arithmetico-geometric series:
Another proof of heapify time complexity:
The advantage of using an AVL is numerous: not only can it serve as both a max PQ and min PQ at the same time, it also supports searching, predecessor and successor queries. A heap is not without its merits too: it has similar asymptotic costs for standard PQ operations, has faster real costs (no constant factors), is simpler to implement and enjoys slightly better concurrency.
Which implementation is preferred clearly depends on the problem context: what is the most frequent operation? Do we start off with a list of priorities or do they come in one at a time etc.
A min-heap is a complete binary tree in which a node has a value lower than that of its children (called min-heap property). Observe that this is a local property, i.e., for any node, we only need to check its left and right children and not any other node. This makes it easy to update on insertion and deletion. (Think what would happen if we had a property that depended on all other nodes in the tree, and we performed insertion - it would take to traverse the tree. Here, we only check the nodes along the root-to-leaf path of the newly inserted node)
So, the root of a min-heap has the lowest value (it follows from the above property). Due to the recursive nature of the defintion, a node has a value lower than all its descendents and higher than all its ancestors (but no relation to ancestor’s other subtree, i.e., it is possible for a node at depth from the root to have a larger value than a node at depth from the root. Obviously 5 is arbitrary here.) So, the lower we go in the heap, the greater the values become. (Remember that there is no condition on the relation between values of a left and right child)
Consider a min-heap represented by the array: (verify the parent-child relationship using the formulae described before as an exercise). Its graph representation is as follows:
Say now we insert the element . We need to maintain two properties - complete BT and min-heap property. Maintaining complete BT is more important since it is a structural property, i.e., it depends on the position of the nodes in the tree. min-heap property can easily be maintained by performing swaps)
Since we perform at most swaps, insertion takes (since we know that the height of a heap is and we are only looking at the nodes along the root-to-leaf path)
and the array representation would be .
It should be obvious by now that the time for deletion is since we perform at most swaps while traversing the root-to-leaf path.
Since both insertions and deletions take time, the total running time of HeapSort
is as insertions and deletions are performed exactly times each (for an array of size ). Even if you use heapify
, deleting elements would take and so the total running time would remain unchanged.
In fact, one cool feature is that you can use the empty space of the same array that represents the heap to store the sorted prefix of the array while you are deleting the elements. (when you delete from a heap, the last space becomes free. Put this deleted element at that position). So, it takes only extra space. Compare this with MergeSort
with needs additional space to store the intermediate stages of the array.
Heapify is a method to create a heap from an unordered array. One way to create a heap is to perform insertions for each element. Then, the direction of adjustment is from the leaf to the root. This will take . Heapify is based on a different procedure (and is faster!!!).
The precondition is that if heapify is correcting a node at index , its left and right children are valid heaps. The postcondition is that the tree rooted at index is now a valid heap.
Recall that if is the root of a heap, u.left
and u.right
are also roots of heaps. The loop invariant in case of heapify is that when heapify is correcting a node at index , all the nodes after are part of some correct heap. Formally, after iterations of heapify
, all subtrees stemming from the last items in the array are valid heaps. This means that by the time we finish iteratoins, all nodes in the tree are by themselves valid heaps and therefore, the heap property is achieved.
We are performing at most swaps and so the time complexity of heapify is (think about the maximum number of swaps that can be performed for every node and sum them all up - don’t use since that is a lose bound)
So, the minimum time to create a heap is using heapify. Recall that creating a heap using insertions takes
An important difference is that when the direction of adjustment is upwards (bubbling the node upwards) and you start from the leaf, it takes swaps since you need to check all the way to the root. But when the direction of adjustment is downwards (bubling the node down) and you start at a node at height , it takes time.
Consider a full binary tree of height and having nodes (obviously ). Then, for we have leaf nodes on which no swaps need to be performed. For nodes, we need to perform only 1 swap (only 1 level below this node). For nodes, we need to perform a maximum of 2 swaps and so on.
Observe that max_heapify takes time for nodes that are one level above the leaves and in general, time for nodes that are levels above the leaves. Further, above that there are nodes at level 1, nodes at level 1, , 1 node at level .
For ease of computation, set . Then, we have
The above convergent series in the brackets is bounded by a constant (in fact, the constant is about 3 since you’re adding 1 before the series below). So, we have .
Consider the series
What is the height of the subtree rooted at item (0-indexed)?
Full height of tree = .
Levels above subtree at :
Height of subtree rooted at :
So, what is the maximum number of comparisons heapify requires on a subheap rooted at index ? : For every vertex touched during the bubbleDown
routine, comparisons are needed. This includes the leaves. So, another analysis of heapify yields: