Sorting --- Quick Sort

Steven J. Zeil

Last modified: Oct 26, 2023
Contents:

Quick sort is a sorting algorithm with the optimal $O(n \log n)$ average case, but a $O(n^2)$ worst case.

Despite its slower worst case behavior, the Quick sort is generally preferred over Merge sort when sorting array-like structures (including vectors and deques). This is because the constant multiplier on quick sort’s average case is much smaller, and, if we are careful, we can make the worst case input of quick sort a relatively obscure case that would seldom be seen in practice.

Like Merge sort, Quick sort also works by dividing the array into progressively smaller parts, but instead of chopping at an arbitrary point (e.g., halfway), it divides into pieces in which

This is often called partitioning the array.

For example, given the array

1 4 3 9 5 8 7 6

it’s not hard to see that we can split the array this way

1 4 3
9 5 8 7 6

so that all items to the left are less than or equal to a pivot value 4, and all items on the right are greater than 4.

On the other hand, for some arrays:

9 1 8 7 2 6 4 0

there’s really no place were we can divide the array “as is” to meet these requirements.

So an algorithm to partition the array must, in some cases, move elements around to make the partition possible. The pivotIndex algorithm from your text does just this.

1 Partitioning the Array

pivot1.cpp
template <typename T>
int pivotIndex(vector<T>& v, int first, int last)
{
  // index for the midpoint of [first,last) and the
  // indices that scan the index range in tandem
  int mid, scanUp, scanDown;
  // pivot value and object used for exchanges
  T pivot, temp;
  
  if (first == last)
    return last;
  else if (first == last-1)
    return first;
  else
    {
      mid = (last + first)/2;
      pivot = v[mid];
      
      // exchange the pivot and the low end of the range
      // and initialize the indices scanUp and scanDown.
      v[mid] = v[first];
      v[first] = pivot;
      
      scanUp = first + 1;
      scanDown = last - 1;
      
      // manage the indices to locate elements that are in
      // the wrong sublist; stop when scanDown <= scanUp
      for(;;)
        {
          // move up lower sublist; stop when scanUp enters
          // upper sublist or identifies an element >= pivot
          while (scanUp <= scanDown && v[scanUp] < pivot)
            scanUp++;
          
          // scan down upper sublist; stop when scanDown locates
          // an element <= pivot; we guarantee we stop at arr[first]
          while (pivot < v[scanDown])
            scanDown--;
          
          // if indices are not in their sublists, partition complete
          if (scanUp >= scanDown)
            break;
          
          // indices are still in their sublists and identify
          // two elements in wrong sublists. exchange
          swap(v[scanUp], v[scanDown]);
          
          scanUp++;
          scanDown--;
        }
      
      // copy pivot to index (scanDown) that partitions sublists
      // and return scanDown
      v[first] = v[scanDown];
      v[scanDown] = pivot;
      return scanDown;
    }
}

First, we choose a value to serve as the pivot element. In this version, we are using the midpoint of the data sequence. There are other possibilities, some of which will be discussed later.

The pivotIndex algorithm basically starts with the scanUp and scanDown indices at opposite ends of the array. These are moved towards each other until they meet.

scanUp is moved up first, until it either meets scanDown or hits an element greater than or equal to the pivot.

Then scanDown is moved down until it hits an element less than or equal to the pivot.

If scanUp is pointing to an element greater than the pivot, and scanDown is pointing to an element less than the pivot, those two elements are clearly out of order with respect to each other. Clearly, we will never find a place to partition the array as long as we have these two elements, the smaller one coming after the larger one. So, we swap the two, and then resume moving scanUp and scanDown towards each other again.

Try out the quicksort in an animation. Run it until the end ofthe first partitioning, watching how elements are swapped between the left nad right sections.

1.1 Analysis of pivotIndex

Let’s do the analysis of this routine.

The loop bodies of the two while loops are clearly O(1).

