13.3. Sorting#

This section compares practical sorting approaches from baseline to efficient methods.

Learning Goals

  • Use Python built-in sorting effectively

  • Implement insertion, merge, bubble, and quicksort

  • Compare runtime trends across input patterns

  • Explain stability and memory trade-offs

Section Roadmap

  • Start with Python’s built-in tools — the right choice for real code.

  • Build and verify four classical algorithms from simplest to most efficient.

  • Use the comparison table and benchmark to connect implementation to complexity.

from time import perf_counter
import random

13.3.1. Design Paradigms#

The four algorithms included in this section illustrate three distinct approaches:

  • Brute force: scan and compare every candidate pair; simple but \(O(n^2)\) (e.g., bubble sort, insertion sort).

  • Divide-and-conquer: split the problem into smaller sub-problems, sort each, then combine; achieves \(O(n \log n)\) (e.g., merge sort, quicksort).

  • In-place vs. out-of-place:

    • An in-place sort rearranges elements around a pivot without allocating extra memory proportional to the input (e.g., bubble sort, insertion sort).

    • An out-of-place sort returns a new list and leaves the original untouched, which is useful when you need both the original and the sorted version (merge sort, sorted(), and quicksort).

13.3.2. Python Built-in Sorting#

Python provides two built-in sorting tools for sequences:

sorted(iterable)

list.sort()

Returns

A new sorted list

None

Original modified?

No: original is untouched

Yes: sorts in-place

Works on

Any iterable

Lists only

Both use Timsort — a hybrid merge/insertion sorting algorithm that is stable (if two items are equal, their original order stays the same) and \(O(n \log n)\) in the worst case. It is highly optimized for real workloads, including partially sorted data.

13.3.2.1. Key parameters#

  • reverse=True — sort in descending order

  • key=callable — sort by a derived value rather than the element itself

The default advice is: Prefer Python’s built-in sorting tools (sorted() or .sort()) in production code. Hand-written sorting implementations are usually for learning, analysis, or specialized needs, not everyday use.

Now let us take a look at the sorted() function and the list.sort() method in terms of:

  • in-place vs out-of-place

  • the key parameter

  • key with lambda

print("=== sorted() vs list.sort() ===")
nums = [5, 2, 9, 1, 5, 6]
print(f"sorted(nums):   {sorted(nums)}      sorted")    # returns a new sorted list — nums is unchanged
print(f"print(nums):    {nums}      unchanged")         # original list is untouched

nums.sort()         # sorts in-place — modifies nums directly, returns None
print(f"nums.sort():    {nums}      in-place")                   # nums is now sorted

print("\n=== Sorting with a key function ===")
# key=callable: sort by a derived value instead of the element itself
words = ["banana", "apple", "fig", "cherry"]
print(f"sorted(words, key=len):                 {sorted(words, key=len)}")               # sort by word length
print(f"sorted(words, key=len, reverse=True):   {sorted(words, key=len, reverse=True)}") # longest first
print(f"sorted(words, key=str.lower):           {sorted(words, key=str.lower)}")         # sort case-insensitively

print("\n=== key= with a lambda ===")
# lambda is useful when the sort criterion is not a named function
print(f"sort by last letter:    {sorted(words, key=lambda w: w[-1])}")   # ordered by final character (a, e, g, y)
print(f"sort by 2nd character:  {sorted(words, key=lambda w: w[1])}")    # sort by the second character of each word
=== sorted() vs list.sort() ===
sorted(nums):   [1, 2, 5, 5, 6, 9]      sorted
print(nums):    [5, 2, 9, 1, 5, 6]      unchanged
nums.sort():    [1, 2, 5, 5, 6, 9]      in-place

=== Sorting with a key function ===
sorted(words, key=len):                 ['fig', 'apple', 'banana', 'cherry']
sorted(words, key=len, reverse=True):   ['banana', 'cherry', 'apple', 'fig']
sorted(words, key=str.lower):           ['apple', 'banana', 'cherry', 'fig']

=== key= with a lambda ===
sort by last letter:    ['banana', 'apple', 'fig', 'cherry']
sort by 2nd character:  ['banana', 'cherry', 'fig', 'apple']

Lambda

key=lambda w: w[-1]
#          ^  ^--- w is now the word being evaluated
#          |
#          w is defined here — you could name it anything: word, x, item

