SEN2211 · Week 14 · Sorting Algorithms II

Sorting II: merge sort, quick sort, heap sort

O(n log n) sorting by divide and conquer: the merge steps in merge sort, the partition trace in quick sort, an overview of heap sort, and a comparison table of all the sorts. Most of the final exam's sorting questions come from here.

divide and conquermerge sortmergeauxiliary arrayquick sortpivotpartitionLomuto partitionworst case O(n^2)heap sortmax-heapsift downstablein-place

The three algorithms of week 13 are O(n²): about 10¹² steps for a million elements, hours. This week we come down to O(n log n) with the idea of divide and conquer: the same million elements take 2 · 10⁷ steps, under a second. There are two main algorithms: merge sort does its work in the merge step, quick sort does its work in the partition step. The third is heap sort, which uses the heap, the data structure of the next course; here we see its general idea. The exam asks for the order of the merge steps, the partition trace and the comparison table.

Golden rules:

  1. Merge sort: divide (easy), combine (the real work). O(n log n) in every case, O(n) extra memory, stable.
  2. Quick sort: partition (the real work), no combine. Average O(n log n), O(n²) with a bad pivot.
  3. After the partition the pivot is in its final place. The two sides are unsorted within themselves.
  4. When writing the trace, check which partition scheme is used. On this page it is Lomuto, pivot = last element.

Merge sort

The idea: an array of one element is already sorted. Split the array into two, sort each half (by recursion), then merge the two sorted halves.

static void mergeSort(int[] a, int[] tmp, int lo, int hi) {
    if (lo >= hi) return;               // 0 or 1 element
    int mid = (lo + hi) / 2;
    mergeSort(a, tmp, lo, mid);
    mergeSort(a, tmp, mid + 1, hi);
    merge(a, tmp, lo, mid, hi);
}

static void merge(int[] a, int[] tmp, int lo, int mid, int hi) {
    int i = lo, j = mid + 1, k = lo;
    while (i <= mid && j <= hi) {
        if (a[i] <= a[j]) tmp[k++] = a[i++];
        else              tmp[k++] = a[j++];
    }
    while (i <= mid) tmp[k++] = a[i++];
    while (j <= hi)  tmp[k++] = a[j++];
    for (k = lo; k <= hi; k++) a[k] = tmp[k];
}

merge is the array version of the merge we wrote on a linked list in week 4. The difference: in an array you can’t merge in place, you write into an auxiliary array (tmp) and copy back.

Trace: {38, 27, 43, 3, 9, 82, 10}

                [38, 27, 43, 3, 9, 82, 10]
               /                          \
      [38, 27, 43, 3]                [9, 82, 10]
       /          \                   /        \
   [38, 27]     [43, 3]           [9, 82]      [10]
    /    \       /   \             /   \
  [38]  [27]   [43]  [3]         [9]  [82]

The merges (program output, in the order they happen):

merge [38] + [27] -> [27, 38]
merge [43] + [3] -> [3, 43]
merge [27, 38] + [3, 43] -> [3, 27, 38, 43]
merge [9] + [82] -> [9, 82]
merge [9, 82] + [10] -> [9, 10, 82]
merge [3, 27, 38, 43] + [9, 10, 82] -> [3, 9, 10, 27, 38, 43, 82]
[3, 9, 10, 27, 38, 43, 82] comps=14

PITFALL · The merge order is not level by level. The recursion finishes the left side entirely and then moves right. The merge of [9, 82] comes after [3, 27, 38, 43] has been formed. If you write “first merge all the pairs”, the order is wrong (that is the order of bottom-up merge sort).

Complexity

T(n)=2T(n/2)+nT(n) = 2T(n/2) + n → O(nlog⁡n)O(n \log n) (week 11). Since the split is always in the middle, best, average and worst are the same. In a single merge the number of comparisons is at least the size of the smaller half and at most n1+n2−1n_1 + n_2 - 1:

merge [1, 2, 3, 4] + [5, 6, 7, 8] -> [1, 2, 3, 4, 5, 6, 7, 8]
comps=4
merge [1, 3, 5, 7] + [2, 4, 6, 8] -> [1, 2, 3, 4, 5, 6, 7, 8]
comps=7

Quick sort

The idea: choose a pivot. Rearrange the array so that the elements smaller than the pivot are on the left and the larger ones on the right (partition). The pivot is now in its final place. Sort the left and right sides by recursion. There is no combine step; everything ends up in place.

Lomuto partition (pivot = last element)

