Quiz 2
Registry Synced

06. Merge Sort — The O(n log n) Breakthrough

1669 words
8 min read

Reading compass

Now · 1. The Divide-and-Conquer Strategy

06. Merge Sort — The O(n log n) Breakthrough

What problem does this solve? Selection sort and insertion sort are (O(n^2)) — they take ~10¹² operations for a million items. Merge sort is (O(n \log n)) — ~20 million operations for the same input. This is the first "fast" sorting algorithm that scales to large datasets.

1. The Divide-and-Conquer Strategy

Mental Model

You need to sort a pile of 1000 exam papers. Instead of scanning all 1000 papers repeatedly, you split the pile into two piles of 500. You ask two friends to sort each pile. When they return sorted piles, you merge them together in one quick pass. Each friend splits their 500 into 250 each, and asks two more friends... This recursive splitting continues until you have piles of size 1 (which are trivially sorted). Then the merging begins.

The Two Key Insights

  1. Divide: A list of 0 or 1 elements is already sorted.
  2. Merge: Two sorted lists can be combined into one sorted list with a single pass ((O(n))). (Diagram)

2. The Merge Process

Mental Model

You have two sorted piles on your desk. You look at the top of each pile, pick the smaller one, and place it on the output pile. Repeat until both piles are empty. (Diagram)

Implementation

python
# runnable
def merge(A, B):
    """Merge two sorted lists into one sorted list.
    Time: O(m + n) where m = len(A), n = len(B)
    Space: O(m + n) for the output
    """
    m, n = len(A), len(B)
    C = []
    i = j = 0
    # Compare and merge
    while i < m and j < n:
        if A[i] <= B[j]:    # Using <= makes merge sort stable
            C.append(A[i])
            i += 1
        else:
            C.append(B[j])
            j += 1
    # Copy remaining elements
    if i < m:
        C.extend(A[i:])
    if j < n:
        C.extend(B[j:])
    return C
# Test merge
A = [32, 43, 78]
B = [13, 57, 63]
print(f"Merged: {merge(A, B)}")  # [13, 32, 43, 57, 63, 78]
# Edge cases
print(merge([], [1, 2, 3]))     # [1, 2, 3]
print(merge([1, 2, 3], []))     # [1, 2, 3]
print(merge([1], [2]))          # [1, 2]
print(merge([2], [1]))          # [1, 2]

Maximum Comparisons in Merge

When merging two lists of sizes m and n, the maximum number of comparisons is (m + n - 1). This happens when the last element of both lists is the largest, and you compare them right until the end.
python
# runnable
def merge_with_count(A, B):
    """Merge and count comparisons."""
    m, n = len(A), len(B)
    C = []
    i = j = 0
    comparisons = 0
    while i < m and j < n:
        comparisons += 1
        if A[i] <= B[j]:
            C.append(A[i])
            i += 1
        else:
            C.append(B[j])
            j += 1
    # No more comparisons needed for remaining elements
    C.extend(A[i:])
    C.extend(B[j:])
    return C, comparisons
# Worst case: alternating values → many comparisons
print(merge_with_count([1, 3, 5], [2, 4, 6]))
# ([1, 2, 3, 4, 5, 6], 5 comparisons = 3+3-1)

3. Full Merge Sort Implementation

python
# runnable
def merge_sort(arr):
    """Sort arr using merge sort. Returns new sorted list.
    Time: O(n log n) — best, average, and worst
    Space: O(n) — creates new lists for merging
    Stable: Yes
    Recurrence: T(n) = 2T(n/2) + O(n) → O(n log n)
    """
    n = len(arr)
    # Base case: single element is already sorted
    if n <= 1:
        return arr
    # Divide
    mid = n // 2
    left = merge_sort(arr[:mid])    # Sort left half
    right = merge_sort(arr[mid:])   # Sort right half
    # Conquer
    return merge(left, right)       # Merge sorted halves
# Test
arr = [38, 27, 43, 3, 9, 82, 10]
sorted_arr = merge_sort(arr)
print(f"Original: {arr}")
print(f"Sorted:   {sorted_arr}")

Step-by-Step Trace for [38, 27, 43, 3, 9, 82, 10]

pseudo
merge_sort([38, 27, 43, 3, 9, 82, 10])
├── merge_sort([38, 27, 43, 3])
│   ├── merge_sort([38, 27])
│   │   ├── merge_sort([38]) → [38]
│   │   └── merge_sort([27]) → [27]
│   │   └── merge([38], [27]) → [27, 38]
│   └── merge_sort([43, 3])
│       ├── merge_sort([43]) → [43]
│       └── merge_sort([3]) → [3]
│       └── merge([43], [3]) → [3, 43]
│   └── merge([27, 38], [3, 43]) → [3, 27, 38, 43]
└── merge_sort([9, 82, 10])
    ├── merge_sort([9, 82])
    │   ├── merge_sort([9]) → [9]
    │   └── merge_sort([82]) → [82]
    │   └── merge([9], [82]) → [9, 82]
    └── merge_sort([10]) → [10]
    └── merge([9, 82], [10]) → [9, 10, 82]
└── merge([3, 27, 38, 43], [9, 10, 82]) → [3, 9, 10, 27, 38, 43, 82]

4. Complexity Analysis

Recursion Tree

(Diagram)
LevelSubarraysSize per subarrayWork per subarrayTotal work
01nn(n)
12n/2n/2(n)
24n/4n/4(n)
...............
(\log n)(n)11(n)
Total work = (n \times (\log n + 1) = O(n \log n))

Recurrence Solution

