Heap Sort - A Deep Dive into an Efficient Sorting Algorithm

Sorting is a fundamental operation in computer science, and while many algorithms exist, some stand out for their efficiency and elegance. Heap Sort is one such algorithm, offering a guaranteed O(n log n) time complexity and the advantage of being an in-place sort.

But what exactly is Heap Sort, how does it work, and when should you reach for it? Let’s break it down.

Understanding the Foundation: The Heap Data Structure

Before we can grasp Heap Sort, we must first understand its namesake: the Heap data structure. Specifically, Heap Sort uses a Binary Heap.

A Binary Heap is a complete binary tree that satisfies the heap property:

  1. Complete Binary Tree: All levels of the tree are fully filled, except possibly the last level, which is filled from left to right. This property is crucial because it allows us to represent the tree efficiently using a simple array.
  2. Heap Property:
    • Max-Heap: For any given node i, the value of i is greater than or equal to the values of its children. The largest element is always at the root.
    • Min-Heap: For any given node i, the value of i is less than or equal to the values of its children. The smallest element is always at the root.

For Heap Sort, we primarily use a Max-Heap.

Array Representation of a Binary Heap

One of the most practical aspects of a binary heap is its representation using an array. Given a 0-indexed array A:

  • The root is at A[0].
  • For any node at index i:
    • Its left child is at A[2 * i + 1].
    • Its right child is at A[2 * i + 2].
    • Its parent is at A[floor((i - 1) / 2)].

This compact representation means we don’t need explicit pointers, saving memory and improving cache performance.

Core Heap Operations for Heap Sort

Heap Sort leverages two primary heap operations: heapify and build_heap.

1. heapify (or max_heapify)

The heapify operation is the cornerstone. Its purpose is to maintain the heap property. If a node i violates the heap property (e.g., it’s smaller than one of its children in a max-heap), heapify corrects it by moving the node down the tree until the property is restored.

How it works (for a Max-Heap):

  1. Compare the node at i with its left child (2*i + 1) and right child (2*i + 2).
  2. Find the largest among the three.
  3. If the largest is not the node i itself, swap A[i] with the largest child.
  4. Recursively call heapify on the subtree rooted at the swapped child to ensure the property holds down the tree.

This operation takes O(log n) time because it traverses a single path from the root down to a leaf in the heap, and the height of a binary heap is log n.

Let’s illustrate heapify with a Python example.

def max_heapify(arr, n, i):
    """
    Maintains the max-heap property for a subtree rooted at index i.
    arr: The array representing the heap.
    n: The current size of the heap (number of elements to consider).
    i: The index of the root of the subtree to heapify.
    """
    largest = i  # Initialize largest as root
    left_child = 2 * i + 1
    right_child = 2 * i + 2

    # Check if left child exists and is greater than root
    if left_child < n and arr[left_child] > arr[largest]:
        largest = left_child

    # Check if right child exists and is greater than current largest
    if right_child < n and arr[right_child] > arr[largest]:
        largest = right_child

    # If largest is not root, swap and recursively heapify the affected sub-tree
    if largest != i:
        arr[i], arr[largest] = arr[largest], arr[i]  # Swap
        max_heapify(arr, n, largest) # Recursively call on the affected sub-tree

print("--- Example: max_heapify ---")
# Example array where max_heapify(arr, 5, 0) needs to be called
# to fix the heap property at index 0
# Initial state: [3, 10, 5, 2, 8]
# Subtree rooted at index 0 (value 3)
#    3
#   / \
#  10  5
# /  \
# 2   8
my_array = [3, 10, 5, 2, 8]
print(f"Original array: {my_array}")
max_heapify(my_array, len(my_array), 0) # Heapify root (index 0)
print(f"Array after max_heapify(arr, 5, 0): {my_array}")

# Another example: fixing a subtree
my_array_2 = [10, 8, 9, 7, 5, 4, 3] # This is already a max-heap
print(f"\nOriginal array (already a heap): {my_array_2}")
my_array_2[0] = 2 # Introduce a violation at the root
print(f"Array after changing root: {my_array_2}")
max_heapify(my_array_2, len(my_array_2), 0)
print(f"Array after max_heapify(arr, 7, 0): {my_array_2}")
--- Example: max_heapify ---
Original array: [3, 10, 5, 2, 8]
Array after max_heapify(arr, 5, 0): [10, 8, 5, 2, 3]

Original array (already a heap): [10, 8, 9, 7, 5, 4, 3]
Array after changing root: [2, 8, 9, 7, 5, 4, 3]
Array after max_heapify(arr, 7, 0): [9, 8, 4, 7, 5, 2, 3]

Note: In the second heapify example, [2, 8, 9, 7, 5, 4, 3], max_heapify at index 0 first finds 9 as the largest (among 2, 8, 9). It swaps 2 and 9. The array becomes [9, 8, 2, 7, 5, 4, 3]. Now, heapify is recursively called on the subtree rooted at index 2 (where 2 moved). In that subtree, 2’s children are 4 and 3. Since 4 is larger than 2, they swap. The final array becomes [9, 8, 4, 7, 5, 2, 3].