static int partition(int[] a, int lo, int hi) {
    int pivot = a[hi];
    int i = lo - 1;                 // end of the smaller region
    for (int j = lo; j < hi; j++) {
        if (a[j] < pivot) {
            i++;
            int t = a[i]; a[i] = a[j]; a[j] = t;
        }
    }
    int t = a[i + 1]; a[i + 1] = a[hi]; a[hi] = t;   // put the pivot in place
    return i + 1;
}

static void quickSort(int[] a, int lo, int hi) {
    if (lo >= hi) return;
    int p = partition(a, lo, hi);
    quickSort(a, lo, p - 1);
    quickSort(a, p + 1, hi);
}

The first partition on {7, 2, 9, 4, 3, 8, 5}, step by step:

pivot=5 start [7, 2, 9, 4, 3, 8, 5]
j=0 a[j]>=pivot, skip     [7, 2, 9, 4, 3, 8, 5]
j=1 a[j]<pivot, i=0 swap -> [2, 7, 9, 4, 3, 8, 5]
j=2 a[j]>=pivot, skip     [2, 7, 9, 4, 3, 8, 5]
j=3 a[j]<pivot, i=1 swap -> [2, 4, 9, 7, 3, 8, 5]
j=4 a[j]<pivot, i=2 swap -> [2, 4, 3, 7, 9, 8, 5]
j=5 a[j]>=pivot, skip     [2, 4, 3, 7, 9, 8, 5]
place pivot at 3 -> [2, 4, 3, 5, 9, 8, 7]

During the loop the array is divided into four regions:

[ lo .. i ]     < pivot
[ i+1 .. j-1 ]  >= pivot
[ j .. hi-1 ]   not yet looked at
[ hi ]          pivot

The whole sort:

partition [0..6] pivot 5 at 3 -> [2, 4, 3, 5, 9, 8, 7]
partition [0..2] pivot 3 at 1 -> [2, 3, 4, 5, 9, 8, 7]
partition [4..6] pivot 7 at 4 -> [2, 3, 4, 5, 7, 8, 9]
partition [5..6] pivot 9 at 6 -> [2, 3, 4, 5, 7, 8, 9]
[2, 3, 4, 5, 7, 8, 9] comps=11

TIP · Other partition schemes. Textbooks and some instructors take the first element or the median of three elements (median-of-three) as the pivot and use a Hoare-style partition that walks inward from both ends. The intermediate states come out different, but the unchanging rule is the same: at the end of the partition the pivot is in place, smaller on its left, larger on its right. On the exam, read which scheme is asked for.

The worst case

If the pivot is the largest (or smallest) element every time, the split is “n − 1 and 0”. Giving a sorted array to quick sort with the last element as pivot does exactly this:

partition [0..5] pivot 6 at 5 -> [1, 2, 3, 4, 5, 6]
partition [0..4] pivot 5 at 4 -> [1, 2, 3, 4, 5, 6]
partition [0..3] pivot 4 at 3 -> [1, 2, 3, 4, 5, 6]
partition [0..2] pivot 3 at 2 -> [1, 2, 3, 4, 5, 6]
partition [0..1] pivot 2 at 1 -> [1, 2, 3, 4, 5, 6]
comps=15

T(n)=T(n−1)+nT(n) = T(n-1) + n → O(n²). 499 500, 1 999 000, 7 998 000 comparisons for n = 1000, 2000, 4000: exactly n(n−1)/2. The fix: choose the pivot at random or take the median of the first, middle and last elements. In the average case the splits are balanced and O(n log n) is obtained.

Heap sort (overview)

A max-heap is a complete binary tree stored in an array in which every node is greater than or equal to its children. In the array, the children of index i are 2i + 1 and 2i + 2, and its parent is (i − 1) / 2. The largest element is always at the root (index 0).

Heap sort has two phases:

  1. Build the heap. Starting from the last internal node (index n/2 − 1) and going toward the root, sift each node down: if the node is smaller than its larger child, swap, and continue. O(n) in total.
  2. Extract. Swap the root (the largest) with the end of the array, shrink the heap by one, and sift the new root down. Repeat n − 1 times. Each sift is O(log n).
static void siftDown(int[] a, int i, int n) {
    while (2 * i + 1 < n) {
        int c = 2 * i + 1;                         // left child
        if (c + 1 < n && a[c + 1] > a[c]) c++;     // larger child
        if (a[i] >= a[c]) break;
        int t = a[i]; a[i] = a[c]; a[c] = t;
        i = c;
    }
}

