Sorting Algorithms
Organize data to enable efficient access and computation.
1. Comparison sorts: quick/merge/heap
The three most important O(n log n) comparison-based sorting algorithms:
Quick Sort:
- Approach: Divide and conquer using partitioning
- Time: O(n log n) average, O(n²) worst case
- Space: O(log n) for recursion stack
- In-place: Yes
- Stable: No
Implementation:
def quick_sort(arr, low, high):
"""Sort array using quick sort"""
if low < high:
# Partition and get pivot index
pivot_idx = partition(arr, low, high)
# Recursively sort left and right
quick_sort(arr, low, pivot_idx - 1)
quick_sort(arr, pivot_idx + 1, high)
def partition(arr, low, high):
"""Lomuto partition scheme"""
pivot = arr[high] # Choose last element as pivot
i = low - 1 # Index of smaller element
for j in range(low, high):
if arr[j] <= pivot:
i += 1
arr[i], arr[j] = arr[j], arr[i]
# Place pivot in correct position
arr[i + 1], arr[high] = arr[high], arr[i + 1]
return i + 1
# Usage
arr = [64, 34, 25, 12, 22, 11, 90]
quick_sort(arr, 0, len(arr) - 1)
Hoare partition (more efficient):
def partition_hoare(arr, low, high):
"""Hoare partition scheme - fewer swaps"""
pivot = arr[low]
i = low - 1
j = high + 1
while True:
i += 1
while arr[i] < pivot:
i += 1
j -= 1
while arr[j] > pivot:
j -= 1
if i >= j:
return j
arr[i], arr[j] = arr[j], arr[i]
Optimizations:
def quick_sort_optimized(arr, low, high):
"""Quick sort with optimizations"""
# Use insertion sort for small subarrays
if high - low < 10:
insertion_sort(arr, low, high)
return
if low < high:
# Median-of-three pivot selection
mid = (low + high) // 2
if arr[mid] < arr[low]:
arr[low], arr[mid] = arr[mid], arr[low]
if arr[high] < arr[low]:
arr[low], arr[high] = arr[high], arr[low]
if arr[mid] < arr[high]:
arr[mid], arr[high] = arr[high], arr[mid]
pivot_idx = partition(arr, low, high)
quick_sort_optimized(arr, low, pivot_idx - 1)
quick_sort_optimized(arr, pivot_idx + 1, high)
Merge Sort:
- Approach: Divide and conquer with merging
- Time: O(n log n) always
- Space: O(n) for auxiliary array
- In-place: No
- Stable: Yes
Implementation:
def merge_sort(arr):
"""Sort array using merge sort"""
if len(arr) <= 1:
return arr
# Divide
mid = len(arr) // 2
left = merge_sort(arr[:mid])
right = merge_sort(arr[mid:])
# Conquer (merge)
return merge(left, right)
def merge(left, right):
"""Merge two sorted arrays"""
result = []
i = j = 0
# Merge while both have elements
while i < len(left) and j < len(right):
if left[i] <= right[j]:
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
# Add remaining elements
result.extend(left[i:])
result.extend(right[j:])
return result
In-place merge sort (more complex):
def merge_sort_inplace(arr, left, right):
"""In-place merge sort"""
if left < right:
mid = (left + right) // 2
merge_sort_inplace(arr, left, mid)
merge_sort_inplace(arr, mid + 1, right)
merge_inplace(arr, left, mid, right)
def merge_inplace(arr, left, mid, right):
"""Merge in-place using O(n) extra space"""
temp = []
i, j = left, mid + 1
while i <= mid and j <= right:
if arr[i] <= arr[j]:
temp.append(arr[i])
i += 1
else:
temp.append(arr[j])
j += 1
temp.extend(arr[i:mid+1])
temp.extend(arr[j:right+1])
for i, val in enumerate(temp):
arr[left + i] = val
Heap Sort:
- Approach: Build max heap, repeatedly extract maximum
- Time: O(n log n) always
- Space: O(1)
- In-place: Yes
- Stable: No
Implementation:
def heap_sort(arr):
"""Sort array using heap sort"""
n = len(arr)
# Build max heap
for i in range(n // 2 - 1, -1, -1):
heapify(arr, n, i)
# Extract elements from heap
for i in range(n - 1, 0, -1):
# Move current root to end
arr[0], arr[i] = arr[i], arr[0]
# Heapify reduced heap
heapify(arr, i, 0)
def heapify(arr, n, i):
"""Maintain max heap property"""
largest = i
left = 2 * i + 1
right = 2 * i + 2
# Check if left child is larger
if left < n and arr[left] > arr[largest]:
largest = left
# Check if right child is larger
if right < n and arr[right] > arr[largest]:
largest = right
# If largest is not root
if largest != i:
arr[i], arr[largest] = arr[largest], arr[i]
heapify(arr, n, largest)
Comparison:
| Algorithm | Best | Average | Worst | Space | Stable | In-place |
|---|---|---|---|---|---|---|
| Quick Sort | O(n log n) | O(n log n) | O(n²) | O(log n) | No | Yes |
| Merge Sort | O(n log n) | O(n log n) | O(n log n) | O(n) | Yes | No |
| Heap Sort | O(n log n) | O(n log n) | O(n log n) | O(1) | No | Yes |
2. Stability and in-place properties
Understanding important sorting algorithm characteristics:
Stability:
- Definition: Maintains relative order of equal elements
- Example: Sorting [(3, “a”), (1, “b”), (3, “c”)] by first element
- Stable: [(1, “b”), (3, “a”), (3, “c”)] - “a” before “c”
- Unstable: [(1, “b”), (3, “c”), (3, “a”)] - order changed
Why stability matters:
# Example: Sort students by grade, then by name
students = [
("Alice", 85),
("Bob", 90),
("Charlie", 85),
("David", 90)
]
# First sort by name (stable)
students.sort(key=lambda x: x[0])
# [("Alice", 85), ("Bob", 90), ("Charlie", 85), ("David", 90)]
# Then sort by grade (stable)
students.sort(key=lambda x: x[1])
# [("Alice", 85), ("Charlie", 85), ("Bob", 90), ("David", 90)]
# Within same grade, alphabetical order preserved!
Stable algorithms:
- ✓ Merge Sort
- ✓ Insertion Sort
- ✓ Bubble Sort
- ✓ Counting Sort
- ✓ Radix Sort
Unstable algorithms:
- ✗ Quick Sort (can be made stable with extra space)
- ✗ Heap Sort
- ✗ Selection Sort
Making Quick Sort stable:
def stable_quick_sort(arr):
"""Stable quick sort using extra space"""
if len(arr) <= 1:
return arr
pivot = arr[len(arr) // 2]
# Maintain order of equal elements
left = [x for x in arr if x < pivot]
middle = [x for x in arr if x == pivot]
right = [x for x in arr if x > pivot]
return stable_quick_sort(left) + middle + stable_quick_sort(right)
In-place sorting:
- Definition: Uses O(1) extra space (excluding recursion stack)
- Benefit: Memory-efficient for large datasets
In-place algorithms:
- ✓ Quick Sort: O(log n) stack space
- ✓ Heap Sort: O(1) extra space
- ✓ Insertion Sort: O(1) extra space
- ✓ Selection Sort: O(1) extra space
- ✓ Bubble Sort: O(1) extra space
Not in-place:
- ✗ Merge Sort: O(n) auxiliary array
- ✗ Counting Sort: O(k) where k is range
- ✗ Radix Sort: O(n + k)
Trade-offs:
# In-place but unstable
heap_sort(arr) # O(1) space, unstable
# Stable but not in-place
merge_sort(arr) # O(n) space, stable
# For most practical purposes:
arr.sort() # Python's Timsort: stable, O(n) space worst case
3. Partitioning and recursion
Core concepts in divide-and-conquer sorting:
Partitioning:
- Goal: Rearrange array so elements < pivot are left, elements > pivot are right
- Key operation: In Quick Sort
Lomuto partition:
def lomuto_partition(arr, low, high):
"""
Simple but does more swaps
Pivot: last element
"""
pivot = arr[high]
i = low - 1
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
# Example: [7, 2, 1, 6, 8, 5, 3, 4]
# Pivot = 4
# After partition: [2, 1, 3, 4, 8, 5, 7, 6]
# ↑ pivot at index 3
Hoare partition:
def hoare_partition(arr, low, high):
"""
More efficient, fewer swaps
Pivot: first element
"""
pivot = arr[low]
i = low - 1
j = high + 1
while True:
# Find element >= pivot from left
i += 1
while arr[i] < pivot:
i += 1
# Find element <= pivot from right
j -= 1
while arr[j] > pivot:
j -= 1
if i >= j:
return j
arr[i], arr[j] = arr[j], arr[i]
3-way partitioning (Dutch National Flag):
def three_way_partition(arr, low, high):
"""
Partition into <pivot, =pivot, >pivot
Efficient for many duplicates
"""
if low >= high:
return
pivot = arr[high]
i = low # Boundary of < pivot
j = low # Current element
k = high # Boundary of > pivot
while j <= k:
if arr[j] < pivot:
arr[i], arr[j] = arr[j], arr[i]
i += 1
j += 1
elif arr[j] > pivot:
arr[j], arr[k] = arr[k], arr[j]
k -= 1
else:
j += 1
return i, k
# Example: [3, 5, 2, 5, 1, 5, 4, 5]
# Pivot = 5
# After: [3, 2, 1, 4, 5, 5, 5, 5]
# ↑ all 5s together
Recursion in sorting:
Recursion tree for Quick Sort:
Array: [8, 3, 1, 7, 0, 10, 2]
[8,3,1,7,0,10,2]
↓ partition(pivot=2)
[1,0,2,7,3,10,8]
/ \
[1,0] [7,3,10,8]
↓ pivot=0 ↓ pivot=8
[0,1] [7,3,8,10]
/ \
[7,3] [10]
↓ pivot=3
[3,7]
Depth: O(log n) average, O(n) worst case
Tail recursion optimization:
def quick_sort_tail_recursive(arr, low, high):
"""Optimize tail recursion to reduce stack space"""
while low < high:
pivot_idx = partition(arr, low, high)
# Recurse on smaller partition
if pivot_idx - low < high - pivot_idx:
quick_sort_tail_recursive(arr, low, pivot_idx - 1)
low = pivot_idx + 1
else:
quick_sort_tail_recursive(arr, pivot_idx + 1, high)
high = pivot_idx - 1
Iterative Quick Sort (no recursion):
def quick_sort_iterative(arr):
"""Quick sort without recursion"""
stack = [(0, len(arr) - 1)]
while stack:
low, high = stack.pop()
if low < high:
pivot_idx = partition(arr, low, high)
# Push subproblems to stack
stack.append((low, pivot_idx - 1))
stack.append((pivot_idx + 1, high))
4. Time/space complexities
Comprehensive complexity analysis:
Time complexity table:
| Algorithm | Best | Average | Worst | Notes |
|---|---|---|---|---|
| Bubble Sort | O(n) | O(n²) | O(n²) | Best when nearly sorted |
| Selection Sort | O(n²) | O(n²) | O(n²) | Always O(n²) |
| Insertion Sort | O(n) | O(n²) | O(n²) | Best for small/nearly sorted |
| Merge Sort | O(n log n) | O(n log n) | O(n log n) | Consistent performance |
| Quick Sort | O(n log n) | O(n log n) | O(n²) | Worst with bad pivot |
| Heap Sort | O(n log n) | O(n log n) | O(n log n) | Consistent, in-place |
| Counting Sort | O(n + k) | O(n + k) | O(n + k) | k = range of values |
| Radix Sort | O(d(n + k)) | O(d(n + k)) | O(d(n + k)) | d = number of digits |
| Bucket Sort | O(n + k) | O(n + k) | O(n²) | Depends on distribution |
Space complexity:
| Algorithm | Space | Type |
|---|---|---|
| Bubble Sort | O(1) | In-place |
| Selection Sort | O(1) | In-place |
| Insertion Sort | O(1) | In-place |
| Merge Sort | O(n) | Not in-place |
| Quick Sort | O(log n) | In-place (stack) |
| Heap Sort | O(1) | In-place |
| Counting Sort | O(k) | Not in-place |
| Radix Sort | O(n + k) | Not in-place |
Lower bound for comparison sorts:
# Theorem: Any comparison-based sort needs Ω(n log n) comparisons
# Proof: Decision tree has n! leaves (all permutations)
# Height >= log₂(n!) ≈ n log n
import math
def min_comparisons(n):
"""Minimum comparisons needed to sort n elements"""
return math.ceil(math.log2(math.factorial(n)))
# Examples:
# n=10: ~22 comparisons
# n=100: ~525 comparisons
Practical performance:
import time
import random
def benchmark_sorts(n=10000):
"""Compare sorting algorithms"""
arr = [random.randint(1, 1000) for _ in range(n)]
# Quick Sort
arr1 = arr.copy()
start = time.time()
quick_sort(arr1, 0, len(arr1) - 1)
print(f"Quick Sort: {time.time() - start:.4f}s")
# Merge Sort
arr2 = arr.copy()
start = time.time()
merge_sort(arr2)
print(f"Merge Sort: {time.time() - start:.4f}s")
# Heap Sort
arr3 = arr.copy()
start = time.time()
heap_sort(arr3)
print(f"Heap Sort: {time.time() - start:.4f}s")
# Python's built-in (Timsort)
arr4 = arr.copy()
start = time.time()
arr4.sort()
print(f"Timsort: {time.time() - start:.4f}s")
# Typical results (n=10000):
# Quick Sort: 0.0120s
# Merge Sort: 0.0180s
# Heap Sort: 0.0250s
# Timsort: 0.0015s (highly optimized C implementation)
5. Non-comparison sorts: counting/radix
Sorting algorithms that don’t compare elements:
Counting Sort:
- Idea: Count occurrences of each value
- Time: O(n + k) where k = range of values
- Space: O(k)
- Stable: Yes
- Limitation: Only works for integers in known range
Implementation:
def counting_sort(arr):
"""Sort array of non-negative integers"""
if not arr:
return arr
# Find range
max_val = max(arr)
min_val = min(arr)
range_size = max_val - min_val + 1
# Count occurrences
count = [0] * range_size
for num in arr:
count[num - min_val] += 1
# Calculate cumulative count
for i in range(1, range_size):
count[i] += count[i - 1]
# Build output array (stable)
output = [0] * len(arr)
for i in range(len(arr) - 1, -1, -1):
num = arr[i]
index = count[num - min_val] - 1
output[index] = num
count[num - min_val] -= 1
return output
# Example: [4, 2, 2, 8, 3, 3, 1]
# Count: [1, 2, 2, 1, 0, 0, 0, 1] for values 1-8
# Output: [1, 2, 2, 3, 3, 4, 8]
When to use:
- Small range of integers (k ≈ n)
- Need stable sort
- Need O(n) time
Radix Sort:
- Idea: Sort digit by digit using stable sort
- Time: O(d(n + k)) where d = digits, k = base
- Space: O(n + k)
- Stable: Yes
Implementation (LSD - Least Significant Digit):
def radix_sort(arr):
"""Sort array using radix sort (base 10)"""
if not arr:
return arr
# Find maximum number to know number of digits
max_num = max(arr)
# Do counting sort for every digit
exp = 1
while max_num // exp > 0:
counting_sort_by_digit(arr, exp)
exp *= 10
def counting_sort_by_digit(arr, exp):
"""Counting sort by specific digit"""
n = len(arr)
output = [0] * n
count = [0] * 10 # Base 10
# Count occurrences of digits
for num in arr:
digit = (num // exp) % 10
count[digit] += 1
# Cumulative count
for i in range(1, 10):
count[i] += count[i - 1]
# Build output (stable)
for i in range(n - 1, -1, -1):
digit = (arr[i] // exp) % 10
output[count[digit] - 1] = arr[i]
count[digit] -= 1
# Copy to original array
for i in range(n):
arr[i] = output[i]
# Example: [170, 45, 75, 90, 802, 24, 2, 66]
# Pass 1 (1s): [170, 90, 802, 2, 24, 45, 75, 66]
# Pass 2 (10s): [802, 2, 24, 45, 66, 170, 75, 90]
# Pass 3 (100s): [2, 24, 45, 66, 75, 90, 170, 802]
Bucket Sort:
- Idea: Distribute elements into buckets, sort each bucket
- Time: O(n + k) average, O(n²) worst
- Best for: Uniformly distributed data
Implementation:
def bucket_sort(arr):
"""Sort array using bucket sort"""
if not arr:
return arr
# Create buckets
bucket_count = len(arr)
max_val = max(arr)
min_val = min(arr)
bucket_range = (max_val - min_val) / bucket_count
buckets = [[] for _ in range(bucket_count)]
# Distribute elements into buckets
for num in arr:
if num == max_val:
buckets[-1].append(num)
else:
index = int((num - min_val) / bucket_range)
buckets[index].append(num)
# Sort each bucket and concatenate
result = []
for bucket in buckets:
result.extend(sorted(bucket)) # Use any sort
return result
Comparison:
| Algorithm | Best Use Case | Limitation |
|---|---|---|
| Counting Sort | Small integer range | Requires known range |
| Radix Sort | Fixed-length integers/strings | Not for arbitrary data |
| Bucket Sort | Uniform distribution | Poor for skewed data |
6. Practical considerations
Real-world factors in choosing sorting algorithms:
Python’s Timsort:
- Used in: Python’s
sort()andsorted() - Hybrid: Merge sort + Insertion sort
- Time: O(n log n) worst case, O(n) best case
- Stable: Yes
- Optimized for: Real-world data with runs
Key features:
# Timsort exploits existing order
arr = [1, 2, 3, 4, 5, 100, 99, 98, 97, 96]
# Detects two runs: [1,2,3,4,5] and [100,99,98,97,96]
# Reverses second run, merges efficiently
# Much faster than O(n log n) for partially sorted data
When to use each algorithm:
Small arrays (n < 50):
def sort_small(arr):
"""Insertion sort for small arrays"""
for i in range(1, len(arr)):
key = arr[i]
j = i - 1
while j >= 0 and arr[j] > key:
arr[j + 1] = arr[j]
j -= 1
arr[j + 1] = key
Nearly sorted data:
# Insertion sort: O(n) for nearly sorted
# Timsort: Excellent for real-world data
Large arrays:
# Quick sort: Fastest average case
# Merge sort: Guaranteed O(n log n), stable
# Heap sort: In-place, guaranteed O(n log n)
Limited memory:
# Heap sort: O(1) extra space
# Quick sort: O(log n) stack space
Need stability:
# Merge sort
# Timsort
# Counting/Radix sort
Optimization techniques:
1. Hybrid approaches:
def hybrid_sort(arr, low, high):
"""Quick sort with insertion sort for small subarrays"""
if high - low < 10:
insertion_sort(arr, low, high)
else:
pivot = partition(arr, low, high)
hybrid_sort(arr, low, pivot - 1)
hybrid_sort(arr, pivot + 1, high)
2. Pivot selection:
def median_of_three(arr, low, high):
"""Choose median of first, middle, last"""
mid = (low + high) // 2
if arr[mid] < arr[low]:
arr[low], arr[mid] = arr[mid], arr[low]
if arr[high] < arr[low]:
arr[low], arr[high] = arr[high], arr[low]
if arr[mid] < arr[high]:
arr[mid], arr[high] = arr[high], arr[mid]
return high
3. Parallel sorting:
from multiprocessing import Pool
def parallel_merge_sort(arr):
"""Parallel merge sort using multiple cores"""
if len(arr) <= 1000:
return sorted(arr)
mid = len(arr) // 2
with Pool(2) as pool:
left, right = pool.map(parallel_merge_sort,
[arr[:mid], arr[mid:]])
return merge(left, right)
Common mistakes:
# 1. Using bubble sort for large data
# BAD: O(n²) always
bubble_sort(large_array)
# GOOD: O(n log n)
large_array.sort()
# 2. Not considering stability
# BAD: May break secondary sort
quick_sort(students) # Unstable
# GOOD: Preserves order
students.sort() # Stable (Timsort)
# 3. Ignoring data characteristics
# BAD: Quick sort on already sorted data
quick_sort(sorted_array) # O(n²) worst case!
# GOOD: Insertion sort or Timsort
insertion_sort(sorted_array) # O(n) for sorted
# 4. Wrong algorithm for data type
# BAD: Comparison sort for small integers
merge_sort([1, 2, 3, 1, 2, 3]) # O(n log n)
# GOOD: Counting sort
counting_sort([1, 2, 3, 1, 2, 3]) # O(n)
Decision tree:
def choose_sort_algorithm(arr, need_stable=False, memory_limited=False):
"""
Choose appropriate sorting algorithm
"""
n = len(arr)
if n < 50:
return "Insertion Sort"
# Check if integers in small range
if all(isinstance(x, int) for x in arr):
range_size = max(arr) - min(arr) + 1
if range_size < 2 * n:
return "Counting Sort"
if memory_limited:
if need_stable:
return "Merge Sort (in-place variant)"
else:
return "Heap Sort"
if need_stable:
return "Merge Sort or Timsort"
return "Quick Sort (with optimizations)"