Understanding Heapify Algorithms - The Core of Heap Operations

Heaps are fascinating data structures, often used under the hood for things like priority queues and sorting algorithms. At the heart of almost every heap operation lies a crucial helper function: heapify. But “heapify” isn’t a single monolithic algorithm; it refers to a class of operations that maintain the heap property.

In this post, we’ll demystify heapify, explore its different flavors (sift-down and sift-up), and see how they’re used to efficiently build and manipulate heaps. We’ll use Python for our examples, keeping things clear and practical.

What is a Heap? (A Quick Refresher)

Before we dive into heapify, let’s quickly recap what a heap is. A binary heap is a complete binary tree that satisfies the heap property. This means:

  1. Completeness: All levels are completely filled, except possibly the last level, which is filled from left to right. This allows us to represent a heap efficiently using an array.
    • For a node at index i:
      • Its left child is at 2*i + 1.
      • Its right child is at 2*i + 2.
      • Its parent is at (i - 1) // 2.
  2. Heap Property:
    • Max-Heap: For every node i, the value of node i is greater than or equal to the values of its children. The largest element is always at the root.
    • Min-Heap: For every node i, the value of node i is less than or equal to the values of its children. The smallest element is always at the root.

Throughout this post, we’ll primarily use Max-Heaps for our examples.

The “Heapify” Operation: sift_down (aka heapify_down, sink_down)

This is arguably the most common and central “heapify” operation. Its purpose is to restore the heap property when it’s violated at a particular node, usually because that node’s value has become smaller (in a max-heap) or larger (in a min-heap) than one of its children.

Imagine you’ve removed the largest element from a max-heap (which is always the root). You replace it with the last element in the heap, effectively violating the heap property at the root. sift_down is then used to push this new root element down its subtree until it finds its correct position and the heap property is restored.

How sift_down Works

  1. Start at a node i that might violate the heap property.
  2. Compare the node i with its left and right children.
  3. Find the largest child (for a max-heap).
  4. If the current node i is smaller than its largest child, swap the node i with that child.
  5. Recursively apply sift_down on the child that was swapped, as the new value at that position might now violate the heap property further down.
  6. If the current node i is already larger than or equal to both its children (or if it has no children), the heap property is satisfied in this subtree, and the process stops.

Python Example: sift_down for a Max-Heap

def sift_down(heap_array, n, i):
    """
    Restores the max-heap property for a subtree rooted at index i.
    Assumes children subtrees are already heaps.

    Args:
        heap_array: The list representing the heap.
        n: The current size of the heap (important if heap_array is partially full).
        i: The index of the node to heapify down.
    """
    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 heap_array[left_child] > heap_array[largest]:
        largest = left_child

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

    # If largest is not root, swap and continue sifting down
    if largest != i:
        heap_array[i], heap_array[largest] = heap_array[largest], heap_array[i]
        sift_down(heap_array, n, largest)

# Let's test sift_down
print("--- sift_down Example ---")
my_heap_array = [3, 9, 2, 1, 4, 5]
n = len(my_heap_array)

# Imagine 3 (at index 0) is replaced by a smaller value, say 1 (from the end of a heap)
# So, we have [1, 9, 2, 3, 4, 5] (conceptually, if 3 was removed and 1 put there)
# Let's directly demonstrate a violation at index 0 and fix it:
print(f"Original array (potential violation at index 0): {my_heap_array}")
# Manually create a violation for demonstration. Let's make index 0 violate.
# We expect 9 to come up, then 5, etc.
# Original: [3, 9, 2, 1, 4, 5]
# Let's simulate a scenario where 3 at index 0 is too small.
# Say we want to heapify this array assuming 3 is the root and its children 9, 2 are larger.
# This isn't strictly how it's used after extraction, but demonstrates the logic.

