4.3. Quicksort and its Variations

  • File: QuickSort.ml

One of the fastest algorithms for comparison-based sorting (i.e., sorting that only relies on the fact that two key in an arrays can be compared with each other) to date is Quicksort. Invented in 1959 by Sir Tony Hoare, this algorithm now is a part of the standard Unix library (known as qsort) and Java Developer Kit (JDK).

4.3.1. Partitioning an array

Similarly to Merge sort, Quicksort belongs to the family of divide-and-conquer algorithms: it splits the problem into multiple “simpler” sub-tasks, which are solved recursively, with a base case (array of length 0 or 1) being trivial to sort. Unlike Merge sort, the main work happens on the “divide” step, i.e., before the problem is partitioned. As Merge sort’s key ingredient is the procedure merge that creates a sorted array our of two sorted arrays of a smaller size, at the heart of Quicksort is the procedure partition, whose implementation in OCaml is shown below:

let partition arr lo hi =
  if hi <= lo then lo
  else
    let pivot = arr.(hi - 1) in
    let i = ref lo in
    for j = lo to hi - 2 do
      if arr.(j) <= pivot
      then begin
        swap arr !i j;
        i := !i + 1
      end
    done;
    swap arr !i (hi - 1);
    !i

The procedure partition takes, as arguments, and array arr and two boundaries to do the partitioning, lo and hi correspondingly. Its goal is to reposition elements in an array with respect to some “pivot” element (typically chosen randomly) and return its position i in the resulting array. All elements with positions k such that lo <= k < i will have keys that are less or equal than the one of pivot, while al elements with indices k from the range that i <= k < hi will have the keys larger than the one of pivot.

There are multiple ways to choose the pivot, typically tailored to optimise for randomness. A better choice of a pivot, which should hit closer to the “median” of the keys in the range, guarantees a better performance of the algorithm. For simplicity, we will assume the uniform distribution of keys (i.e., the probability of getting a particular key from an array at a certain position is the same), and will be always choosing as pivot the last element of the array (it could be also the first, but the algorithm would be slightly different).

The partition-ing, thus, works as follows. If the subarray is empty, it just returns its lower end. Otherwise, it picks the pivot to be its last element (arr.(hi - 1)). It then allocates two counters i and j, with j starting to iterate through the range lo ... hi. Any element with a key smaller or equal than the one pivot, gets swapped to the “beginning”, i.e., in the range lo ... i, and then i is advanced. Any element with a key larger than the one of pivot remains where it is. Therefore, at any moment all keys that are less ot equal than pivot are in the range [lo ... i) and all keys that are larger than pivot are in the range [i ... j]. Therefore, once j reaches the end (hi - 2), the only unprocessed element is pivot itself, which can be thus swapped with the element at the position i (Why is it correct? Convince yourself). The procedure returns the position of i as the position of pivot. At that moment all elements with smaller or equal keys are left of i, and all elements with larger keys are on the right of i.

4.3.2. Partitioning in action

Let us trace the execution of the partition on a small array:

# let a = [|4; 5; 1; 2; 3|];;

Now let’s instrument partition with tracing as follows:

let partition_print arr lo hi =
  let open Printf in
  if hi <= lo then lo
  else
    let pivot = arr.(hi - 1) in
    printf "pivot = %d\n" pivot;
    let i = ref lo in
    for j = lo to hi - 2 do

      printf "lo = %d to  i = %d: " lo !i;
      print_int_sub_array lo !i arr; print_newline ();
      printf "i = %d  to j = %d: " !i j;
      print_int_sub_array !i j arr; print_newline ();
      printf "j = %d  to hi = %d: " j hi;
      print_int_sub_array j (hi - 1) arr; print_newline ();
      print_newline ();

      if arr.(j) <= pivot
      then begin
        swap arr !i j;
        i := !i + 1
      end
    done;
    swap arr !i (hi - 1);
    print_int_sub_array lo hi arr; print_newline ();
    !i

Running it with a will produce the following output:

# partition_print a 0 5;;

 pivot = 3
 lo = 0 to  i = 0: [|  |]
 i = 0  to j = 0: [|  |]
 j = 0  to hi = 5: [| 4; 5; 1; 2 |]

 lo = 0 to  i = 0: [|  |]
 i = 0  to j = 1: [| 4 |]
 j = 1  to hi = 5: [| 5; 1; 2 |]

 lo = 0 to  i = 0: [|  |]
 i = 0  to j = 2: [| 4; 5 |]
 j = 2  to hi = 5: [| 1; 2 |]

 lo = 0 to  i = 1: [| 1 |]
 i = 1  to j = 3: [| 5; 4 |]
 j = 3  to hi = 5: [| 2 |]

 [| 1; 2; 3; 5; 4 |]
 - : int = 2

That is, at each loop iteration for j = 0 .. 4 (since the length is 5), we can see the three segments of the partitioned array (less-or-equal than pivot, greater than pivot and unprocessed), withe the final array being as follows with the pivot element 3 standing at the position i = 2:

# a;;
- : int array = [|1; 2; 3; 5; 4|]

4.3.3. Sorting via partitioning

Having seen the main working horse of Quicksort, namely partition, the main procedure is surprisingly simple:

let quick_sort arr =
  let rec sort arr lo hi =
    if hi - lo <= 1 then ()
    else
      let mid = partition arr lo hi in
      sort arr lo mid;
      sort arr mid hi
  in
  sort arr 0 (Array.length arr)

As a classical divide-and-conquer sorting algorithm, it does nothing for the sub-arrays of size 0 an 1. For arrays of a larger size, it performs the partitioning, obtaining the index mid of the newly acquired position of the pivot, and runs itself recursively. One might wonder: why does this work at all? The answer to that is not difficult, and follows directly from the postcondition of partition, which does all the heavy lifting. It is suggested that you answer this question by means of providing an invariant (see Exercise 6).

By the way, what do you think, why do we exclude the pivot with index mid when running sort recursively, so it is not a part of either of sub-arrays to be sorted?