Hello! Welcome to the final lesson in our module on heaps and priority queues.
In our previous lesson, you mastered the "k-way merge" pattern by using a min-heap to efficiently merge K sorted lists. You saw how a heap can maintain a small set of candidates to find the "best" next item—in that case, the node with the smallest value.
Today, we'll tackle another high-ROI interview problem that builds on this idea: designing a data structure to find the median from a data stream. This problem tests your ability to maintain order in a dynamic dataset and is a classic application of heaps. By the end of this lesson, you will be able to design, implement, and analyze an efficient solution using two heaps to solve this problem.
1. The Challenge: A "Running" Median
First, let's define the problem. You're receiving a continuous stream of numbers, one at a time. Your data structure needs to support two operations:
addNum(num): Add a new number to your collection.findMedian(): Return the median of all numbers added so far.
Recall that the median is the middle value in a sorted list.
- If the list has an odd number of elements, the median is the single middle element. Example:
[2, 3, 8]-> median is3. - If the list has an even number of elements, the median is the average of the two middle elements. Example:
[2, 3, 8, 10]-> median is(3 + 8) / 2 = 5.5.
A naive approach would be to store all numbers in a dynamic array. For every findMedian() call, you would sort the array and find the middle element(s). Given your background in complexity analysis, you can see the issue: sorting takes time, which is too slow if findMedian() is called frequently.
We need a more intelligent way to track the "middle" of the dataset without fully sorting it every time.
2. The Core Insight: Splitting the Stream with Two Heaps
To find the median, we only need access to the middle one or two elements. This suggests that if we could partition the numbers into two halves—a "smaller half" and a "larger half"—we could solve the problem efficiently.
This is where heaps shine. We can use two heaps to maintain this partition:
- A Max-Heap to store the smaller half of the numbers. The top of this heap (
max-heap.peek()) will give us the largest element in the smaller half. - A Min-Heap to store the larger half of the numbers. The top of this heap (
min-heap.peek()) will give us the smallest element in the larger half.

