In this note, we will discover three super efficient sorting algorithms: Heap Sort, Quick Sort, and Merge Sort!
Efficient Sorting Algorithms
The elementary sorting algorithms discussed in my previous note, Basic Sorting Algorithms - DSA #5, are of the order , which is pretty inefficient especially for large datasets.
There are more efficient sorting algorithms that are of the order , and three typical algorithms are Heap Sort, Quick Sort, and Merge Sort.
Heap Sort
Heap Sort was invented by J. W. J. Williams in 1964. This was also the birth of the heap, presented already by Williams as a useful data structure.
The idea of Heap Sort comes from the method of sorting by the Selection Sort — repeatedly choosing the least key (or the smallest item) and move it to the start/end of the array. How can this idea possibly be improved?
Basic Concept of Heap Sort
The First Stage
Traditional Selection Sort finds the smallest element in each pass using comparisons. This process discards useful information, as it only identifies a single least item per scan. Instead of merely identifying the smallest element in each pass, comparisons can be structured hierarchically:
- Compare items in pairs, requiring comparisons to find the smaller of each pair.
- Compare the smaller values from the previous step, using comparisons.
- This continues recursively, reducing the number of comparisons at each level.
Suppose we have the array . We compare in pairs:, , , . The total comparisons of this round is .
After the first round, we are left with elements. In the second round, we again compare them in pairs, needing comparisons. The process continues until one element remains.
With comparisons, we can build a selection tree, and identify the root as the desired least key. This is the idea of the heap data structure.
Let’s construct a heap from the above example array:
Compare the elements in pairs of the array to select the smaller element of each pair:
The number of comparisons in this round is .
In the next round, continue comparing the remaining elements in pairs:
The number of comparisons in this round is .
Identify the desired least key:
The number of comparisons in this round is . The above is a simple heap data structure from the given integers.
The Second Stage
The following steps will be repeated until the heap is full of the key :
- Extract the least key at the root and replace it by an empty hole.
- Descend down along the path marked by the least key and replace it by an empty hole.
- Fill the empty hole at the bottom with and ascend up along the path marked by empty holes, filling them with or the key at the alternative branch.
Each of the above repetitions is called a selection step.
Let’s get back to the heap we’ve constructed in the previous step:
Let’s use this to visualize how this would work ( as empty holes):
1. Select as the least key, descend down the path marked by , and mark the path by empty holes. The hole at the bottom is filled with .
3. Select as the least key, descend down the path marked by , and mark the path by empty holes. The hole at the bottom is filled with .
5. Select as the least key, descend down the path marked by , and mark the path by empty holes. The hole at the bottom is filled with .
7. Select as the least key, descend down the path marked by , and mark the path by empty holes. The hole at the bottom is filled with .
9. Select as the least key, descend down the path marked by , and mark the path by empty holes. The hole at the bottom is filled with .
11. Select as the least key, descend down the path marked by , and mark the path by empty holes. The hole at the bottom is filled with .
13. Select as the least key, descend down the path marked by , and mark the path by empty holes. The hole at the bottom is filled with .
15. Select as the least key, descend down the path marked by , and mark the path by empty holes. The hole at the bottom is filled with .
2. Ascend up along the path marked by empty hole and fill with . In the pair , since so is chosen to fill the remaining empty holes instead of .
4. Ascend up along the path marked by empty hole and fill with . In the pair , since so is chosen to fill the remaining empty holes instead of .
6. Ascend up along the path marked by empty hole and fill with . In the pair , since so is chosen to fill the remaining empty holes instead of . The same applies to the pair .
8. Ascend up along the path marked by empty hole and fill with . In the pair , we choose to fill the remaining empty holes instead of .
10. Ascend up along the path marked by empty hole and fill with . In the pair , since so is chosen to fill the remaining empty holes instead of .
12. Ascend up along the path marked by empty hole and fill with . In the pair , we choose to fill the remaining empty holes.
14. Ascend up along the path marked by empty hole and fill with . In the pair , we choose to fill the remaining empty holes.
16. Ascend up along the path marked by empty hole and fill with . Since the heap is full of the key , the selection process stops.
From the illustration, we can see that the algorithm repeatedly select the root key, and they’re actually picked up in the correct order: ! Isn’t that cool?
Remarks
Without a doubt, after selection steps, the tree is full of , and the sorting process is terminated. Each of the selection steps requires comparisons; thus, the second stage requires a linearithmic running time.
However, this approach has several weaknesses:
- The heap requires units of storage to store items.
- The occurrences of in the heap are the source of many unnecessary comparisons.
- Most importantly, it is easier said than done, as it is too difficult for computers to simulate!
An Ingenious Idea
Heap Definition
From the above drawbacks, clearly, we need to find a way to store the structure using units of storage instead of . From this, the author of Heap Sort proposes a super cool idea:
A heap is a sequence of elements such that:for all .
The sequence of elements is a natural heap, and the element of a heap is its least value.
This is an example heap:
where is a natural heap.
Let’s arrange this sequence of elements into a pictorial representation of a heap:
Finally, a heap can be stored using units of storage!
Heap Construction
How can we reconstruct a heap if the element at the root is not the smallest key? The solution is as follows:
- A new heap is obtained by letting the element on top of the subheap “sift down” along the path of the smaller comparand, which at the same time move up.
- The “sift down” process stops when the element on top of the subheap is smaller than or equal to both its comparands.
Let’s visualize the process with this tree:
We choose the subheap containing the elements . Sift down to the smaller comparand , and move up towards the position of .
There’s actually another subheap to take into account. We will now choose the subheap of . Sift down to , and move up towards the position of .
After the sifting process, we now have a valid heap!
The Sifting Algorithm
As mentioned above, given an array of integers , the sequence of elements is a natural heap.
Starting from the natural heap, we extend it to the left where in each step a new element is included and properly positioned by a sift.
left = n / 2;
while (left > 0) {
sift(a, left, n);
left--;
}
Here is how we would implement the sifting algorithm:
sift(a[], left, right) {
i = left; j = 2 * i;
x = a[i];
// Extract the least key
// We need to store the key to another variable, x
while (j ≤ right) {
// j < right: Check if a[i]
// has one or two comparands
if (j < right)
// a[j] > a[j + 1]: Make sure that a[j] is
// always the smaller among the 2 comparands
if (a[j] > a[j + 1]) j++;
if (x ≤ a[j]) break;
a[i] = a[j]; // "sift down" along the path
i = j; j = 2 * i; // of the smaller comparand
}
a[i] = x;
}
Let’s simulate this with an example array (the natural heap is marked as ):
Let’s start with , , . The value of is always throughout the process.
is already smaller compared to . Since is larger than , these two elements will be swapped. Then we will update and . Since , there’s no need for another sifting!
We only want the smaller between and , so is incremented by to get the index of that smaller element.
so . Then, so this stage is done.
so . Then, . We need to do another sifting.
We increment to to get the smaller element. so . Then, so this stage is done.
so . Then, . We need to do another sifting.
We increment to to get the smaller element. so . Then, so this stage is done.
Our final heap should be:
The Sorting Process
So we’ve discovered how to construct a heap. How can we actually use this heap for sorting our data?
Let’s assume we’re given a heap of elements. In order to obtain the sorted elements, steps have to be executed:
- Pick up the element at the top of the heap (and store it somewhere). This allows us to get the smallest one among the elements.
- Reconstruct the remaining elements to form a heap.
Here, two questions arise. Where can we store those emerging top elements? Creating a new array isn’t a good solution because it would double the amount of memory required for the algorithm. Also, how can we reconstruct the remaining elements to form a heap? Do we need to write something new do this or can we reuse something we’ve already done?
A solution for all of these questions is as follows:
- In each step, the leftmost element and the rightmost element of the current heap are exchanged.
- Then, let the new leftmost element sift down into its proper position.
- After steps, the array will be sorted (in the reverse order).
This is how the solution is implemented:
right = n;
// Index of the right-most element
// of the current heap
while (right > 1) {
a[1] ⇄ a[right];
right--;
sift(a, 1, right);
}
Let’s get our hands dirty and sort the heap we’ve just constructed above, shall we? This is our constructed heap, now we’d like to sort all of the elements which form this heap.
First, exchange with . Then we’re going to let the new sift down to its proper position in the heap marked with :
I will only demonstrate the changes of the heap without much detail, since we’ve already discussed the sifting algorithm in the above section. If you want to see the sifting process as well, just click on the button before the changes.
Sifting process
Sifting process
Sifting process
Sifting process
We will do this repeatedly until our array is sorted!
Performance Analysis of Heap Sort
The Heap Sort algorithm runs in because it consists of two stages:
- The heap construction stage: The algorithm executes sift steps where each step runs in time. As a result, this stage takes time.
- The sorting stage: The algorithm executes steps where each step runs in time. Therefore, this stage takes time.
As a result, the worst-case complexity of Heap Sort is , and this is also similar to the average case. For the best case, it is when every elements in the array is identical to each other.
Heap Sort only requires - constant space.
Quick Sort
The Quick Sort algorithm was developed in 1959 by Tony Hoare. It was selected as one of the 10 algorithms “with the greatest influence on the development and practice of science and engineering in the 20th century”.
Basic Concept of Quick Sort
Quick Sort is based on the idea of an array partition.
An Array Partition
An array partition is an arrangement of the array’s elements so that:
After a partition is achieved, will be in its final position in the sorted array.
We can continue sorting the two subarrays to the left and to the right of independently by the same method.
For example, assume that we have the array:
In the given array, is in its final position because . This position will never be visited again by the algorithm.
This is the implementation for the above idea:
QuickSort(a[], left, right) {
if (left < right) {
s = Partition(a, left, right);
QuickSort(a, left, s - 1);
QuickSort(a, s + 1, right);
}
}
The Partition Function
The Partition()
function starts by selecting a pivot , which is an element of the (sub)array being partitioned. For simplicity, we’re going to assume that .
The function will then scan the (sub)array from both ends, comparing the (sub)array’s elements to the pivot .
- : left to right scan, stops when encountering .
- : right to left scan, stops when encountering .
After both scans stop, three situations may arise:
- If :
The function exchanges and , then it resume the scans by incrementing and decrementing by .
- If :
The function will have partitioned the (sub)array after exchanging the pivot with . The split position is .
- : new value of ; : new pivot value
- The array is separated by the element with the value .
- If :
The value that two scanning indices are pointing to must be equal to . Thus, we have the (sub)array partitioned, with the split position .
- If we swap , it would be redundant. , if we do that it’s still correct but redundant). We should only return or as the split position in this case.
Let’s see how this Partition()
function is implemented:
Partition(a[], left, right) {
p = a[left];
i = left;
j = right + 1;
do {
do i++; while (a[i] < p);
do j--; while (a[j] > p);
a[i] ⇄ a[j];
} while (i < j);
a[i] ⇄ a[j]; // undo last swap when i ≥ j
a[left] ⇄ a[j];
return j;
}
This is called the Hoare’s Partition Algorithm.
When this function runs, is less than () for most of the iterations, so the loop primarily handles this case.
If , this only happens once per partition operation, at which point we need to undo the last swap (). This is because in the last iteration where , and were swapped unnecessarily before exiting the loop.
As for the final pivot swap (), while it may be redundant when , its time complexity is constant, making it an acceptable trade-off for maintaining the simplicity and clarity of the function.
Overall, the Partition()
function is just a beautiful piece of code.
Performance Analysis of Quick Sort
In the Quick Sort algorithm, the basic operation is the comparisons in the do-while loop. Therefore, the time efficiency of Quick Sort depends on the value of selected in each partition.
- In the best case: The value of is always the median of the (sub)array, allowing the pivot to consistently divide the array into two nearly equal halves. This balanced partitioning results in a logarithmic number of recursive calls, each processing linear elements.
- In the worst case: The value of is always the maximum or minimum value of the (sub)array. In such cases, the depth of recursion becomes linear, and the algorithm degrades to quadratic time complexity.
- In the average case: On average, even with random pivot selections, the partitions tend to be reasonably balanced, leading to efficient sorting times.
As for space efficiency, due to the recursive call stack, Quick Sort uses space.
Merge Sort
Merge Sort is a divide-and-conquer approach that was invented by John von Neumann in 1945.
Basic Concept of Merge Sort
How This Works
Conceptually, the algorithm works as follows:
- Divide the unsorted array into subarrays, each containing one element. This can be done by divide the array recursively into two halves until it can no longer able to be divided.
- Repeatedly merge sorted subarrays to produce new sorted subarrays until there is only one subarray remaining.
This remaining subarray will be the sorted array.
Let’s use Quick Sort to sort an example array:
We’ll divide this array recursively into two halves:
When we’ve already split the array into pieces, we will merge these pieces back together. As we merge, values from each subarray are compared so that the smallest value always comes first.
After the merging process, we finally have a sorted array.
The implementation for Merge Sort is pretty straightforward:
MergeSort(a[], low, high) {
if (low >= high) return;
mid = (low + high) / 2;
MergeSort(a, low, mid);
MergeSort(a, mid + 1, high);
Merge(a, low, mid, high)
}
The Merge Function
In this function, we definitely need to create a temporary array to store the elements of the merged sorted arrays. In this array, the left half will be stored from low
to mid
, while mid + 1
to high
is for the right half.
Using the while loop, we’ll select one element from the left half, one element from the right half, and compare them together. Then we’ll insert the smaller one into the temporary array, followed by copying any remaining elements.
Finally, the temporary array is copied back into the original array.
Merge(a, low, mid, high) {
n = high - low + 1;
T[n] = []; // Create a temporary array
left = low; // Starting index of the left half
right = mid + 1; // Starting index of the right half
k = 0; // Pointer for the temporary array
// Merge the two subarrays into T
while (left ≤ mid && right ≤ high) {
if (a[left] <= a[right]) T[k++] = a[left++];
else T[k++] = a[right++];
}
// Copy any remaining elements
// from the left subarray
while (left ≤ mid) T[k++] = a[left++];
// Copy any remaining elements
// from the right subarray
while (right ≤ high) T[k++] = a[right++];
// Copy the merged result back
// into the original array
for (i = low; i ≤ high; i++) {
a[i] = T[i - low];
}
}
Performance Analysis of Merge Sort
The array is repeatedly split in half until an array of size is reached. The number of splits required is . At each level of recursion, the merging process processes all elements once. Thus, each level contributes time.
Since there are levels and each level performs work, the overall time is:
- For the best case: Even if the array is already sorted, merge sort will still perform all operations, as it does not take advantage of any pre-existing order during the merge process.
- For the average case: The process of dividing and merging remains the same of regardless of the initial order of elements.
- For the worst case: The time complexity is as well, making Merge Sort a very efficient sorting algorithm.
Merge Sort allocates an auxiliary array of size (or at least the size of the current subarray being merged) to hold the merged output. Therefore, the extra space required is . In addition, the depth of the recursion is , which means the additional space used by the call stack is . Compared to the auxiliary array, this is relatively minor.
Combining the space for the temporary array and the recursion stack, the overall space complexity is .
👏 Thanks for reading!