T(n)=2T(n/2)+n=2[2T(n/4)+n/2]+n=4T(n/4)+2n=4[2T(n/8)+n/4]+2n=8T(n/8)+3n=2kT(n/2k)+kn\begin{aligned} T(n) &= 2T(n/2) + n \\ &= 2[2T(n/4) + n/2] + n = 4T(n/4) + 2n \\ &= 4[2T(n/8) + n/4] + 2n = 8T(n/8) + 3n \\ &= 2^k T(n/2^k) + kn \end{aligned}
When (n/2^k = 1) (i.e., (k = \log_2 n)):
T(n)=nT(1)+nlogn=n+nlogn=O(nlogn)T(n) = n \cdot T(1) + n \log n = n + n \log n = O(n \log n)

5. Variations

3-Way Merge Sort

python
# runnable
def merge_sort_3way(arr):
    """3-way merge sort: divide into 3 equal parts."""
    n = len(arr)
    if n <= 1:
        return arr
    mid1 = n // 3
    mid2 = 2 * n // 3
    left = merge_sort_3way(arr[:mid1])
    middle = merge_sort_3way(arr[mid1:mid2])
    right = merge_sort_3way(arr[mid2:])
    return merge(merge(left, middle), right)
# Still O(n log n) but with different constants

In-Place Merge Sort (Not Practical)

Standard merge sort requires (O(n)) extra space for merging. In-place merge sort exists but is complex and slower in practice.

Bottom-Up (Iterative) Merge Sort

python
# runnable
def merge_sort_bottom_up(arr):
    """Iterative merge sort — no recursion."""
    n = len(arr)
    size = 1
    while size < n:
        for left_start in range(0, n, 2 * size):
            mid = left_start + size
            right_end = min(left_start + 2 * size, n)
            if mid < right_end:
                # Merge arr[left_start:mid] and arr[mid:right_end]
                left = arr[left_start:mid]
                right = arr[mid:right_end]
                merged = merge(left, right)
                arr[left_start:right_end] = merged
        size *= 2
    return arr
arr = [38, 27, 43, 3, 9, 82, 10]
print(f"Bottom-up: {merge_sort_bottom_up(arr)}")

6. When to Use Merge Sort

✅ Use when❌ Don't use when
You need guaranteed O(n log n) performanceMemory is limited (uses O(n) extra space)
You need a stable sortThe data fits easily in RAM and is small
You're sorting linked lists (no random access needed)The data is nearly sorted (insertion sort is faster)
You're sorting large files (external sorting)You need an in-place sort

Practice Questions

Q1. Trace merge sort on [6, 4, 2, 8, 1, 5, 3, 7]. Show the merge tree. Q2. How many comparisons does merge sort make in the worst case for n = 8? Q3. Why is merge sort's worst case O(n log n) while quicksort's worst case is O(n²)? Q4. Prove that merging two sorted lists of sizes m and n takes at most (m + n - 1) comparisons. Q5. Write a function merge_three(A, B, C) that merges three sorted lists. Q6. What is the space complexity of merge sort? Why? Q7. Can you implement merge sort without recursion? (Hint: bottom-up) Q8. For n = 1,000,000, compare the approximate number of operations: merge sort vs selection sort. Q9. Given two sorted lists A and B, find the number of pairs (a, b) such that a > b efficiently. Q10. How would you external sort a 100 GB file that doesn't fit in RAM using merge sort principles?
Answers
A1.
pseudo
├── [6, 4, 2, 8, 1, 5, 3, 7]
│   ├── [6, 4, 2, 8]
│   │   ├── [6, 4] → [4, 6]
│   │   └── [2, 8] → [2, 8]
│   │   └── merge → [2, 4, 6, 8]
│   └── [1, 5, 3, 7]
│       ├── [1, 5] → [1, 5]
│       └── [3, 7] → [3, 7]
│       └── merge → [1, 3, 5, 7]
│   └── merge → [1, 2, 3, 4, 5, 6, 7, 8]
A2. Worst case: At each merge of n elements, make n-1 comparisons. With log n levels, total ≈ 8 × 3 = 24 comparisons. More precisely: At level 1 (four merges of size 2): 4 × 1 = 4. Level 2 (two merges of size 4): 2 × 3 = 6. Level 3 (one merge of size 8): 1 × 7 = 7. Total = 17 comparisons.
A3. Merge sort always divides exactly in half ((T(n) = 2T(n/2) + n)), guaranteeing log n levels. Quicksort can divide unevenly ((T(n) = T(n-1) + n)), creating n levels.
A4. Each comparison places one element in the output. After (m+n-1) comparisons, at most (m+n-1) elements are placed. The last element needs no comparison (it's the only one left). So maximum comparisons = (m+n-1).
A5. Merge A and B first, then merge the result with C. Or merge all three simultaneously with three pointers.
A6. (O(n)). Each merge creates a new list. But the total memory at any recursion depth is (O(n)) (all active merges together total n).
A7. Yes — bottom-up merge sort (shown above) starts with size=1 and doubles, avoiding recursion.
A8. Merge sort: ~20 million operations (n log₂ n). Selection sort: ~5 × 10¹¹ operations (n²). Merge sort is ~25,000 times faster.
A9. Use binary search: For each element in A, find how many in B are less than it. Complexity O(m log n).
A10. Divide into chunks that fit in RAM (e.g., 1 GB). Sort each chunk (using merge sort). Then use a k-way merge (priority queue) to merge all sorted chunks into one file. Join Discord Previous05. Selection Sort & Insertion SortNext07. Quick Sort — The Fastest Practical Sort
Document outline

Keep your place and jump directly to a heading.

Table of Contents
System Normal // Awaiting Context

Intelligence Hub

Navigate the knowledge graph to generate context. The Hub adapts dynamically to surface backlinks, related notes, and metadata insights.