LeetCode 295. 数据流的中位数:双堆解法实战解析

在算法面试中,数据流相关问题一直是高频考点,而「数据流的中位数」更是其中的经典题型。它不仅考察对中位数概念的理解,更考验我们对数据结构的灵活运用------如何在动态插入数据的同时,高效地获取当前所有元素的中位数?今天我们就来手把手拆解 LeetCode 295 题,从问题分析到代码实现,彻底搞懂双堆解法的核心逻辑。

一、题目核心需求拆解

首先明确题目要求:实现一个 MedianFinder 类,支持两个核心操作:

  • addNum(int num):将整数 num 加入数据流(动态插入,数据无序);

  • findMedian():返回当前所有元素的中位数(奇数个元素取中间值,偶数个元素取两个中间值的平均值)。

这里的关键痛点的是「动态插入」+「高效查询」。如果我们用普通数组存储,每次插入后排序,查询中位数的时间复杂度是 O(n log n),插入的时间复杂度也是 O(n log n),当数据流很大时,效率会非常低。因此,我们需要一种更优的数据结构来平衡插入和查询的效率。

二、核心思路:双堆协同,各司其职

解决这个问题的最优思路是使用「两个堆」------大顶堆和小顶堆,让它们分工协作,共同维护数据流的有序性,同时保证中位数可以快速获取。

我们先明确两个堆的职责:

  1. 大顶堆(maxHeap):存储数据流中较小的一半元素,堆顶是这一半元素中的最大值(也就是整个数据流的「左中位数」);

  2. 小顶堆(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] &lt;= 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 &lt;= 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()
 */

四、复杂度分析(关键考点)

这道题的核心优势在于平衡了插入和查询的效率,我们来分析一下时间和空间复杂度:

  1. 时间复杂度

    • addNum 操作:堆的插入和弹出操作都是 O(log n)(n 为当前数据流的元素个数),因此 addNum 的时间复杂度是 O(log n);

    • findMedian 操作:直接获取两个堆的堆顶,时间复杂度是 O(1)。

  2. 空间复杂度:O(n),需要用两个堆存储所有数据流中的元素。

对比普通数组排序的解法(插入 O(n log n),查询 O(1)),双堆解法在插入操作上的效率提升非常明显,尤其适合数据流较大的场景。

五、常见问题与注意点

  • 堆的调整逻辑易错点:大顶堆和小顶堆的向上/向下调整条件容易弄反,记住「大顶堆子节点大则交换,小顶堆子节点小则交换」;

  • 堆的平衡时机:必须在插入元素后立即平衡,否则会导致后续中位数计算错误;

  • 边界处理:当堆为空时,弹出操作返回 undefined,需要用 ! 确保类型安全(LeetCode 中可正常运行);

  • 父节点索引计算:(i - 1) >> 1 等价于 Math.floor((i - 1)/2),是堆操作中计算父节点索引的常用技巧,效率更高。

六、总结

「数据流的中位数」本质上是对「动态数据的有序维护」的考察,双堆解法的核心思想是「分治」------将数据流分成两个部分,用两个堆分别维护,通过平衡规则确保中位数可快速获取。

这道题的价值不仅在于解决一个具体问题,更在于掌握堆的灵活运用------堆作为一种高效的优先级队列,在动态数据处理、TopK 等问题中都有广泛应用。掌握了双堆协同的思路,以后遇到类似的动态数据查询问题,就能快速想到解决方案。

相关推荐
青槿吖2 小时前
第一篇:Redis集群从入门到踩坑:3主3从保姆级搭建+核心原理一次性讲透|面试必看
前端·redis·后端·面试·职场和发展·bootstrap·html
迷藏4942 小时前
**雾计算中的边缘智能:基于Python的轻量级任务调度系统设计与实现**在物联网(IoT)飞速发展的今天,传统云
java·开发语言·python·物联网
美狐美颜sdk3 小时前
2026主流直播美颜sdk对比:效果、算法与成本分析
前端·人工智能·计算机视觉·美颜sdk·直播美颜sdk·第三方美颜sdk·视频美颜sdk
大鹏说大话3 小时前
MySQL与PostgreSQL:底层架构差异与项目选型指南
开发语言
王霸天3 小时前
🚨 还在用 rem) 做大屏适配?用 vfit.js 一键搞定,告别改稿8版的噩梦!
前端·vue.js·数据可视化
Aaron15883 小时前
RFSOC+VU13P/VU9P+GPU通用一体化硬件平台
人工智能·算法·fpga开发·硬件架构·硬件工程·信息与通信·基带工程
c++逐梦人3 小时前
DFS剪枝与优化
算法·深度优先·剪枝
量化炼金 (CodeAlchemy)3 小时前
【交易策略】基于随机森林的市场结构预测:机器学习在量化交易中的实战应用
算法·随机森林·机器学习
文心快码BaiduComate3 小时前
Comate AI IDE三大能力升级:支持语音输入& AI可操作浏览器 & Figma设计与代码双向转换
前端·后端·程序员