static void heapSort(int[] a) {
    int n = a.length;
    for (int i = n / 2 - 1; i >= 0; i--) siftDown(a, i, n);
    for (int end = n - 1; end > 0; end--) {
        int t = a[0]; a[0] = a[end]; a[end] = t;
        siftDown(a, 0, end);
    }
}

{4, 10, 3, 5, 1}:

max-heap: [10, 5, 3, 4, 1]
extract 10 -> [5, 4, 3, 1, 10]
extract 5 -> [4, 1, 3, 5, 10]
extract 4 -> [3, 1, 4, 5, 10]
extract 3 -> [1, 3, 4, 5, 10]

Always O(n log n), O(1) extra memory. But it is unstable, and because it is not cache friendly it is slower than quick sort in practice. The details of the heap structure are in SEN2212.

Measurement: n log n

Number of comparisons on random arrays:

n=10000 merge comps=120464 quick comps=154970  n*log2(n)=132877
n=20000 merge comps=260792 quick comps=342401  n*log2(n)=285754
n=40000 merge comps=561703 quick comps=701837  n*log2(n)=611508
n=80000 merge comps=1203361 quick comps=1545944  n*log2(n)=1303017

Both are of the same magnitude as n log₂ n and grow about 2.1-2.2 times when n doubles (it would be 4 if quadratic). Even though quick sort makes more comparisons, it is usually faster in practice: no auxiliary array, no copying.

All the sorts: a comparison table

AlgorithmBestAverageWorstExtra memoryStable
Bubble (with flag)O(n)O(n²)O(n²)O(1)yes
SelectionO(n²)O(n²)O(n²)O(1)no
InsertionO(n)O(n²)O(n²)O(1)yes
MergeO(n log n)O(n log n)O(n log n)O(n)yes
QuickO(n log n)O(n log n)O(n²)O(log n) stackno
HeapO(n log n)O(n log n)O(n log n)O(1)no

A stability test: applying a single Lomuto partition to records with keys (2a, 2b, 1)

after one Lomuto partition: [1, 2b, 2a]

While pivot 1 was swapped to the front, 2a was thrown to the end. Merge sort gives 1 2a 2b on the same input.

Common mistakes

WrongCorrect
In merge sort, merging all the pairs firstWhen the left subtree is entirely finished, move to the right
Not copying the leftovers in mergeThe leftover left and right, with two while loops
Not copying back from tmp to afor (k = lo; k <= hi; k++) a[k] = tmp[k];
Using < in merge<= preserves stability
Merge sort is in-placeO(n) auxiliary array
quickSort(a, lo, p)Excluding the pivot: (lo, p − 1) and (p + 1, hi)
After the partition both sides are sortedOnly the pivot is in place; the sides are unsorted
Quick sort is always O(n log n)With a bad pivot (sorted input + last element) it is O(n²)
Heap sort is stableIt isn’t; the root-end swap breaks the order of equal elements

Test yourself

Exam-style questions. Solve them on paper first, then reveal the answer.

Question 1easy

The sorted halves [3, 8, 12] and [5, 9, 15, 20] are merged. What is the result, and how many key comparisons are made?

Show answer
[3, 5, 8, 9, 12, 15, 20], 5 comparisons

3 < 5 → 3; 8 > 5 → 5; 8 < 9 → 8; 12 > 9 → 9; 12 < 15 → 12. The left half is finished, and 15 and 20 are copied without comparisons. 5 comparisons.

Question 2medium

Merging two sorted halves of 4 elements each: what are the minimum and maximum possible numbers of comparisons? Give an example of each.

Show answer
min 4 ([1,2,3,4]+[5,6,7,8]), max 7 ([1,3,5,7]+[2,4,6,8])

If one half is entirely smaller, that half finishes in 4 comparisons and the other is copied. If the elements interleave like gear teeth, comparisons continue up to the last element: n1+n2−1=7n_1 + n_2 - 1 = 7. The program counted both cases: 4 and 7.

Question 3medium

Top-down merge sort (mid = (lo + hi) / 2, left half first) is applied to {38, 27, 43, 3, 9, 82, 10}. List the merges in the order they happen.

Show answer
[38]+[27], [43]+[3], [27,38]+[3,43], [9]+[82], [9,82]+[10], [3,27,38,43]+[9,10,82]

The recursion splits and merges the left half all the way first, then moves to the right half.