A lambda function is equivalent to a named function:

def last_letter(w):
    letter = w[-1]      ### expression
    return letter

sorted(words, key=last_letter)  ### calling last_letter()

13.3.2.2. Stability and key=#

Stability in sorting means that when two elements compare as equal, their original relative order is preserved in the output. Python’s sorted() and .sort() are both stable.

Stability matters when records share the same key and you want original order preserved. This happens when you sort data in multiple passes. If you sort a list of students by score, a stable sort guarantees that students with the same score stay in whatever order they were before — letting you layer sorts predictably.

students = [
    {"name": "Ava",   "score": 90},
    {"name": "Ben",   "score": 90},  # same score as Ava — tied key
    {"name": "Cara",  "score": 85},
    {"name": "Dana",  "score": 85},  # same score as Cara — second tied pair
]

result = sorted(students, key=lambda s: s["score"], reverse=True)   ### sort by score, highest first
for s in result:
    print(f"{s['name']:6}  {s['score']}")       ### :6 means to left-align the name in a 6-character field

# Stable sort guarantees tied elements keep their original relative order:
#   Ava before Ben  (both 90, Ava was first in input)
#   Cara before Dana (both 85, Cara was first in input)
# An unstable sort might swap them — useful when layering multiple sorts
# (e.g., sort by score, then by grade level) without losing prior ordering.
Ava     90
Ben     90
Cara    85
Dana    85
### Exercise: Sort by Word Length
#   1. Given the list `words` below, use `sorted()` with `key=` to sort
#      by word length, shortest to longest.
#   2. Print the sorted list.
#   3. Two words have the same length. Does their relative order change?
#      What does this confirm about stability?
### Your code starts here.
words = ["banana", "apple", "cherry", "date", "elderberry", "fig"]



### Your code ends here.

Hide code cell source

### Solution
words = ["banana", "apple", "cherry", "date", "elderberry", "fig"]
result = sorted(words, key=len)
print(result)
# "banana" and "cherry" are both length 6 — their relative order is preserved.
# Different-length words are ordered by key value (length), as expected.
['fig', 'date', 'apple', 'banana', 'cherry', 'elderberry']

13.3.3. Sorting Concepts & Intuition#

This section introduces core design paradigms and visual intuition before implementation details.

13.3.3.1. Algorithm Intuition#

The animations below show each algorithm working on the same unsorted input. Watch where the sorted region grows, how many passes are needed, and how much movement each approach requires. These visuals build intuition for the complexity differences before you read the code.

Insertion Sort
Insertion Sort (wikipedia)
  • Brute Force*: Builds a sorted region from the left, one element at a time. Each new element slides left past larger elements until it reaches its correct position — like inserting a playing card into a hand. (*also classified as a Decrease-and-Conquer algorithm.)
  • In-place
  • stable

  • Best case $O(n)$ when the input is already sorted (only comparisons, no shifts). Average and worst case $O(n^2)$. Python's Timsort uses insertion sort internally for small sub-arrays.
Bubble Sort
Bubble Sort (wikipedia)
  • Brute Force: Makes repeated passes through the list, swapping any adjacent pair that is out of order. After each pass, the largest unsorted element has “bubbled up” to its final position. An early-exit flag stops the loop as soon as a pass completes with no swaps.
  • In-place
  • stable

  • $O(n^2)$ average and worst case. Best case $O(n)$ on already-sorted input (one pass, no swaps). Included for teaching and conceptual contrast only.
Merge Sort
Merge Sort (wikipedia)
  • Divide-and-Conquer: recursively split the list in half until each sub-list has one element, then merge sorted pairs back together. The merge step uses two pointers to interleave the halves in order.
  • Out-of-place ($O(n)$ extra memory)
  • stable

  • $O(n \log n)$ best, average, and worst case — guaranteed regardless of input order. The consistent performance makes it a good choice when stability is required.
Quick Sort
Quicksort (wikipedia)
  • Divide-and-Conquer by value: pick a pivot, partition all other elements into “less”, “equal”, and “greater” groups, then recursively sort the outer two. No merge step needed — the three pieces concatenate directly.
  • Not stable

  • $O(\log n)$ stack space on average. $O(n \log n)$ average with a randomly chosen pivot. Worst case $O(n^2)$ when the pivot consistently lands at one extreme (e.g., always the minimum on a sorted list). Typically the fastest general-purpose sort in practice.

