#【LeetCode 295】数据流的中位数:大小堆的极致配合

0 阅读4分钟

在算法面试中, “数据流的中位数” (LeetCode 295)是一道考察数据结构设计能力的经典题目。很多同学在初次遇到这道题时,往往会被“动态维护中位数”这个需求卡住。今天,我们将用最通俗的语言,带你彻底吃透这道题,掌握大小堆(对顶堆) 这一核心利器。

一、 题目解析:我们在找什么?

中位数是有序列表中间的数。如果列表长度是偶数,中位数就是中间两个数的平均值。
这道题的特殊之处在于:数据是动态流入的。我们需要设计一个数据结构,支持两个操作:

  1. addNum(num):添加一个数。
  2. 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. 平衡大小:如果小顶堆比大顶堆多超过1个元素,就把小顶堆的堆顶移回大顶堆。

  3. 查找中位数

    • 如果两堆大小相等,中位数 = (大顶堆堆顶 + 小顶堆堆顶) / 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(); // 小顶堆多一个时,中位数是小顶堆堆顶
    }
};

代码深度解析(为什么这么写?)

  1. 为什么先放 maxHeap 再移到 minHeap

    • 这是为了保证小顶堆的所有元素都大于等于大顶堆。如果直接放 minHeap,可能会破坏这个性质。
  2. 为什么平衡条件是 minHeap.size() > maxHeap.size() + 1

    • 我们允许小顶堆比大顶堆多1个(当总数为奇数时,中位数在小顶堆)。但如果多2个及以上,就必须移回去。
  3. 时间复杂度真的是 O(log n) 吗?

    • 是的。addNum 中最多进行3次堆操作(push/pop),每次堆操作是 O(log n)。findMedian 是 O(1)。所以整体是 O(log n)。

四、 面试加分项

如果在面试中遇到这道题,除了写出上述代码,你还可以主动补充以下两点,展示你的工程思维:

  1. 空间换时间:大小堆本质上是用 O(n) 的空间复杂度,换取了 O(log n) 的插入和 O(1) 的查询,这是算法设计中非常经典的权衡。
  2. 实际应用场景:这种“动态维护中位数”的模型,在实际工程中常用于实时监控系统(如CPU使用率的中位数统计)金融数据流分析以及推荐系统中的实时特征计算等场景。

希望这篇题解能帮你彻底拿下“数据流的中位数”!如果有任何疑问,欢迎在评论区交流。