2. build_heap

The build_heap operation converts an arbitrary array into a max-heap. It does this by applying heapify to all non-leaf nodes, starting from the last non-leaf node and moving up to the root.

Why start from the last non-leaf node? Leaf nodes are inherently valid heaps of size 1. So, we only need to worry about nodes that have children. The last non-leaf node is at index floor((n/2) - 1) (for a 0-indexed array of size n). By starting there and working upwards, we ensure that when heapify is called on a node, its children’s subtrees are already valid heaps.

Time Complexity of build_heap: While it might seem like O(n log n) because heapify is O(log n) and it’s called n/2 times, the actual complexity is O(n). This is because most heapify calls are on subtrees of small height. The sum of the work done across all heapify calls is linear.

def build_max_heap(arr):
    """
    Builds a max-heap from an unsorted array.
    arr: The array to be converted into a max-heap.
    """
    n = len(arr)
    # Start from the last non-leaf node and go up to the root
    # Last non-leaf node is at index (n // 2) - 1 for 0-indexed array
    for i in range(n // 2 - 1, -1, -1):
        max_heapify(arr, n, i)

print("\n--- Example: build_max_heap ---")
unsorted_array = [4, 1, 3, 2, 16, 9, 10, 14, 8, 7]
print(f"Unsorted array: {unsorted_array}")
build_max_heap(unsorted_array)
print(f"Array after building max-heap: {unsorted_array}")

unsorted_array_2 = [1, 2, 3, 4, 5, 6, 7, 8, 9]
print(f"\nUnsorted array (ascending): {unsorted_array_2}")
build_max_heap(unsorted_array_2)
print(f"Array after building max-heap: {unsorted_array_2}")
--- Example: build_max_heap ---
Unsorted array: [4, 1, 3, 2, 16, 9, 10, 14, 8, 7]
Array after building max-heap: [16, 14, 10, 8, 7, 9, 3, 2, 4, 1]

Unsorted array (ascending): [1, 2, 3, 4, 5, 6, 7, 8, 9]
Array after building max-heap: [9, 8, 7, 6, 5, 4, 3, 2, 1]

The Heap Sort Algorithm

Now that we understand heapify and build_heap, the Heap Sort algorithm itself is straightforward. It works in two main phases:

Phase 1: Build a Max-Heap

  • Transform the input array into a max-heap using the build_max_heap operation. After this phase, the largest element is guaranteed to be at the root (index 0).

Phase 2: Extract Elements and Sort

  • Since the largest element is at the root, swap it with the last element of the heap. This effectively “removes” the largest element from the heap and places it in its correct sorted position at the end of the array.
  • Decrement the heap size (as one element is now sorted).
  • Call max_heapify on the root (index 0) to restore the max-heap property for the remaining elements.
  • Repeat this process n-1 times until the heap size becomes 1 (meaning all elements are sorted).

Because we’re continually swapping the largest element to the end of the unsorted part of the array, the array becomes sorted in ascending order from right to left.

Let’s put it all together in a full Python implementation.

def max_heapify(arr, n, i):
    """
    Maintains the max-heap property for a subtree rooted at index i.
    arr: The array representing the heap.
    n: The current size of the heap (number of elements to consider).
    i: The index of the root of the subtree to heapify.
    """
    largest = i
    left_child = 2 * i + 1
    right_child = 2 * i + 2

    if left_child < n and arr[left_child] > arr[largest]:
        largest = left_child

    if right_child < n and arr[right_child] > arr[largest]:
        largest = right_child

    if largest != i:
        arr[i], arr[largest] = arr[largest], arr[i]
        max_heapify(arr, n, largest)

def heap_sort(arr):
    """
    Sorts an array using the Heap Sort algorithm.
    arr: The list of elements to be sorted.
    """
    n = len(arr)

    # Phase 1: Build a max-heap
    # Start from the last non-leaf node and go up to the root
    for i in range(n // 2 - 1, -1, -1):
        max_heapify(arr, n, i)

    # Phase 2: Extract elements one by one from the heap
    for i in range(n - 1, 0, -1):
        # Move current root (largest element) to end
        arr[i], arr[0] = arr[0], arr[i]
        # Call max_heapify on the reduced heap
        # The 'i' here represents the current size of the heap to consider
        max_heapify(arr, i, 0) # n is now 'i' because we exclude sorted elements

print("\n--- Full Heap Sort Example ---")

test_cases = [
    [12, 11, 13, 5, 6, 7],
    [4, 1, 3, 9, 7],
    [], # Empty array
    [1], # Single element
    [5, 4, 3, 2, 1], # Reverse sorted
    [1, 2, 3, 4, 5], # Already sorted
    [3, 1, 4, 1, 5, 9, 2, 6], # With duplicates
    [100, -5, 20, 0, 75] # With negative numbers
]

for arr in test_cases:
    original_arr = list(arr) # Create a copy for printing
    heap_sort(arr)
    print(f"Original: {original_arr:<25} Sorted: {arr}")
--- Full Heap Sort Example ---
Original: [12, 11, 13, 5, 6, 7]  Sorted: [5, 6, 7, 11, 12, 13]
Original: [4, 1, 3, 9, 7]        Sorted: [1, 3, 4, 7, 9]
Original: []                     Sorted: []
Original: [1]                    Sorted: [1]
Original: [5, 4, 3, 2, 1]        Sorted: [1, 2, 3, 4, 5]
Original: [1, 2, 3, 4, 5]        Sorted: [1, 2, 3, 4, 5]
Original: [3, 1, 4, 1, 5, 9, 2, 6] Sorted: [1, 1, 2, 3, 4, 5, 6, 9]
Original: [100, -5, 20, 0, 75]   Sorted: [-5, 0, 20, 75, 100]

Time and Space Complexity Analysis

Understanding the performance characteristics is key to choosing the right algorithm.

Time Complexity

  • Building the Max-Heap (Phase 1): build_max_heap takes O(n) time, as discussed earlier.

  • Extracting Elements and Sorting (Phase 2): This phase involves a loop that runs n-1 times. In each iteration:

    • A swap operation: O(1)
    • A max_heapify call on a heap of decreasing size: O(log k) where k is the current heap size. In the worst case, k is n, so each heapify takes O(log n). Since max_heapify is called n-1 times, this phase contributes O(n log n).
  • Overall Time Complexity: O(n) + O(n log n) = O(n log n).

This is a significant advantage: Heap Sort guarantees O(n log n) performance in its worst-case, average-case, and best-case scenarios. This contrasts with Quick Sort, which can degrade to O(n^2) in its worst-case.

Space Complexity

  • Heap Sort is an in-place sorting algorithm. It only requires a constant amount of additional space for temporary variables (like largest, left_child, right_child, and the swap variable).
  • Auxiliary Space Complexity: O(1).

This makes Heap Sort very memory-efficient, especially for large datasets where extra memory might be constrained.

Advantages and Disadvantages of Heap Sort

Like any algorithm, Heap Sort has its strengths and weaknesses.

Advantages:

  • Guaranteed O(n log n) Performance: This is its strongest suit. You always know what you’re going to get, unlike Quick Sort which can be O(n^2) in the worst case.
  • In-Place Sorting (O(1) Auxiliary Space): Extremely memory efficient, making it suitable for systems with limited memory.
  • Reliable Performance: It doesn’t suffer from performance degradation on specific input types (like already sorted or reverse-sorted arrays), unlike Quick Sort or Insertion Sort.
  • Basis for Priority Queues: While not a direct sorting advantage, the underlying heap data structure is fundamental for implementing efficient priority queues, which have numerous real-world applications.

Disadvantages:

  • Not a Stable Sort: Heap Sort is not stable. The relative order of elements with equal values is not preserved. If you have (A, 5) and (B, 5), Heap Sort might put (B, 5) before (A, 5) even if (A, 5) came first in the original array. This is often not an issue, but crucial for some applications.
  • Cache Performance: Due to the scattered access patterns in the array (jumping between parent and child indices), Heap Sort can exhibit poor cache locality compared to algorithms like Merge Sort or Quick Sort. This means that while its theoretical complexity is strong, its real-world performance on modern CPUs might sometimes be slower than Quick Sort on average, despite Quick Sort’s worse theoretical worst-case.
  • More Complex to Implement: Compared to simpler sorts like Insertion Sort or Selection Sort, or even Merge Sort, Heap Sort’s underlying logic (heapifying and building the heap) can be a bit trickier to grasp and implement correctly.

When to Use Heap Sort

Given its characteristics, Heap Sort is a good candidate for specific scenarios:

  • When Worst-Case Performance is Critical: If O(n log n) is a strict requirement for all inputs (e.g., in real-time systems or competitive programming where worst-case time limits are tight), Heap Sort is a solid choice over Quick Sort.
  • Memory-Constrained Environments: Its O(1) auxiliary space makes it excellent for large datasets where extra memory for recursion (like Merge Sort) or stack frames (like Quick Sort) is limited.
  • Top K Problems: While not sorting the entire array, using a min-heap (for finding the largest K elements) or a max-heap (for finding the smallest K elements) is a highly efficient way to solve “find the K largest/smallest elements” problems. Heap Sort itself is derived from this heap property.
  • As a Building Block for Priority Queues: If you need to implement a priority queue data structure, the heap is the most common and efficient underlying structure.

Conclusion

Heap Sort is a powerful and reliable comparison-based sorting algorithm. Its elegance lies in leveraging the simple yet effective heap data structure to achieve a guaranteed O(n log n) time complexity and O(1) space complexity. While it might not always be the absolute fastest in practice due to cache considerations, its consistent performance and memory efficiency make it a valuable tool in a developer’s algorithmic toolkit.

Understanding Heap Sort not only equips you with another sorting method but also deepens your appreciation for how clever data structures can lead to efficient algorithms.

Last updated on