merge [38] + [27] -> [27, 38]
merge [43] + [3] -> [3, 43]
merge [27, 38] + [3, 43] -> [3, 27, 38, 43]
merge [9] + [82] -> [9, 82]
merge [9, 82] + [10] -> [9, 10, 82]
merge [3, 27, 38, 43] + [9, 10, 82] -> [3, 9, 10, 27, 38, 43, 82]
7 elements are split as 4 + 3 (mid = 3). The right half [9, 82, 10] becomes [9, 82] + [10].

Question 4easy

Give the best, average and worst case time of merge sort, and its extra space.

Show answer
O(n log n) in all cases, O(n) extra space

The recurrence is always T(n) = 2T(n/2) + n: the input order doesn't change the splitting. log n levels, n work at each level. An auxiliary array of n is needed for merging (not in-place). On a random array the program counted 1 203 361 comparisons for n = 80 000; n log₂ n ≈ 1 303 017.

Question 5medium

Lomuto partition with the last element as pivot is applied to {7, 2, 9, 4, 3, 8, 5}. What is the array after partitioning, and at which index does the pivot end up?

Show answer
[2, 4, 3, 5, 9, 8, 7], pivot 5 at index 3

i holds the last position of the elements smaller than the pivot (initially −1). j walks from left to right; if a[j] < pivot, it increments i and swaps.

j=0  7 >= 5 skip       [7, 2, 9, 4, 3, 8, 5]
j=1  2 < 5, i=0 swap   [2, 7, 9, 4, 3, 8, 5]
j=2  9 skip
j=3  4 < 5, i=1 swap   [2, 4, 9, 7, 3, 8, 5]
j=4  3 < 5, i=2 swap   [2, 4, 3, 7, 9, 8, 5]
j=5  8 skip
put the pivot at i+1 = 3  [2, 4, 3, 5, 9, 8, 7]
The pivot is in its final place; those to its left are smaller and those to its right are larger, but unsorted among themselves.

Question 6medium

Continuing quick sort on that array, which subarrays are partitioned next, and with which pivots?

Show answer
[0..2] pivot 3, then [4..6] pivot 7, then [5..6] pivot 9

partition [0..6] pivot 5 at 3 -> [2, 4, 3, 5, 9, 8, 7]
partition [0..2] pivot 3 at 1 -> [2, 3, 4, 5, 9, 8, 7]
partition [4..6] pivot 7 at 4 -> [2, 3, 4, 5, 7, 8, 9]
partition [5..6] pivot 9 at 6 -> [2, 3, 4, 5, 7, 8, 9]
Single-element or empty pieces (for example [0..0], [2..2]) are base cases and are not partitioned. 11 comparisons in total.

Question 7medium

Quick sort with last-element pivot is run on the already sorted array {1, 2, 3, 4, 5, 6}. How many comparisons are made, and what is the Big-O of this case?

Show answer
15, O(n^2)

The pivot is the largest element every time; the split is 'n − 1 elements + empty'. Comparisons 5 + 4 + 3 + 2 + 1 = 15 = n(n−1)/2. The recurrence T(n) = T(n−1) + n → O(n²). For n = 1000, 2000, 4000, 499 500, 1 999 000, 7 998 000 were counted. The remedy: a random pivot or median-of-three.

Question 8hard

Write static void merge(int[] a, int[] tmp, int lo, int mid, int hi) that merges the sorted ranges a[lo..mid] and a[mid+1..hi].

Show answer
Two indices i, j; copy the smaller (use <= for stability) into tmp; copy leftovers; copy tmp back.

static void merge(int[] a, int[] tmp, int lo, int mid, int hi) {
    int i = lo, j = mid + 1, k = lo;
    while (i <= mid && j <= hi) {
        if (a[i] <= a[j]) tmp[k++] = a[i++];   // <= for stability
        else              tmp[k++] = a[j++];
    }
    while (i <= mid) tmp[k++] = a[i++];          // leftover left
    while (j <= hi)  tmp[k++] = a[j++];          // leftover right
    for (k = lo; k <= hi; k++) a[k] = tmp[k];    // copy back
}

static void mergeSort(int[] a, int[] tmp, int lo, int hi) {
    if (lo >= hi) return;
    int mid = (lo + hi) / 2;
    mergeSort(a, tmp, lo, mid);
    mergeSort(a, tmp, mid + 1, hi);
    merge(a, tmp, lo, mid, hi);
}
The array version of the linked list merge from week 4. If you forget the copy back, a doesn't change.

Question 9hard

Write the Lomuto static int partition(int[] a, int lo, int hi) (pivot = a[hi]) and the quickSort method that uses it.

