← All Posts
DSA Series · Arrays · Sorting Techniques · Comparison Sorts

Comparison Sorts: Merge Sort and Quicksort

Merge sort and quicksort are the two most important sorting algorithms in computer science. Both use divide and conquer, but they divide differently: merge sort does the hard work during the merge step, while quicksort does the hard work during the partition step.

This post covers both algorithms in full detail. We will trace through every step with interactive SVG animations, write production-quality C++ code, analyze the recurrences, compare the two, and understand when to pick one over the other.

Merge Sort

Merge sort was invented by John von Neumann in 1945. It is the simplest $O(n \log n)$ sorting algorithm and the easiest to prove correct. The idea is straightforward:

  1. Divide: split the array into two halves.
  2. Conquer: recursively sort each half.
  3. Combine: merge the two sorted halves into one sorted array.

The beauty of merge sort is that the merge step is where all the real work happens. Dividing costs nothing (just compute the midpoint). Merging two sorted arrays of total length $n$ takes exactly $O(n)$ time. Since we halve the array $\log n$ times and do $O(n)$ work at each level, the total time is $O(n \log n)$.

The Recurrence

The time complexity satisfies the recurrence:

$$T(n) = 2T\!\left(\frac{n}{2}\right) + O(n)$$

By the Master Theorem (case 2), this gives $T(n) = O(n \log n)$. This holds in the best, average, and worst case. Merge sort always divides evenly and always merges, regardless of the input.

The Merge Operation

Given two sorted sub-arrays left[] and right[], merging works as follows:

  1. Maintain two pointers, one at the start of each sub-array.
  2. Compare the elements at both pointers.
  3. Copy the smaller element to the output array and advance that pointer.
  4. When one sub-array is exhausted, copy the remaining elements from the other.

This process takes $O(n)$ time and $O(n)$ space for the output array.

Merge Sort: Step-Through Animation

Watch the array split recursively into halves, then merge back in sorted order. Highlighted cells show active comparisons during the merge phase.

C++ Implementation

void merge(vector<int>& arr, int left, int mid, int right) {
    vector<int> tmp;
    int i = left, j = mid + 1;
    while (i <= mid && j <= right) {
        if (arr[i] <= arr[j])  // <= preserves stability
            tmp.push_back(arr[i++]);
        else
            tmp.push_back(arr[j++]);
    }
    while (i <= mid)  tmp.push_back(arr[i++]);
    while (j <= right) tmp.push_back(arr[j++]);
    for (int k = left; k <= right; k++)
        arr[k] = tmp[k - left];
}

void mergeSort(vector<int>& arr, int left, int right) {
    if (left >= right) return;
    int mid = left + (right - left) / 2;
    mergeSort(arr, left, mid);
    mergeSort(arr, mid + 1, right);
    merge(arr, left, mid, right);
}

Properties of Merge Sort

Why stability matters in merge: The line if (arr[i] <= arr[j]) ensures that when two elements are equal, the one from the left half is placed first. Since the left half comes before the right half in the original array, this preserves relative order of equal elements.

Recursion Tree Visualization

The recursion tree helps visualize the work at each level. At every level, the total work is $O(n)$ because we process every element exactly once (either in a split or a merge). There are $\log_2 n$ levels, giving $O(n \log n)$ total.

[38, 27, 43, 3, 9, 82, 10] n work [38, 27, 43, 3] [9, 82, 10] n work [38, 27] [43, 3] [9, 82] [10] n work 38 27 43 3 9 82 base cases log n levels x O(n) work per level = O(n log n) total
Merge sort recursion tree. Every level processes all n elements exactly once.

Quicksort

Quicksort was invented by Tony Hoare in 1959. It is the fastest comparison-based sort in practice for most inputs. The idea:

  1. Partition: pick a pivot element and rearrange the array so that all elements less than the pivot come before it, and all elements greater come after it.
  2. Recurse: sort the left partition and the right partition independently.

Unlike merge sort, quicksort does not need a merge step. Once the partitions are sorted, the entire array is sorted. The hard work happens during partitioning.

Lomuto Partition Scheme

The Lomuto partition is the simpler of the two standard schemes. It uses the last element as the pivot and maintains a boundary index i that separates elements less than or equal to the pivot from those greater.

Lomuto Partition

Choose pivot = arr[high]. Initialize boundary i = low - 1. Scan from j = low to high - 1:

  • If arr[j] <= pivot, increment i and swap arr[i] with arr[j].
  • After the scan, swap arr[i+1] with arr[high] to place the pivot in its final position.

The pivot is now at index i+1. Everything to its left is <= pivot, everything to its right is > pivot.

int lomutoPartition(vector<int>& arr, int low, int high) {
    int pivot = arr[high];
    int i = low - 1;
    for (int j = low; j < high; j++) {
        if (arr[j] <= pivot) {
            i++;
            swap(arr[i], arr[j]);
        }
    }
    swap(arr[i + 1], arr[high]);
    return i + 1;
}

void quickSort(vector<int>& arr, int low, int high) {
    if (low < high) {
        int pi = lomutoPartition(arr, low, high);
        quickSort(arr, low, pi - 1);
        quickSort(arr, pi + 1, high);
    }
}

Quicksort Lomuto Partition: Step-Through

Red cell is the pivot. Blue cells are being compared. Green cells are in the "less than or equal" region.

Hoare Partition Scheme