pivot2.cpp
template <typename T>
int pivotIndex(vector<T>& v, int first, int last)
{
  // index for the midpoint of [first,last) and the
  // indices that scan the index range in tandem
  int mid, scanUp, scanDown;
  // pivot value and object used for exchanges
  T pivot, temp;
  
  if (first == last)
    return last;
  else if (first == last-1)
    return first;
  else
    {
      mid = (last + first)/2;
      pivot = v[mid];
      
      // exchange the pivot and the low end of the range
      // and initialize the indices scanUp and scanDown.
      v[mid] = v[first];
      v[first] = pivot;
      
      scanUp = first + 1;
      scanDown = last - 1;
      
      // manage the indices to locate elements that are in
      // the wrong sublist; stop when scanDown <= scanUp
      for(;;)
        {
          // move up lower sublist; stop when scanUp enters
          // upper sublist or identifies an element >= pivot
          while (scanUp <= scanDown && v[scanUp] < pivot)
            scanUp++;   // O(1)
          
          // scan down upper sublist; stop when scanDown locates
          // an element <= pivot; we guarantee we stop at arr[first]
          while (pivot < v[scanDown])
            scanDown--;   // O(1)
          
          // if indices are not in their sublists, partition complete
          if (scanUp >= scanDown)
            break;
          
          // indices are still in their sublists and identify
          // two elements in wrong sublists. exchange
          swap(v[scanUp], v[scanDown]);
          
          scanUp++;
          scanDown--;
        }
      
      // copy pivot to index (scanDown) that partitions sublists
      // and return scanDown
      v[first] = v[scanDown];
      v[scanDown] = pivot;
      return scanDown;
    }
}

So are the while loop conditions.

pivot3.cpp
template <typename T>
int pivotIndex(vector<T>& v, int first, int last)
{
  // index for the midpoint of [first,last) and the
  // indices that scan the index range in tandem
  int mid, scanUp, scanDown;
  // pivot value and object used for exchanges
  T pivot, temp;
  
  if (first == last)
    return last;
  else if (first == last-1)
    return first;
  else
    {
      mid = (last + first)/2;
      pivot = v[mid];
      
      // exchange the pivot and the low end of the range
      // and initialize the indices scanUp and scanDown.
      v[mid] = v[first];
      v[first] = pivot;
      
      scanUp = first + 1;
      scanDown = last - 1;
      
      // manage the indices to locate elements that are in
      // the wrong sublist; stop when scanDown <= scanUp
      for(;;)
        {
          // move up lower sublist; stop when scanUp enters
          // upper sublist or identifies an element >= pivot
          while (scanUp <= scanDown && v[scanUp] < pivot) // O(1)
            scanUp++;   // O(1)
          
          // scan down upper sublist; stop when scanDown locates
          // an element <= pivot; we guarantee we stop at arr[first]
          while (pivot < v[scanDown]) // O(1)
            scanDown--;   // O(1)
          
          // if indices are not in their sublists, partition complete
          if (scanUp >= scanDown)
            break;
          
          // indices are still in their sublists and identify
          // two elements in wrong sublists. exchange
          swap(v[scanUp], v[scanDown]);
          
          scanUp++;
          scanDown--;
        }
      
      // copy pivot to index (scanDown) that partitions sublists
      // and return scanDown
      v[first] = v[scanDown];
      v[scanDown] = pivot;
      return scanDown;
    }
}

Question: How many times does each while loop execute (in the worst case)?

