1.4 Mergesort

Mergesort, like quicksort, is also one of the most well-known sorting algorithms and also a typical instance of divide-n-conquer (again, divide-conquer-combine). But the two of them have very different allocations of work between the divide and combine steps. Quicksort, on the one hand, puts most of the work in the divide step (partitioning), while its combine step is simple (list concatenation in our out-of-place implementation) or trivial (no work at all in the conventional in-place implementation). Mergesort, on the other hand, puts most of its work in the combine step, while its divide step is trivial.

At a high level, mergesort is divide-conquer-combine:

The main program can be extremely short, again in a functional style:

def mergesort(a):
    if (n:=len(a)) <= 1:
        return a
    return mergesorted(mergesort(a[:n//2]), mergesort(a[n//2:]))

Python caveats:

def mergesort(a, i, j):
    if j-i <= 1:
        return a[i:j]          # slicing: new copy
    mid = (i+j)//2             # split point
    left = mergesort(a, i, mid)
    right = mergesort(a, mid+1, j)
    return mergesorted(left, right)

Merging Two Sorted Lists

The non-trivial work in mergesort lies in the combination step, i.e., merging of two sorted lists. For example, merging [1, 4, 6] and [2, 3, 5], we get [1, 2, 3, 4, 5, 6]. Here we use a very simple idea of “two-pointer scan”, where the left and right pointers start at the first element of each array, respectively, and take the smaller number while advancing the corresponding pointer:

a: [1, 4, 6]   b: [2, 3, 5]  # two sorted arrays
    ^              ^
    *=>                      # left is smaller
c: [1...                     # combined array (1st number)

a: [1, 4, 6]   b: [2, 3, 5]  # advancing the left pointer
       ^           ^
                   *=>       # right is smaller 
c: [1, 2...                  # combined array (2nd number)

a: [1, 4, 6]   b: [2, 3, 5]  # advancing the right pointer
       ^              ^
                      *=>    # right is smaller
c: [1, 2, 3...               # combined array (3rd number)

a: [1, 4, 6]   b: [2, 3, 5] # advancing the right pointer
       ^                 ^ 
       *=>                  # left is smaller
c: [1, 2, 3, 4...           # combined array (4th number)

a: [1, 4, 6]   b: [2, 3, 5]  # advancing the left pointer
          ^              ^ 
                         *=> # right is smaller
c: [1, 2, 3, 4, 5...         # combined array (5th number)       

a: [1, 4, 6]   b: [2, 3, 5]
          ^                ^ # advancing the right pointer (right is empty)

until one side is empty (in this case, the right array). Then we copy the rest of the other side (in this case, only [6]) to the combined array:

[1, 2, 3, 4, 5, 6]     # combined array (complete)

This process takes \(O(n)\) time. Why? You can count the number of comparisons. There are at most \(n\) comparisons, because each of them results in a new number added to the resulting array.

Caveat: if you want to avoid the last “copying” step, you can append a dummy \(+\infty\) to both the left and right arrays.

Complexity Analysis

The analysis of mergesort is much simpler than quicksort, since

Therefore:

\[T(n) = 2T(n/2) + O(n) = O(n\log n)\]

Summary

Here is a table summarizing this comparison:

algorithm divide conquer combine
quicksort partitioning: \(O(n)\) \(2\times\): best: \(n/2+n/2\); worst: \((n-1)+0\) trivial: \(O(1)\) (in-place) or \(O(n)\) (out-of-place)
mergesort trivial: \(O(1)\) (in-place) or \(O(n)\) (out-of-place) \(2\times\): always balanced (\(n/2+n/2\)) merging two sorted lists: \(O(n)\)

Historical Notes

Mergesort was invented by John von Newmann in 1945.