13.3.4. Core Sorting Algorithms#

The following implementations are included for conceptual comparison and practice of sorting algorithms.

13.3.4.1. Insertion Sort#

Insertion sort builds a sorted section at the front of the list one element at a time. Think of sorting playing cards: you pick up each new card and slide it left past any cards that are larger, until it reaches its correct position. At every step, everything to the left of the current position is sorted; the right side is the unsorted remainder yet to be processed.

  • Time complexity: \(O(n^2)\) average/worst; \(O(n)\) best (already sorted; only comparisons, no shifts)

  • Space complexity: \(O(1)\) extra; fully in-place

  • Useful on small or nearly sorted inputs; Python’s Timsort uses insertion sort internally for small sub-arrays

  • Algorithm:

    • Start with the second element as the first element is assumed to be sorted.

    • Compare the second element with the first if the second is smaller then swap them.

    • Move to the third element, compare it with the first two, and put it in its correct position

    • Repeat until the entire array is sorted.

def insertion_sort(arr):                  # sorts in-place; mutates the list passed in
    for i in range(1, len(arr)):          # start at 1 since the first element is already "sorted"
        current_value = arr[i]                      # the "key" is the element we want to insert into the sorted portion of the list
        j = i - 1                           # j is the index of the last element in the sorted portion of the list  

                                            # if the first one is greater the the second one, 
        while j >= 0 and arr[j] > current_value:    # while there are still elements in the sorted portion and the current element is greater than the key
            arr[j + 1] = arr[j]         # shift the current element one position to the right to make room for the key
            j -= 1                          # move left in the sorted portion to compare the next element

        arr[j + 1] = current_value                  # insert the key into its correct position in the sorted portion of the list
    return arr                            # returns same (now-sorted) list for chaining

assert insertion_sort([]) == []
assert insertion_sort([1]) == [1]
assert insertion_sort([3, 1, 2]) == [1, 2, 3]
assert insertion_sort([1, 2, 3]) == [1, 2, 3]   # already sorted
assert insertion_sort([3, 2, 1]) == [1, 2, 3]   # reverse sorted
assert insertion_sort([2, 2, 1]) == [1, 2, 2]   # duplicates    ### edge case: duplicate values should be sorted correctly, and their relative order should be preserved (stable sort)
print('insertion_sort checks passed.')
insertion_sort checks passed.
# Step-by-step trace: watch insertion sort work on [3, 1, 4, 1].
# Each pass picks the next element (key) and slides it left to its correct position.
trace = [3, 1, 4, 1]
print(f"  start:             {trace}")
for i in range(1, len(trace)):
    key = trace[i]
    j = i - 1
    while j >= 0 and trace[j] > key:
        trace[j + 1] = trace[j]
        j -= 1
    trace[j + 1] = key
    print(f"  after placing {key} at index {j+1}: {trace}")
  start:             [3, 1, 4, 1]
  after placing 1 at index 0: [1, 3, 4, 1]
  after placing 4 at index 2: [1, 3, 4, 1]
  after placing 1 at index 1: [1, 1, 3, 4]
### Exercise: Count Comparisons
#   1. Write `insertion_sort_count(items)` that sorts a copy of items
#      using insertion sort logic and counts every comparison made.
#   2. Return a tuple: (sorted_list, comparison_count).
#   3. Test with [5, 2, 4, 6, 1, 3] and with [1, 2, 3, 4, 5, 6] (already sorted).
#   4. How do the comparison counts differ? What does this confirm about
#      best-case vs. average-case complexity for insertion sort?
### Your code starts here.



### Your code ends here.

Hide code cell source

### Solution

def insertion_sort_count(items):
    items = items[:]  # Work on a copy; do not mutate input.
    comparisons = 0
    for i in range(1, len(items)):
        key = items[i]
        j = i - 1
        while j >= 0 and (comparisons := comparisons + 1) and items[j] > key:
            items[j + 1] = items[j]
            j -= 1
        items[j + 1] = key
    return items, comparisons

sorted_list, count = insertion_sort_count([5, 2, 4, 6, 1, 3])
print(f"unsorted input: {sorted_list}, comparisons: {count}")

