Skip to content
CCC Python Course

Heaps and lazy deletion; ordered-set substitutes

Module
M5.11
Lesson
1 of 1
Reading time
5 min

In this lesson

  • Implement min-heaps and max-heaps using heapq and negation.
  • Detect and skip out-of-date entries using validity checks.
  • Simulate multiset operations and keep running aggregates.
  • Maintain a sliding window minimum using a lazy-deletion deque.

Many competitive programming problems need quick access to the smallest or largest item in a changing collection. A priority queue solves this. Python's heapq module provides a min-heap: a list where the smallest item is always at index 0, and both push and pop run in logarithmic time.

Heaps in Python

A heap is a complete binary tree stored in a list where each parent is smaller than its children. When you push a new item, it goes at the end and moves up. When you pop, the smallest item is removed, the last item takes its place, and moves down. Both operations take O(log n) time.

examples/heap_basics.py
import heapq
h = []heapq.heappush(h, 5)heapq.heappush(h, 2)heapq.heappush(h, 8)heapq.heappush(h, 1)
print("Heap:", h)
while h:    print(heapq.heappop(h), end=" ")print()

Output

Heap: [1, 2, 8, 5]
1 2 5 8 
A min-heap with push and pop

The list stores the tree level by level. Index 0 is the root. Index 1 and 2 are its children. Python does this bookkeeping for you.

Max-heaps by negation

Python gives you min-heaps only. To build a max-heap, push negative values and negate them on the way out.

examples/max_heap.py
import heapq
h = []heapq.heappush(h, -10)heapq.heappush(h, -3)heapq.heappush(h, -7)
print("Items in descending order:")while h:    print(-heapq.heappop(h), end=" ")print()

Output

Items in descending order:
10 7 3 
A max-heap simulated with negation

Pushing -10 pushes the negated value. When it pops, negating again gives 10. The smallest negative is the largest positive.

Lazy deletion with validity checks

Sometimes an item's value improves and you want to add it to the heap again. Many textbooks update the old entry in place, but heaps are hard to search. Instead, push a new entry and skip the old one when it comes out.

Mark each item as valid or invalid. When you pop, skip invalid items until you find a valid one. This is called lazy deletionAn optimization technique where items are marked invalid instead of being removed immediately, with removal deferred until they are popped.In the glossary.

examples/lazy_deletion.py
import heapq
h = []valid = {}
# Add item 0 with priority 8heapq.heappush(h, (8, 0))valid[(8, 0)] = False
# Update item 0 to priority 2heapq.heappush(h, (2, 0))valid[(2, 0)] = True
# Pop items, skipping invalid onesprint("Popping from heap:")while h:    priority, item = heapq.heappop(h)    if valid.get((priority, item), False):        print(f"Item {item} with priority {priority}")    else:        print(f"Skipping stale entry ({priority}, {item})")

Output

Popping from heap:
Item 0 with priority 2
Skipping stale entry (8, 0)
Skip outdated entries in a heap

Here, item 0 is pushed twice with different priorities. When the better one (priority 2) pops first, it is marked valid. The worse one (priority 8) is marked invalid when it pops. The skip costs one comparison per outdated item.

Maintaining a multiset with aggregates

Suppose you have a multiset and need to know the total and the count whenever someone asks. Items can be added or removed. You can store the sum and count in variables, updating them on each operation. But if you need the maximum or minimum too, a single variable does not work for those.

A lazy-deletion heap lets you simulate a balanced tree. When an item's value changes, you do not update it; you push a new entry with the new value and mark the old one invalid. When you query the maximum, you pop items until you find a valid one.

examples/multiset.py
import heapq
h = []removed = set()count = 0
# Add itemsfor val in [3, 7, 2, 9, 5]:    heapq.heappush(h, -val)    count += 1
print(f"Added 5 items, count = {count}")
# Remove item with value 9removed.add(9)count -= 1
# Find maximum, skipping stale entries already accounted for abovewhile h:    val = -heapq.heappop(h)    if val not in removed:        print(f"Maximum: {val}, remaining count: {count}")        break

Output

Added 5 items, count = 5
Maximum: 7, remaining count: 4
Maintaining the maximum in a changing multiset