**Answer:**
pivot4.cpp
template <typename T>
int pivotIndex(vector<T>& v, int first, int last)
{
  // index for the midpoint of [first,last) and the
  // indices that scan the index range in tandem
  int mid, scanUp, scanDown;
  // pivot value and object used for exchanges
  T pivot, temp;
  
  if (first == last)
    return last;
  else if (first == last-1)
    return first;
  else
    {
      mid = (last + first)/2;
      pivot = v[mid];
      
      // exchange the pivot and the low end of the range
      // and initialize the indices scanUp and scanDown.
      v[mid] = v[first];
      v[first] = pivot;
      
      scanUp = first + 1;
      scanDown = last - 1;
      
      // manage the indices to locate elements that are in
      // the wrong sublist; stop when scanDown <= scanUp
      for(;;)
        {
          // move up lower sublist; stop when scanUp enters
          // upper sublist or identifies an element >= pivot
          while (scanUp <= scanDown && v[scanUp] < pivot) // cond: O(1) #: (scanDown-scanUp)
            scanUp++;   // O(1)
          
          // scan down upper sublist; stop when scanDown locates
          // an element <= pivot; we guarantee we stop at arr[first]
          while (pivot < v[scanDown]) // cond: O(1)  #: (scanDown-scanUp)
            scanDown--;   // O(1)
          
          // if indices are not in their sublists, partition complete
          if (scanUp >= scanDown)
            break;
          
          // indices are still in their sublists and identify
          // two elements in wrong sublists. exchange
          swap(v[scanUp], v[scanDown]);
          
          scanUp++;
          scanDown--;
        }
      
      // copy pivot to index (scanDown) that partitions sublists
      // and return scanDown
      v[first] = v[scanDown];
      v[scanDown] = pivot;
      return scanDown;
    }
}

So the while loops are each O(scanDown-scanUp).

pivot5.cpp
template <typename T>
int pivotIndex(vector<T>& v, int first, int last)
{
  // index for the midpoint of [first,last) and the
  // indices that scan the index range in tandem
  int mid, scanUp, scanDown;
  // pivot value and object used for exchanges
  T pivot, temp;
  
  if (first == last)
    return last;
  else if (first == last-1)
    return first;
  else
    {
      mid = (last + first)/2;
      pivot = v[mid];
      
      // exchange the pivot and the low end of the range
      // and initialize the indices scanUp and scanDown.
      v[mid] = v[first];
      v[first] = pivot;
      
      scanUp = first + 1;
      scanDown = last - 1;
      
      // manage the indices to locate elements that are in
      // the wrong sublist; stop when scanDown <= scanUp
      for(;;)
        {
          O(scanDown-scanUp)
          O(scanDown-scanUp)
          
          // if indices are not in their sublists, partition complete
          if (scanUp >= scanDown)
            break;
          
          // indices are still in their sublists and identify
          // two elements in wrong sublists. exchange
          swap(v[scanUp], v[scanDown]);
          
          scanUp++;
          scanDown--;
        }
      
      // copy pivot to index (scanDown) that partitions sublists
      // and return scanDown
      v[first] = v[scanDown];
      v[scanDown] = pivot;
      return scanDown;
    }
}

So the body of the for loop is clearly $O(\mbox{scanDown}-\mbox{scanUp})$. How many times does this loop repeat? It’s hard to say, but we don’t actually need an exact count. Each time the two inner loops were executed, they may move scanDown and scanUp closer to one another. In addition, the final increments in the for loop move those variables still closer to one another. The total number of times the for loop body gets executed can be no more than scanDown-scanUp times, and the total number of times both while loop bodies can be executed is also scanDown-scanUp.

Of course, those loops also change the values of scanDown and scanUp, so it is more accurate to say that the total number of executions of those loops is the initial value of scanDown-scanUp.

Looking at the initialization before the loop, we see that these initial values are last-1 and first+1, respectively. So the loop (and the pivotIndex algorithm) are $O(\mbox{last}-\mbox{first})$. In other words, this algorithm is linear in the number of elements to be partitioned.

2 Quick Sort


To actually sort data, we use the pivotIndex function within the quicksort routine shown here.

template <typename T>
void quicksort(vector<T>& v, int first, int last)
{
  // index of the pivot
  int pivotLoc;
  // temp used for an exchange when [first,last) has
  // two elements
  T temp;

  // if the range is not at least two elements, return
  if (last - first <= 1)
    return;

  // if sublist has two elements, compare v[first] and
  // v[last-1] and exchange if necessary
  else if (last - first == 2)
    {
      if (v[last-1] < v[first])
       {
         temp = v[last-1];
         v[last-1] = v[first];
         v[first] = temp;
       }
      return;
    }
  else
    {
      pivotLoc = pivotIndex(v, first, last);

      // make the recursive call
      quicksort(v, first, pivotLoc);

      // make the recursive call
      quicksort(v, pivotLoc +1, last);
    }
}

