在算法面试中,数据流相关问题一直是高频考点,而「数据流的中位数」更是其中的经典题型。它不仅考察对中位数概念的理解,更考验我们对数据结构的灵活运用------如何在动态插入数据的同时,高效地获取当前所有元素的中位数?今天我们就来手把手拆解 LeetCode 295 题,从问题分析到代码实现,彻底搞懂双堆解法的核心逻辑。
一、题目核心需求拆解
首先明确题目要求:实现一个 MedianFinder 类,支持两个核心操作:
-
addNum(int num):将整数 num 加入数据流(动态插入,数据无序);
-
findMedian():返回当前所有元素的中位数(奇数个元素取中间值,偶数个元素取两个中间值的平均值)。
这里的关键痛点的是「动态插入」+「高效查询」。如果我们用普通数组存储,每次插入后排序,查询中位数的时间复杂度是 O(n log n),插入的时间复杂度也是 O(n log n),当数据流很大时,效率会非常低。因此,我们需要一种更优的数据结构来平衡插入和查询的效率。
二、核心思路:双堆协同,各司其职
解决这个问题的最优思路是使用「两个堆」------大顶堆和小顶堆,让它们分工协作,共同维护数据流的有序性,同时保证中位数可以快速获取。
我们先明确两个堆的职责:
-
大顶堆(maxHeap):存储数据流中较小的一半元素,堆顶是这一半元素中的最大值(也就是整个数据流的「左中位数」);
-
小顶堆(minHeap):存储数据流中较大的一半元素,堆顶是这一半元素中的最小值(也就是整个数据流的「右中位数」)。
为了保证中位数能直接通过堆顶获取,我们需要遵守两个关键平衡规则:
-
规则1:大顶堆的长度 只能等于 小顶堆的长度,或 比小顶堆多1;
-
规则2:绝对不允许小顶堆的长度大于大顶堆(避免右半部分元素比左半部分多,导致中位数计算偏差)。
这样一来,中位数的计算就变得异常简单:
-
当元素总数为奇数时,大顶堆的堆顶就是中位数(因为大顶堆多一个元素);
-
当元素总数为偶数时,中位数是大顶堆堆顶和小顶堆堆顶的平均值。
三、具体实现步骤(附完整代码)
接下来我们分步骤实现 MedianFinder 类,核心分为三部分:堆的基础操作(插入、弹出)、addNum 中的堆平衡、findMedian 中的中位数计算。
1. 类的初始化
首先定义两个堆,分别作为大顶堆和小顶堆,初始化时均为空数组。
typescript
class MedianFinder {
maxHeap: Array<number>;
minHeap: Array<number>;
constructor() {
this.maxHeap = []; // 存储左半部分(较小元素),大顶堆
this.minHeap = []; // 存储右半部分(较大元素),小顶堆
}
}
2. 堆的基础操作(私有方法)
堆的核心操作是「插入时向上调整」和「弹出时向下调整」,我们分别实现大顶堆和小顶堆的这两个操作。
(1)大顶堆的插入与弹出
大顶堆的规则:每个父节点的值 >= 子节点的值。插入时向上调整,弹出堆顶后向下调整。
typescript
// 大顶堆插入:向上调整
private pushMaxHeap(num: number) {
this.maxHeap.push(num);
let i = this.maxHeap.length - 1; // 插入的元素索引(最后一位)
while (i > 0) {
const parent = (i - 1) >> 1; // 父节点索引(等价于 Math.floor((i-1)/2))
if (this.maxHeap[parent] >= this.maxHeap[i]) break; // 父节点 >= 子节点,符合大顶堆,退出
// 交换父节点和子节点,继续向上调整
[this.maxHeap[parent], this.maxHeap[i]] = [this.maxHeap[i], this.maxHeap[parent]];
i = parent;
}
}
// 大顶堆弹出:弹出堆顶(最大值),并向下调整
private popMaxHeap(): number | undefined {
if (this.maxHeap.length === 0) return undefined;
const top = this.maxHeap[0]; // 堆顶(最大值)
const last = this.maxHeap.pop()!; // 弹出最后一个元素,用于替换堆顶
if (this.maxHeap.length > 0) {
this.maxHeap[0] = last; // 用最后一个元素替换堆顶
let i = 0;
const n = this.maxHeap.length;
while (true) {
const left = 2 * i + 1; // 左子节点索引
const right = 2 * i + 2; // 右子节点索引
let largest = i; // 初始化最大值索引为当前节点
// 找到左、右子节点中较大的那个
if (left < n && this.maxHeap[left] > this.maxHeap[largest]) largest = left;
if (right < n && this.maxHeap[right] > this.maxHeap[largest]) largest = right;
if (largest === i) break; // 没有比当前节点大的子节点,退出
// 交换当前节点和最大子节点,继续向下调整
[this.maxHeap[i], this.maxHeap[largest]] = [this.maxHeap[largest], this.maxHeap[i]];
i = largest;
}
}
return top;
}
(2)小顶堆的插入与弹出
小顶堆的规则:每个父节点的值 <= 子节点的值。插入和弹出的调整逻辑与大顶堆相反。
typescript
// 小顶堆插入:向上调整
private pushMinHeap(num: number) {
this.minHeap.push(num);
let i = this.minHeap.length - 1;
while (i > 0) {
const parent = (i - 1) >> 1;
if (this.minHeap[parent] <= this.minHeap[i]) break; // 父节点 <= 子节点,符合小顶堆,退出
[this.minHeap[parent], this.minHeap[i]] = [this.minHeap[i], this.minHeap[parent]];
i = parent;
}
}
// 小顶堆弹出:弹出堆顶(最小值),并向下调整
private popMinHeap(): number | undefined {
if (this.minHeap.length === 0) return undefined;
const top = this.minHeap[0];
const last = this.minHeap.pop()!;
if (this.minHeap.length > 0) {
this.minHeap[0] = last;
let i = 0;
const n = this.minHeap.length;
while (true) {
const left = 2 * i + 1;
const right = 2 * i + 2;
let smallest = i;
if (left < n && this.minHeap[left] < this.minHeap[smallest]) smallest = left;
if (right < n && this.minHeap[right] < this.minHeap[smallest]) smallest = right;
if (smallest === i) break;
[this.minHeap[i], this.minHeap[smallest]] = [this.minHeap[smallest], this.minHeap[i]];
i = smallest;
}
}
return top;
}
3. addNum:插入元素并平衡堆
插入元素时,先判断元素应该放入哪个堆,再根据平衡规则调整两个堆的大小,确保符合我们之前定义的规则。
typescript
addNum(num: number): void {
// 第一步:决定放入哪个堆
if (this.maxHeap.length === 0 || num <= this.maxHeap[0]) {
// 元素 <= 大顶堆堆顶 → 放入大顶堆(左半部分)
this.pushMaxHeap(num);
} else {
// 元素 > 大顶堆堆顶 → 放入小顶堆(右半部分)
this.pushMinHeap(num);
}
// 第二步:平衡两个堆的大小,遵守核心规则
if (this.maxHeap.length > this.minHeap.length + 1) {
// 大顶堆比小顶堆多2个及以上 → 挪一个最大元素到小顶堆
const val = this.popMaxHeap()!;
this.pushMinHeap(val);
} else if (this.minHeap.length > this.maxHeap.length) {
// 小顶堆比大顶堆长 → 挪一个最小元素到大顶堆
const val = this.popMinHeap()!;
this.pushMaxHeap(val);
}
}
4. findMedian:计算并返回中位数
根据两个堆的大小关系,直接计算中位数,逻辑非常简洁。
typescript
findMedian(): number {
const maxHeapSize = this.maxHeap.length;
const minHeapSize = this.minHeap.length;
// 奇数个元素:大顶堆堆顶是中位数
// 偶数个元素:两个堆顶的平均值
return maxHeapSize === minHeapSize
? (this.maxHeap[0] + this.minHeap[0]) / 2
: this.maxHeap[0];
}
5. 完整代码(可直接提交 LeetCode)
typescript
class MedianFinder {
maxHeap: Array<number>;
minHeap: Array<number>;
constructor() {
this.maxHeap = [];
this.minHeap = [];
}
addNum(num: number): void {
if (this.maxHeap.length === 0 || num <= this.maxHeap[0]) {
this.pushMaxHeap(num);
} else {
this.pushMinHeap(num);
}
if (this.maxHeap.length > this.minHeap.length + 1) {
const val = this.popMaxHeap()!;
this.pushMinHeap(val);
} else if (this.minHeap.length > this.maxHeap.length) {
const val = this.popMinHeap()!;
this.pushMaxHeap(val);
}
}
findMedian(): number {
const maxHeapSize = this.maxHeap.length;
const minHeapSize = this.minHeap.length;
if (maxHeapSize === minHeapSize) return (this.maxHeap[0] + this.minHeap[0]) / 2;
else return this.maxHeap[0];
}
private pushMaxHeap(num: number) {
this.maxHeap.push(num);
let i = this.maxHeap.length - 1;
while (i > 0) {
const parent = (i - 1) >> 1;
if (this.maxHeap[parent] >= this.maxHeap[i]) break;
[this.maxHeap[parent], this.maxHeap[i]] = [this.maxHeap[i], this.maxHeap[parent]];
i = parent;
}
}
private popMaxHeap(): number | undefined {
if (this.maxHeap.length === 0) return undefined;
const top = this.maxHeap[0];
const last = this.maxHeap.pop()!;
if (this.maxHeap.length > 0) {
this.maxHeap[0] = last;
let i = 0;
const n = this.maxHeap.length;
while (true) {
let left = 2 * i + 1;
let right = 2 * i + 2;
let largest = i;
if (left < n && this.maxHeap[left] > this.maxHeap[largest]) largest = left;
if (right < n && this.maxHeap[right] > this.maxHeap[largest]) largest = right;
if (largest === i) break;
[this.maxHeap[i], this.maxHeap[largest]] = [this.maxHeap[largest], this.maxHeap[i]];
i = largest;
}
}
return top;
}
private pushMinHeap(num: number) {
this.minHeap.push(num);
let i = this.minHeap.length - 1;
while (i > 0) {
const parent = (i - 1) >> 1;
if (this.minHeap[parent] <= this.minHeap[i]) break;
[this.minHeap[parent], this.minHeap[i]] = [this.minHeap[i], this.minHeap[parent]];
i = parent;
}
}
private popMinHeap(): number | undefined {
if (this.minHeap.length === 0) return undefined;
const top = this.minHeap[0];
const last = this.minHeap.pop()!;
if (this.minHeap.length > 0) {
this.minHeap[0] = last;
let i = 0;
const n = this.minHeap.length;
while (true) {
let left = 2 * i + 1;
let right = 2 * i + 2;
let smallest = i;
if (left < n && this.minHeap[left] < this.minHeap[smallest]) smallest = left;
if (right < n && this.minHeap[right] < this.minHeap[smallest]) smallest = right;
if (smallest === i) break;
[this.minHeap[i], this.minHeap[smallest]] = [this.minHeap[smallest], this.minHeap[i]];
i = smallest;
}
}
return top;
}
}
/**
* Your MedianFinder object will be instantiated and called as such:
* var obj = new MedianFinder()
* obj.addNum(num)
* var param_2 = obj.findMedian()
*/
四、复杂度分析(关键考点)
这道题的核心优势在于平衡了插入和查询的效率,我们来分析一下时间和空间复杂度:
-
时间复杂度:
-
addNum 操作:堆的插入和弹出操作都是 O(log n)(n 为当前数据流的元素个数),因此 addNum 的时间复杂度是 O(log n);
-
findMedian 操作:直接获取两个堆的堆顶,时间复杂度是 O(1)。
-
-
空间复杂度:O(n),需要用两个堆存储所有数据流中的元素。
对比普通数组排序的解法(插入 O(n log n),查询 O(1)),双堆解法在插入操作上的效率提升非常明显,尤其适合数据流较大的场景。
五、常见问题与注意点
-
堆的调整逻辑易错点:大顶堆和小顶堆的向上/向下调整条件容易弄反,记住「大顶堆子节点大则交换,小顶堆子节点小则交换」;
-
堆的平衡时机:必须在插入元素后立即平衡,否则会导致后续中位数计算错误;
-
边界处理:当堆为空时,弹出操作返回 undefined,需要用 ! 确保类型安全(LeetCode 中可正常运行);
-
父节点索引计算:(i - 1) >> 1 等价于 Math.floor((i - 1)/2),是堆操作中计算父节点索引的常用技巧,效率更高。
六、总结
「数据流的中位数」本质上是对「动态数据的有序维护」的考察,双堆解法的核心思想是「分治」------将数据流分成两个部分,用两个堆分别维护,通过平衡规则确保中位数可快速获取。
这道题的价值不仅在于解决一个具体问题,更在于掌握堆的灵活运用------堆作为一种高效的优先级队列,在动态数据处理、TopK 等问题中都有广泛应用。掌握了双堆协同的思路,以后遇到类似的动态数据查询问题,就能快速想到解决方案。