Merge Sort
Every algorithm in the previous lesson does roughly comparisons in the worst case. Merge sort breaks that ceiling with one idea: split the problem in half, solve each half, then spend a single linear pass merging the two sorted halves back together. That’s divide and conquer, and it’s the same pattern behind binary search, quicksort, and a huge share of efficient algorithms you’ll meet later.
What you’ll learn
- The merge step — combining two already-sorted lists into one sorted list in a single pass.
- The recursive split — dividing the array in half until pieces are trivially sorted (size 0 or 1).
- Why the recurrence solves to .
- Why merge sort is stable and needs extra space — unlike the in-place sorts from the last lesson.
- Where Python’s own
sorted()sorted()fits into this story.
The merge step: combine two sorted lists in one pass
If you already have two sorted lists, you never need to look backward to merge them: walk both with a pointer, always take the smaller of the two current fronts, and advance that pointer. When one list runs out, the rest of the other list is already in order — just copy it over.
def merge(left, right):
merged = []
i = 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.extend(left[i:]) # copy whatever's left, if anything
merged.extend(right[j:])
return merged
left = [1, 4, 7]
right = [2, 3, 9]
print(merge(left, right)) # expect [1, 2, 3, 4, 7, 9]def merge(left, right):
merged = []
i = 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.extend(left[i:]) # copy whatever's left, if anything
merged.extend(right[j:])
return merged
left = [1, 4, 7]
right = [2, 3, 9]
print(merge(left, right)) # expect [1, 2, 3, 4, 7, 9]Every element is looked at exactly once, so mergemerge runs in time
where is the combined length of both lists — this is the linear “combine”
step that makes the whole algorithm work.
The recursive split
Merge only helps if you already have two sorted halves. Merge sort gets those halves the same way it got the whole array sorted: recursively. Split the array in half, sort each half (recursively), then merge the results. The recursion bottoms out at size 0 or 1 — a list of one element is trivially already sorted.
def merge(left, right):
merged = []
i = 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.extend(left[i:])
merged.extend(right[j:])
return merged
def merge_sort(arr):
if len(arr) <= 1: # base case: already sorted
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid])
right = merge_sort(arr[mid:])
return merge(left, right)
nums = [8, 3, 5, 1, 9, 2]
print(merge_sort(nums)) # expect [1, 2, 3, 5, 8, 9]def merge(left, right):
merged = []
i = 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.extend(left[i:])
merged.extend(right[j:])
return merged
def merge_sort(arr):
if len(arr) <= 1: # base case: already sorted
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid])
right = merge_sort(arr[mid:])
return merge(left, right)
nums = [8, 3, 5, 1, 9, 2]
print(merge_sort(nums)) # expect [1, 2, 3, 5, 8, 9]The recursion builds a tree levels deep — each level does a total of merge work across all its calls, since every element is touched exactly once per level:
graph TD
N0["[8, 3, 5, 1]"] --> N1["[8, 3]"]
N0 --> N2["[5, 1]"]
N1 --> N3["[8]"]
N1 --> N4["[3]"]
N2 --> N5["[5]"]
N2 --> N6["[1]"]
Splitting stops at single elements (the base case), and then merging climbs
back up: [8][8] and [3][3] merge into [3, 8][3, 8]; [5][5] and [1][1] merge into
[1, 5][1, 5]; finally [3, 8][3, 8] and [1, 5][1, 5] merge into [1, 3, 5, 8][1, 3, 5, 8].
Why this is O(n log n)
Every recursive call splits the array in half and does work in the merge step to recombine the results. That’s exactly the shape of recurrence covered in Recurrences and the Master Theorem:
There are levels of recursion (each halving shrinks down to 1 after steps), and every level does total merge work across all the calls at that depth. Multiply the two together:
Unlike bubble, selection, and insertion sort, this bound holds in the best, average, and worst case — merge sort never degrades, because the split is always exactly in half regardless of the input’s order.
Time and space complexity
| Case | Complexity |
|---|---|
| Best | |
| Average | |
| Worst | |
| Space | |
| Stable? | Yes |
LeetCode problem set
| # | Problem | Difficulty | The twist |
|---|---|---|---|
| 912 | Sort an Array | Medium | Implement merge sort (or any sort) from scratch |
| 21 | Merge Two Sorted Lists | Easy | The mergemerge step above, applied to a linked list instead of a Python list |
| 315 | Count of Smaller Numbers After Self | Hard | A classic “augment the merge step” problem: count cross-inversions while merging |
Practice — real LeetCode problems
Each exercise is the actual LeetCode problem with its real method signature and LeetCode’s own examples as the test. Write the body, press Run, and match the expected output.
LC 88 — Merge Sorted Array · Easy
Problem. nums1nums1 has length m + nm + n, with its first mm slots holding sorted
values and the rest set to 00. Merge the nn sorted values of nums2nums2 into nums1nums1
in place.
Constraints. 0 <= m, n <= 2000 <= m, n <= 200, both inputs sorted non-decreasing.
Examples. nums1 = [1,2,3,0,0,0], m = 3, nums2 = [2,5,6], n = 3nums1 = [1,2,3,0,0,0], m = 3, nums2 = [2,5,6], n = 3 gives
[1,2,2,3,5,6][1,2,2,3,5,6]
Editorial
This is the merge step of merge sort, with the twist that the output must go into one of the inputs.
Time . Space .
Merging backwards is the key. Forwards, writing to nums1[0]nums1[0] would clobber a
value still needed. Backwards, the write pointer starts in the zero-padding and
always stays at or ahead of both readers — so a collision is impossible.
Two details:
while j >= 0while j >= 0is the right loop condition. Oncenums2nums2is exhausted, any remainingnums1nums1values are already in their correct positions, so no copying is needed. Looping oni >= 0 or j >= 0i >= 0 or j >= 0also works but does pointless writes.i >= 0i >= 0inside the comparison guards the case wherenums1nums1’s values run out first and onlynums2nums2remains.
([0], 0, [1], 1)([0], 0, [1], 1) is that case: m = 0m = 0, so everything comes from nums2nums2.
Follow-ups: “Why not use sorted(nums1[:m] + nums2)sorted(nums1[:m] + nums2)?” — correct in Python and
, but it defeats the point and is not in place. “Merge kk sorted
arrays?” — a heap; see K-way Merge.
“What if nums1nums1 had no spare room?” — you would need extra space, or the
much harder in-place merge algorithms.
LC 977 — Squares of a Sorted Array · Easy
Problem. Given an array sorted in non-decreasing order, return an array of the squares of each number, also sorted non-decreasing. Aim for .
Constraints. 1 <= len(nums) <= 10^41 <= len(nums) <= 10^4, -10^4 <= nums[i] <= 10^4-10^4 <= nums[i] <= 10^4, sorted.
Examples. [-4,-1,0,3,10][-4,-1,0,3,10] gives [0,1,9,16,100][0,1,9,16,100] ·
[-7,-3,2,3,11][-7,-3,2,3,11] gives [4,9,9,49,121][4,9,9,49,121]
Editorial
Squaring destroys the ordering in the middle but preserves a useful fact: the input is sorted, so the largest magnitude is at one of the two ends. Comparing the ends and emitting the larger square — into the output’s back — is exactly a merge of two sorted sequences: the negatives read right-to-left and the non-negatives left-to-right.
Time . Space for the output.
while left <= rightwhile left <= right (not <<) matters: with an odd length the two pointers meet on
the middle element, which still needs writing. [-1][-1] and [1][1] are the minimal tests.
[-2,-1][-2,-1] giving [1,4][1,4] is the all-negative case, where the left end holds the
largest square throughout.
The obvious sorted(x*x for x in nums)sorted(x*x for x in nums) is and perfectly fine to
mention — the problem explicitly asks for an alternative, which is the whole
exercise.
Follow-ups: “Do it in place?” — awkward, since squares can exceed the space freed; output is expected. “What if the input were not sorted?” — then is unavoidable. “Cubes instead of squares?” — cubing preserves order, so the answer is just the mapped array.
LC 148 — Sort List · Medium
Problem. Sort a linked list in time and, ideally, extra space.
Constraints. 0 <= number of nodes <= 5 * 10^40 <= number of nodes <= 5 * 10^4,
-10^5 <= Node.val <= 10^5-10^5 <= Node.val <= 10^5.
Examples. [4,2,1,3][4,2,1,3] gives [1,2,3,4][1,2,3,4] · [-1,5,3,4,0][-1,5,3,4,0] gives
[-1,0,3,4,5][-1,0,3,4,5] · [][] gives [][]
Editorial
Merge sort is the right choice for a linked list, because both of its operations — splitting and merging — need only sequential access. Quicksort needs random access to partition efficiently, so it fares badly here.
Time . Space for the recursion (a bottom-up iterative version reaches genuine ).
Two details decide whether it works:
fast = head.nextfast = head.next, notheadhead. Withfast = headfast = headand two nodes,slowslowstays atheadhead,midmidis the second node, and the recursion makes no progress — infinite recursion. Startingfastfastone ahead splits[2,1][2,1]into[2][2]and[1][1]. That case is in the tests for exactly this reason.slow.next = Noneslow.next = None. Without the cut the first half still runs into the second, and the recursion never shrinks. This is the same warning as in Reorder List.
This composes three things already covered: fast/slow pointers to find the middle, a dummy head to merge, and the divide-and-conquer skeleton.
Follow-ups: “Truly space?” — bottom-up merge sort, merging runs of size 1, 2, 4, … iteratively. “Why not quicksort?” — no random access, and worst-case . “Copy the values into an array and sort?” — works, space, and defeats the exercise.
Recap
- Merge combines two sorted lists in one pass by always taking the smaller current front.
- Merge sort recursively splits in half down to single elements, then merges back up — the recurrence solves to .
- It’s stable ( time, guaranteed, in every case) but needs extra space — unlike the in-place sorts from the previous lesson.
- Python’s
sorted()sorted()uses Timsort, a hybrid built on these same ideas plus insertion sort for small runs — covered in a later lesson.
Next: Quick Sort — another divide-and-conquer sort, in-place this time, with an average case just as fast but a worst case that depends entirely on how you pick the pivot.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
