剑指Offer:数据流中的中位数(最大堆与最小堆经典实践)

在数据处理场景中,“中位数”是反映数据集中趋势的关键指标。对于静态数组,我们可以通过排序直接找到中位数,但面对数据流(数据动态、持续输入,无法提前存储所有元素)时,传统方法会因频繁排序导致性能瓶颈。本文将详解如何用“最大堆+最小堆”高效解决数据流的中位数问题,结合C++实现与优化思路,让复杂逻辑变得通俗易懂。

一、问题本质:数据流与中位数的矛盾

首先明确问题需求:

  • 输入:持续不断的整数数据流(如实时日志、传感器数据)。

  • 输出:任意时刻数据流的中位数——

  1. 若数据总数为奇数,中位数是排序后中间位置的元素;

  2. 若数据总数为偶数,中位数是排序后中间两个元素的平均值。

为什么传统方法不可行?

若用“每次插入后排序”的思路:

  • 假设插入n个元素,每次排序时间复杂度为O(n log n),总复杂度为O(n² log n),当数据量较大时(如10万级),性能会急剧下降。

  • 核心痛点:不需要完整排序,只需要快速获取“中间位置的1~2个元素”,因此需要更轻量的结构来维护数据的“中间状态”。

二、核心思路:用两个堆拆分数据,平衡效率与查询

1. 堆的特性选择

堆(优先队列)的核心优势是O(1)获取极值、O(log n)插入/删除,恰好满足我们“快速找中间元素”的需求。我们需要两个堆配合工作:

  • 最大堆(左堆):存储数据流中较小的一半元素,堆顶是这部分的最大值(即“左半区天花板”)。

  • 最小堆(右堆):存储数据流中较大的一半元素,堆顶是这部分的最小值(即“右半区地板”)。

通过这种拆分,中位数的计算可以直接通过两个堆的堆顶得到:

  • 数据总数为奇数:右堆元素比左堆多1个,中位数 = 右堆堆顶(中间元素);

  • 数据总数为偶数:左右堆元素数量相等,中位数 = (左堆堆顶 + 右堆堆顶) / 2。

2. 关键约束:保证堆的“平衡”与“有序”

为了让堆顶始终对应中间元素,必须遵守两个核心规则:

规则1:元素数量平衡

两个堆的元素数量之差不能超过1,且默认让右堆多1个元素(奇数时直接取右堆顶)。具体分配逻辑:

  • 当总元素数为偶数时,新元素插入右堆(最终右堆比左堆多1);

  • 当总元素数为奇数时,新元素插入左堆(最终左右堆数量相等)。

规则2:元素大小有序

左堆的所有元素  右堆的所有元素(保证堆顶是中间值)。插入时需先“过滤”元素:

  • 若应插入右堆,但新元素比左堆顶小(违反有序):先插入左堆,再将左堆顶(左半区最大值)移到右堆;

  • 若应插入左堆,但新元素比右堆顶大(违反有序):先插入右堆,再将右堆顶(右半区最小值)移到左堆。

通过这两个规则,两个堆始终处于“左小右大、数量平衡”的状态,确保中位数计算的正确性。

三、C++实现:基于STL的堆操作

C++ STL中没有直接的堆容器,但提供了push_heap(建堆)、pop_heap(弹出堆顶)算法,配合vector可模拟堆结构。同时通过仿函数指定堆类型:

  • less<int>():默认大顶堆(最大堆),堆顶是最大值;

  • greater<int>():小顶堆(最小堆),堆顶是最小值。

完整代码实现

#include <iostream>
#include <vector>
#include <algorithm>  // 包含push_heap、pop_heap
#include <stdexcept>  // 用于抛出空数据异常

using namespace std;

class MedianFinder {
public:
    // 构造函数:初始化两个堆(vector作为底层存储)
    MedianFinder() {}

    // 核心方法1:向数据流中插入一个整数
    void addNum(int num) {
        // 1. 判断当前总元素数的奇偶性,决定新元素应插入的目标堆
        int totalSize = maxHeap.size() + minHeap.size();
        
        // 总元素为偶数:目标堆是最小堆(右堆)
        if ((totalSize & 1) == 0) {
            // 过滤:若新元素比左堆顶小,先插入左堆并取其最大值
            if (!maxHeap.empty() && num < maxHeap[0]) {
                // 插入左堆(最大堆)并调整堆结构
                maxHeap.push_back(num);
                push_heap(maxHeap.begin(), maxHeap.end(), less<int>());
                
                // 取出左堆顶(最大值),准备插入右堆
                num = maxHeap[0];
                // 弹出左堆顶(pop_heap会将堆顶移到末尾,需配合pop_back删除)
                pop_heap(maxHeap.begin(), maxHeap.end(), less<int>());
                maxHeap.pop_back();
            }
            // 将过滤后的值插入右堆(最小堆)并调整
            minHeap.push_back(num);
            push_heap(minHeap.begin(), minHeap.end(), greater<int>());
        }
        // 总元素为奇数:目标堆是最大堆(左堆)
        else {
            // 过滤:若新元素比右堆顶大,先插入右堆并取其最小值
            if (!minHeap.empty() && num > minHeap[0]) {
                // 插入右堆(最小堆)并调整
                minHeap.push_back(num);
                push_heap(minHeap.begin(), minHeap.end(), greater<int>());
                
                // 取出右堆顶(最小值),准备插入左堆
                num = minHeap[0];
                // 弹出右堆顶
                pop_heap(minHeap.begin(), minHeap.end(), greater<int>());
                minHeap.pop_back();
            }
            // 将过滤后的值插入左堆(最大堆)并调整
            maxHeap.push_back(num);
            push_heap(maxHeap.begin(), maxHeap.end(), less<int>());
        }
    }

