Heaps
Steven J. Zeil
Problem: Given a collection of elements that carry a numeric “score”, find and remove the element with the smallest [largest] score. New elements may be added at any time.
In an earlier lesson, we saw that this collection is called a priority queue. Now we will look at an efficient way of implementing it.
1 Recap: the std Priority Queue Interface
This is the entire priority queue interface.
template <class T, class Container, class Compare>
class priority_queue
{
public:
typedef T value_type;
typedef Container::size_type size_type;
protected:
Container c;
Compare comp;
public:
priority_queue(const Compare& x = Compare());
template <class Iterator>
priority_queue(Iterator first,
Iterator last,
const Compare& x = Compare());
bool empty() const;
size_type size() const;
value_type& top();
const value_type& top() const;
void push(const value_type& x);
void pop();
};
-
We can check the size of the priority queue or ask if it is empty.
-
We can look at the top (largest) element.
-
We can remove the largest element by popping.
-
We can push a new element into the priority queue.
Unlike pushing onto a stack or queue, however, the element does not automatically become the first or last thing we will next retrieve. Exactly when we will see this element again depends on its priority value.
1.1 The Priority Queue Implementation
template <class T, class Container, class Compare>
class priority_queue
{
public:
typedef T value_type;
typedef Container::size_type size_type;
protected:
Container c;
Compare comp;
public:
priority_queue(const Compare& x = Compare())
: c(), comp(x) {}
template <class Iterator>
priority_queue(Iterator first,
Iterator last,
const Compare& x = Compare())
: c(first, last), comp(x)
{
make_heap(c.begin(), c.end(), comp); ➀
}
bool empty() const { return c.empty(); }
size_type size() const { return c.size(); }
value_type& top() { return c.front(); }
const value_type& top() const
{ return c.front(); }
void push(const value_type& x)
{
c.push_back(x);
push_heap(c.begin(), c.end(), comp); ➁
}
void pop()
{
pop_heap(c.begin(), c.end(), comp); ➂
c.pop_back();
}
};
Most of the work is in the 3 algorithm fragments, make_heap
➀ , push_heap
➁ , and pop_heap
➂ . These are template functions from the standard library that are used to implement a “heap”, a new data structure that we will look at in detail shortly.
First, though, let’s look at what we could do to implement priority queues using the data structures we already know.
One possibility would be to use a sorted sequential structure (array, vector, or list). For example, using a vector, we would try to keep the elements in ascending order by priority. Then we could get the top() of the priority queue as the back() of the implementing vector.
Question: With this data structure, what would the complexities of the priority queue push
and pop
operations be?
-
O(1) and O(1)
-
O(1) and O(n)
-
O(n) and O(1)
-
O(n) and O(n)
We can do better than that.
We might consider instead using a balanced binary search tree to store the priority queue. This time, it will be a little easier if we store the items in descending order by priority. We could get the top() of the priority queue as *begin().
Question:
Using a balanced binary search tree as the underlying data structure, what would the complexities of the priority queue push and pop operations be?
-
$O(1)$; $O(\log n)$
-
$O(\log n)$; $O(1)$
-
$O(\log n)$; $O(\log n)$
-
$O(\log n)$; $O(n)$
-
$O(n)$; $O(\log n)$
-
$O(n)$; $O(n)$
That sounds pretty good.
We can’t actually hope to improve on the worst case times offered by balanced search trees, we can match those worst case times (and improve on the multiplicative constant) and actually achieve O(1) average case complexities by the use of a new data structure, called a “heap”.
2 Implementing Priority Queues - the Heap
We can implement priority queues using a data structure called a (binary) heap.
2.1 Binary Heaps
A binary heap is a binary tree with the properties:
-
The tree is complete (entirely filled, except possibly on the lowest level, which is filled from left to right).
-
Each non-root node in the tree has a smaller (or equal) value than its parent.
**Important: A heap is a binary tree, but not a binary search tree. The ordering rules for heaps are different from those of binary search trees.
What I have defined here is sometimes called a max-heap, because the largest value in the heap will be at the root. We can also have a min-heap, in which every child has a value larger than its parent.
Max-heaps always have their very largest value in their root. Min-heaps always have their smallest value in the root.
In this course, we will always assume that a “heap” is a “max-heap” unless explicitly stated otherwise.
Let’s look at the implications of each of these two properties.
2.2 Heaps are complete trees
Here’s an example of a complete binary tree.
Complete binary trees have a very simple linear representation, allowing us to implement them in an array or vector with no pointers.
-
The parent of node $i$ is in slot $\left\lfloor \frac{i-1}{2} \right\rfloor$.
-
The children of node $i$ are in $2i+1$ and $2i+2$.
2.3 Children’s Values are Smaller than Their Parent’s
Using the same tree shape, we can fill in some values to show an example of a heap.
Each parent has a value larger than its children’s values (and, therefore, larger than the values of any of its descendants).
So when we ask for the front (largest value) of a priority queue, we find it in the root of the heap, which in turn will be in position 0 of the array/vector.
2.4 Bubbling and Percolating
Before looking in detail at how to add and delete elements from a heap, let’s consider a situation in which we have a “damaged” heap with one node out of position.
How do we “fix” the heap? There are two cases to consider.
-
The out-of-place node is too large (i.e., larger than its parent).
-
The out-of-place node is too small (i.e., smaller than one or both of its children).
2.4.1 Bubble Up
When we have a node that is larger than its parent, we bubble it up by swapping it with its parent until it has reached its proper position.
void bubbleUp (vector<T>& heap,
unsigned nodeToBubble)
{
unsigned parent = (nodeToBubble - 1) / 2;
while (node > 0 && heap[nodeToBubble] > heap[parent])
{
swap(heap[nodeToBubble], heap[parent]);
nodeToBubble = parent;
parent = (nodeToBubble - 1) / 2;
}
}
Note that we have repaired the heap. The final arrangement satisfies the ordering requirements for a heap.
2.4.2 Percolate Down
When we have a node that is smaller than one or both of its children, we percolate it down by swapping it with the larger of its children until it has reached its proper position.
void percolateDown (vector<T>& heap,
unsigned nodeToPerc)
{
while (2*nodeToPerc+1 < heap.size())
{
unsigned child1 = 2*nodeToPerc +1;
unsigned child2 = child1+1;
unsigned largerChild = child1;
if (child2 < heap.size()
&& heap[child2] > heap[child1])
largerChild = child2;
if (heap[largerChild] > heap[nodeToPerc])
{
swap (heap[nodeToPerc], heap[largerChild]);
nodeToPerc = largerChild;
}
else
nodeToPerc = heap.size();
}
}
This is only a little more complicated than bubbling up. The main complication is that the current node might have 0 children, 1 child, or 2 children, so we need to be careful that we don’t try to access the value of non-existent children.
2.5 Inserting into a heap
Suppose we have this heap and we want to add a new item to it.
Now, after we add an item to the heap, it will have one more tree node than it currently does. Because heaps are complete trees, we know exactly how the shape of the tree will change, even if we can’t be sure how the data values in the tree might be rearranged.
Question: How will the shape of the tree shown above change?
-
A new child will be added to the node that currently contains 48.
-
A new child will be added to the one of the nodes that currently contain 48, 60, or 11.
-
A new child will be added to one of the current leaves.
-
None of the above.
Well, suppose that we just go ahead and put the new value into that position.
We’ve got two possibilities.
-
We might get lucky – maybe this is where the new value belongs.
-
If the new value is out of position, it must be because it is larger than its parent.
It would be the only node that was out of position, and we know how to “repair” a heap with a single node out of position that is larger than its parent — we bubble up!
void add_to_heap (vector<T>& heap, const T& newValue)
{
heap.push_back (newValue); // add newValue to complete tree
bubbleUp (heap, heap.size()-1); // repair the heap
}
2.6 Removing from Heaps
When we pop, or remove, from a heap, we know that the value being removed is the value currently in the root.
We also know how the tree shape will change. The rightmost node in the bottom level will disappear.
Now, unless the heap only has one node, the node that’s disappearing does not contain the value that we’re actually removing. So, we have two problems:
-
What value goes into the root to replace the one being removed?
-
What do we do with the value currently in the node that’s going to disappear?
So, we’ve got a node with no data, and data that needs a node. The natural thing to do is to put the data in that node.
That data value will almost certainly be out of position, being smaller than one or both of its children, but, again, that’s only a single node that’s out of position. We know how to fix that.
void remove_from_heap (vector<T>& heap)
{
heap[0] = heap[heap.size()-1]; // replace root value
heap.pop_back(); // remove the duplicate node
percolateDown (heap, 0); // repair the heap
}
3 Analysis
A binary heap has the same shape as a balanced binary search tree.
Therefore its height, for $n$ nodes, is $\left\lfloor \log(n) \right\rfloor$.
push_heap and pop do $O(1)$ work on each node along a path that runs, at worst, between a single leaf and the root.
Hence both operations are $O(\log n)$, worst case.
The average case for push_heap is $O(1)$. The proof of this is beyond scope of this class.
3.1 Building a Heap
A single insertion is $O(\log n)$ worst case and O(1) average.
What happens if we start with an empty heap and do $n$ inserts? The resulting total could be $O(n \log n)$.
As it happens, we can do better with a special make_heap operation to build an entire heap from an array (or array-like structure such as a vector).
void build_heap (vector<T>& heap)
{
unsigned i = (heap.size()-1)/2;
do {
percolateDown (heap, i);
--i;
} while (i >= 0);
}
-
Start with the data in any order.
-
Force heap order by percolating each non-leaf node.
Since each percolateDown takes, in worst case, a time proportional to the height of the node being percolated, the total time for build_heap is proportional to the sum of the heights of all the nodes in a complete tree. It is possible to show that this sum is itself $O(n)$, where $n$ is the number of nodes in the tree. Therefore build_heap is $O(n)$.
So it’s cheaper to build a heap all at once than to do it one push at a time, although neither approach is terribly expensive.
4 Implementing the Heap with Iterators
I’ve shown the operations add_to_heap, remove_from_heap, and build_heap in a simplified form so far. I’ve used [ ]
indexing to get access to the elements because doing so makes the child/parent position calculations easier to read.
But the actual std function templates for heap manipulation use iterators, so let’s look at the “real thing” now.
4.1 push_heap
template <class RandomIterator, class Compare>
void push_heap (RandomIterator first,
RandomIterator last,
Compare comp)
//Pre: The range [first,last-1) is a valid heap.
//Post: Places the value in the location last-1 into the resulting
// heap [first, last).
{
RandomIterator nodeToBubble = last - 1;
RandomIterator parent = first + (nodeToBubble - first - 1)/2;
while (nodeToBubble != first && comp(*parent, *nodeToBubble))
{
swap(*nodeToBubble, *parent);
nodeToBubble = parent;
parent = first + (nodeToBubble - first - 1)/2;
}
}
void bubbleUp (vector<T>& heap,
unsigned nodeToBubble)
{
unsigned parent = (nodeToBubble - 1) / 2;
while (node > 0 && heap[nodeToBubble] > heap[parent])
{
swap(heap[nodeToBubble], heap[parent]);
nodeToBubble = parent;
parent = (nodeToBubble - 1) / 2;
}
}
Compare that code for push_heap to our earlier code for bubbleUp. You can see that push_heap is pretty much a 1-to-1 translation of bubbleUp in which we have made the following changes:
-
Replaced unsigned indices by iterators
-
Replaced indexing
[ ]
by the iterator*
-
The parent calculations are offset by the starting position first
-
The comparison function (or functor) comp is used to compare items instead of
<
.
4.2 pop_heap
The std::pop_heap function relies on a utility, _percolateDown. [^ The “_” on the front of the function name _percolateDown is a signal that this function name is not part of the C++ standard, and so may not actually exist in your compiler’s standard library, unlike pop_heap or push_heap, which are part of the standard.]
template <class RandomIterator, class Compare>
void pop_heap (RandomIterator first,
RandomIterator last,
Compare comp)
//Pre: The range [first,last) is a valid heap.
//Post: Swaps the value in location first with the value in the location
// last-1 and makes [first, last-1) into a heap.
{
swap (*first, *(last-1));
_percolateDown (first, last-1, comp, 0, last-first-1);
}
4.3 percolateDown
The utility function _percolateDown …
template <class RandomIterator, class Compare, class Distance>
void _percolateDown
(RandomIterator first,
RandomIterator last,
Compare comp,
Distance nodeToPerc,
Distance heapSize)
{
while (2*nodeToPerc+1 < heapSize)
{
Distance child1 = 2*nodeToPerc +1;
Distance child2 = child1+1;
Distance largerChild = child1;
if (child2 < heapSize
&& *(first + child2) > *(first+child1))
largerChild = child2;
if (*(first + largerChild) > *(first + nodeToPerc))
{
swap (*(first + nodeToPerc), *(first + largerChild));
nodeToPerc = largerChild;
}
else
nodeToPerc = heapSize;
}
}
… differs from our earlier percolateDown …
void percolateDown (vector<T>& heap,
unsigned nodeToPerc)
{
while (2*nodeToPerc+1 < heap.size())
{
unsigned child1 = 2*nodeToPerc +1;
unsigned child2 = child1+1;
unsigned largerChild = child1;
if (child2 < heap.size()
&& heap[child2] > heap[child1])
largerChild = child2;
if (heap[largerChild] > heap[nodeToPerc])
{
swap (heap[nodeToPerc], heap[largerChild]);
nodeToPerc = largerChild;
}
else
nodeToPerc = heap.size();
}
}
… by the same changes as we saw for push_heap and bubbleUp.
-
Replaced unsigned indices by iterators
-
Replaced indexing
[ ]
by the iterator*
-
The parent/child calculations are offset by the starting position first
-
The comparison function (or functor) comp is used to compare items instead of
<
.
4.4 make_heap
make_heap uses the same _percolateDown utility as does pop_heap.
template <class RandomIterator, class Compare>
void make_heap (RandomIterator first,
RandomIterator last,
Compare comp)
//Pre:
//Post: Arranges the values in [first, last) into a heap.
{
if (first != last)
{
RandomIterator i = first+(last-first-1)/2 + 1;
while (i != first)
{
--i;
_percolateDown (first, last, comp, i-first, last-first);
}
}
}
It invokes _percolateDown on each non-leaf node, working back towards the root.
Try out the heap algorithms in an animation.
4.4.1 Complexity
make_heap
is $O(N)$ worst case.
Consider an array with $N$ elements. Let $h$ be the height of the complete tree repsenting that array. $h = \log N$
We apply percolate to the first $N/2$ elements.
- The first element can move at most $h-1$ times.
- The next two elements can move at most $h-2$ times.
- The next four elements can move at most $h-2$ times.
- The next eight elements can move at most $h-3$ times.
⋮
-
The last $N/2$ elements don’t move at all.
Total work: $\sum_{i=0}^h i \frac{N}{2^{i-1}}$
\[ = \sum_{i=0}^h N \frac{i}{2^{i-1}}\]
\[ = N * \sum_{i=0}^h \frac{i}{2^{i-1}} \]
Using one of our simplifications from the FAQ,
\[ < N * 3 \]
\[ = O(N) \]
5 Recap: Complexity of Heap Operations
- Building a heap from an array of N items: $O(N)$, worst-case
- Inserting one element into a heap of size N: $O(\log N)$, worst-case, $O(1)$ average
- Removing the largest element from a heap of size N: $O(\log N)$, worst-case