By keeping these two heaps balanced, the median will always be either the top of one of the heaps or the average of both tops.
To solidify this core idea, let's dive into the intuition behind this design.
295. Find Median from Data Stream - In-Depth Explanation
The article '295. Find Median from Data Stream' on Algo.monster has an excellent section explaining the intuition behind the two-heap approach. It clearly explains why this partition works and how heaps are the natural choice.
Read the 'Intuition' section. Focus on understanding why a max-heap for the left half and a min-heap for the right half gives you efficient access to the middle elements.
3. Maintaining the Heap Invariants
For this system to work, we must enforce two critical rules, or invariants, at all times:
- Value Invariant: Every number in the max-heap (the "small" half) must be less than or equal to every number in the min-heap (the "large" half).
- Size Invariant: The two heaps must be balanced. Their sizes can differ by at most 1.
How do we maintain these invariants every time we call addNum? The process is quite clever. Let's watch a detailed walkthrough that demonstrates the algorithm for adding numbers and rebalancing the heaps.
Find Median from Data Stream - Heap & Priority Queue - Leetcode 295
The NeetCode channel provides one of the clearest explanations of this algorithm. This video will walk you through the logic of adding numbers and rebalancing the heaps to maintain the invariants.
Watch the video from 04:20 to 14:16. This segment explains the two-heap concept, the properties they must maintain, and provides a step-by-step example of adding several numbers while keeping the heaps balanced.
Let's summarize the logic for addNum(num):
- Add to a Heap: A simple rule is to always add the new number to one of the heaps first. Let's say we always add to the max-heap (small half).
- Maintain Value Invariant: The number we just added might be larger than some numbers in the min-heap (large half), violating our rule. To fix this, we immediately take the largest element from the max-heap (its top) and move it to the min-heap. This single transfer elegantly ensures the value invariant holds.
- Maintain Size Invariant (Rebalance): After the transfer, one heap might have grown too large. We check the sizes. If one heap has more than one extra element compared to the other, we move the top element from the larger heap to the smaller one.
By convention, we often maintain the balance such that if there's an odd number of elements, the heap corresponding to the larger numbers (the min-heap) holds the extra element.
Test your understanding!
Suppose our heaps are in the following state:
max_heap(small half):[3, 1](top is 3)min_heap(large half):[5, 8](top is 5)
Now, we call addNum(2). Walk through the balancing steps. What will be the final state of the two heaps?
Show answer
- Add to
max_heap: We push2intomax_heap. The state becomesmax_heap = [3, 1, 2]. - Maintain Value Invariant: We pop the max element from
max_heap(which is3) and push it tomin_heap.max_heapis now[2, 1](top is 2).min_heapis now[3, 5, 8](top is 3).
- Rebalance Sizes:
max_heaphas 2 elements, andmin_heaphas 3. The size difference is 1, which is acceptable. No rebalancing is needed.
Final State:
max_heap:[2, 1]min_heap:[3, 5, 8]
The median would be3(the top of the larger heap).
4. Implementation in Python
With the logic for adding and balancing clear, the findMedian operation becomes straightforward.
- If the heaps have the same size (even total elements), the median is
(max_heap.top() + min_heap.top()) / 2. - If one heap has one more element (odd total elements), the median is
larger_heap.top().
Now, let's look at the implementation. A key detail in Python is that the heapq library only provides a min-heap. To simulate a max-heap, we use a clever trick: we insert the negative of the numbers. This way, the smallest negative number (which corresponds to the largest absolute value) will be at the top of the heap.
Find Median from Data Stream - Heap & Priority Queue - Leetcode 295
Let's return to the NeetCode video to see the full Python implementation. Pay close attention to how the max-heap is simulated and how the values are handled during median calculation.
Watch from 15:13 to the end. This part covers the Python code for addNum and findMedian, including the critical detail of negating numbers for the max-heap.
Here's a clean implementation based on the principles discussed:
import heapq
class MedianFinder:
def __init__(self):
# The 'small' half of numbers, stored as a max-heap
# We store negative values to simulate a max-heap with heapq
self.small_heap = []
# The 'large' half of numbers, stored as a min-heap
self.large_heap = []
def addNum(self, num: int) -> None:
# Add to max-heap first
heapq.heappush(self.small_heap, -1 * num)
# Ensure value invariant: every num in small_heap <= every num in large_heap
if self.small_heap and self.large_heap and (-1 * self.small_heap[0]) > self.large_heap[0]:
val = -1 * heapq.heappop(self.small_heap)
heapq.heappush(self.large_heap, val)
# Ensure size invariant: heaps' sizes differ by at most 1
if len(self.small_heap) > len(self.large_heap) + 1:
val = -1 * heapq.heappop(self.small_heap)
heapq.heappush(self.large_heap, val)
if len(self.large_heap) > len(self.small_heap) + 1:
val = heapq.heappop(self.large_heap)
heapq.heappush(self.small_heap, -1 * val)
def findMedian(self) -> float:
# If small_heap is larger (odd total)
if len(self.small_heap) > len(self.large_heap):
return -1 * self.small_heap[0]
# If large_heap is larger (odd total)
if len(self.large_heap) > len(self.small_heap):
return self.large_heap[0]
# If sizes are equal (even total)
return (-1 * self.small_heap[0] + self.large_heap[0]) / 2.0
Complexity Analysis
Let's formally analyze the performance.
- = total number of elements added so far.
295. Find Median from Data Stream - In-Depth Explanation
For a final review, the 'Time and Space Complexity' and 'Common Pitfalls' sections from the Algo.monster article are invaluable. They summarize the performance and highlight mistakes to avoid in an interview.
Read both sections. The pitfalls, such as incorrect balancing, forgetting negation, and integer division, are exactly the kinds of bugs that interviewers look for.
-
Time Complexity:
addNum: Each call involves one or two heap pushes and pops. Since each heap operation costs , the total time is .findMedian: This only requires looking at the top elements of the heaps, which is a operation.
-
Space Complexity:
- We need to store all numbers in the two heaps, so the space complexity is .
This is a highly efficient solution that perfectly balances the cost of adding numbers and finding the median, making it ideal for data stream scenarios.
Conclusion
Congratulations on completing the Heaps and Priority Queues module! You've tackled some of the most important heap-based interview patterns, from finding "Top K" elements to merging K sorted lists, and now, finding the median from a data stream.
Key Takeaways:
- The "Find Median from Data Stream" problem can be solved efficiently using two heaps: a max-heap for the smaller half and a min-heap for the larger half.
- Maintaining a value invariant (small half <= large half) and a size invariant (sizes differ by at most 1) is crucial.
- This design achieves for
addNumand forfindMedian, a massive improvement over naive sorting. - In Python, you can simulate a max-heap with the
heapq(min-heap) library by negating the values.
This lesson concludes our deep dive into heap-based data structures. In our next module, we will shift focus from data structures to powerful algorithmic techniques, starting with Recursion and Backtracking, where you'll learn to solve problems involving exploration and combinatorial generation.