Elementary Sorting Algorithms
Sorting algorithms arrange the elements of a list in a specific order (ascending or descending). We will begin with three elementary sorting algorithms: bubble sort, selection sort, and insertion sort.
Bubble Sort
Algorithm
Bubble sort works by repeatedly stepping through the list, comparing adjacent elements and swapping them if they are in the wrong order. This process is repeated until the list is sorted. Larger elements “bubble” to the end of the list.
Python Implementation
Time Complexity
- Worst-case: - When the list is in reverse order.
- Best-case: - When the list is already sorted. A check can be added to stop if no swaps are performed in a pass.
- Average-case: - On average, we expect a quadratic number of comparisons and swaps.
Correctness Proof (Loop Invariant)
Invariant: At the start of each iteration of the outer loop (indexed by i), the last i elements of the list (data[n-i...n-1]
) are sorted and contain the i largest elements of the original list.
- Initialization: Before the outer loop starts (i=0), the last 0 elements are trivially sorted, and there are no “largest elements” placed yet. So, the invariant holds.
- Maintenance: Suppose the invariant holds at the beginning of the outer loop iteration i. In the inner loop (indexed by j), adjacent elements are compared, and swapped if they are in the wrong order. After the i-th iteration of outer loop, the (n-i)th largest element will be bubbled up to
data[n-i-1]
. Therefore,data[n-i-1...n-1]
will contain the i+1 largest elements, sorted. This ensures that the invariant holds for the next iteration (i+1). - Termination: The outer loop terminates when i = n. At this point, the invariant states that the last n elements (which is the entire list) are sorted and contain the n largest elements (which is all of them). Hence, the entire list is sorted.
Selection Sort
Algorithm
Selection sort divides the list into two parts: sorted and unsorted. It repeatedly selects the smallest (or largest) element from the unsorted part and moves it to the end of the sorted part.
Python Implementation
Time Complexity
- Worst-case: - In each iteration, we scan the remaining unsorted part to find the minimum element.
- Best-case: - The number of comparisons is always the same, irrespective of the initial order.
- Average-case: - Similar to the worst case.
Correctness Proof (Loop Invariant)
Invariant: At the start of each iteration of the outer loop (indexed by i), the first i elements of the list (data[0...i-1]
) are sorted and contain the i smallest elements of the original list, and all elements in data[i...n-1]
are greater than or equal to the elements in data[0...i-1]
.
- Initialization: Before the outer loop starts (i=0), the first 0 elements are trivially sorted and smallest, and there are no constraints on
data[0...n-1]
. So, the invariant holds. - Maintenance: Suppose the invariant holds at the beginning of iteration i. The inner loop (indexed by j) finds the index
min_index
of the smallest element indata[i...n-1]
. We then swapdata[i]
withdata[min_index]
. Now,data[0...i]
contains the i+1 smallest elements, sorted, and all elements indata[i+1...n-1]
are greater than or equal to those indata[0...i]
. Thus the invariant holds for the next iteration. - Termination: The outer loop terminates when i = n. The invariant then states that the first n elements (entire list) are sorted and contains the n smallest elements (all elements), thus the entire list is sorted.
Insertion Sort
Algorithm
Insertion sort also divides the list into sorted and unsorted parts. It iterates through the unsorted part and inserts each element into its correct position in the sorted part by shifting greater elements to the right.
Python Implementation
Time Complexity
- Worst-case: - When the list is in reverse order, each element has to be shifted to the beginning.
- Best-case: - When the list is already sorted, each element is compared only once.
- Average-case: - On average, we expect to shift half of the sorted part for each element.
Correctness Proof (Loop Invariant)
Invariant: At the start of each iteration of the outer loop (indexed by i), the sublist data[0...i-1]
is sorted.
- Initialization: Before the outer loop starts (i=1),
data[0...0]
is a sublist of size 1, which is trivially sorted. So the invariant holds. - Maintenance: Suppose the invariant holds at the beginning of iteration i. We pick
key = data[i]
and want to insert it into its correct sorted position withindata[0...i-1]
. The inner while loop shifts elements indata[0...i-1]
that are greater thankey
one position to the right, creating a space forkey
. When the while loop terminates, we have found the position j+1 such that eitherj
becomes-1
(meaningkey
is the smallest element seen so far and must be at index 0), or thatdata[j]
is less than or equal tokey
. Thus, insertingkey
at positionj+1
ensures thatdata[0...i]
is sorted. Hence, the invariant holds for the next iteration. - Termination: The outer loop terminates when i = n. At this point, the invariant states that
data[0...n-1]
(which is the entire list) is sorted. Thus the entire list has been sorted.
Advanced Sorting Algorithms
We now move on to more advanced sorting algorithms that offer better time complexity, especially for larger datasets: merge sort, quicksort, and heapsort.
Merge Sort
Algorithm
Merge sort is a divide-and-conquer algorithm. It works by recursively dividing the list into two halves until each sublist contains only one element (which is considered sorted). Then, it repeatedly merges the sorted sublists to produce new sorted sublists until there is only one sorted list remaining.
Python Implementation
Time Complexity
- Worst-case: - The list is divided logarithmically, and merging takes linear time.
- Best-case: - Same as the worst case, as the algorithm always divides and merges.
- Average-case: - On average, the performance is similar to the worst case.
Correctness Proof (Inductive Proof)
Base Case: If the length of the list is 0 or 1, it is already sorted. The algorithm does nothing, and thus the sorted list is returned (correctly).
Inductive Hypothesis: Assume that merge_sort
correctly sorts all sublists of size less than n, for n>1.
Inductive Step: Consider a list of size n (n>1). The list is split into two sublists, left_half
and right_half
. Since both sublists have size less than n, by the inductive hypothesis, the calls merge_sort(left_half)
and merge_sort(right_half)
correctly sort these sublists.
Now, we need to show that merging the two sorted sublists results in a correctly sorted list. This is proven by loop invariant for merge process :
Invariant (for merge): At the start of each iteration of the merge while loop, the data[0...k-1]
contains the smallest k
elements from left_half
and right_half
in sorted order, and data[k...n-1]
is unchanged. Also, i
and j
are the indices of the next elements to be considered from left_half
and right_half
, respectively.
- Initialization: Before the while loop starts,
k=0
,i=0
,j=0
sodata[0...k-1]
is empty (trivially sorted), and i, j point to starts of respective arrays. - Maintenance: In each iteration, we compare
left_half[i]
andright_half[j]
. The smaller element is copied todata[k]
, and the respective index (i or j) and k are incremented. Sinceleft_half
andright_half
are sorted, the element copied is the next smallest element overall. Thusdata[0...k]
is sorted and contains thek+1
smallest elements and the invariant is maintained. - Termination: The loop terminates when either i reaches the end of
left_half
or j reaches the end ofright_half
. The remaining elements of the non-empty list are then copied into data (they are the next largest element as lists were sorted) ensuring the entiredata
is sorted.
Since the merge step is correct, and by the inductive hypothesis the sublists are correctly sorted, the list of size n is also correctly sorted. Therefore by induction, merge_sort
correctly sorts any list.
Quicksort
Algorithm
Quicksort is another divide-and-conquer algorithm. It works by selecting a ‘pivot’ element from the list and partitioning the other elements into two sublists, according to whether they are less than or greater than the pivot. The sublists are then recursively sorted.
Python Implementation
Time Complexity
- Worst-case: - When the pivot consistently divides the list very unevenly (e.g., one sublist has 0 elements and the other n-1 elements).
- Best-case: - When the pivot always divides the list into roughly equal sublists.
- Average-case: - On average, with random pivot selection, we expect a balanced partition.
Correctness Proof (Inductive Proof)
Base Case: If the list has size 0 or 1, it’s already sorted. The algorithm returns the list as is, which is correct.
Inductive Hypothesis: Assume that quicksort correctly sorts all sublists of size less than n, for n>1.
Inductive Step: Consider a list of size n (n>1). We pick a pivot and partition the list into three sublists: less
(elements less than the pivot), equal
(elements equal to the pivot), and greater
(elements greater than the pivot). Each of these sublists has size less than n. By the inductive hypothesis, quicksort(less)
and quicksort(greater)
correctly sort these sublists. Since less
contains elements smaller than pivot
and greater
contains elements larger than pivot
, the concatenation quicksort(less) + equal + quicksort(greater)
will result in a sorted list.
Therefore, by induction, quicksort
correctly sorts any list.
Heapsort
Algorithm
Heapsort uses a binary heap data structure to sort a list. It first builds a max-heap from the list, where the largest element is at the root. Then, it repeatedly removes the largest element from the heap and adds it to the end of the sorted part of the list. After removing the largest element, it restores the heap property.
Python Implementation
Time Complexity
- Worst-case: - Building the heap takes O(n) time, and each of the n extractions takes O(log n) time.
- Best-case: - Same as the worst case.
- Average-case: - Similar to the worst case.
Correctness Proof (Loop Invariant)
Invariant (for heapify
): At the start of each call to heapify(data, n, i)
, the binary trees rooted at the left and right children of node i are max-heaps, but the element at i might be smaller than its children.
-
Initialization: When
heapify
is first called, the subtrees at the children are either leaves (trivially max-heaps), or have been previously heapified, satisfying the invariant. -
Maintenance: The function identifies the largest element among the node i and its children. If the largest is not i, it swaps i with the largest child and recursively calls
heapify
on that child’s subtree. This restores the max-heap property in that subtree and ensures that the invariant holds for the next recursive call. -
Termination: The recursion terminates when the node i is a leaf node, or the largest element is already at i, which implies we have a max heap rooted at the original index i. At that time a max heap property is restored at i and below (in the subtree).
Invariant (for building max-heap): At the start of each iteration of the first for loop (in heapsort), all nodes with index greater than i are roots of max-heaps.
-
Initialization: Before the loop starts, for i from n//2 -1 to 0, consider the children of node i. Nodes greater than n//2 - 1 are leaves. Any node with only leaf as its children is trivially a max heap, so for i = n//2 -1 , children satisfy the invariant.
-
Maintenance: In each iteration,
heapify(data, n, i)
is called. The invariant for heapify ensures that after calling this function, the subtree rooted at i becomes a max-heap. After this, all nodes with index greater than i-1 are roots of max heaps. Decrementing i ensures that the invariant holds for the next iteration. -
Termination: The loop terminates when i = -1. At this point, the invariant states that all nodes (entire array from 0 to n-1) are roots of max-heaps. Hence,
data
is now a max-heap.
Invariant (for sorting): At the start of each iteration of the second for loop (in heapsort), the sublist data[i+1...n-1]
contains the i+1 largest elements in sorted order, and data[0...i]
forms a max-heap containing the remaining elements.
-
Initialization: Before the loop starts, the entire list
data[0...n-1]
is a max heap.data[n...n-1]
is empty, trivially containing the 0 largest elements in sorted order. The remainingdata[0...n-1]
forms a max heap. -
Maintenance: In each iteration, the largest element (root of the max-heap) is swapped with the last element of the heap part (
data[i]
). Now the last i+1 elements containsi+1
largest elements, but the root may not maintain the max heap property. Then the heap size is reduced by 1 (by consideringdata[0...i-1]
), andheapify(data, i, 0)
is called to restore the max-heap property on the remaining elementsdata[0...i-1]
. After this,data[i...n-1]
containsn-i
largest elements in sorted order anddata[0...i-1]
is a max heap. The invariant thus holds for the next iteration. -
Termination: The loop terminates when i = 0. At this point, the invariant states that
data[1...n-1]
contains the n-1 largest elements in sorted order, anddata[0]
contains the smallest element. Therefore, the entire listdata[0...n-1]
is sorted.