sorted_list, count = insertion_sort_count([1, 2, 3, 4, 5, 6])
print(f"sorted input  : {sorted_list}, comparisons: {count}")
# Already-sorted input: each key requires only 1 comparison (with its predecessor).
# That is O(n) total — the best case.
unsorted input: [1, 2, 3, 4, 5, 6], comparisons: 12
sorted input  : [1, 2, 3, 4, 5, 6], comparisons: 5

13.3.4.2. Bubble Sort#

Bubble sort makes repeated passes through the list, comparing each adjacent pair and swapping them if they are out of order. After each full pass, the largest unsorted element has “bubbled up” to its final position at the end of the unsorted region. An early-exit flag (swapped) lets the loop terminate as soon as a full pass completes with no swaps, which means the list is already sorted.

Despite the optimization, bubble sort is \(O(n^2)\) on average and worst-case inputs. It is included here for conceptual contrast and complexity discussion, not for practical use.

Algorithm:

  • Sorts the array using multiple passes. After the first pass, the maximum goes to end (its correct position). Same way, after second pass, the second largest goes to second last position and so on.

  • In every pass, process only those that have already not moved to correct position. After k passes, the largest k must have been moved to the last k positions.

  • In a pass, we consider remaining elements and compare all adjacent and swap if larger element is before a smaller element. If we keep doing this, we get the largest (among the remaining elements) at its correct position.

def bubble_sort(items):
    n = len(items)
    for i in range(n):
        swapped = False
        for j in range(0, n - i - 1):
            if items[j] > items[j + 1]:
                items[j], items[j + 1] = items[j + 1], items[j]
                swapped = True
        if not swapped:
            break
    return items

assert bubble_sort([]) == []
assert bubble_sort([1]) == [1]
assert bubble_sort([3, 1, 2]) == [1, 2, 3]
assert bubble_sort([1, 2, 3]) == [1, 2, 3]   # already sorted — early-exit flag should trigger
assert bubble_sort([3, 2, 1]) == [1, 2, 3]   # reverse sorted
assert bubble_sort([2, 2, 1]) == [1, 2, 2]   # duplicates
print('bubble_sort checks passed.')
bubble_sort checks passed.
### Exercise: Count Swaps
#   1. Write `bubble_sort_count(items)` that sorts a copy of items
#      using bubble sort and counts every swap made.
#   2. Return a tuple: (sorted_list, swap_count).
#   3. Test with [3, 1, 2], [1, 2, 3] (already sorted), and [3, 2, 1] (reverse sorted).
#   4. Which input produces the most swaps? Does this match bubble sort's worst-case input?
### Your code starts here.



### uncomment to test your function:
# for data in [[3, 1, 2], [1, 2, 3], [3, 2, 1]]:
#     result, count = bubble_sort_count(data)
#     print(f"input {data}: sorted {result}, swaps: {count}")
### Your code ends here.

Hide code cell source

### Solution

def bubble_sort_count(items):
    items = items[:]  # Work on a copy; do not mutate input.
    n = len(items)
    swaps = 0
    for i in range(n):
        swapped = False
        for j in range(0, n - i - 1):
            if items[j] > items[j + 1]:
                items[j], items[j + 1] = items[j + 1], items[j]
                swaps += 1
                swapped = True
        if not swapped:
            break
    return items, swaps

for data in [[3, 1, 2], [1, 2, 3], [3, 2, 1]]:
    result, count = bubble_sort_count(data)
    print(f"input {data}: sorted {result}, swaps: {count}")
# Reverse-sorted input has the most swaps — this is the O(n^2) worst case.
# Already-sorted input exits after one pass with zero swaps (early-exit flag).
input [3, 1, 2]: sorted [1, 2, 3], swaps: 2
input [1, 2, 3]: sorted [1, 2, 3], swaps: 0
input [3, 2, 1]: sorted [1, 2, 3], swaps: 3

13.3.4.3. Merge Sort#

Merge sort follows a divide-and-conquer strategy: recursively split the list in half until every sub-list contains one element (a single element is trivially sorted), then repeatedly merge pairs of sorted sub-lists back into a single sorted list.