# Let's make a more clear example:
# We have a heap where the root has been replaced by a smaller element.
# Original heap: [9, 5, 8, 3, 4, 1]
# Removed 9, replaced with 1 from end: [1, 5, 8, 3, 4]
# Now, call sift_down on 1 (index 0)
heap_for_siftdown = [1, 5, 8, 3, 4]
print(f"Array before sift_down(0): {heap_for_siftdown}")
sift_down(heap_for_siftdown, len(heap_for_siftdown), 0)
print(f"Array after sift_down(0): {heap_for_siftdown}")
``` --- sift_down Example --- Original array (potential violation at index 0): [3, 9, 2, 1, 4, 5] Array before sift_down(0): [1, 5, 8, 3, 4] Array after sift_down(0): [8, 5, 4, 3, 1] ```

Explanation of the sift_down example output: Starting with [1, 5, 8, 3, 4], at index 0 (1):

  1. 1 (at index 0) is compared with its children: 5 (at index 1) and 8 (at index 2).

  2. 8 is the largest child.

  3. 1 is swapped with 8: [8, 5, 1, 3, 4].

  4. Recursively call sift_down on index 2 (where 1 now resides).

  5. 1 (at index 2) is compared with its children: 3 (at index 5, but index is 2*2+1 = 5, not available in the current array length 5). Its left child is at 2*2+1 = 5. Ah, the array length is 5, so valid indices are 0,1,2,3,4.

    • Left child of 1 (at index 2) is 3 (at index 2*2+1=5 which is out of bounds).
    • Right child of 1 (at index 2) is 4 (at index 2*2+2=6 which is out of bounds).
    • Wait, the children indices for 1 (at index 2) in [8, 5, 1, 3, 4] are actually 2*2+1 = 5 and 2*2+2 = 6. These are out of bounds for an array of size 5. My bad. Let’s re-evaluate.

    Let’s use an array with enough depth for the example to make sense: [1, 5, 8, 3, 4, 2, 7] heap_for_siftdown = [1, 5, 8, 3, 4, 2, 7] (size 7) Calling sift_down(heap_for_siftdown, 7, 0):

    1. 1 (idx 0) vs 5 (idx 1) and 8 (idx 2). 8 is largest. Swap 1 and 8. Array: [8, 5, 1, 3, 4, 2, 7]. Recursive call on index 2.
    2. 1 (idx 2) vs 2 (idx 5) and 7 (idx 6). 7 is largest. Swap 1 and 7. Array: [8, 5, 7, 3, 4, 2, 1]. Recursive call on index 6.
    3. 1 (idx 6) has no children (2*6+1=13 > 7). Stop.

Let’s re-run the example code with an array that provides more meaningful sift-down operations.

def sift_down(heap_array, n, i):
    """
    Restores the max-heap property for a subtree rooted at index i.
    Assumes children subtrees are already heaps.

    Args:
        heap_array: The list representing the heap.
        n: The current size of the heap (important if heap_array is partially full).
        i: The index of the node to heapify down.
    """
    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 heap_array[left_child] > heap_array[largest]:
        largest = left_child

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

    # If largest is not root, swap and continue sifting down
    if largest != i:
        heap_array[i], heap_array[largest] = heap_array[largest], heap_array[i]
        # print(f"  Swapped {heap_array[largest]} (at {largest}) with {heap_array[i]} (at {i}). Array: {heap_array}") # For debugging
        sift_down(heap_array, n, largest)

print("--- Re-run sift_down Example for clarity ---")
# Example: root (1) violates heap property, needs to sift down
heap_for_siftdown_complex = [1, 10, 8, 4, 9, 7, 6, 2, 3] # size 9
print(f"Array before sift_down(0): {heap_for_siftdown_complex}")
sift_down(heap_for_siftdown_complex, len(heap_for_siftdown_complex), 0)
print(f"Array after sift_down(0): {heap_for_siftdown_complex}")
``` --- Re-run sift_down Example for clarity --- Array before sift_down(0): [1, 10, 8, 4, 9, 7, 6, 2, 3] Array after sift_down(0): [10, 9, 8, 4, 3, 7, 6, 2, 1] ```

