Sorting Comparison

You've now coded five sorting algorithms by hand: Bubble, Selection, Insertion, Merge, and Quick sort. Interviewers rarely ask you to write a sixth from scratch — what they actually want is for you to pick the right one and justify the pick with time complexity, space complexity, stability, and whether it sorts in-place. This lesson puts all five side by side in one table, then looks at what Python's own sorted() and list.sort() do under the hood — a single, carefully engineered algorithm called Timsort — and how the key= parameter lets you sort by anything without writing a custom comparison function.

The Full Comparison Table

Algorithm Best Average Worst Space Stable? In-place? When to use
Bubble Sort O(n) O(n2) O(n2) O(1) Yes Yes Teaching only — never in production code.
Selection Sort O(n2) O(n2) O(n2) O(1) No Yes When the cost of swapping is very high and comparisons are cheap — it does the fewest swaps of any O(n2) sort (at most n−1).
Insertion Sort O(n) O(n2) O(n2) O(1) Yes Yes Small lists, or nearly-sorted/streaming data — this is exactly the algorithm Timsort falls back to for small runs, see below.
Merge Sort O(n log n) O(n log n) O(n log n) O(n) Yes No When you need a guaranteed O(n log n) with no worst-case cliff, or stability matters, or you're sorting a linked structure.
Quick Sort O(n log n) O(n log n) O(n2) O(log n) No Yes General-purpose in languages without a great built-in sort — fast in practice, but Python already gives you something better tuned (see below).
sorted() / list.sort() O(n) O(n log n) O(n log n) O(n) Yes list.sort(): yes. sorted(): no (returns a new list) The default choice for essentially everything in Python — see the rest of this lesson.
Notice Timsort has no O(n2) worst case, unlike Quick Sort. That's one of the things that makes it a genuinely good default rather than just "the built-in one" — you get Merge Sort's worst-case guarantee and Insertion Sort's excellent best-case behavior on nearly-sorted data, in one algorithm.

Python's Built-In sorted() and list.sort() — Timsort

Every Python list already knows how to sort itself, and you should reach for the built-in before ever hand-rolling a sort in real code. Both entry points run the same algorithm, Timsort (named after Tim Peters, who wrote it for CPython in 2002 — it's since been adopted by Java for object arrays, and by Android and V8 as well):

  • sorted(iterable) — works on any iterable (list, tuple, string, dict keys, generator...) and always returns a brand-new list, leaving the original untouched.
  • some_list.sort() — a method that exists only on list, sorts it in-place, and returns None (a common beginner bug: writing my_list = my_list.sort() silently sets my_list to None).
numbers = [5, 2, 9, 1, 5, 6]

# sorted() -- returns a new list, original is untouched
result = sorted(numbers)
print(result)   # [1, 2, 5, 5, 6, 9]
print(numbers)  # [5, 2, 9, 1, 5, 6] -- unchanged

# list.sort() -- sorts in-place, returns None
numbers.sort()
print(numbers)  # [1, 2, 5, 5, 6, 9] -- the original list is now sorted

# Both accept reverse=True for descending order
print(sorted(numbers, reverse=True))  # [9, 6, 5, 5, 2, 1]

Time complexity: O(n log n) worst case, O(n) best case on already-sorted or nearly-sorted input (Timsort detects existing order and exploits it — this is what "adaptive" means below). Space complexity: O(n) for the temporary merge buffer Timsort allocates.

Why Timsort is a hybrid, not "just merge sort": Timsort scans the input for existing ascending or descending "runs" (already-ordered stretches), extends short runs up to a minimum length using insertion sort (cheap and fast for small runs, and stable), and then merges the runs together using the same merging idea as Merge Sort. On data that's already mostly sorted — a genuinely common real-world case — this means far fewer comparisons than a textbook merge sort would need, which is exactly why its best case is O(n) instead of O(n log n).

Timsort's Three Defining Properties

PropertyWhat it means in practice
HybridInsertion sort handles small runs, merge sort combines them — you get the strengths of both without the weaknesses of either alone.
StableTwo elements considered equal by the comparison keep their original relative order. This matters whenever you sort by one field and need ties broken by an earlier sort's order.
AdaptiveThe more pre-existing order in the input, the less work Timsort does — O(n) on fully sorted or fully reverse-sorted input, degrading gracefully toward O(n log n) as the input gets more random.
Worst case is still O(n log n), never O(n2). This is the property Quick Sort cannot promise (its worst case is O(n2) on an unlucky pivot sequence). Timsort's worst case is the same bound as Merge Sort, because that's ultimately what it falls back on for badly-ordered data.

The key= Parameter — Sorting By Anything

Rather than writing a custom two-argument comparison function (Python 3 removed the old cmp= parameter entirely), both sorted() and list.sort() take a key= function: it's called once per element, and the results are what actually get compared — the original elements are then arranged to match that comparison order.

# Sort strings by length instead of alphabetically
words = ["banana", "kiwi", "fig", "apple"]
print(sorted(words, key=len))
# ['fig', 'kiwi', 'apple', 'banana'] -- 3, 4, 5, 6 letters

# Sort a list of tuples by their second element
scores = [("Alice", 72), ("Bob", 95), ("Cara", 88)]
print(sorted(scores, key=lambda pair: pair[1], reverse=True))
# [('Bob', 95), ('Cara', 88), ('Alice', 72)] -- highest score first

# Sort case-insensitively without changing the original casing
names = ["bob", "Alice", "cara"]
print(sorted(names, key=str.lower))
# ['Alice', 'bob', 'cara']

# Sort dicts/objects with operator.itemgetter / attrgetter (faster than a lambda)
from operator import itemgetter

people = [{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}]
print(sorted(people, key=itemgetter("age")))
# [{'name': 'Bob', 'age': 25}, {'name': 'Alice', 'age': 30}]

Time complexity: O(n log n) — the key function itself runs in O(1) per call in all four examples above (each is called exactly once per element, not once per comparison, which is why key= is efficient even for an expensive-to-compute key). Space complexity: O(n) — one cached key value per element, plus Timsort's own O(n) merge buffer.

Why key= beats a custom comparator: the key function runs exactly n times (once per element, cached), while an old-style comparator would run O(n log n) times (once per comparison). If computing the key is expensive, key= is asymptotically cheaper, not just more convenient to write.

A Decision Checklist For Interviews

Question to ask yourselfPoints toward
Is n small, or is the data already nearly sorted?Insertion sort by hand, or just trust Timsort — it detects this automatically and runs close to O(n).
Do I need a worst-case guarantee?Merge sort / Timsort — no O(n2) cliff, unlike Quick Sort.
Am I sorting objects and must preserve tie order?Merge sort / Timsort — stability required.
Is this an interview question specifically about implementing a sort?Write the one being asked for — but say out loud that in real code you'd use sorted().
Is this just "sort this data" with no special constraint?sorted() / list.sort() — don't hand-roll what CPython already tuned for two decades.
Up next: Every sort you've written so far is a loop. Recursion Basics introduces the tool that makes Merge Sort, Quick Sort, tree traversals, and backtracking all possible — a function that calls itself.