The merge step is where the work happens: two pointers scan the left and right halves simultaneously, always appending the smaller front element to the result. Because each element is visited once per level of recursion, each level costs \(O(n)\). The recursion depth is \(O(\log n)\) (halving \(n\) until size 1), so the total is \(O(n \log n)\) — and this holds for every input, regardless of order.

  • Time complexity: \(O(n \log n)\) best / average / worst

  • Space complexity: \(O(n)\) extra — each merge allocates a new list

  • Stable: equal elements maintain their original relative order

  • Algorithm:

    • Divide: Divide the list or array recursively into two halves until it can no more be divided.

    • Conquer: Each subarray is sorted individually using the merge sort algorithm.

    • Merge: The sorted subarrays are merged back together in sorted order. The process continues until all elements from both subarrays have been merged.

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(items):
    if len(items) <= 1:
        return items[:]

    mid = len(items) // 2
    left = merge_sort(items[:mid])
    right = merge_sort(items[mid:])
    return merge(left, right)
### Exercise: Merge Two Sorted Lists
#   1. Write `merge_sorted(a, b)` that combines two already-sorted lists
#      into one sorted list.
#   2. Do not call sorted() or any sort function — use only the two-pointer
#      merge logic from the merge() function above.
#   3. Test with:
#      a = [1, 3, 5], b = [2, 4, 6]  → expected [1, 2, 3, 4, 5, 6]
#      a = [],        b = [1, 2, 3]  → expected [1, 2, 3]
#      a = [1, 1],    b = [1, 2]     → expected [1, 1, 1, 2]
### Your code starts here.



### Your code ends here.

Hide code cell source

### Solution

def merge_sorted(a, b):
    result = []
    i = j = 0
    while i < len(a) and j < len(b):
        if a[i] <= b[j]:
            result.append(a[i])
            i += 1
        else:
            result.append(b[j])
            j += 1
    result.extend(a[i:])
    result.extend(b[j:])
    return result

assert merge_sorted([1, 3, 5], [2, 4, 6]) == [1, 2, 3, 4, 5, 6]
assert merge_sorted([], [1, 2, 3]) == [1, 2, 3]
assert merge_sorted([1, 1], [1, 2]) == [1, 1, 1, 2]
print(merge_sorted([1, 3, 5], [2, 4, 6]))
[1, 2, 3, 4, 5, 6]

13.3.4.4. Quicksort#

Quicksort is also divide-and-conquer, but partitions by value rather than by position. It selects a pivot element and rearranges all other values into three groups: elements less than the pivot, elements equal to the pivot, and elements greater. It then recursively sorts the less-than and greater-than groups. No merge step is needed — the three already-sorted pieces concatenate directly into a sorted list.

Pivot choice determines performance. With a well-chosen pivot that splits the data roughly in half, recursion depth is \(O(\log n)\) and total work is \(O(n \log n)\). With a consistently bad pivot — for example, always picking the minimum value on an already-sorted list — one partition is always empty and the other has \(n-1\) elements, producing \(O(n^2)\) behaviour. Random pivoting avoids this: a random element is unlikely to be the extreme value on every level, so expected performance remains \(O(n \log n)\) across typical inputs.

  • Average: \(O(n \log n)\)

  • Worst-case: \(O(n^2)\) — degenerate pivot at every level

  • Space: \(O(\log n)\) average stack frames from recursion; \(O(n)\) worst-case

  • Algorithm:

    • Choose a Pivot: Select an element from the array as the pivot. The choice of pivot can vary (e.g., first element, last element, random element, or median).

    • Partition the Array: Re arrange the array around the pivot. After partitioning, all elements smaller than the pivot will be on its left, and all elements greater than the pivot will be on its right.

    • Recursively Call: Recursively apply the same process to the two partitioned sub-arrays.

    • Base Case: The recursion stops when there is only one element left in the sub-array, as a single element is already sorted.

def quicksort(items):
    if len(items) <= 1:
        return items[:]

    pivot = items[random.randint(0, len(items) - 1)]
    less = [x for x in items if x < pivot]
    equal = [x for x in items if x == pivot]
    greater = [x for x in items if x > pivot]

    return quicksort(less) + equal + quicksort(greater)
