bigonotes
DSA: Python & HacksPython Internals

Sorting

How to use Python's built-in sort well, plus how bubble, insertion, merge and quick sort work and when each one fits.

8 min readLast updated Python Internals

In most interviews you call Python's built-in sort and move on. You still need two things: fluency with key= and stability for multi-criteria sorts, and enough understanding of the classic algorithms to answer "implement merge sort", "why is quicksort O(n2)O(n^2) in the worst case?" or "which sort is stable?". This note covers both.

Python's built-in sort#

Python gives you two ways to sort:

a.sort() sorted(iterable)
Works on lists only any iterable (str, dict, generator, ...)
Returns None (sorts in place) a new list
Original changed untouched

Both use Timsort, a hybrid of merge sort and insertion sort that finds already-sorted runs in the data and merges them.

Property Value
Worst / average time O(nlog⁡n)O(n \log n)
Best time O(n)O(n) on already-sorted (or reverse-sorted) input
Extra space up to O(n)O(n)
Stable Yes: equal keys keep their original order

key=, reverse= and multi-key sorts#

words = ["banana", "Apple", "cherry", "date"]
print(sorted(words, key=str.lower))          # case-insensitive
print(sorted(words, key=len, reverse=True))  # longest first

people = [("Alice", 25), ("Bob", 20), ("Charlie", 25)]
# Age descending, then name ascending.
# Tuples compare element by element.
people.sort(key=lambda p: (-p[1], p[0]))
print(people)   # [('Alice', 25), ('Charlie', 25), ('Bob', 20)]

counts = {"a": 3, "b": 1, "c": 2}
# sort a dict by value
print(sorted(counts.items(), key=lambda kv: kv[1]))

Negating only works for numbers. To sort strings descending inside a multi-key sort, use stability: sort by the secondary key first, then by the primary key.

people = [("Bob", 25), ("Alice", 25), ("Cara", 20)]
# secondary key first: name descending
people.sort(key=lambda p: p[0], reverse=True)
# then the primary key: age ascending (stable keeps the name order)
people.sort(key=lambda p: p[1])
print(people)   # [('Cara', 20), ('Bob', 25), ('Alice', 25)]

When the order depends on comparing two items directly (e.g. Largest Number), write a comparator and wrap it:

from functools import cmp_to_key

nums = ["3", "30", "34", "5", "9"]
nums.sort(key=cmp_to_key(lambda a, b: -1 if a + b > b + a else 1))
print("".join(nums))   # 9534330

Sorting n strings of length L costs O(nlog⁡n⋅L)O(n \log n \cdot L), because each comparison can scan up to L characters. Remember this when you sort words or use "".join(sorted(word)) as a key.

The classic algorithms at a glance#

Here is how the four classic sorts compare before you look at each one.

Algorithm Best Average Worst Space Stable
Bubble O(n)O(n) O(n2)O(n^2) O(n2)O(n^2) O(1)O(1) Yes
Insertion O(n)O(n) O(n2)O(n^2) O(n2)O(n^2) O(1)O(1) Yes
Merge O(nlog⁡n)O(n \log n) O(nlog⁡n)O(n \log n) O(nlog⁡n)O(n \log n) O(n)O(n) Yes
Quick O(nlog⁡n)O(n \log n) O(nlog⁡n)O(n \log n) O(n2)O(n^2) O(log⁡n)O(\log n) No

Quick sort's space is its recursion stack, O(log⁡n)O(\log n) on average.

Bubble sort#

Walk the array comparing neighbours and swap any pair that's out of order. Each pass carries the largest unsorted element to the end, so the unsorted range shrinks by one per pass.

Tracing [5, 3, 8, 1, 2]:

Pass Neighbour comparisons Array after the pass
1 swap 5,3 · keep 5,8 · swap 8,1 · swap 8,2 [3, 5, 1, 2, 8] (8 placed)
2 keep 3,5 · swap 5,1 · swap 5,2 [3, 1, 2, 5, 8] (5 placed)
3 swap 3,1 · swap 3,2 [1, 2, 3, 5, 8] (3 placed)
4 no swaps, so stop early [1, 2, 3, 5, 8]
def bubble_sort(arr):
    n = len(arr)
    for i in range(n):
        swapped = False
        # the last i items are already in place
        for j in range(n - i - 1):
            # strict > keeps equal items in order (stable)
            if arr[j] > arr[j + 1]:
                arr[j], arr[j + 1] = arr[j + 1], arr[j]
                swapped = True
        # a pass with no swaps means sorted: O(n) best case
        if not swapped:
            break
    return arr

When to use: practically never. It's the baseline other sorts are compared against.

Insertion sort#

Like sorting cards in your hand: take the next element, slide every bigger element in the sorted prefix one step right, and drop the element into the gap.

Tracing [5, 3, 8, 1, 2] (the bar | separates the sorted prefix from the rest):

Step Array What happened
start [5 | 3, 8, 1, 2] a single item is already sorted
1 [3, 5 | 8, 1, 2] 3 slides before 5
2 [3, 5, 8 | 1, 2] 8 is already in place
3 [1, 3, 5, 8 | 2] 1 slides to the front
4 [1, 2, 3, 5, 8] 2 slides in after 1
def insertion_sort(arr):
    for i in range(1, len(arr)):
        key = arr[i]
        j = i - 1
        while j >= 0 and arr[j] > key:   # shift bigger items right
            arr[j + 1] = arr[j]
            j -= 1
        arr[j + 1] = key                 # drop key into the gap
    return arr
  • Best case O(n)O(n): on sorted input each element is compared once and never moves.
  • Worst case O(n2)O(n^2): on reverse-sorted input every element slides to the front, 1+2+⋯+(n−1)1 + 2 + \dots + (n-1) moves.
  • Why it beats bubble sort: it stops as soon as the element is in place, so nearly-sorted data costs close to O(n)O(n).

When to use: small arrays, nearly-sorted data, or data that arrives one item at a time. Timsort uses it for short runs.

Merge sort#

Split the array in half until pieces have one element (already sorted), then merge sorted halves back together.

Merge sort on 5, 3, 8, 1, 2: the array splits into halves down to single items, then sorted halves merge back up into 1, 2, 3, 5, 8

Merging two sorted lists uses two pointers: compare the front items, take the smaller, advance that pointer. When one side runs out, append the rest of the other.

Merging left = [3, 5] with right = [1, 2, 8]:

Compare Take Result so far
3 vs 1 1 [1]
3 vs 2 2 [1, 2]
3 vs 8 3 [1, 2, 3]
5 vs 8 5 [1, 2, 3, 5]
left is empty 8 (the rest of right) [1, 2, 3, 5, 8]
def merge_sort(arr):
    if len(arr) <= 1:
        return arr
    mid = len(arr) // 2
    return merge(merge_sort(arr[:mid]), merge_sort(arr[mid:]))

def merge(left, right):
    result = []
    i = j = 0
    while i < len(left) and j < len(right):
        # <= takes from the left on ties: stable
        if left[i] <= right[j]:
            result.append(left[i])
            i += 1
        else:
            result.append(right[j])
            j += 1
    # at most one of these is non-empty
    result.extend(left[i:])
    result.extend(right[j:])
    return result

Why O(nlog⁡n)O(n \log n): each level of splitting merges all nn elements once, and halving gives log⁡2n\log_2 n levels (about 20 for a million items). For a million elements that's around 2×1072 \times 10^7 steps versus 101210^{12} for an O(n2)O(n^2) sort.

Trade-off: the merge needs a new array, so extra space is O(n)O(n).

When to use:

  • You need a guaranteed O(nlog⁡n)O(n \log n) or a stable sort.
  • Sorting a linked list: merging just relinks nodes, so no extra array is needed (Sort List).
  • External sorting: data larger than memory. Sort chunks that fit, then merge them from disk.
  • The merge step itself is a pattern: Merge Two Sorted Lists, Merge k Sorted Lists, counting inversions.

