在算法面试中, “数据流的中位数” (LeetCode 295)是一道考察数据结构设计能力的经典题目。很多同学在初次遇到这道题时,往往会被“动态维护中位数”这个需求卡住。今天,我们将用最通俗的语言,带你彻底吃透这道题,掌握大小堆(对顶堆) 这一核心利器。
一、 题目解析:我们在找什么?
中位数是有序列表中间的数。如果列表长度是偶数,中位数就是中间两个数的平均值。
这道题的特殊之处在于:数据是动态流入的。我们需要设计一个数据结构,支持两个操作:
addNum(num):添加一个数。findMedian():返回当前所有数的中位数。
核心破题点:
不要一上来就想着“每次添加都排序”,那样太慢了。我们需要思考:中位数本质上把数据分成了两半。
- 左半部分的最大值(记为
maxLeft) - 右半部分的最小值(记为
minRight)
如果数据总数是奇数,中位数就是 maxLeft 或 minRight 中的一个;如果是偶数,中位数就是 (maxLeft + minRight) / 2。
二、 从暴力到最优解的演进
1. 暴力排序法(O(n log n))
最直观的想法是:用一个数组存所有数,每次 findMedian 时先排序,再取中间值。
- 缺点:每次查找都要 O(n log n),当数据量达到 10^5 时,必然超时。
2. 大小堆优化(O(log n))
暴力法的核心瓶颈在于“每次都要重新排序”。有没有办法在添加数据时,就自动维护好“左半部分”和“右半部分”的顺序?
大小堆(对顶堆) 就是为此而生的。我们维护两个堆:
- 大顶堆(MaxHeap) :存左半部分,堆顶是左半部分的最大值。
- 小顶堆(MinHeap) :存右半部分,堆顶是右半部分的最小值。
核心逻辑:
-
添加数据时:先放入大顶堆,再把大顶堆的堆顶移到小顶堆(保证小顶堆的所有数都大于等于大顶堆)。
-
平衡大小:如果小顶堆比大顶堆多超过1个元素,就把小顶堆的堆顶移回大顶堆。
-
查找中位数:
- 如果两堆大小相等,中位数 =
(大顶堆堆顶 + 小顶堆堆顶) / 2 - 如果大顶堆多一个,中位数 =
大顶堆堆顶
- 如果两堆大小相等,中位数 =
三、 代码实现与深度解析
// 简易堆实现(支持自定义比较器)
class Heap {
constructor(compare) {
this.heap = [];
this.compare = compare; // (a, b) => a > b (大顶堆) 或 a < b (小顶堆)
}
push(val) {
this.heap.push(val);
this._bubbleUp(this.heap.length - 1);
}
pop() {
if (this.heap.length === 0) return null;
const top = this.heap[0];
const last = this.heap.pop();
if (this.heap.length > 0) {
this.heap[0] = last;
this._sinkDown(0);
}
return top;
}
peek() {
return this.heap[0];
}
size() {
return this.heap.length;
}
_bubbleUp(i) {
while (i > 0) {
const parent = Math.floor((i - 1) / 2);
if (this.compare(this.heap[i], this.heap[parent])) {
[this.heap[i], this.heap[parent]] = [this.heap[parent], this.heap[i]];
i = parent;
} else break;
}
}
_sinkDown(i) {
const n = this.heap.length;
while (true) {
let target = i;
const left = 2 * i + 1, right = 2 * i + 2;
if (left < n && this.compare(this.heap[left], this.heap[target])) target = left;
if (right < n && this.compare(this.heap[right], this.heap[target])) target = right;
if (target !== i) {
[this.heap[i], this.heap[target]] = [this.heap[target], this.heap[i]];
i = target;
} else break;
}
}
}
/**
* @param {number[]} heights
* @return {number}
*/
var MedianFinder = function() {
// 大顶堆:存左半部分,堆顶是左半部分最大值
this.maxHeap = new Heap((a, b) => a > b);
// 小顶堆:存右半部分,堆顶是右半部分最小值
this.minHeap = new Heap((a, b) => a < b);
};
/**
* @param {number} num
* @return {void}
*/
MedianFinder.prototype.addNum = function(num) {
// 1. 先放入大顶堆
this.maxHeap.push(num);
// 2. 把大顶堆的堆顶移到小顶堆(保证小顶堆的所有数 >= 大顶堆)
this.minHeap.push(this.maxHeap.pop());
// 3. 平衡大小:如果小顶堆比大顶堆多超过1个,移回大顶堆
if (this.minHeap.size() > this.maxHeap.size() + 1) {
this.maxHeap.push(this.minHeap.pop());
}
};
/**
* @return {number}
*/
MedianFinder.prototype.findMedian = function() {
if (this.maxHeap.size() === this.minHeap.size()) {
return (this.maxHeap.peek() + this.minHeap.peek()) / 2;
} else {
return this.minHeap.peek(); // 小顶堆多一个时,中位数是小顶堆堆顶
}
};
代码深度解析(为什么这么写?)
-
为什么先放
maxHeap再移到minHeap?- 这是为了保证小顶堆的所有元素都大于等于大顶堆。如果直接放
minHeap,可能会破坏这个性质。
- 这是为了保证小顶堆的所有元素都大于等于大顶堆。如果直接放
-
为什么平衡条件是
minHeap.size() > maxHeap.size() + 1?- 我们允许小顶堆比大顶堆多1个(当总数为奇数时,中位数在小顶堆)。但如果多2个及以上,就必须移回去。
-
时间复杂度真的是 O(log n) 吗?
- 是的。
addNum中最多进行3次堆操作(push/pop),每次堆操作是 O(log n)。findMedian是 O(1)。所以整体是 O(log n)。
- 是的。
四、 面试加分项
如果在面试中遇到这道题,除了写出上述代码,你还可以主动补充以下两点,展示你的工程思维:
- 空间换时间:大小堆本质上是用 O(n) 的空间复杂度,换取了 O(log n) 的插入和 O(1) 的查询,这是算法设计中非常经典的权衡。
- 实际应用场景:这种“动态维护中位数”的模型,在实际工程中常用于实时监控系统(如CPU使用率的中位数统计) 、金融数据流分析以及推荐系统中的实时特征计算等场景。
希望这篇题解能帮你彻底拿下“数据流的中位数”!如果有任何疑问,欢迎在评论区交流。