### Exercise: Three-Way Partition
#   Quicksort works by partitioning around a pivot. Practice the logic directly:
#   1. Write `partition(items, pivot)` that returns a tuple (less, equal, greater):
#      - less:    all elements < pivot
#      - equal:   all elements == pivot
#      - greater: all elements > pivot
#   2. Do not sort; just partition.
#   3. Test:
#      partition([3, 1, 4, 1, 5, 9, 2, 6, 5], 5)
#      expected: ([3, 1, 4, 1, 2], [5, 5], [9, 6])
### Your code starts here.



### Your code ends here.

Hide code cell source

### Solution

def partition(items, pivot):
    less    = [x for x in items if x < pivot]
    equal   = [x for x in items if x == pivot]
    greater = [x for x in items if x > pivot]
    return less, equal, greater

less, equal, greater = partition([3, 1, 4, 1, 5, 9, 2, 6, 5], 5)
print(f"less:    {less}")
print(f"equal:   {equal}")
print(f"greater: {greater}")
# Notice this is exactly the logic inside quicksort() — three list comprehensions
# over the input, using the pivot to assign each element to its group.
less:    [3, 1, 4, 1, 2]
equal:   [5, 5]
greater: [9, 6]

13.3.5. APIs & Complexity#

This section compares sorting API contracts (in-place mutation with returned list vs returned new list), then connects those behaviors to complexity growth.

13.3.5.1. API Contracts#

A function’s contract is its behavior guarantee: what input it expects, what it returns, and whether it mutates its arguments. In other words, a contract is an agreement between a function and its caller: what the caller must provide, and what the function guarantees in return. All four implementations below should produce the same result as Python’s built-in sorted(). But equally important is how each algorithm interacts with its input, which is a distinction that matters every time you choose a sorting function in real code.

Two different API contracts:

  • insertion_sort and bubble_sort sort in-place: they rearrange the original list and also return the same list object for convenience. After the call, the variable you passed in has been modified.

  • merge_sort and quicksort (as implemented here) return a new sorted list and leave the original untouched — the same contract as Python’s built-in sorted(); by contrast, .sort() sorts in place and returns None.

The first cell below makes the in-place side-effect visible. The second cell runs all four algorithms on the same demo list; demo[:] (a shallow copy) is passed to the in-place functions so that demo survives unchanged for the next call.

# --- In-place side-effect ---
# insertion_sort (and bubble_sort) modify the list in place and return the same list.
# calling them without a copy permanently changes the original list.
victim = [5, 2, 4, 6, 1, 3]
result = insertion_sort(victim)
print('return value :', result)   # sorted list (same object as victim)
print('victim after :', victim)   # [1, 2, 3, 4, 5, 6] — original is gone!

print()

# --- Consistent demo using copies ---
# because insertion_sort and bubble_sort mutate their argument, we pass demo[:]
# (a copy) so that demo stays intact for each subsequent call.
demo = [5, 2, 4, 6, 1, 3]
print('insertion:', insertion_sort(demo[:]))  # copy; in-place mutates input
print('merge    :', merge_sort(demo))          # new list; demo unchanged
print('bubble   :', bubble_sort(demo[:]))      # copy; in-place mutates input
print('quick    :', quicksort(demo))           # new list; demo unchanged
print('builtin  :', sorted(demo))              # new list; demo unchanged
print()
print('demo unchanged:', demo)                 # still [5, 2, 4, 6, 1, 3]
return value : [1, 2, 3, 4, 5, 6]
victim after : [1, 2, 3, 4, 5, 6]

insertion: [1, 2, 3, 4, 5, 6]
merge    : [1, 2, 3, 4, 5, 6]
bubble   : [1, 2, 3, 4, 5, 6]
quick    : [1, 2, 3, 4, 5, 6]
builtin  : [1, 2, 3, 4, 5, 6]

demo unchanged: [5, 2, 4, 6, 1, 3]

13.3.5.2. Complexity Growth#

The comparison table above names the Big O of each algorithm. The table below makes those classes concrete: for large \(n\), the gap between \(O(n^2)\) and \(O(n \log n)\) is not a small constant factor — it is the difference between feasible and impractical.

import math

sizes = [10, 100, 1_000, 10_000, 100_000, 1_000_000]
print(f"{'n':>8} {'log2(n)':>10} {'n (O(n))':>12} {'n*log2(n)':>14} {'n^2':>14}")
for n in sizes:
    print(f"{n:>8} {math.log2(n):>10.2f} {n:>12} {n*math.log2(n):>14.2f} {n**2:>14}")
       n    log2(n)     n (O(n))      n*log2(n)            n^2
      10       3.32           10          33.22            100
     100       6.64          100         664.39          10000
    1000       9.97         1000        9965.78        1000000
   10000      13.29        10000      132877.12      100000000
  100000      16.61       100000     1660964.05    10000000000
 1000000      19.93      1000000    19931568.57  1000000000000