The recursive routine is designed to sort all elements of an array from position low to high, inclusively.

Try out the full quicksort algorithm in an animation.

3 Quick Sort Analysis

3.1 Best Case

qs1.cpp
template <typename T>
void quicksort(vector<T>& v, int first, int last)
{
  // index of the pivot
  int pivotLoc;
  // temp used for an exchange when [first,last) has
  // two elements
  T temp;
  
  // if the range is not at least two elements, return
  if (last - first <= 1)
    return;
  
  // if sublist has two elements, compare v[first] and
  // v[last-1] and exchange if necessary
  else if (last - first == 2)
    {
      if (v[last-1] < v[first])
        {
          temp = v[last-1];
          v[last-1] = v[first];
          v[first] = temp;
        }
      return;
    }
  else
    {
      pivotLoc = pivotIndex(v, first, last);
      
      // make the recursive call
      quicksort(v, first, pivotLoc);
      
      // make the recursive call
      quicksort(v, pivotLoc +1, last);
    }
}

We don’t often do a “best case” analysis, but it might help to understand the behavior of this particular algorithm.

Ideally, Each call to pivotIndex will divide the array exactly in half. Thus each recursive call will work on exactly half of the array.

pivotIndex, we have determined, is $O(\mbox{last}-\mbox{first})$. Suppose we start with $N$ items. Partitioning this via pivotIndex takes $O(N)$ time.

Then, in this best case, we would have two sub-arrays, each with $N/2$ elements. Eventually, each of these will be partitioned at a cost of $O(N/2) + O(N/2) = O(N)$ effort.

Each of those sub-arrays will, upon partitioning, yield a pair of sub-arrays of size $N/4$. So, we will, over the course of the entire algorithm, be asked 4 times to partition sub-arrays of size $N/4$, for a total cost of $4*O(N/4) = O(N)$.

You can see what’s happening. In general, at the $k^{\mbox{th}}$ level of recursion, there will be $2^k$ sub-arrays of size $N/(2^k)$ to be pivoted, at a total cost of $\sum_{i=1}^{2^k} O(N/(2^k)) = O(N)$. This continues until $N/(2^k)$ has been reduced to 1.

So all calls at on sub-arrays of the same size add up to $O(N)$. There are $\log(N)$ levels, so the best case is $O(N \log(N))$.

3.2 Average Case

The average case for quick sort is $O(N \, log(N))$. The proof of is rather heavy going, but is presented in the final section for those who are interested.

3.3 Worst Case

In the worst case, each call to pivotIndex would divide the array with one element on one side and all of the other elements on the other side. Then, when we do the recursive calls, one call returns immediately but the other has almost as much work left to do as before we did the pivot. The recursive calls go $N-1$ levels deep, each applying a linear time pivot to its part of the array. Hence we get a total effort from these calls of

\[ O(N-1) + O(N-2) + … + O(1) = O\left(\sum_{i=1}^{n-1} i \right) = O(N^2) \]

3.4 Great Average, Dreadful Worst

Quick sort therefore poses an interesting dilemma. Its average case is very fast. In addition, its multiplicative constant is also fairly low, so it tends to be the fastest, on average, of the known $O(N \, \log N)$ average-case sorting algorithms in actual clock-time. But its worst case is just dreadful.

So the suitability of quick sort winds up coming down to a question of how often we would actually expect to encounter the worst-case or near-worst-case behavior. That, in turn, depends upon the choice of pivot.

3.4.1 Choosing the Pivot

The code shown here selects the midpoint of the range as the pivot location.

