LeetCode经典算法面试题 #295:数据流的中位数(双堆法、有序列表、平衡树等多种实现方案详解)

目录

  • 1.问题描述
  • 2.问题分析
    • [2.1 题目理解](#2.1 题目理解)
    • [2.2 核心洞察](#2.2 核心洞察)
    • [2.3 破题关键](#2.3 破题关键)
  • 3.算法设计与实现
    • [3.1 解法一:双堆法(优先队列)](#3.1 解法一:双堆法(优先队列))
    • [3.2 解法二:有序列表(二分插入)](#3.2 解法二:有序列表(二分插入))
    • [3.3 解法三:平衡二叉搜索树(TreeSet 模拟)](#3.3 解法三:平衡二叉搜索树(TreeSet 模拟))
    • [3.4 解法四:基于数组的插入排序](#3.4 解法四:基于数组的插入排序)
    • [3.5 解法五:支持删除操作的动态中位数(懒删除 + 双堆)](#3.5 解法五:支持删除操作的动态中位数(懒删除 + 双堆))
  • 4.性能对比
    • [4.1 理论复杂度对比表](#4.1 理论复杂度对比表)
    • [4.2 实际性能测试(估算)](#4.2 实际性能测试(估算))
    • [4.3 各场景适用性分析](#4.3 各场景适用性分析)
  • 5.扩展与变体
    • [5.1 变体一:滑动窗口的中位数](#5.1 变体一:滑动窗口的中位数)
    • [5.2 变体二:数据流中的第K大元素](#5.2 变体二:数据流中的第K大元素)
    • [5.3 变体三:数据流中的众数](#5.3 变体三:数据流中的众数)
    • [5.4 变体四:支持删除的动态中位数](#5.4 变体四:支持删除的动态中位数)
  • 6.总结
    • [6.1 核心思想总结](#6.1 核心思想总结)
    • [6.2 实际应用场景](#6.2 实际应用场景)
    • [6.3 面试建议](#6.3 面试建议)
    • [6.4 常见面试问题Q&A](#6.4 常见面试问题Q&A)

1.问题描述

中位数是有序整数列表中的中间值。如果列表的大小是偶数,则没有中间值,中位数是两个中间值的平均值。

例如 arr = [2,3,4] 的中位数是 3

例如 arr = [2,3] 的中位数是 (2 + 3) / 2 = 2.5

实现 MedianFinder 类:

  • MedianFinder() 初始化对象。
  • void addNum(int num) 将数据流中的整数 num 添加到数据结构中。
  • double findMedian() 返回到目前为止所有元素的中位数。

示例:

复制代码
输入:
["MedianFinder", "addNum", "addNum", "findMedian", "addNum", "findMedian"]
[[], [1], [2], [], [3], []]
输出:
[null, null, null, 1.5, null, 2.0]

解释:
MedianFinder medianFinder = new MedianFinder();
medianFinder.addNum(1);    // arr = [1]
medianFinder.addNum(2);    // arr = [1, 2]
medianFinder.findMedian(); // 返回 1.5 ((1 + 2) / 2)
medianFinder.addNum(3);    // arr = [1, 2, 3]
medianFinder.findMedian(); // 返回 2.0

提示:

  • -10^5 <= num <= 10^5
  • 在调用 findMedian 之前,数据结构中至少有一个元素
  • 最多 5 × 10^4 次调用 addNumfindMedian

2.问题分析

2.1 题目理解

这是一个动态数据流中求中位数的问题。与静态数组不同,数据是不断增加的,每次添加一个数后,我们都需要能够快速(通常要求 O(log n) 或更优)得到当前所有数的中位数。

中位数将有序数组分成两个长度相等(或相差 1)的部分:左半部分的所有元素 ≤ 右半部分的所有元素。因此,我们可以维护两个堆来分别存储左半部分和右半部分,并保持大小平衡。

2.2 核心洞察

  • 两堆平衡:将数据分为较小的一半和较大的一半,较小的一半用最大堆存储,较大的一半用最小堆存储。
  • 堆的维护 :每次插入时,根据元素大小决定放入哪个堆,然后调整两个堆的大小,使它们满足 size_maxHeap >= size_minHeapsize_maxHeap - size_minHeap <= 1(或相反)。
  • 中位数计算
    • 当元素总数为奇数时,中位数是较大堆(或较小堆)的堆顶;
    • 当元素总数为偶数时,中位数是两个堆顶的平均值。
  • 时间复杂度:每次插入 O(log n),查询 O(1)。

2.3 破题关键

  • 使用 Java 的 PriorityQueue 实现最大堆和最小堆。
  • 注意堆的排序规则:最小堆用默认比较器,最大堆用 (a, b) -> b - aCollections.reverseOrder()
  • 平衡策略:确保最大堆的大小 ≥ 最小堆的大小,且差值 ≤ 1。通常做法是:先默认将元素插入最大堆,然后将最大堆的堆顶移入最小堆,再调整两个堆的大小。

3.算法设计与实现

3.1 解法一:双堆法(优先队列)

核心思想

维护两个堆:最大堆(small)存放较小的一半,最小堆(large)存放较大的一半。始终保持 small.size() >= large.size()small.size() - large.size() <= 1

算法思路

  1. addNum(num)
    • 先将 num 插入 small(最大堆)。
    • small 的堆顶元素移到 large(最小堆)中。
    • 如果 small.size() < large.size(),将 large 的堆顶移回 small
  2. findMedian()
    • 如果总数为奇数(small.size() > large.size()),返回 small.peek()
    • 否则返回 (small.peek() + large.peek()) / 2.0

Java代码实现

java 复制代码
import java.util.PriorityQueue;

class MedianFinder {
    private PriorityQueue<Integer> small; // 最大堆,存放较小的一半
    private PriorityQueue<Integer> large; // 最小堆,存放较大的一半
    
    public MedianFinder() {
        small = new PriorityQueue<>((a, b) -> b - a);
        large = new PriorityQueue<>();
    }
    
    public void addNum(int num) {
        small.offer(num);
        large.offer(small.poll());
        // 保持 small 的大小 >= large 的大小
        if (small.size() < large.size()) {
            small.offer(large.poll());
        }
    }
    
    public double findMedian() {
        if (small.size() > large.size()) {
            return small.peek();
        } else {
            return (small.peek() + large.peek()) / 2.0;
        }
    }
}

性能分析

  • 时间复杂度:addNum O(log n),findMedian O(1)
  • 空间复杂度:O(n),存储所有元素
  • 优点:实现简单,每个操作都是对数时间
  • 缺点:需要两个堆

3.2 解法二:有序列表(二分插入)

核心思想

使用 ArrayList 存储所有元素,每次插入时通过二分查找找到插入位置,然后 add 插入。虽然插入是 O(n),但实现简单,对于小规模数据(如本题 5×10^4)可能勉强可行,但并非最优。

算法思路

  1. addNum(num)
    • 使用 Collections.binarySearch 找到插入位置。
    • 调用 list.add(pos, num) 插入。
  2. findMedian()
    • 直接根据列表大小返回中位数。

Java代码实现

java 复制代码
import java.util.ArrayList;
import java.util.Collections;

class MedianFinder {
    private ArrayList<Integer> list;
    
    public MedianFinder() {
        list = new ArrayList<>();
    }
    
    public void addNum(int num) {
        int pos = Collections.binarySearch(list, num);
        if (pos < 0) pos = -pos - 1;
        list.add(pos, num);
    }
    
    public double findMedian() {
        int n = list.size();
        if (n % 2 == 1) {
            return list.get(n / 2);
        } else {
            return (list.get(n / 2 - 1) + list.get(n / 2)) / 2.0;
        }
    }
}

性能分析

  • 时间复杂度:addNum O(n),findMedian O(1)
  • 空间复杂度:O(n)
  • 优点:实现直观
  • 缺点:插入效率低,不满足对数要求

3.3 解法三:平衡二叉搜索树(TreeSet 模拟)

核心思想

Java 的 TreeSet 基于红黑树实现,但默认不允许重复元素。为了处理重复,我们可以存储自定义对象(元素值和插入顺序),并维护一个指针来快速访问中位数。但获取第 k 个元素需要遍历,复杂度 O(k),实际不可行。这里展示一种使用 TreeSet 并配合迭代器的方法,但仅作为思路参考。

算法思路

  1. 使用 TreeSet<Pair> 存储元素,Pair 包含值和序号。
  2. 维护一个迭代器指向中位数位置。
  3. 插入时,根据位置调整迭代器。

Java代码实现(简化,仅演示)

java 复制代码
import java.util.TreeSet;

class MedianFinder {
    private TreeSet<Pair> set;
    private int count;
    private Pair medianLeft, medianRight; // 用于快速访问
    
    private static class Pair implements Comparable<Pair> {
        int value;
        int id;
        Pair(int value, int id) {
            this.value = value;
            this.id = id;
        }
        @Override
        public int compareTo(Pair o) {
            if (this.value != o.value) return Integer.compare(this.value, o.value);
            return Integer.compare(this.id, o.id);
        }
    }
    
    public MedianFinder() {
        set = new TreeSet<>();
        count = 0;
        medianLeft = medianRight = null;
    }
    
    public void addNum(int num) {
        Pair p = new Pair(num, count++);
        set.add(p);
        // 更新中位数指针,逻辑复杂,这里省略
        // 需要维护两个指针指向中间位置,插入后可能需要移动
    }
    
    public double findMedian() {
        // 需要正确维护 medianLeft 和 medianRight
        // 实际实现复杂,此处仅示意
        return 0.0;
    }
}

说明:由于实现复杂且性能不如双堆,实际中不推荐使用。

3.4 解法四:基于数组的插入排序

核心思想

使用数组存储元素,每次插入时找到合适位置并移动元素。本质是插入排序,效率低。

Java代码实现

java 复制代码
import java.util.Arrays;

class MedianFinder {
    private int[] arr;
    private int size;
    
    public MedianFinder() {
        arr = new int[50000]; // 预分配最大容量
        size = 0;
    }
    
    public void addNum(int num) {
        int pos = 0;
        while (pos < size && arr[pos] < num) pos++;
        // 移动元素
        System.arraycopy(arr, pos, arr, pos + 1, size - pos);
        arr[pos] = num;
        size++;
    }
    
    public double findMedian() {
        if (size % 2 == 1) {
            return arr[size / 2];
        } else {
            return (arr[size / 2 - 1] + arr[size / 2]) / 2.0;
        }
    }
}

性能分析

  • 时间复杂度:addNum O(n),findMedian O(1)
  • 空间复杂度:O(n)
  • 优点:简单
  • 缺点:插入效率低

3.5 解法五:支持删除操作的动态中位数(懒删除 + 双堆)

核心思想

在双堆基础上增加一个 HashMap 记录待删除的元素,当堆顶元素需要被删除时,延迟删除并调整堆。这种结构支持删除任意元素(需要知道元素值),但题目未要求删除,此处作为扩展。

算法思路

  • 维护 small(最大堆)和 large(最小堆)以及一个 Map 记录待删除元素的计数。
  • addNum(num) 同双堆法,但需要平衡时清理堆顶的待删除元素。
  • removeNum(num)num 加入待删除映射,然后触发平衡。
  • findMedian() 前清理堆顶。

Java代码实现

java 复制代码
import java.util.PriorityQueue;
import java.util.HashMap;
import java.util.Map;

class MedianFinderWithDelete {
    private PriorityQueue<Integer> small; // 最大堆
    private PriorityQueue<Integer> large; // 最小堆
    private Map<Integer, Integer> toRemove; // 待删除计数
    private int smallSize, largeSize; // 实际大小(不含待删除)
    
    public MedianFinderWithDelete() {
        small = new PriorityQueue<>((a, b) -> b - a);
        large = new PriorityQueue<>();
        toRemove = new HashMap<>();
        smallSize = largeSize = 0;
    }
    
    public void addNum(int num) {
        if (small.isEmpty() || num <= small.peek()) {
            small.offer(num);
            smallSize++;
        } else {
            large.offer(num);
            largeSize++;
        }
        balance();
    }
    
    public void removeNum(int num) {
        toRemove.put(num, toRemove.getOrDefault(num, 0) + 1);
        // 如果堆顶正好是要删除的元素,需要延迟清理
        if (!small.isEmpty() && small.peek() == num) {
            prune(small);
            smallSize--;
        } else if (!large.isEmpty() && large.peek() == num) {
            prune(large);
            largeSize--;
        } else {
            // 不在堆顶,只减少计数,不立即删除
            if (num <= small.peek()) smallSize--;
            else largeSize--;
        }
        balance();
    }
    
    private void prune(PriorityQueue<Integer> heap) {
        while (!heap.isEmpty() && toRemove.getOrDefault(heap.peek(), 0) > 0) {
            int top = heap.poll();
            toRemove.put(top, toRemove.get(top) - 1);
            if (toRemove.get(top) == 0) toRemove.remove(top);
        }
    }
    
    private void balance() {
        if (smallSize > largeSize + 1) {
            large.offer(small.poll());
            smallSize--;
            largeSize++;
            prune(small);
        } else if (largeSize > smallSize) {
            small.offer(large.poll());
            smallSize++;
            largeSize--;
            prune(large);
        }
        // 清理可能出现在堆顶的待删除元素
        prune(small);
        prune(large);
    }
    
    public double findMedian() {
        if (smallSize > largeSize) {
            return small.peek();
        } else {
            return (small.peek() + large.peek()) / 2.0;
        }
    }
}

性能分析

  • 时间复杂度:addNum O(log n),removeNum O(log n)(分摊),findMedian O(1)
  • 空间复杂度:O(n)
  • 优点:支持删除操作
  • 缺点:实现复杂,需要维护待删除映射

4.性能对比

4.1 理论复杂度对比表

解法 addNum 时间复杂度 findMedian 时间复杂度 空间复杂度 优点 缺点
双堆法 O(log n) O(1) O(n) 高效,常用 需维护两个堆
有序列表 O(n) O(1) O(n) 简单 插入慢
平衡树 O(log n) O(log n) O(n) 通用 实现复杂
插入排序 O(n) O(1) O(n) 简单 插入慢
懒删除双堆 O(log n) O(1) O(n) 支持删除 实现复杂

4.2 实际性能测试(估算)

对于 5×10^4 次插入:

  • 双堆法:约 5×10^4 × log(5×10^4) ≈ 8×10^5 次操作,很快。
  • 有序列表:最坏 O(n²) ≈ 2.5×10^9,不可接受。

4.3 各场景适用性分析

  • 面试场景:双堆法是标准答案,必须掌握。
  • 实际生产:双堆法足够。
  • 需要删除操作:懒删除双堆法。

5.扩展与变体

5.1 变体一:滑动窗口的中位数

题目描述:给定一个数组和一个整数 k,求所有长度为 k 的连续子数组的中位数。

思路:使用双堆 + 延迟删除,维护一个滑动窗口。

Java代码实现

java 复制代码
import java.util.*;

class SlidingWindowMedian {
    public double[] medianSlidingWindow(int[] nums, int k) {
        int n = nums.length;
        double[] result = new double[n - k + 1];
        MedianFinderWithDelete mf = new MedianFinderWithDelete();
        for (int i = 0; i < n; i++) {
            mf.addNum(nums[i]);
            if (i >= k) {
                mf.removeNum(nums[i - k]);
            }
            if (i >= k - 1) {
                result[i - k + 1] = mf.findMedian();
            }
        }
        return result;
    }
}

(注:此处使用前面实现的 MedianFinderWithDelete 类)

5.2 变体二:数据流中的第K大元素

题目描述:设计一个类,从数据流中接收元素,并能随时返回当前第 k 大的元素。

Java代码实现

java 复制代码
import java.util.PriorityQueue;

class KthLargest {
    private PriorityQueue<Integer> minHeap;
    private int k;
    
    public KthLargest(int k, int[] nums) {
        this.k = k;
        minHeap = new PriorityQueue<>(k);
        for (int num : nums) {
            add(num);
        }
    }
    
    public int add(int val) {
        if (minHeap.size() < k) {
            minHeap.offer(val);
        } else if (val > minHeap.peek()) {
            minHeap.poll();
            minHeap.offer(val);
        }
        return minHeap.peek();
    }
}

5.3 变体三:数据流中的众数

题目描述:实时返回数据流中出现次数最多的元素(若有多个返回任意一个)。

Java代码实现

java 复制代码
import java.util.*;

class MajorityFinder {
    private Map<Integer, Integer> freq;
    private PriorityQueue<Map.Entry<Integer, Integer>> maxHeap;
    
    public MajorityFinder() {
        freq = new HashMap<>();
        maxHeap = new PriorityQueue<>((a, b) -> b.getValue() - a.getValue());
    }
    
    public void add(int num) {
        freq.put(num, freq.getOrDefault(num, 0) + 1);
        // 更新堆:懒删除,每次查询时重建或使用更高效方法
        // 为简单,每次查询时重建堆
    }
    
    public int getMajority() {
        // 重建堆(实际效率低,仅示意)
        maxHeap.clear();
        for (Map.Entry<Integer, Integer> e : freq.entrySet()) {
            maxHeap.offer(e);
        }
        return maxHeap.peek().getKey();
    }
}

更高效方法 :使用 HashMapTreeMap 按频率排序,但实现复杂。

5.4 变体四:支持删除的动态中位数

题目描述:在双堆基础上增加删除操作(见 3.5 解法五)。

6.总结

6.1 核心思想总结

  • 使用两个堆(最大堆和最小堆)分别存储较小的一半和较大的一半。
  • 保持两个堆的大小平衡,使得中位数可以通过堆顶元素直接计算。
  • 每个插入操作 O(log n),查询 O(1)。

6.2 实际应用场景

  • 实时统计系统指标(如响应时间的中位数)
  • 金融领域中的移动平均线
  • 科学计算中的在线分位数估计

6.3 面试建议

  • 重点掌握双堆法,并能手写代码。
  • 解释为什么用最大堆和最小堆。
  • 分析时间复杂度和平衡策略。
  • 可以提及更复杂的变体(如支持删除)。

6.4 常见面试问题Q&A

Q1:为什么需要两个堆?

A1:因为中位数需要将数据分成左右两部分,两个堆分别维护这两部分,且能快速获取左右的最大值和最小值。

Q2:如何保证两个堆的大小平衡?

A2:通过每次插入后调整,确保 size_maxHeap >= size_minHeap 且差值 ≤ 1。常见的调整方式:先插入最大堆,再移一个到最小堆,再根据大小调整。

Q3:如果有大量重复元素,堆法有效吗?

A3:有效,因为堆只比较元素值,重复元素会被正常处理。

Q4:能否用一个堆实现?

A4:不能,因为一个堆无法同时获取最大值和最小值,也无法确定中位数位置。

Q5:数据流的中位数可以扩展到多个数据流吗?

A5:可以,但需要更复杂的数据结构,如对顶堆的扩展或使用平衡树。

相关推荐
x_xbx2 小时前
LeetCode:215. 数组中的第K个最大元素
数据结构·算法·leetcode
黎阳之光2 小时前
AI数智筑防线 绿色科技启新篇——黎阳之光硬核技术赋能生态安全双升级
大数据·人工智能·算法·安全·数字孪生
2501_924952692 小时前
C++中的过滤器模式
开发语言·c++·算法
2401_873204652 小时前
C++中的组合模式实战
开发语言·c++·算法
西野.xuan2 小时前
内存布局(堆vs栈)一篇详解!!
java·数据结构·算法
2401_831824962 小时前
高性能压缩库实现
开发语言·c++·算法
2401_874732532 小时前
C++中的策略模式进阶
开发语言·c++·算法
大熊背2 小时前
ISP离线模式应用(二)-如何利用 ISP 离线模式 加速 3DNR 收敛
linux·算法·rtos·isp pipeline·3dnr
zhangfeng11332 小时前
`transformers` 的 `per_device_train_batch_size` 不支持小于 1 的浮点数值,llamafactory 支持
人工智能·算法·batch