Merge sort
Insertion sort is O(n²) on most lists: for 200,000 values in a random order, about 10 billion moves, far too slow in Python. Merge sort does the same job in O(n log n). Its idea is called divide and conquer: split the problem into halves, solve each half in the same way, and combine the two answers into one.
Merging two sorted lists
The combining step is a merge: two lists that are each sorted become one sorted list. The smallest item of all is at the front of one of the two lists, so compare the two front items, take the smaller one, and step past it. Two indexes, i and j, remember where the front of each list is now:
def merge(left, right):
merged = []
i = 0
j = 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
merged.append(left[i])
i += 1
else:
merged.append(right[j])
j += 1
merged += left[i:]
merged += right[j:]
return merged
print(merge([2, 5, 8, 13], [1, 5, 6, 17, 30]))
[1, 2, 5, 5, 6, 8, 13, 17, 30]Each round of the loop takes one item, so a merge costs O(len(left) + len(right)). The loop stops as soon as one list runs out, and the other one may still have items, all at least as big as everything taken so far. The last two lines add them. Forget them, and this merge returns [1, 2, 5, 5, 6, 8, 13]: no error, just 17 and 30 quietly gone.
Two more details matter. On a tie, <= takes the item from the left list first, so equal items keep their order: merge sort is stable. And don't take the front item with left.pop(0). Removing the first item of a list moves all the others one place, which is O(n) each time; the indexes keep each step O(1).
Splitting and merging
Now merge sort itself. A list of 0 or 1 items is already sorted: that's the base case. Any longer list is split in the middle, each half is sorted by a call of merge_sort, and the two sorted halves are merged:
def merge(left, right):
merged = []
i = 0
j = 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
merged.append(left[i])
i += 1
else:
merged.append(right[j])
j += 1
merged += left[i:]
merged += right[j:]
return merged
def merge_sort(values):
if len(values) <= 1:
return values
middle = len(values) // 2
left = merge_sort(values[:middle])
right = merge_sort(values[middle:])
return merge(left, right)
print(merge_sort([9, 4, 7, 1, 6, 3, 8]))
[1, 3, 4, 6, 7, 8, 9]Here is what happens inside, level by level. Seven items don't halve evenly, so the left half gets 3 and the right half 4:
[9, 4, 7, 1, 6, 3, 8]
[9, 4, 7] [1, 6, 3, 8] split
[9] [4, 7] [1, 6] [3, 8] split
[9] [4] [7] [1] [6] [3] [8] single items are sorted
[9] [4, 7] [1, 6] [3, 8] merge
[4, 7, 9] [1, 3, 6, 8] merge
[1, 3, 4, 6, 7, 8, 9] merge
Why is it right? A list of 0 or 1 items is sorted. A longer list splits into two shorter ones, so the calls get smaller and end; and when both halves come back sorted, merging them gives a sorted list.
What it costs
- Time. Halving 200,000 values again and again gets down to single items after 18 levels: about log n levels (log to the base 2). At each level, the merges together take each of the n values once, so each level is O(n). In all, O(n log n): about 3.6 million steps for 200,000 values, where insertion sort needs billions. That holds in the worst case too: no input slows merge sort down.
- Memory. The slices
values[:middle]andvalues[middle:]are copies, and each merge builds a new list, so merge sort needs O(n) extra memory. Insertion sort needs none. - Recursion depth. The calls only go about log n deep, 18 levels for 200,000 values, so Python's limit of about 1,000 nested calls is never near.