Show answer
i = lo-1; for j in lo..hi-1 if a[j] < pivot swap a[++i], a[j]; swap a[i+1], a[hi]; return i+1.

static int partition(int[] a, int lo, int hi) {
    int pivot = a[hi];
    int i = lo - 1;
    for (int j = lo; j < hi; j++) {
        if (a[j] < pivot) {
            i++;
            int t = a[i]; a[i] = a[j]; a[j] = t;
        }
    }
    int t = a[i + 1]; a[i + 1] = a[hi]; a[hi] = t;
    return i + 1;
}

static void quickSort(int[] a, int lo, int hi) {
    if (lo >= hi) return;
    int p = partition(a, lo, hi);
    quickSort(a, lo, p - 1);    // EXCLUDING the pivot
    quickSort(a, p + 1, hi);
}
If you write quickSort(a, lo, p), the pivot is processed again and on some inputs there is infinite recursion.

Question 10hard

Heap sort first builds a max-heap from {4, 10, 3, 5, 1} (children of index i are 2i+1 and 2i+2), then repeatedly swaps the root to the end. What is the array after building the heap, and after the first extraction?

Show answer
[10, 5, 3, 4, 1], then [5, 4, 3, 1, 10]

Building the heap does sift down going backward from the last internal node (index 1): since 10 > 5, index 1 stays in place; the root 4 is swapped with its larger child 10, then 4, with children 5 and 1, is swapped with 5: [10, 5, 3, 4, 1]. First extraction: the root 10 and the last element 1 swap places, and 1 sinks down (with 5, then with 4): [5, 4, 3, 1, 10]. 10 is now in place.

Question 11easy

Which of these are stable: merge sort, quick sort (Lomuto), heap sort, insertion sort?

Show answer
merge sort and insertion sort

Merge is stable because on a tie it takes from the left (<=). Quick sort does distant swaps: on the array (2a, 2b, 1), while pivot 1 comes to the front, 2a is thrown to the end, giving 1 2b 2a. Heap sort also breaks the order of equal elements while swapping the root to the end. Insertion is stable (week 13).

Question 12medium

Pick an algorithm: (a) sort 1 million records, stability required, O(n log n) guaranteed; (b) fastest in-place sort on average for random arrays; (c) in-place with guaranteed O(n log n); (d) 20 elements that are almost sorted.

Show answer
(a) merge sort (b) quick sort (c) heap sort (d) insertion sort

(a) Merge is n log n in every case and stable; the cost is O(n) memory. (b) Quick sort's inner loop is very simple and cache friendly; the fastest on average but O(n²) at worst. (c) Heap sort is always n log n with O(1) extra memory, but unstable and in practice slower than quick. (d) Insertion is close to O(n) on nearly sorted data; it has the lowest constants for small n. Java's Arrays.sort is merge-based (TimSort) for objects and quick-sort-based for primitive types.

Verification: Merge sort, quick sort (Lomuto, pivot = last element) and heap sort were run on the JDK in verify/SEN2211/W14.java (java -Xss64m W14.java). The merge steps, each j step of the partition and the heap states were taken verbatim from the program's trace. Comparison counts were measured with a counter: on random arrays for n = 10 000 ... 80 000 of the same magnitude as n log₂ n, and n(n−1)/2 for quick sort on sorted input.

Review card

DIVIDE AND CONQUER: divide → solve the subproblems by recursion → combine MERGE SORT: split in the middle, sort the two halves, MERGE; T(n)=2T(n/2)+n → O(n log n) IN EVERY CASE merge: i, j; the smaller goes to tmp (<= is stable); copy the leftovers; tmp → a merge comparisons: min = size of the smaller half, max = n1 + n2 - 1 O(n) extra memory, STABLE; merge order: the left subtree entirely first QUICK SORT: choose a pivot, PARTITION (smaller on the left, larger on the right), recurse on both sides; no combine step Lomuto: pivot = a[hi]; i = lo-1; a[j] < pivot → i++, swap(i,j); at the end swap(i+1, hi) recursion (lo, p-1) and (p+1, hi) EXCLUDING the pivot average O(n log n), on sorted input with last-element pivot O(n²) (n(n-1)/2 comparisons) in-place (O(log n) stack), NOT STABLE HEAP SORT: build a max-heap (sift down going back from n/2-1), throw the root to the end, shrink the heap, repeat children 2i+1, 2i+2; O(n log n) in every case, O(1) memory, NOT STABLE SUMMARY: bubble/selection/insertion O(n²) | merge/heap O(n log n) always | quick avg n log n, bad n²