Operations here are additions and removals. Removing an item drops the count once, right away, since that is when it truly leaves the multiset. The heap still holds a stale entry for it, so the query loop keeps popping and discarding entries already marked removed, without touching the count again, until it reaches one that is still current.

Sliding window minimum

Another use case is finding the minimum in a sliding window. As the window moves right, it adds one element and removes one. You could scan the window each time (O(n) per window), or use a deque with lazy deletion.

A double-ended queue (deque)A data structure that supports efficient insertion and removal at both ends, useful for sliding window problems.In the glossary is faster than a list for removing from the front. When the window includes indices 0 through 4, the deque stores indices of potential minimums in sorted order. When a new index enters, you remove indices from the back that are larger than the new one (they will never be the minimum). When the front index leaves the window, you remove it.

examples/sliding_min.py
from collections import deque
arr = [3, 1, 4, 1, 5, 9, 2, 6]k = 3
dq = deque()results = []
for i, val in enumerate(arr):    # Remove indices outside the window    while dq and dq[0] < i - k + 1:        dq.popleft()
    # Remove larger values from the back    while dq and arr[dq[-1]] > val:        dq.pop()
    dq.append(i)
    # Window is complete    if i >= k - 1:        results.append(arr[dq[0]])
print("Sliding window minima:", results)

Output

Sliding window minima: [1, 1, 1, 1, 2, 2]
Sliding window minimum with a deque

The deque keeps only indices that might become the minimum. Adding a new index and removing old ones each cost O(1) amortized because each index is added and removed once. The overall time is O(n).

Why lazy deletion works and its cost

Lazy deletion is a trade-off. Instead of immediately removing outdated entries (which would require searching the heap), you leave them and skip them when they pop. The cost of skipping is tiny: one comparison and a boolean check. The benefit is that you do not need to search or restructure the heap.

Each entry pops at most once. Even if an item's value improves ten times and you push ten entries for it, each one still pops exactly once. The total skips across all operations is at most the total pushes. If you push M times and skip K outdated entries, the total heap operations are O((M + K) log M).

For many problems, K is small. An item's value often improves a constant number of times or only once. In those cases, lazy deletion is faster and simpler than maintaining a balanced BST (which Python does not have in the standard library anyway).

Compare this to a deterministic update: you would need to find the entry in O(N) time by searching, then sift it up. Over many updates, this is slower than pushing and skipping.

A second example: tracking the best price

Imagine you are tracking the best price to buy a stock. New prices arrive one at a time. You want to quickly query the lowest price seen so far, even as old prices become outdated or no longer relevant.

You could scan all past prices each time (O(N) per query), or use a min-heap. Push each price with a timestamp. When you pop a price, check if it is still relevant (e.g., within the last K days). Skip outdated prices until you find a current one.

The min-heap gives you O(log N) per push and O(log N) amortized per query because each price is added and removed once. Without the heap, you waste time scanning old prices that are no longer relevant.

Common mistakes

A common error is forgetting to check validity or timestamp before using a value from the heap. If the item is outdated, skip it. If you use it anyway, your answer is wrong. You might get the price from a year ago instead of the current week.

Another mistake is using a recursive approach to navigate trees. Heaps are arrays, not tree structures you build manually. The indices do the navigation for you. Recursive navigation is slow and risks stack overflow on large heaps.

A third error is trying to update an entry in place. It breaks the heap invariant and produces a corrupted structure. If an entry needs a new value, push a new one and mark the old as invalid. This keeps the heap property intact.

Also, do not assume that all items in the heap are current. Once you pop an entry, mark it as used so you do not process it twice. Some problems require you to track which items you have already acted on.

Practice

Try these on the judge. Each link opens the problem on WMOJ.

  1. 2021 S4
    Daily Commute (opens on WMOJ in a new tab) WMOJ

    Find the minimum of dynamic expressions using lazy-deletion heaps to track candidates.

  2. 2025 S3
    Pretty Pens (opens on WMOJ in a new tab) WMOJ

    Track the best combination of items in a changing collection under updates.

    Why DMOJ: A greedy problem over a changing collection, solved with heaps and lazy deletion.