    // 核心方法2:获取当前数据流的中位数
    double findMedian() {
        int totalSize = maxHeap.size() + minHeap.size();
        // 异常处理:无数据时抛出错误(避免返回无意义的0)
        if (totalSize == 0) {
            throw invalid_argument("数据流为空,无法获取中位数");
        }

        // 总元素为奇数:中位数是右堆顶(minHeap[0])
        if ((totalSize & 1) == 1) {
            return static_cast<double>(minHeap[0]);
        }
        // 总元素为偶数:中位数是(左堆顶 + 右堆顶)/ 2
        else {
            return (static_cast<double>(maxHeap[0]) + minHeap[0]) / 2.0;
        }
    }

private:
    vector<int> maxHeap;  // 左堆:最大堆,存储较小的一半元素
    vector<int> minHeap;  // 右堆:最小堆,存储较大的一半元素
};

// 测试代码
int main() {
    MedianFinder mf;
    
    // 测试1:插入奇数个元素
    mf.addNum(1);
    mf.addNum(3);
    mf.addNum(2);
    cout << "当前中位数(1,3,2):" << mf.findMedian() << endl;  // 输出2.0

    // 测试2:插入偶数个元素
    mf.addNum(4);
    cout << "当前中位数(1,3,2,4):" << mf.findMedian() << endl;  // 输出2.5

    // 测试3:空数据流(会抛出异常,可注释后运行)
    // MedianFinder emptyMf;
    // emptyMf.findMedian();

    return 0;
}

代码关键细节解析

  1. 堆操作逻辑

  • push_heap(v.begin(), v.end(), comp):将v的最后一个元素插入堆,并根据comp调整堆结构(时间复杂度O(log n));

  • pop_heap(v.begin(), v.end(), comp):将堆顶元素(v[0])移到v的末尾,再调整剩余元素为堆结构(需配合pop_back()删除末尾的原堆顶)。

  • 类型转换

    • static_cast<double>()避免整数除法(如(2+3)/2会得到2,转换后得到2.5)。

  • 异常处理

    • 当数据流为空时,findMedian()抛出invalid_argument,比返回0更符合工程规范(避免隐藏错误)。

    四、性能分析与优化方向

    1. 时间复杂度

    • 插入操作(addNum):每次插入涉及1~2次堆调整,每次堆调整时间为O(log n),因此总时间复杂度为**O(log n)**(n为当前数据总数);

    • 查询操作(findMedian):直接取堆顶元素,时间复杂度为**O(1)**。

    相比“每次插入后排序”的O(n log n),此方案在大数据量下性能优势显著(如n=1e5时,log n≈17,效率提升近6000倍)。

    2. 优化方向

    (1)改用priority_queue简化代码

    C++ STL的priority_queue(优先队列)是堆的封装类,可简化push_heap/pop_heap的手动调用。修改后的核心代码如下:

    #include <queue>  // 包含priority_queue
    
    class MedianFinder {
    public:
        MedianFinder() {}
    
        void addNum(int num) {
            int totalSize = maxHeap.size() + minHeap.size();
            if ((totalSize & 1) == 0) {
                if (!maxHeap.empty() && num < maxHeap.top()) {
                    maxHeap.push(num);
                    num = maxHeap.top();
                    maxHeap.pop();
                }
                minHeap.push(num);
            } else {
                if (!minHeap.empty() && num > minHeap.top()) {
                    minHeap.push(num);
                    num = minHeap.top();
                    minHeap.pop();
                }
                maxHeap.push(num);
            }
        }
    
        double findMedian() {
            // 逻辑与之前一致,省略...
        }
    
    private:
        // priority_queue默认是最大堆,最小堆需指定greater<int>
        priority_queue<int> maxHeap;  // 左堆:最大堆
        priority_queue<int, vector<int>, greater<int>> minHeap;  // 右堆:最小堆
    };

    priority_queuetop()方法直接获取堆顶,push()/pop()自动维护堆结构,代码更简洁,推荐工程中使用。

    (2)处理海量数据的扩展

    若数据流规模极大(如超过内存限制),可结合外部排序分布式堆

    • 将数据分片存储在磁盘,每个分片维护局部堆;

    • 合并局部堆的堆顶,得到全局的中间元素(类似归并排序的合并阶段)。

    五、总结

    数据流的中位数问题,核心是用“堆”替代“完整排序”,通过“最大堆+最小堆”的组合,将插入和查询的时间复杂度优化到O(log n)。关键在于记住两个规则:

    1. 数量平衡:总元素偶数插右堆,奇数插左堆;

    2. 大小有序:插入前先过滤,确保左堆≤右堆。

    本文的实现基于C++ STL,兼顾了代码可读性与工程实用性,同时提供了priority_queue简化方案和异常处理,可直接应用于实际项目。掌握这种“拆分数据、利用数据结构特性”的思路,也能解决类似“动态找第k大元素”等问题。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值