pivot6.cpp
template <typename T>
int pivotIndex(vector<T>& v, int first, int last)
{
  // index for the midpoint of [first,last) and the
  // indices that scan the index range in tandem
  int mid, scanUp, scanDown;
  // pivot value and object used for exchanges
  T pivot, temp;
  
  if (first == last)
    return last;
  else if (first == last-1)
    return first;
  else
    {
      mid = (last + first)/2;
      pivot = v[mid];
      
      // exchange the pivot and the low end of the range
      // and initialize the indices scanUp and scanDown.
      v[mid] = v[first];
      v[first] = pivot;
      
      scanUp = first + 1;
      scanDown = last - 1;
      
      // manage the indices to locate elements that are in
      // the wrong sublist; stop when scanDown <= scanUp
      for(;;)
        {
          // move up lower sublist; stop when scanUp enters
          // upper sublist or identifies an element >= pivot
          while (scanUp <= scanDown && v[scanUp] < pivot)
            scanUp++;
          
          // scan down upper sublist; stop when scanDown locates
          // an element <= pivot; we guarantee we stop at arr[first]
          while (pivot < v[scanDown])
            scanDown--;
          
          // if indices are not in their sublists, partition complete
          if (scanUp >= scanDown)
            break;
          
          // indices are still in their sublists and identify
          // two elements in wrong sublists. exchange
          swap(v[scanUp], v[scanDown]);
          
          scanUp++;
          scanDown--;
        }
      
      // copy pivot to index (scanDown) that partitions sublists
      // and return scanDown
      v[first] = v[scanDown];
      v[scanDown] = pivot;
      return scanDown;
    }
}

Other choices that were once popular included choosing the first element or the last element of the range to serve as the pivot. You can see that, once we have chosen a pivot, the first thing we do is to move it “out of the way” by swapping it with the first element. So, if we simply chose the first [or last] element as the pivot, it would already be out of the way, and we could save that step.

These turned out to be poor choices, however, as the worst case would then occur whenever the array was already sorted or was in exactly reverse order. Those are, in practice, very common situations.

Choosing the midpoint is somewhat better. The worst case is somewhat more obscure and far less likely in practice. It’s not entirely unlikely however – some tree traversal algorithms that we will covered in later lessons could give rise to listings of elements that would have the largest/smallest elements in each range at the midpoint, causing the $O(N^2)$ behavior.

In practice then, the most commonly recommended choices seem to be:

4 Optimizing the Algorithm

If you have run the quick sort, you may have noticed that a lot of its work is spent on very small sub-arrays. These small arrays are also responsible for a good portion of the storage overhead of Quick sort — space on the run-time stack.

A significant speedup comes from only applying quick sort to fairly large arrays:

qs2.cpp
template <typename T>
void quicksort(vector<T>& v, int first, int last)
{
  // index of the pivot
  int pivotLoc;
  // temp used for an exchange when [first,last) has
  // two elements
  T temp;
  
  // if the range is not at least ten elements, return
  if (last - first <= 10)
    return;
  
  // if sublist has two elements, compare v[first] and
  // v[last-1] and exchange if necessary
  else if (last - first == 2)
    {
      if (v[last-1] < v[first])
        {
          temp = v[last-1];
          v[last-1] = v[first];
          v[first] = temp;
        }
      return;
    }
  else
    {
      pivotLoc = pivotIndex(v, first, last);
      
      // make the recursive call
      quicksort(v, first, pivotLoc);
      
      // make the recursive call
      quicksort(v, pivotLoc +1, last);
    }
}
template <typename T>
void quicksortR(vector<T>& v, int first, int last)
{
  // index of the pivot
  int pivotLoc;
  // temp used for an exchange when [first,last) has
  // two elements
  T temp;
  
  // if the range is not at least two elements, return
  if (last - first <= 10)
    return;
  
  // if sublist has two elements, compare v[first] and
  // v[last-1] and exchange if necessary
  else if (last - first == 2)
    {
      if (v[last-1] < v[first])
        {
          temp = v[last-1];
          v[last-1] = v[first];
          v[first] = temp;
        }
      return;
    }
  else
    {
      pivotLoc = pivotIndex(v, first, last);
      
      // make the recursive call
      quicksort(v, first, pivotLoc);
      
      // make the recursive call
      quicksort(v, pivotLoc +1, last);
    }
}


