数据流中的中位数
题目描述 如何得到一个数据流中的中位数?如果从数据流中读出奇数个数值,那么中位数就是所有数值排序之后位于中间的数值。如果从数据流中读出偶数个数值,那么中位数就是所有数值排序之后中间两个数的平均值。由于数据是从一个数据流中读出来,因而数据的数目随着时间的增加而增加,如果用一个数据容器来保存从流中读出来的数据,则当新的数据从流中读出来的时候,这些数据就插入数据容器。这个数据用什么数据结构...
题目描述
如何得到一个数据流中的中位数?如果从数据流中读出奇数个数值,那么中位数就是所有数值排序之后位于中间的数值。如果从数据流中读出偶数个数值,那么中位数就是所有数值排序之后中间两个数的平均值。
由于数据是从一个数据流中读出来,因而数据的数目随着时间的增加而增加,如果用一个数据容器来保存从流中读出来的数据,则当新的数据从流中读出来的时候,这些数据就插入数据容器。这个数据用什么数据结构定义合适呢?接下来讨论一下最合适的数据结构:
首先,先用一个表格列出可用数据结构的各复杂度
数据结构 | 插入的时间复杂度 | 得到中位数的时间复杂度 |
没有排序的数组 | O(1) | O(n) |
排序的数组 | O(n) | O(1) |
排序的链表 | O(n) | O(1) |
二叉搜索树 | 平均O(logN),最差O(n) | 平均O(logN),最差O(n) |
VAL树 | O(logN) | O(1) |
最大堆和最小堆 | O(logN) | O(1) |
接下来,细细分析一下:
- 数组是最简单的数据容器。如果数组没有排序,那么可以用前面提到的Partition函数找出数组中的中位数。在没有排序的数组中插入一个数字和找出中位数的时间复杂度分别是O(1)和O(n)。
- 在往数组里插入数字的时候我们可以让数组保持排序。这时由于可能需要移动O(n)个数字,因此需要O(n)的时间复杂度。在已排好的数组中取中位数,我们只需要O(1)的时间复杂度即可。
- 排序的链表是另外一种选择,我们需要O(n)的时间复杂度才能在链表中找到其合适的位置插入新的数据。如果定义两个指针指向链表中间的节点,那么可以在O(1)的时间内得出中位数。
- 二叉搜索树可以把插入新数据的平均时间复杂度降低到最低,但是由于二叉搜索树极度不平衡,从而看起来像一个排序的链表的时候,插入新数据的时间仍然是O(n),为了得到中位数,可以在二叉树节点中添加一个表示子树节点数目的字段。有了这个字段,可以在平均O(logN)时间内得到中位数,但最差情况仍然是O(logN)。
- 为了避免二叉搜索树的最差情况,可以利用平衡的二叉搜索树,即VAL树。通常VAL树的平衡因子是左右子树的高度差。可以稍作修改,把VAL树的平衡因子改为左右子树节点数目之差。有了这个改动,可以用O(logN)时间往VAL树中添加一个新节点,同时用O(1)时间得到所有节点的中位数。
- 虽然VAL树的效率很高,但是大部分编程语言的函数库是没有实现这个数据结构的,在面试期间短短几十分钟也是不可能实现这个功能的,所以只能考虑其他办法。
从上图可以看到,如果容器中数据的数目是奇数,那么两个指针指向同一个数据。
我们可以发现,整个数据容器被分成两个部分。位于容器左边部分的数据结构比右边的数据结构小。另外,p1指针指向的数据是左边部分最大的数,p2指向的是右边部分最小的数。
OK,找出最优的数据结构后,我们再考虑一点细节。首先要保证数据平均分配到两个堆中,因此两个堆中的数据的数目之差不能为1。为了实现平均分配,可以在数据的总数目是偶数的情况下把新数据插入最小堆,否则插入最大堆。
还要保证最大堆的所有数都大于最小堆,因此按照前面的分配规则,会把新的数据插入最小堆。如果此时插入最小堆的数据大于最大堆的最小值,那么它就不能成为最小堆的数据,因此我们需要把这个新数据先插入最大堆,然后取出最大堆里最小值min,再把找到min插入最小堆中,以此来满足我们的规则——最小堆中所有数字都小于最大堆的数字。同理可插入新数据进入最大堆。
以下为C#实现的代码,用两个list作为堆容器来实现的:
using System;
using System.Collections.Generic;
namespace 数据流中的中位数
{
class Program
{
static void Main(string[] args)
{
int[] num = new int[9] { 5, 2, 3, 4, 1, 6, 7, 0, 8 };
Solution s = new Solution();
for (int i = 0; i < num.Length; i++) //模拟逐次从流中读出数据,取其中位数查看结果
{
s.Insert(num[i]);
Console.WriteLine(s.GetMedian());
}
}
}
class Solution
{
private List<int> max = new List<int>(); //容器右边较大数所存储的堆
private List<int> min = new List<int>(); //容器左边较小数所存储的堆
/// <summary>
/// 首先需要保证最大堆和最小堆能够平均分配数据,因此两个堆的数目差不能超过1.为了实现平均分配,可以在数据的总数目是偶数时把新数据插入最小堆,否则插入最大堆。
/// </summary>
/// <param name="num">插入的数字</param>
public void Insert(int num)
{
//如果两个容器数量不平衡,则让其保持平衡,把新数据添加到max中
if (((min.Count + max.Count) & 1) == 0)
{
//新插入的数据大于最小堆的最大值,那么先把这个数据添加进最大堆,然后取出最大堆的最小值,把最小值插入最小堆min中
if (max.Count > 0 && num > max[0])
{
Add(max, num);
num = max[0];
max.RemoveAt(0);
}
Add(min, num);
}
else
{
//如果要把新数据插入最大堆max,先判断新数据是否小于最小堆的最大值,如果是,那么先把新数据插入最小堆,取出最小堆的最大值,
//把得到的最大值插入最大堆max中
if (min.Count > 0 && min[min.Count - 1] > num)
{
Add(min, num);
num = min[min.Count - 1];
min.RemoveAt(min.Count - 1);
}
Add(max, num);
}
}
/// <summary>
/// 用来得到中位数,如果最大堆和最小堆的数量加起来是偶数,说明中位数得计算,那么求最大堆最小值与最小堆最大值的和的一半即可
/// </summary>
/// <returns>中位数</returns>
public double GetMedian()
{
int size = min.Count + max.Count;
//如果两个堆数据数目之和为奇数,说明返回最小堆的最大值即可(因为优先插入最小堆,最小堆会比最大堆多一个数)
if ((size & 1) == 0)
return (double)(min[min.Count - 1] + max[0]) / 2.0;
return (double)min[min.Count - 1];
}
//排好序的添加到两个堆中,都按升序排列,在调用最小堆时,则逆序调用即可
private void Add(List<int> list, int num)
{
if (list.Count == 0)
list.Add(num);
else
{
for (int i = 0; i < list.Count; i++)
{
if (list[i] > num)
{
list.Insert(i, num);
return;
}
}
list.Add(num);
}
}
}
}
更多推荐
所有评论(0)