Skip to content

Merge Sort

Every algorithm in the previous lesson does roughly n2n^2 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 O(n)O(n) pass.
  • The recursive split — dividing the array in half until pieces are trivially sorted (size 0 or 1).
  • Why the recurrence T(n)=2T(n/2)+O(n)T(n) = 2T(n/2) + O(n) solves to O(nlogn)O(n \log n).
  • Why merge sort is stable and needs O(n)O(n) extra space — unlike the in-place O(n2)O(n^2) 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.

merge_step.py
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]
merge_step.py
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 O(n)O(n) time where nn is the combined length of both lists — this is the linear “combine” step that makes the whole algorithm work.

sketch Merging two sorted lists p5.js
Two pointers walk left and right. The smaller of the two current fronts (amber) is copied into merged -- blue if it came from left, green if it came from right. Once one side runs out, the rest of the other side is copied straight across.

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.

merge_sort.py
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]
merge_sort.py
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 log2n\log_2 n levels deep — each level does a total of O(n)O(n) merge work across all its calls, since every element is touched exactly once per level:

diagram Merge sort recursion tree for [8, 3, 5, 1] mermaid

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 O(n)O(n) work in the merge step to recombine the results. That’s exactly the shape of recurrence covered in Recurrences and the Master Theorem:

T(n)=2T ⁣(n2)+O(n)T(n) = 2\,T\!\left(\frac{n}{2}\right) + O(n)

There are log2n\log_2 n levels of recursion (each halving shrinks nn down to 1 after log2n\log_2 n steps), and every level does O(n)O(n) total merge work across all the calls at that depth. Multiply the two together:

T(n)=O(nlogn)T(n) = O(n \log n)

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

CaseComplexity
BestO(nlogn)O(n \log n)
AverageO(nlogn)O(n \log n)
WorstO(nlogn)O(n \log n)
SpaceO(n)O(n)
Stable?Yes

LeetCode problem set

#ProblemDifficultyThe twist
912Sort an ArrayMediumImplement merge sort (or any O(nlogn)O(n \log n) sort) from scratch
21Merge Two Sorted ListsEasyThe mergemerge step above, applied to a linked list instead of a Python list
315Count of Smaller Numbers After SelfHardA 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 O(m+n)O(m + n). Space O(1)O(1).

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 >= 0 is the right loop condition. Once nums2nums2 is exhausted, any remaining nums1nums1 values are already in their correct positions, so no copying is needed. Looping on i >= 0 or j >= 0i >= 0 or j >= 0 also works but does pointless writes.
  • i >= 0i >= 0 inside the comparison guards the case where nums1nums1’s values run out first and only nums2nums2 remains.

([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 O((m+n)log(m+n))O((m+n)\log(m+n)), 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 O(n)O(n) 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 O(n)O(n).

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 O(n)O(n). Space O(n)O(n) 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 O(nlogn)O(n \log n) and perfectly fine to mention — the problem explicitly asks for an O(n)O(n) alternative, which is the whole exercise.

Follow-ups: “Do it in place?” — awkward, since squares can exceed the space freed; O(n)O(n) output is expected. “What if the input were not sorted?” — then O(nlogn)O(n \log n) 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 O(nlogn)O(n \log n) time and, ideally, O(1)O(1) 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 O(nlogn)O(n \log n). Space O(logn)O(\log n) for the recursion (a bottom-up iterative version reaches genuine O(1)O(1)).

Two details decide whether it works:

  • fast = head.nextfast = head.next, not headhead. With fast = headfast = head and two nodes, slowslow stays at headhead, midmid is the second node, and the recursion makes no progress — infinite recursion. Starting fastfast one 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 O(1)O(1) space?” — bottom-up merge sort, merging runs of size 1, 2, 4, … iteratively. “Why not quicksort?” — no random access, and worst-case O(n2)O(n^2). “Copy the values into an array and sort?” — works, O(n)O(n) space, and defeats the exercise.

Recap

  • Merge combines two sorted lists in one O(n)O(n) pass by always taking the smaller current front.
  • Merge sort recursively splits in half down to single elements, then merges back up — the recurrence T(n)=2T(n/2)+O(n)T(n) = 2T(n/2) + O(n) solves to O(nlogn)O(n \log n).
  • It’s stable (O(n)O(n) time, guaranteed, in every case) but needs O(n)O(n) extra space — unlike the O(1)O(1) 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 coffee

Was this page helpful?

Let us know how we did