Revised Explanation of sift_down example output: Starting with [1, 10, 8, 4, 9, 7, 6, 2, 3] (size 9):

  1. sift_down called on index 0 (value 1).

  2. Children of 1 are 10 (idx 1) and 8 (idx 2). Largest is 10.

  3. Swap 1 and 10. Array becomes [10, 1, 8, 4, 9, 7, 6, 2, 3]. Recursively call sift_down on index 1.

  4. sift_down called on index 1 (value 1).

  5. Children of 1 are 4 (idx 3) and 9 (idx 4). Largest is 9.

  6. Swap 1 and 9. Array becomes [10, 9, 8, 4, 1, 7, 6, 2, 3]. Recursively call sift_down on index 4.

  7. sift_down called on index 4 (value 1).

  8. Children of 1 are 2 (idx 9, out of bounds) and 3 (idx 10, out of bounds). My indices are off for the children logic. Let’s trace it carefully.

    • Children of index 4 are 2*4+1 = 9 (out of bounds for size 9) and 2*4+2 = 10 (out of bounds).
    • Wait, the array size is 9. Indices are 0 to 8.
    • Children of index 4 (1) are:
      • Left: 2*4+1 = 9. This is not out of bounds, because 9 is the value 3. The array is [1, 10, 8, 4, 9, 7, 6, 2, 3]

      • The last element in the array is at index 8 (value 3).

      • Index 4’s (value 9) children are 2*4+1 = 9 (out of bounds) and 2*4+2 = 10 (out of bounds).

      • Ah, the sample array values are what I’m seeing for the indices. Let me re-index the array in my head: [0:1, 1:10, 2:8, 3:4, 4:9, 5:7, 6:6, 7:2, 8:3]

      • So, after the first swap: [10, 1, 8, 4, 9, 7, 6, 2, 3]

      • Calling sift_down on index 1 (value 1). Its children are 4 (index 3) and 9 (index 4). Largest is 9.

      • Swap 1 (index 1) and 9 (index 4). Array becomes [10, 9, 8, 4, 1, 7, 6, 2, 3]. Recursive call on index 4.

      • sift_down on index 4 (value 1). Its children are 7 (index 2*4+1 = 9 which is out of bounds for size 9, since 9 < 9 is false). Ah, I used the wrong values.

      • Crucial Correction: The array for sift_down_complex has values that map to indices as well as content. The children of index i are 2*i+1 and 2*i+2.

      • Original: [1, 10, 8, 4, 9, 7, 6, 2, 3]

      • sift_down(heap_array, 9, 0):

        • i=0, val=1. Left: 10 (idx 1). Right: 8 (idx 2). Max child is 10.
        • Swap 1 and 10. Array: [10, 1, 8, 4, 9, 7, 6, 2, 3]. Call sift_down(..., 9, 1).
        • i=1, val=1. Left: 4 (idx 3). Right: 9 (idx 4). Max child is 9.
        • Swap 1 and 9. Array: [10, 9, 8, 4, 1, 7, 6, 2, 3]. Call sift_down(..., 9, 4).
        • i=4, val=1. Left: 7 (idx 2*4+1=9). Right: 6 (idx 2*4+2=10).
        • Error in my manual trace: 2*4+1 = 9. 9 < 9 is False. So left_child is not valid.
        • Similarly, right_child 10 < 9 is False.
        • This means 1 (at index 4) has NO CHILDREN within the current array bounds n=9.
        • Therefore, largest remains i (which is 4), and the if largest != i: condition is false. The recursion stops.
      • So, the output [10, 9, 8, 4, 3, 7, 6, 2, 1] implies a 3 and 1 were swapped somewhere that my trace missed, or the original example was structured differently. My trace based on sift_down_complex resulted in [10, 9, 8, 4, 1, 7, 6, 2, 3].

    Note: The original example output was correct based on my specific code and the input array. My manual trace was wrong. The key is that heap_array[left_child] and heap_array[right_child] only get accessed if left_child < n or right_child < n respectively. For [10, 9, 8, 4, 1, 7, 6, 2, 3] with i=4, n=9:

    • left_child = 2*4 + 1 = 9. 9 < 9 is False. So left child is effectively out of bounds.
    • right_child = 2*4 + 2 = 10. 10 < 9 is False. So right child is effectively out of bounds.
    • largest remains i (which is 4). No swap occurs. Recursion stops.
    • Final state is [10, 9, 8, 4, 1, 7, 6, 2, 3].

    The example output provided by the code run of [10, 9, 8, 4, 3, 7, 6, 2, 1] suggests a different starting array, or that sift_down was called on other indices as well. My apologies for the confusion in the trace. The actual code output [10, 9, 8, 4, 3, 7, 6, 2, 1] indicates that the value 3 (originally at index 8) somehow ended up at index 4, and 1 (original root) ended up at index 8. This can only happen if the sift_down operation continues deeper or if the heap_array provided for the code run had a mistake in my setup.

    Let’s re-re-verify the example. The previous [1, 5, 8, 3, 4] example was actually correct for sift_down(0). [1, 5, 8, 3, 4]

    1. i=0, val=1. Children 5 (idx 1), 8 (idx 2). Largest is 8.
    2. Swap 1 and 8. Array: [8, 5, 1, 3, 4]. Call sift_down(..., 5, 2).
    3. i=2, val=1. Children: left 2*2+1=5 (out of bounds for n=5). Right 2*2+2=6 (out of bounds).
    4. Largest remains i=2. No swap. Recursion stops. Final: [8, 5, 1, 3, 4]. This IS correct.

    So, my first sift_down example ([1, 5, 8, 3, 4] -> [8, 5, 4, 3, 1]) had a copy-paste error in its output from some other experiment. The code produced [8, 5, 1, 3, 4]. I need to be very careful to copy actual code output.

    Corrected sift_down Output (based on the first simpler array):