void quicksort(vector<T>& v, int first, int last)
{
  quicksortR (v, first, last);
  insertionSort (v, first, last);
}

Although insertionSort is $O(N^2)$ in its worst case, in the special case where all elements are no more than $k$ places away from their final position, insertionSort is $O(k*N)$.

In this situation, we know that the recursive quick sort has brought each element to within $10$ positions of its sorted position, so this insertionSort pass is $O(10*N) = O(N)$. Since we’ve already expended an average of $O(N \, \log(N))$ work before that pass, this cleanup phase is still efficient by comparison.

5 Average Case Analysis of Quick Sort

The average case run time of Quick sort is $O(n \, \log n)$. Proving this is really a bit much for this course, but we present the proof here for the curious:

Time $T$ to sort the $n$ elements is then:

\[ T(n) = T(i)+T(n-i)+c*n \]

Because $i$ is equally likely to be any value from $0$ to $n-1$, the average (expected) value of $T(i)$ is

\[E(T(i)) = 1/n \sum_{j=0}^{n-1} T(j) \]

Since $n-i$ can take on the same values as $i$, and all such values are equally likely,

\[ E(T(n-i))=E(T(i)) \]

On average, then

\[ T(n) = 2/n \left(\sum_{j=0}^{n-1} T(j)\right) + c*n \]

Multiply through by $n$:

\[n * T(n) = 2 * \left(\sum_{j=0}^{n-1} T(j)\right) + c*n^2 \]

$n$ is just a variable, so this must be true no matter what value we plug in for it. Try replacing $n$ by $n-1$.

\[(n-1) * T(n-1) = 2 * \left(\sum_{j=0}^{n-2} T(j)\right) + c*(n-1)^2 \]

So now both of these are true:

\[ \begin{eqnarray} n * T(n) & = & 2 * \left(\sum_{j=0}^{n-1} T(j)\right) + c*n^2 \\ (n-1) * T(n-1) & = & 2 * \left(\sum_{j=0}^{n-2} T(j)\right) + c*(n-1)^2 \end{eqnarray} \]

Subtract the 2nd equation from the first, obtaining: \[ nT(n)-(n-1)T(n-1)=2T(n-1)+2cn-c \]

Collect the $T(n-1)$ terms together and drop the $-c$ term: \[ n T(n)=(n+1)T(n-1)+2cn \]

Next we apply a standard technique for solving recurrence relations. Divide by $n(n+1)$ and “telescope”: \[ \begin{eqnarray*} \frac{T(n)}{n+1} & = & \frac{T(n-1)}{n} + \frac{2c}{n+1} \\ \frac{T(n-1)}{n} & = & \frac{T(n-2)}{n-1} + \frac{2c}{n} \\ \frac{T(n-2)}{n-1} & = & \frac{T(n-3)}{n-2} + \frac{2c}{n-1} \\ & \vdots & \end{eqnarray*} \]

Note that most of the terms on the left will have appeared on the right in the previous equation, so if we were to add up all these equations, these terms would appear on both sides and could be dropped: \[ \frac{T(n)}{n+1} = \frac{T(1)}{2} + 2 c \sum_{j=3}^{n+1} 1/j \]

As $n$ gets very large, $\sum_{j=3}^{n+1} 1/j$ approaches $\ln(n) + \gamma$, where $\gamma$ is Euler’s constant, $0.577\ldots$.

Hence \[ \begin{eqnarray*} \frac{T(n)}{n+1} & = & \frac{T(1)}{2} + 2 c * \ln(n) + 2 c \gamma \\ & = & \ln(n) + c_2 \\ & = & O(\ln(n)) \end{eqnarray*} \]

and so $T(n)$ is $O(n \log(n))$.