13.3.5.3. Sorting Cheat Sheet#

The table below summarizes time complexity, space complexity, and practical use cases. It includes both algorithms implemented here and a few additional reference algorithms for broader comparison.

Algorithm

Category

Time (best)

Time (avg)

Time (worst)

Space

In-place?

Stable?

Recommended for

Insertion sort

Naive

O(n)

O(n²)

O(n²)

O(1)

Yes

Yes

Small or nearly-sorted data

Bubble sort

Naive

O(n)

O(n²)

O(n²)

O(1)

Yes

Yes

Teaching only

Selection sort

Naive

O(n²)

O(n²)

O(n²)

O(1)

Yes

No

Teaching only

Shell sort

Hybrid

O(n log n)†

Varies by gap sequence

O(n²)

O(1)

Yes

No

Moderate data; memory-constrained cases

Smooth sort

Hybrid

O(n)

O(n log n)

O(n log n)

O(1)

Yes

No

Nearly-sorted data; rare in practice

Merge sort

Efficient

O(n log n)

O(n log n)

O(n log n)

O(n)

No

Yes

Stable sort required; linked lists

Quicksort

Efficient

O(n log n)

O(n log n)

O(n²)

O(log n)*

No (here)

No

General-purpose; typically fastest

Heapsort

Efficient

O(n log n)

O(n log n)

O(n log n)

O(1)

Yes

No

Guaranteed O(n log n); no extra memory

Timsort

Efficient

O(n)

O(n log n)

O(n log n)

O(n)

No

Yes

Python’s built-in; best real-world choice

Python sorted()/.sort()

Built-in

O(n)

O(n log n)

O(n log n)

O(n)*

.sort() mutates

Yes

Default choice for all real code

* Quicksort space O(log n) is the call stack depth for average case; worst case stack is O(n). For this notebook’s out-of-place implementation, additional list allocation is also used.
† Shell-sort bounds depend on the chosen gap sequence; values shown are common teaching approximations.

Default advice: Prefer Python’s built-in sorted() or .sort() in production code. Hand-written sorting implementations are usually for learning, analysis, or specialized needs, not everyday use. Study algorithms to understand trade-offs, Big \(O\) reasoning, and algorithm design — knowledge that transfers beyond Python.

13.3.6. Evaluation#

This section moves from correctness checks to performance benchmarking and then to non-comparison sorting approaches.

13.3.6.1. Validating Correctness#

All four implementations must return the same result as Python’s built-in sorted() for any valid input. Randomized testing is used rather than hand-picked cases because it exercises orderings and value distributions that manual test design tends to miss — including duplicates, already-sorted inputs, and reverse-sorted inputs across many sizes. Always run correctness checks before timing: a subtle bug that only surfaces on certain inputs could silently corrupt the benchmark results.

for _ in range(50):                                         ### a loop of 50 random checks to catch edge cases
    data = [random.randint(0, 1000) for _ in range(60)]     ### generate a list of 60 random integers between 0 and 1000
    expected = sorted(data)                                 ### sort list using Python's built-in sorted()      

    assert insertion_sort(data[:]) == expected
    assert merge_sort(data) == expected
    assert bubble_sort(data[:]) == expected
    assert quicksort(data) == expected

print('All randomized checks passed.')
All randomized checks passed.

13.3.6.2. Input Pattern#

Pattern matters. Compare random, sorted, reverse, and duplicate-heavy data.

Timing continuity:

  • time.time() works for basic measurement.

  • time.perf_counter() is preferred in these short benchmarks for better precision.

Connection to Chapter 08:

  • As before, use timing to support Big O intuition rather than focusing on tiny decimal differences.

(See also 1002-searching.ipynb for the 30-repeat timing utility and a discussion of preprocessing cost.)

Predict before running: For each algorithm, which input pattern do you expect to be fastest — random, sorted, reverse, or duplicates? Write your predictions before executing the cell.