Quick sort#

Pick a pivot, partition so smaller items go left and larger go right, and the pivot lands in its final position. Recurse on both sides.

Partition (Lomuto scheme)#

i marks the end of the "≤ pivot" zone. j scans; whenever arr[j] <= pivot, grow the zone and swap arr[j] into it. Finally, swap the pivot just after the zone.

Tracing [5, 3, 8, 1, 2] with pivot = 2 (the last item) and i = -1:

Step Check Action Array
j = 0 5 > 2 skip [5, 3, 8, 1, 2]
j = 1 3 > 2 skip [5, 3, 8, 1, 2]
j = 2 8 > 2 skip [5, 3, 8, 1, 2]
j = 3 1 ≤ 2 i = 0, swap arr[0], arr[3] [1, 3, 8, 5, 2]
place pivot swap arr[1], arr[4] [1, 2, 8, 5, 3]

Pivot 2 is now in its final position. Recurse on [1] and [8, 5, 3].

import random

def quick_sort(arr, low=0, high=None):
    if high is None:
        high = len(arr) - 1
    if low < high:
        p = partition(arr, low, high)
        quick_sort(arr, low, p - 1)
        quick_sort(arr, p + 1, high)
    return arr

def partition(arr, low, high):
    # a random pivot avoids the sorted-input worst case
    r = random.randint(low, high)
    arr[r], arr[high] = arr[high], arr[r]
    pivot = arr[high]
    i = low - 1        # end of the "<= pivot" zone
    for j in range(low, high):
        if arr[j] <= pivot:
            i += 1
            arr[i], arr[j] = arr[j], arr[i]
    arr[i + 1], arr[high] = arr[high], arr[i + 1]
    return i + 1

print(quick_sort([5, 3, 8, 1, 2]))   # [1, 2, 3, 5, 8]

Average O(nlog⁡n)O(n \log n): a reasonable pivot splits the range roughly in half, giving about log⁡n\log n levels of O(n)O(n) partitioning.

Worst O(n2)O(n^2): if the pivot is always the smallest or largest item (for example, "last element" pivot on already-sorted input), each level only removes one element, giving nn levels. A random pivot makes this extremely unlikely.

Why it's often faster than merge sort in practice: it partitions in place (no new arrays per level) and scans memory sequentially, which suits CPU caches.

The pattern beyond sorting: partitioning alone gives quickselect, which finds the k-th smallest element in O(n)O(n) average time (Kth Largest Element in an Array, K Closest Points). Recurse only into the side that contains index k.

Quick sort vs merge sort#

Both are O(nlog⁡n)O(n \log n) on average, but they make different trade-offs.

Quick sort Merge sort
Average time O(nlog⁡n)O(n \log n) O(nlog⁡n)O(n \log n)
Worst time O(n2)O(n^2) (rare with random pivot) O(nlog⁡n)O(n \log n)
Extra space O(log⁡n)O(\log n) stack on average O(n)O(n)
Stable No Yes
In place Yes No
Best for general in-memory arrays linked lists, external sorts, stability

What real libraries use:

  • Python list.sort: Timsort (merge + insertion, stable).
  • Other runtimes often use dual-pivot quicksort for arrays of primitives and Timsort for objects, which need stability.
  • C++ std::sort: introsort (quicksort that falls back to heapsort when recursion gets too deep).

Key takeaways#

  • Use sorted()/.sort() with key=; tuple keys and stability handle multi-criteria ordering.
  • Python's sort is stable, O(nlog⁡n)O(n \log n) worst case and O(n)O(n) on sorted input.
  • Insertion sort shines on small or nearly-sorted data; bubble sort is only a baseline.
  • Merge sort: guaranteed O(nlog⁡n)O(n \log n), stable, O(n)O(n) space, ideal for linked lists.
  • Quick sort: in place and fast on average, O(n2)O(n^2) worst case; randomise the pivot. Its partition step powers quickselect.

Found a mistake? Tell me on LinkedIn ↗

Further reading#