题目描述
中位数是有序列表中间的数。如果列表长度是偶数,中位数则是中间两个数的平均值。
例如,
[2,3,4] 的中位数是 $3$
[2,3] 的中位数是 $(2 + 3) / 2 = 2.5$
设计一个支持以下两种操作的数据结构:
void addNum(int num) – 从数据流中添加一个整数到数据结构中。
double findMedian() – 返回目前所有元素的中位数。
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/find-median-from-data-stream\
样例
样例
addNum(1) addNum(2) findMedian() -> 1.5 addNum(3) findMedian() -> 2
算法与数据结构
堆
题解
一种显然的做法是,将数字存储 ArrayList 中,每次需要输出中位数时,对其进行排序,并输出中间值。
对于一组静态数据,中位数是固定的,我们可以先排序,第 $\frac{n}{2}$ 个数据就是中位数。不论询问多少次中位数,直接返回这个固定的值就好了。所以,尽管排序的代价比较大,但是边际成本会很小。
但是,如果我们面对的是动态数据集合,中位数在不停地变动,如果再用先排序的方法,每次询问中位数的时候,都要先进行排序,那效率就不高了。
一种显然的做法是使用优先级队列,对于一个优先级队列,每次遇到 addNum() 就往里面加入新的数据,优先级队列会自动帮我们从小到大排序,而每次遇到 findMedian() 的时候,就输出 pq.size() / 2 位置上的值(如果 size 为奇数)或者 pq.size() / 2 与 pq.size() / 2 + 1 这两个位置的平均值(如果 size 为偶数)。
然而,对于一般的优先级队列,只能访问队首的元素,并不提供随机访问的接口,这时候就只能手动维护两个堆了。
我们需要维护两个堆,一个大顶堆,一个小顶堆。大顶堆中存储前半部分数据,小顶堆中存储后半部分数据,且小顶堆中的数据都大于大顶堆中的数据。
也就是说,如果有 $n$ 个数据,$n$ 是偶数,我们从小到大排序,那前 $\frac{n}{2}$ 个数据存储在大顶堆中,后 $\frac{n}{2}$ 个数据存储在小顶堆中。如果 $n$ 是奇数,情况是类似的,大顶堆就存储 $\frac{n}{2} + 1$ 个数据,小顶堆中就存储 $\frac{n}{2}$ 个数据。
在寻找中位数的时候,如果 $n$ 是奇数,那么中位数就是大顶堆的堆顶元素,如果 $n$ 是偶数,那么中位数就是大顶堆和小顶堆各自堆顶元素的平均值。这个结论是显然成立的。
于是,findMedian() 就很容易得到了。
if (max.size() == min.size()) {
// n 是偶数
return 0.5 * (max.peek() + min.peek());
} else {
// n 是奇数
return max.peek();
}
这样,我们所有的工作就集中在了堆的调整上,如何在插入新数据的时候,依然保证上面的约定条件成立呢?
如果新加入的数据小于等于大顶堆的堆顶元素,我们就将这个新数据插入到大顶堆;否则,我们就将这个新数据插入到小顶堆。
if (num < max.peek()) {
max.add(num);
} else {
min.add(num);
}
这个时候就有可能出现两个堆中的数据个数不符合前面约定的情况:如果 $n$ 是偶数,两个堆中的数据个数都是 $\frac{n}{2}$;如果 $n$ 是奇数,大顶堆有 $\frac{n}{2} + 1$ 个数据,小顶堆有 $\frac{n}{2}$ 个数据。
这个时候,我们可以从一个堆中不停地将堆顶元素移动到另一个堆,通过这样的调整,来让两个堆中的数据满足上面的约定。
我们可以写一个循环,当大顶堆的元素个数小于小顶堆的元素个数时,将小顶堆的堆顶元素出堆,并加入大顶堆;反之,则将大顶堆的堆顶元素出堆,加入小顶堆。这个循环的终止条件是,两个堆的元素个数相等($n$ 为偶数),或者大顶堆比小顶堆多 $1$ 个元素($n$ 为奇数)。
while (true) {
int minSize = min.size();
int maxSize = max.size();
if (minSize == maxSize || minSize + 1 == maxSize) {
break;
} else {
if (minSize < maxSize) {
min.add(max.poll());
} else {
max.add(min.poll());
}
}
}
最后检查一下 Corner Case。
对于本题来说,有可能出现错误的地方,就是当堆为空的时候,访问 peek() 的结果不能交给 int。
在 findMedian() 中,我们可以很自信地说出,这样的情况不可能存在。
因为既然是找中位数,则至少有 $1$ 个元素,而大顶堆的元素个数一定大于或者等于小顶堆的元素个数,因此,大顶堆元素个数不可能为 $0$,大顶堆不可能为空。
既然大顶堆不可能为空,那么,若 $n$ 为奇数,返回大顶堆的堆顶元素,就不可能遇到 null,而若 $n$ 为偶数,说明大顶堆和小顶堆的元素个数相等,那么小顶堆自然也不可能为空,因此 peek() 也是安全的,可以正常计算两个数的均值。
public double findMedian() {
assert !max.isEmpty();
if (max.size() == min.size()) {
return 0.5 * (max.peek() + min.peek());
} else {
return max.peek();
}
}
另外还有一个地方用到了 peek(),那就是在插入的地方。
上面说到,插入新的数据的时候,永远是与大顶堆的堆顶元素比较,而大顶堆不可能为空,所以也是安全的?
等等,那么,第一个元素怎么办?
简单做一下特判就可以了。
if (max.isEmpty() || num < max.peek()) {}
完整代码
class MedianFinder {
private static PriorityQueue<Integer> min;
private static PriorityQueue<Integer> max;
public MedianFinder() {
min = new PriorityQueue<>();
max = new PriorityQueue<>(new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
return o2-o1;
}
});
}
public void addNum(int num) {
if (max.isEmpty() || num < max.peek()) {
max.add(num);
} else {
min.add(num);
}
while (true) {
int minSize = min.size();
int maxSize = max.size();
if (minSize == maxSize || minSize + 1 == maxSize) {
break;
} else {
if (minSize < maxSize) {
min.add(max.poll());
} else {
max.add(min.poll());
}
}
}
}
public double findMedian() {
assert !max.isEmpty();
if (max.size() == min.size()) {
return 0.5 * (max.peek() + min.peek());
} else {
return max.peek();
}
}
}
备注
时间复杂度: $O(5 \cdot \log n) + O(1) \approx O(\log n)$。
最坏情况下,从顶部有三个堆插入和两个堆删除。每一个都需要花费 $O(\log n)$ 时间。
找到平均值需要持续的 $O(1)$ 时间,因为可以直接访问堆的顶部。
空间复杂度:$O(n)$ 用于在容器中保存输入的线性空间。
另外,在上文中提到了
if (pq.size() % 2 == 0) {
return 0.5 * (pq.get(pq.size()) + pq.get(pq.size() + 1));
} else {
return pq.get(pq.size() / 2);
}
自平衡二进制搜索树(如 AVL 树)具有一些非常有趣的特性。它们将树的高度保持在对数范围内。因此,插入新元素具有相当好的时间性能。中值总是缠绕在树根或它的一个子树上。大多数语言实现模拟这种行为的是 multiset 类。唯一的问题是跟踪中值元素。
我们保持两个指针:一个用于中位数较低的元素,另一个用于中位数较高的元素。当元素总数为奇数时,两个指针都指向同一个中值元素(因为在本例中只有一个中值)。当元素数为偶数时,指针指向两个连续的元素,其平均值是输入的代表中位数。