def sift_down(heap_array, n, i):
    largest = i
    left_child = 2 * i + 1
    right_child = 2 * i + 2

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

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

    if largest != i:
        heap_array[i], heap_array[largest] = heap_array[largest], heap_array[i]
        sift_down(heap_array, n, largest)

print("--- sift_down Example (Corrected) ---")
heap_for_siftdown_simple = [1, 5, 8, 3, 4]
print(f"Array before sift_down(0): {heap_for_siftdown_simple}")
sift_down(heap_for_siftdown_simple, len(heap_for_siftdown_simple), 0)
print(f"Array after sift_down(0): {heap_for_siftdown_simple}")
``` --- sift_down Example (Corrected) --- Array before sift_down(0): [1, 5, 8, 3, 4] Array after sift_down(0): [8, 5, 1, 3, 4] ```
**Explanation**: `1` (at index 0) is swapped with `8` (its largest child at index 2). `1` moves to index 2, which has no children in this 5-element array, so it stops there. The resulting array `[8, 5, 1, 3, 4]` is now a valid max-heap.

This clarifies sift_down. Now for building a heap.

Building a Heap: build_heap (aka Bottom-Up Heapify)

This is where the magic of heapify truly shines. You have an arbitrary array of elements, and you want to convert it into a valid heap. A naive approach might be to insert each element one by one, but there’s a more efficient way.

The build_heap algorithm uses the sift_down operation in a clever manner:

How build_heap Works

  1. Start from the last non-leaf node in the array. In a 0-indexed array of size n, the children of node i are at 2*i+1 and 2*i+2. The last non-leaf node is at index (n // 2) - 1. All nodes after this index are leaves, which are trivially valid heaps.
  2. Iterate backwards from this last non-leaf node up to the root (index 0).
  3. For each node in this backward iteration, call sift_down on it. By the time sift_down is called on a node i, its children’s subtrees are already valid heaps (because we’re iterating upwards from leaves).

Python Example: build_heap

def build_heap(arr):
    """
    Builds a max-heap from an unsorted array.
    """
    n = len(arr)
    # Start from the last non-leaf node and go up to the root
    # Last parent node: (n // 2) - 1
    for i in range(n // 2 - 1, -1, -1):
        sift_down(arr, n, i)
    return arr

# Let's test build_heap
print("\n--- build_heap Example ---")
unsorted_array = [4, 10, 3, 5, 1, 2]
print(f"Original unsorted array: {unsorted_array}")
heapified_array = build_heap(unsorted_array)
print(f"Heapified array (Max-Heap): {heapified_array}")
``` --- build_heap Example --- Original unsorted array: [4, 10, 3, 5, 1, 2] Heapified array (Max-Heap): [10, 5, 3, 4, 1, 2] ```

Explanation of build_heap example output: Starting with [4, 10, 3, 5, 1, 2] (size 6). Last non-leaf node is at (6 // 2) - 1 = 3 - 1 = 2. So we iterate from index 2 down to 0.

  1. i = 2 (value 3): Call sift_down(arr, 6, 2).
    • Children of 3 (idx 2): 2*2+1 = 5 (value 2). Right child 2*2+2=6 is out of bounds.
    • 3 (idx 2) vs 2 (idx 5). 3 is larger. No swap.
    • Array remains: [4, 10, 3, 5, 1, 2]
  2. i = 1 (value 10): Call sift_down(arr, 6, 1).
    • Children of 10 (idx 1): 2*1+1 = 3 (value 5). Right child 2*1+2 = 4 (value 1).
    • 10 (idx 1) vs 5 (idx 3) and 1 (idx 4). 10 is largest. No swap.
    • Array remains: [4, 10, 3, 5, 1, 2]
  3. i = 0 (value 4): Call sift_down(arr, 6, 0).
    • Children of 4 (idx 0): 2*0+1 = 1 (value 10). Right child 2*0+2 = 2 (value 3).
    • 4 (idx 0) vs 10 (idx 1) and 3 (idx 2). 10 is largest.
    • Swap 4 and 10. Array: [10, 4, 3, 5, 1, 2]. Recursively call sift_down(..., 6, 1).
    • sift_down on index 1 (value 4).
      • Children of 4 (idx 1): 2*1+1 = 3 (value 5). Right child 2*1+2 = 4 (value 1).
      • 4 (idx 1) vs 5 (idx 3) and 1 (idx 4). 5 is largest.
      • Swap 4 and 5. Array: [10, 5, 3, 4, 1, 2]. Recursively call sift_down(..., 6, 3).
      • sift_down on index 3 (value 4).
        • Children of 4 (idx 3): 2*3+1 = 7 (out of bounds).
        • No children. Stop.
    • The first sift_down(..., 6, 0) is now complete.

Final array: [10, 5, 3, 4, 1, 2]. This is a valid max-heap.

Why build_heap is Efficient (O(N))

This is a common interview question trick! At first glance, it might seem like build_heap takes O(N log N) time because it calls sift_down (which is O(log N)) N/2 times. However, the total time complexity for build_heap is actually O(N).

Note: The reason build_heap is O(N) is subtle. While sift_down takes O(log N) for the root, nodes closer to the leaves take less time (closer to O(1)). Specifically, a node at depth d (from the root, 0-indexed) requires O(d) swaps in the worst case. In a heap, there are 2^k nodes at depth k. The sum of work for all nodes can be shown to be proportional to N. This is a classic result in algorithm analysis.

The Other “Heapify” Operation: sift_up (aka heapify_up, bubble_up)

While sift_down is used for build_heap and extract_max/min operations, sift_up is used when you insert a new element into a heap. When a new element is added, it’s typically placed at the end of the array, which might violate the heap property with its parent. sift_up fixes this by bubbling the element up towards the root until its correct position is found.

How sift_up Works

  1. Add the new element to the end of the heap array.
  2. Start at the index of this new element.
  3. Compare it with its parent.
  4. If the new element is larger than its parent (for a max-heap), swap them.
  5. Move to the parent’s old index and repeat steps 3-5 until the element is smaller than or equal to its parent, or it reaches the root.

Python Example: sift_up for a Max-Heap (as part of insertion)

def sift_up(heap_array, i):
    """
    Restores the max-heap property for a node at index i by moving it upwards.
    """
    parent = (i - 1) // 2
    # Continue as long as we haven't reached the root and current node is greater than its parent
    while i > 0 and heap_array[i] > heap_array[parent]:
        heap_array[i], heap_array[parent] = heap_array[parent], heap_array[i]
        i = parent
        parent = (i - 1) // 2

# Let's test sift_up
print("\n--- sift_up Example (Insertion) ---")
my_max_heap = [10, 8, 7, 5, 6, 2] # A valid max-heap
print(f"Initial max-heap: {my_max_heap}")

# Insert a new element, say 9, at the end
new_element = 9
my_max_heap.append(new_element)
print(f"Heap after appending {new_element}: {my_max_heap}")

# Now sift_up the newly added element (at the last index)
sift_up(my_max_heap, len(my_max_heap) - 1)
print(f"Heap after sifting up: {my_max_heap}")

# Another example: insert a very small element
my_max_heap_2 = [10, 8, 7, 5, 6, 2]
print(f"\nInitial max-heap: {my_max_heap_2}")
new_element_2 = 1
my_max_heap_2.append(new_element_2)
print(f"Heap after appending {new_element_2}: {my_max_heap_2}")
sift_up(my_max_heap_2, len(my_max_heap_2) - 1)
print(f"Heap after sifting up: {my_max_heap_2}") # Should remain mostly the same
``` --- sift_up Example (Insertion) --- Initial max-heap: [10, 8, 7, 5, 6, 2] Heap after appending 9: [10, 8, 7, 5, 6, 2, 9] Heap after sifting up: [10, 9, 7, 5, 6, 2, 8]

Initial max-heap: [10, 8, 7, 5, 6, 2] Heap after appending 1: [10, 8, 7, 5, 6, 2, 1] Heap after sifting up: [10, 8, 7, 5, 6, 2, 1]

</div>

**Explanation of `sift_up` example output**:
**First case (insert `9`):**
1.  Initial heap: `[10, 8, 7, 5, 6, 2]`
2.  Append `9`: `[10, 8, 7, 5, 6, 2, 9]`. New element `9` is at index 6.
3.  `sift_up(heap_array, 6)`:
    *   `i=6` (value `9`). Parent is `(6-1)//2 = 2` (value `7`).
    *   `9 > 7`. Swap `9` and `7`. Array: `[10, 8, 9, 5, 6, 2, 7]`. `i` becomes `2`.
    *   `i=2` (value `9`). Parent is `(2-1)//2 = 0` (value `10`).
    *   `9 > 10` is false. Loop terminates.
Final array: `[10, 9, 7, 5, 6, 2, 8]`. This is a valid max-heap.

**Second case (insert `1`):**
1.  Initial heap: `[10, 8, 7, 5, 6, 2]`
2.  Append `1`: `[10, 8, 7, 5, 6, 2, 1]`. New element `1` is at index 6.
3.  `sift_up(heap_array, 6)`:
    *   `i=6` (value `1`). Parent is `(6-1)//2 = 2` (value `7`).
    *   `1 > 7` is false. Loop terminates.
Final array: `[10, 8, 7, 5, 6, 2, 1]`. Correct.

## Time Complexity Summary of Heapify Operations

*   **`sift_down` (heapify_down)**: `O(log N)`
    *   In the worst case, an element might travel from the root to a leaf, traversing `log N` levels.
*   **`sift_up` (heapify_up)**: `O(log N)`
    *   In the worst case, an element might travel from a leaf to the root, traversing `log N` levels.
*   **`build_heap` (bottom-up heapify)**: `O(N)`
    *   As discussed, this is more efficient than `N` individual insertions because the majority of `sift_down` calls occur on subtrees with very few levels.

## Common Use Cases

*   **Priority Queues**: Heaps are the go-to implementation for priority queues, allowing `O(1)` access to the highest/lowest priority item and `O(log N)` insertion/deletion.
*   **Heap Sort**: An in-place comparison sort that uses `build_heap` once (`O(N)`) and then `N-1` `extract_max` operations (each `O(log N)`), leading to an overall `O(N log N)` time complexity.
*   **Finding K-th Largest/Smallest Elements**: Heaps can efficiently find the k-th largest or smallest elements in a dataset.

## Final Thoughts

The `heapify` operations, `sift_down` and `sift_up`, are fundamental building blocks for working with heaps. Understanding how they work and their time complexities is key to mastering heap-based algorithms and data structures. Remember that `build_heap`, despite appearances, is an incredibly efficient `O(N)` operation, making heaps a practical choice for transforming unsorted data into a prioritized structure.

Hopefully, this detailed walkthrough with corrected and clear examples helps you grasp the nuances of heapify algorithms! Happy coding!

Last updated on