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 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 | |
| Best time | on already-sorted (or reverse-sorted) input |
| Extra space | up to |
| 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 , 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 | Yes | ||||
| Insertion | Yes | ||||
| Merge | Yes | ||||
| Quick | No |
Quick sort's space is its recursion stack, 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 : on sorted input each element is compared once and never moves.
- Worst case : on reverse-sorted input every element slides to the front, moves.
- Why it beats bubble sort: it stops as soon as the element is in place, so nearly-sorted data costs close to .
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.
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 : each level of splitting merges all elements once, and halving gives levels (about 20 for a million items). For a million elements that's around steps versus for an sort.
Trade-off: the merge needs a new array, so extra space is .
When to use:
- You need a guaranteed 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 : a reasonable pivot splits the range roughly in half, giving about levels of partitioning.
Worst : 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 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 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 on average, but they make different trade-offs.
| Quick sort | Merge sort | |
|---|---|---|
| Average time | ||
| Worst time | (rare with random pivot) | |
| Extra space | stack on average | |
| 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()withkey=; tuple keys and stability handle multi-criteria ordering. - Python's sort is stable, worst case and on sorted input.
- Insertion sort shines on small or nearly-sorted data; bubble sort is only a baseline.
- Merge sort: guaranteed , stable, space, ideal for linked lists.
- Quick sort: in place and fast on average, worst case; randomise the pivot. Its partition step powers quickselect.