目录
- 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次调用addNum和findMedian
2.问题分析
2.1 题目理解
这是一个动态数据流中求中位数的问题。与静态数组不同,数据是不断增加的,每次添加一个数后,我们都需要能够快速(通常要求 O(log n) 或更优)得到当前所有数的中位数。
中位数将有序数组分成两个长度相等(或相差 1)的部分:左半部分的所有元素 ≤ 右半部分的所有元素。因此,我们可以维护两个堆来分别存储左半部分和右半部分,并保持大小平衡。
2.2 核心洞察
- 两堆平衡:将数据分为较小的一半和较大的一半,较小的一半用最大堆存储,较大的一半用最小堆存储。
- 堆的维护 :每次插入时,根据元素大小决定放入哪个堆,然后调整两个堆的大小,使它们满足
size_maxHeap >= size_minHeap且size_maxHeap - size_minHeap <= 1(或相反)。 - 中位数计算 :
- 当元素总数为奇数时,中位数是较大堆(或较小堆)的堆顶;
- 当元素总数为偶数时,中位数是两个堆顶的平均值。
- 时间复杂度:每次插入 O(log n),查询 O(1)。
2.3 破题关键
- 使用 Java 的
PriorityQueue实现最大堆和最小堆。 - 注意堆的排序规则:最小堆用默认比较器,最大堆用
(a, b) -> b - a或Collections.reverseOrder()。 - 平衡策略:确保最大堆的大小 ≥ 最小堆的大小,且差值 ≤ 1。通常做法是:先默认将元素插入最大堆,然后将最大堆的堆顶移入最小堆,再调整两个堆的大小。
3.算法设计与实现
3.1 解法一:双堆法(优先队列)
核心思想:
维护两个堆:最大堆(small)存放较小的一半,最小堆(large)存放较大的一半。始终保持 small.size() >= large.size() 且 small.size() - large.size() <= 1。
算法思路:
addNum(num):- 先将
num插入small(最大堆)。 - 将
small的堆顶元素移到large(最小堆)中。 - 如果
small.size() < large.size(),将large的堆顶移回small。
- 先将
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;
}
}
}
性能分析:
- 时间复杂度:
addNumO(log n),findMedianO(1) - 空间复杂度:O(n),存储所有元素
- 优点:实现简单,每个操作都是对数时间
- 缺点:需要两个堆
3.2 解法二:有序列表(二分插入)
核心思想:
使用 ArrayList 存储所有元素,每次插入时通过二分查找找到插入位置,然后 add 插入。虽然插入是 O(n),但实现简单,对于小规模数据(如本题 5×10^4)可能勉强可行,但并非最优。
算法思路:
addNum(num):- 使用
Collections.binarySearch找到插入位置。 - 调用
list.add(pos, num)插入。
- 使用
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;
}
}
}
性能分析:
- 时间复杂度:
addNumO(n),findMedianO(1) - 空间复杂度:O(n)
- 优点:实现直观
- 缺点:插入效率低,不满足对数要求
3.3 解法三:平衡二叉搜索树(TreeSet 模拟)
核心思想:
Java 的 TreeSet 基于红黑树实现,但默认不允许重复元素。为了处理重复,我们可以存储自定义对象(元素值和插入顺序),并维护一个指针来快速访问中位数。但获取第 k 个元素需要遍历,复杂度 O(k),实际不可行。这里展示一种使用 TreeSet 并配合迭代器的方法,但仅作为思路参考。
算法思路:
- 使用
TreeSet<Pair>存储元素,Pair包含值和序号。 - 维护一个迭代器指向中位数位置。
- 插入时,根据位置调整迭代器。
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;
}
}
}
性能分析:
- 时间复杂度:
addNumO(n),findMedianO(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;
}
}
}
性能分析:
- 时间复杂度:
addNumO(log n),removeNumO(log n)(分摊),findMedianO(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();
}
}
更高效方法 :使用 HashMap 和 TreeMap 按频率排序,但实现复杂。
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:可以,但需要更复杂的数据结构,如对顶堆的扩展或使用平衡树。