Specific questions to consider:

  • Insertion sort is \(O(n)\) on already-sorted data. Which column should confirm this?

  • Bubble sort has an early-exit flag. Should sorted input be faster than reverse-sorted?

  • Quicksort with a random pivot should not degrade on any particular pattern — do the numbers agree?

# Shared timing utility — see also 1002-searching.ipynb for the 30-repeat version.
def average_runtime(fn, *args, repeats=8):
    total = 0.0
    for _ in range(repeats):
        start = perf_counter()
        fn(*args)
        total += perf_counter() - start
    return total / repeats

n = 1200
random_data = [random.randint(0, 10_000) for _ in range(n)]
sorted_data = sorted(random_data)
reverse_data = sorted_data[::-1]
duplicate_data = [random.choice([1, 2, 3, 4, 5]) for _ in range(n)]

datasets = [
    ('random', random_data),
    ('sorted', sorted_data),
    ('reverse', reverse_data),
    ('duplicates', duplicate_data),
]

print(f"{'pattern':>12} {'bubble':>10} {'insertion':>12} {'merge':>10} {'quick':>10} {'builtin':>10}")
for label, data in datasets:
    t_bubble = average_runtime(lambda d: bubble_sort(d[:]), data)
    t_insert = average_runtime(lambda d: insertion_sort(d[:]), data)
    t_merge = average_runtime(merge_sort, data)
    t_quick = average_runtime(quicksort, data)
    t_builtin = average_runtime(sorted, data)
    print(f"{label:>12} {t_bubble:>10.6f} {t_insert:>12.6f} {t_merge:>10.6f} {t_quick:>10.6f} {t_builtin:>10.6f}")
     pattern     bubble    insertion      merge      quick    builtin
      random   0.031566     0.014358   0.001012   0.000995   0.000036
      sorted   0.000032     0.000065   0.000722   0.000906   0.000003
     reverse   0.038851     0.027725   0.000751   0.000998   0.000004
  duplicates   0.029354     0.012030   0.001040   0.000128   0.000029

13.3.6.3. Beyond Comparison#

From the timing table above, even the fastest comparison-based methods still scale around \(O(n \log n)\) as input grows.

All four algorithms above sort by comparing pairs of elements. Comparison sorting has a proven theoretical lower bound of \(O(n \log n)\) in the general case.

Counting sort sidesteps this limit entirely. Instead of comparing elements, it uses each element’s value as an array index, tallying how many times each value appears, then reconstructing the sorted output. This runs in \(O(n + k)\) where \(k\) is the range of input values.

The trade-off: counting sort only works on integers (or values mappable to a bounded integer range). It cannot sort arbitrary objects, floats, or strings directly. When \(k\) is small relative to \(n\) (e.g., sorting exam scores 0–100 for thousands of students), it is significantly faster than any comparison sort.

### Exercise: Counting Sort
#   Unlike comparison-based sorts, counting sort runs in O(n + k) where k is the value range.
#   Implement `counting_sort_nonnegative(items)`:
#   1. Accept only nonnegative integers; raise `ValueError` otherwise.
#   2. Return a new sorted list (do not sort in place).
#   3. As a comment, explain when counting sort is preferable to merge sort.
### Your code starts here.




### Your code ends here.

Hide code cell source

### Solution

def counting_sort_nonnegative(items):
    if not items:
        return []

    if any(x < 0 for x in items):  # Guard clause: validate before any allocation.
        raise ValueError('counting_sort_nonnegative only supports nonnegative integers')

    max_value = max(items)
    counts = [0] * (max_value + 1)

    for value in items:
        counts[value] += 1

    result = []
    for value, freq in enumerate(counts):
        result.extend([value] * freq)
    return result

exercise_data = [3, 1, 2, 1, 0, 3, 2]
print(counting_sort_nonnegative(exercise_data))

# Verification
assert counting_sort_nonnegative([]) == []
assert counting_sort_nonnegative([1]) == [1]
assert counting_sort_nonnegative([3, 1, 2, 1, 0]) == [0, 1, 1, 2, 3]
try:
    counting_sort_nonnegative([-1, 3])
    assert False, 'Should have raised ValueError'
except ValueError:
    pass
print('counting_sort_nonnegative checks passed.')
[0, 1, 1, 2, 2, 3, 3]
counting_sort_nonnegative checks passed.