如何得到一个数据流中的中位数?如果从数据流中读出奇数个数值,那么中位数就是所有数值排序之后位于中间的数值。如果从数据流中读出偶数个数值,那么中位数就是所有数值排序之后中间两个数的平均值。
例如,
[2,3,4] 的中位数是 3
[2,3] 的中位数是 (2 + 3) / 2 = 2.5
设计一个支持以下两种操作的数据结构:
- void addNum(int num) - 从数据流中添加一个整数到数据结构中。
- double findMedian() - 返回目前所有元素的中位数。
示例 1
输入:
["MedianFinder","addNum","addNum","findMedian","addNum","findMedian"]
[[],[1],[2],[],[3],[]]
输出:[null,null,null,1.50000,null,2.00000]
示例 2
输入:
["MedianFinder","addNum","findMedian","addNum","findMedian"]
[[],[2],[],[3],[]]
输出:[null,null,2.00000,null,2.50000]
限制
- 最多会对
addNum、findMedia
进行50000
次调用。
插入排序解法
对于中位数和顺序统计量的问题,算法导论中有专门的一章来讲。对于一个长度为 n 的未排序数组,要查找它的第几大的数,或者中位数。可以基于快速排序中的分治思想实现一种期望时间复杂度为 O ( n ) O(n) O(n) 的算法,但是,这种方法的最坏时间复杂度为 O ( n 2 ) O(n^2) O(n2)。不过一种更加复杂的方法可以将最坏时间复杂度降至 O ( n ) O(n) O(n)。但是想了想,对于这个题来说,没有想出怎么用这种方式实现一种优于插入排序方法的解法。
但是基于好奇,还是想看下用插入排序的插入过程维护数组,效果怎么样。用插入排序的插入过程维持数组,就是当需要插入新数值时,每次都将其插入到正确的位置上,这样保证整个数组始终是排好序的。这样插入新数的时间复杂度为 O ( n ) O(n) O(n),而返回中位数时,我们只需要根据数组的下标计算即可,所以时间复杂度为 O ( 1 ) O(1) O(1)。下面是具体的Java程序实现,比较简单。
public class MedianFinder {
int[] nums;
int size;
public MedianFinder(){
nums = new int[50000];
size = 0;
}
public void addNum(int num){
int ptr = size;
while(ptr>0 && nums[ptr-1]>num){
nums[ptr] = nums[ptr-1];
ptr--;
}
nums[ptr] = num;
size++;
}
public double findMedian(){
if(size==0){
return 0;
}
if(size%2==1){
return nums[size/2];
}else{
return (0.0+nums[size/2]+nums[size/2-1]) / 2.0;
}
}
}
/**
* Your MedianFinder object will be instantiated and called as such:
* MedianFinder obj = new MedianFinder();
* obj.addNum(num);
* double param_2 = obj.findMedian();
*/
提交结果为
执行用时:215 ms, 在所有 Java 提交中击败了9.34%的用户
内存消耗:49.5 MB, 在所有 Java 提交中击败了99.21%的用户
从直觉上判断,这个题时间复杂的极限可能是把这个 O ( n ) O(n) O(n)改为 O ( l o g n ) O(logn) O(logn)。
AVL树解法
打开《剑指offer》,才发现这个题可以用AVL树的解法来实现 O ( l o g n ) O(logn) O(logn)。不过,要是用AVL树解的话,可以放弃了。无论是笔试还是面试,应该都是写不完的。因而,这里也就不放代码了,其实我也没去实现。
最大堆,最小堆解法
由于AVL树解法的不现实性,《剑指offer》书中给出了另一种同时使用最大堆和最小堆的方法。这个方法还是颇为巧妙的。让较大的一般放在最大堆中,较小的一半放在最小堆中,这样插入新数字的时间复杂度为 O ( l o n g n ) O(longn) O(longn),获取中位数的时间复杂度为 O ( 1 ) O(1) O(1)。妙呀。
下面是具体的Java程序实现,建议再写的时候把最大堆和最小堆单独拿出来分别作为一个新的类,我这种写法显得类有点大。
public class MedianFinder {
int size, leftSize, rightSize;
int[] leftHeap; // 最大堆,存放较小的一半数据
int[] rightHeap; // 最小堆,存放较大的一半数据
final int MAXLENGTH = 50000;
public MedianFinder(){
leftHeap = new int[MAXLENGTH];
rightHeap = new int[MAXLENGTH];
size = 0;
leftSize = 0;
rightSize = 0;
}
public void addNum(int num){
if(size==0){
leftHeap[0] = num;
size = 1;
leftSize = 1;
return;
}
if(num>leftHeap[0]){
// 将其放入最小堆
rightHeap[rightSize] = num;
rightSize++;
keepMinHeap();
}else{
// 将其放入最大堆
leftHeap[leftSize] = num;
leftSize++;
keepMaxHeap();
}
size = leftSize + rightSize;
keepMean();
}
public double findMedian(){
if(size==0){
return -1;
}
if(size%2==1){
return leftHeap[0];
}else{
return (0.0+leftHeap[0]+rightHeap[0]) / 2.0;
}
}
// 均衡最小堆与最大堆之间的元素个数,使最大堆中元素个数比最小堆多1,或相等
private void keepMean(){
if(leftSize==rightSize || leftSize-rightSize==1){
return;
}
if(leftSize>rightSize){
// 需要从最大堆中取出最大值,并放到最小堆中
int temp = peekMaxHeap();
rightHeap[rightSize] = temp;
rightSize++;
keepMinHeap();
}else{
// 需要从最小堆中取出最小值,并放到最大堆中
int temp = peekMinHeap();
leftHeap[leftSize] = temp;
leftSize++;
keepMaxHeap();
}
}
// 从最小堆中取出堆顶元素
private int peekMinHeap(){
if(rightSize==0){
return -1;
}
if(rightSize==1){
rightSize = 0;
return rightHeap[0];
}
int res = rightHeap[0];
rightHeap[0] = rightHeap[rightSize-1];
rightSize--;
int current = 0;
int leftChild = leftChildIndex(current);
int rightChild = rightChildIndex(current);
while(leftChild<rightSize){
int min = current;
if(rightHeap[leftChild]<rightHeap[min]){
min = leftChild;
}
if(rightChild<rightSize && rightHeap[rightChild]<rightHeap[min]){
min = rightChild;
}
if(min==leftChild){
int temp = rightHeap[current];
rightHeap[current] = rightHeap[leftChild];
rightHeap[leftChild] = temp;
current = leftChild;
}else if(min==rightChild){
int temp = rightHeap[current];
rightHeap[current] = rightHeap[rightChild];
rightHeap[rightChild] = temp;
current = rightChild;
}else{
break;
}
leftChild = leftChildIndex(current);
rightChild = rightChildIndex(current);
}
return res;
}
// 从最大堆中取出顶元素
private int peekMaxHeap(){
if(leftSize==0){
return -1;
}
if(leftSize==1){
leftSize = 0;
return leftHeap[0];
}
int res = leftHeap[0];
leftHeap[0] = leftHeap[leftSize-1];
leftSize--;
int current = 0;
int leftChild = leftChildIndex(current);
int rightChild = rightChildIndex(current);
while(leftChild<leftSize){
int max = current;
if(leftHeap[leftChild]>leftHeap[max]){
max = leftChild;
}
if(rightChild<leftSize && leftHeap[rightChild]>leftHeap[max]){
max = rightChild;
}
if(max==leftChild){
int temp = leftHeap[current];
leftHeap[current] = leftHeap[leftChild];
leftHeap[leftChild] = temp;
current = leftChild;
}else if(max==rightChild){
int temp = leftHeap[current];
leftHeap[current] = leftHeap[rightChild];
leftHeap[rightChild] = temp;
current = rightChild;
}else{
break;
}
leftChild = leftChildIndex(current);
rightChild = rightChildIndex(current);
}
return res;
}
// 往最小堆中插入元素之后,调整最小堆
private void keepMinHeap(){
if(rightSize<=1){
return;
}
int current = rightSize-1;
while(current>0){
int parent = parentIndex(current);
if(rightHeap[current]<rightHeap[parent]){
int temp = rightHeap[current];
rightHeap[current] = rightHeap[parent];
rightHeap[parent] = temp;
current = parent;
}else{
break;
}
}
}
// 往最大堆中插入元素之后,调整最大堆
private void keepMaxHeap(){
if(leftSize<2){
return;
}
int current = leftSize - 1;
while(current>0){
int parent = parentIndex(current);
if(leftHeap[current]>leftHeap[parent]){
int temp = leftHeap[current];
leftHeap[current] = leftHeap[parent];
leftHeap[parent] = temp;
current = parent;
}else{
break;
}
}
}
// 计算堆中某个位置的父节点索引
private int parentIndex(int current){
return (current-1)/2;
}
private int leftChildIndex(int current){
return current*2+1;
}
private int rightChildIndex(int current){
return current*2+2;
}
}
在 LeetCode 系统中提交的结果为:
执行用时:67 ms, 在所有 Java 提交中击败了99.41%的用户
内存消耗:50.9 MB, 在所有 Java 提交中击败了81.18%的用户
当数据具有一定规模时,堆的方法明显要比插入排序的方法快很多。