Hoare's original partition scheme is more efficient than Lomuto's because it does fewer swaps on average. It uses two pointers that start at opposite ends and move toward each other.

Hoare Partition

Choose pivot = arr[low]. Set i = low - 1, j = high + 1.

  • Move i right until arr[i] >= pivot.
  • Move j left until arr[j] <= pivot.
  • If i < j, swap arr[i] and arr[j].
  • When i >= j, return j as the partition boundary.

After partitioning, elements in [low..j] are <= pivot and elements in [j+1..high] are >= pivot. The pivot may not be at index j.

int hoarePartition(vector<int>& arr, int low, int high) {
    int pivot = arr[low];
    int i = low - 1, j = high + 1;
    while (true) {
        do { i++; } while (arr[i] < pivot);
        do { j--; } while (arr[j] > pivot);
        if (i >= j) return j;
        swap(arr[i], arr[j]);
    }
}

void quickSortHoare(vector<int>& arr, int low, int high) {
    if (low < high) {
        int p = hoarePartition(arr, low, high);
        quickSortHoare(arr, low, p);
        quickSortHoare(arr, p + 1, high);
    }
}
Key difference: With Hoare partition, the recursive calls use [low..p] and [p+1..high], not [low..p-1] and [p+1..high] like Lomuto. The pivot's final position is not necessarily at index p.

Quicksort Hoare Partition: Step-Through

Red cell is the pivot. Two pointers scan from opposite ends. Swaps happen when both pointers find out-of-place elements.

Lomuto vs Hoare

PropertyLomutoHoare
Pivot choiceLast elementFirst element (typically)
Swaps (average)~n/2~n/6
Equal elementsAll go to one sideDistributed across both sides
Code simplicitySimplerSlightly tricky
Duplicates handlingPoor (degrades to O(n^2))Better (balanced partitions)

Pivot Selection Strategies

The choice of pivot determines quicksort's performance. A bad pivot leads to unbalanced partitions and $O(n^2)$ time.

Strategy 1: First or Last Element

Simple but dangerous. If the array is already sorted or reverse-sorted, the pivot is always the minimum or maximum, creating partitions of size 0 and $n-1$. This gives $O(n^2)$ time.

Strategy 2: Random Pivot

Pick a random index and swap the element there to the pivot position before partitioning. This makes the worst case extremely unlikely. Expected time is $O(n \log n)$ for any input.

int randomPartition(vector<int>& arr, int low, int high) {
    int randIdx = low + rand() % (high - low + 1);
    swap(arr[randIdx], arr[high]);
    return lomutoPartition(arr, low, high);
}

Strategy 3: Median of Three

Choose the median of the first, middle, and last elements as the pivot. This avoids the worst case on sorted and reverse-sorted inputs, which are common in practice.

int medianOfThree(vector<int>& arr, int low, int high) {
    int mid = low + (high - low) / 2;
    if (arr[mid] < arr[low]) swap(arr[low], arr[mid]);
    if (arr[high] < arr[low]) swap(arr[low], arr[high]);
    if (arr[high] < arr[mid]) swap(arr[mid], arr[high]);
    swap(arr[mid], arr[high]);  // place median at high
    return lomutoPartition(arr, low, high);
}

Worst Case Analysis

Quicksort's worst case happens when every partition is maximally unbalanced: one side has 0 elements and the other has $n-1$. This gives the recurrence:

$$T(n) = T(n-1) + T(0) + O(n) = T(n-1) + O(n)$$

Solving by expansion:

$$T(n) = O(n) + O(n-1) + \cdots + O(1) = O\!\left(\frac{n(n+1)}{2}\right) = O(n^2)$$

This happens when:

Competitive programming warning: Some contest problems have anti-quicksort test cases that trigger $O(n^2)$ when you use a predictable pivot. Always use random pivot or median-of-three in competitive programming.

Average Case

On average, the pivot lands near the middle, splitting the array into roughly equal halves. The recurrence becomes:

$$T(n) = 2T\!\left(\frac{n}{2}\right) + O(n)$$

This gives $O(n \log n)$, same as merge sort. In practice, quicksort is faster than merge sort because:

Three-Way Partition (Dutch National Flag)

When the array contains many duplicates, standard quicksort degrades because equal elements end up on one side of the pivot. The three-way partition groups elements into three regions: less than pivot, equal to pivot, greater than pivot.

void threeWayQuickSort(vector<int>& arr, int low, int high) {
    if (low >= high) return;
    int lt = low, gt = high, i = low;
    int pivot = arr[low];
    while (i <= gt) {
        if (arr[i] < pivot)
            swap(arr[lt++], arr[i++]);
        else if (arr[i] > pivot)
            swap(arr[i], arr[gt--]);
        else
            i++;
    }
    // arr[lt..gt] are all equal to pivot
    threeWayQuickSort(arr, low, lt - 1);
    threeWayQuickSort(arr, gt + 1, high);
}

Merge Sort vs Quicksort

PropertyMerge SortQuicksort
Best time$O(n \log n)$$O(n \log n)$
Average time$O(n \log n)$$O(n \log n)$
Worst time$O(n \log n)$$O(n^2)$
Space$O(n)$$O(\log n)$ average stack
StableYesNo
In-placeNoYes
Cache performanceModerate (sequential access, but extra array)Excellent (in-place, sequential scan)
ParallelizableYes (independent sub-problems)Yes (independent partitions)

When to Use Which

Summary