问题描述
从一组无序的数组里求它的中间值,要求定义一个数据结构,保证用常量的时间可以找到中间值,而插入一个元素的时间复杂度为O(logn),而删除中间值元素的时间复杂度为O(logn)。这个问题要求里最严格的一点是,以上的这些特性的保持是动态的。比如说,我们要输入的元素可能有1000个,但是在我输入前面的若干个元素的时候,它们也满足以上的特性。
分析
在分析这个问题之前,我们先看看中间值的定义,它无非是一组元素中间的值,使得这个值将一个数组分割成两个部分,一部分是比该元素大,另外一部分是比该元素小。但是这两个部分的元素个数相同。比如下图所示的几种情况:
对于上面这种情况,正好是奇数个,我们这里取的是一种特殊情况,让这个数组里元素本身是排好序了的。所以这里取的是中间的元素3.
而这种情况就稍微有点不一样,因为它这里元素的个数是偶数个,如果直接划分成两个部分的话,则表示左边有1, 2, 3, 而右边有4, 5, 6。那么取这个中间值,应该怎么来算呢?在左边那边串中间,最接近中间值的是3, 右边最接近的是4, 所以取的是(3 + 4) / 2。得到的值3.5就是期望的中间值。
前面这里举出的实例更多的是一种特例,因为所有的元素都是排序了的,在大多数情况下,元素并不是排序的,很多像下图那样:
针对这种情形,如果我们要求它的中间值,需要考虑的重点在哪里呢?既然我们要考虑的是中间值,也就是将整个数组划分成相等两个部分的那个点,可不可以用一种划分的方式来处理呢?比如说我们首先设定一个值,这个值作为临时的中间点。然后将比这个值小的放在一个集合里,然后比这个大的值放在另外一个集合里。因为前面设定的这个中间值是假设的,它可能并不准确,需要进行动态的调整。所以要达到这个条件的话,需要保证比中间值小的集合元素个数和比中间值元素大的集合个数相同。理想的情况下,它们应该是一个如下图的关系:
所以,至少需要记录两个集合的元素个数,保证它们至少个数是很接近的,最好是相同。但是,仅仅是记录两个集合的个数就够了吗?比如说,假定左边集合已经有如下元素:{1, 5, 7}, 而右边的元素为{9, 12, 17},中间元素为8。这个时候,确实满足中间值就是8。如下图:
如果下一个元素是4呢?那么左边元素集合为{1, 5, 7, 4},中间元素为8, 而右边元素不变。这个时候,实际上中间值应该就在7和8之间。这个时候对应的情况如下:
如果不调整中间值,而如果后面再增加一个元素比如3呢?这个时候,左边的集合成了{1, 5, 7, 4, 3},中间值是8, 右边的还是没有变化。现在就有问题了。左边的集合元素比右边的集合大2, 必须要动态的去调整中间值节点。所以,必须要将8移到右边的集合里,而从左边的集合里取最大的那个来作为中间值。
嗯,再重复一下前面的过程,当左边的集合元素个数比右边大2的时候,取左边最大的元素作为中间值。那么右边比左边大2的时候呢?取右边最小的元素作为中间值就可以了。到这一步,我们已经有一点感觉了。我们不但要记录两边集合元素的个数,还要记录左边集合的最大值和右边集合的最小值。
保持一个集合里最大值或者最小值,可以有好几种办法来实现。一个是通过比较,保存一个比较得到的最大或者最小值,还有一种就是最大或者最小堆。现在,再结合前面问题里的一个要求,就是插入一个元素和删除一个元素的时间复杂度为O(logn)。所以,这里必然要用到最大最小堆无疑。所以,综合起来来说,前面的过程大致是这样:将一个集合开始的时候划分成两个部分。左边部分是一个最大堆,右边部分是一个最小堆。因为左边要获取最大的元素,而右边部分要获取最小的元素。当两边元素相同的时候,取的中间值就是(左边堆顶元素topleft + 右边堆顶元素topright) / 2。
而如果不相等呢,因为前面有一个调整的手法,当两者相差达到2的时候,将元素多的堆的堆顶元素取出来加入到另外一个堆中间,这样两边的个数就相同了。所以相差为2的时候可以调整为相同。而相差为1的时候,表示某一边多一个元素,这个时候,中间值元素就正好是这个多一个的堆的堆顶元素。
上面过程的伪码实现如下:
public double getMedian(leftset, rightSet, input) { //其中左边集合为比较小的元素集合,右边集合为大的元素集合
while((item = input.read()) != null) // 可以读取到元素{
if(item < leftSet.top) { // 如果小于左边集合
leftSet.insert(item);
} else {
rightSet.insert(item); //将元素加入到右边集合
}
// 判断两边元素个数的差异,进行调整
if(leftSet.size() - rightSet.size() == 2) { //如果左边元素集合比右边元素集合大2, 取顶元素加入到右边
item = leftSet.remove();
rightSet.insert(item);
}
// 同样应用于右边元素比左边元素大2的情况
if(rightSet.size() - leftSet.size() == 2) { //如果右边元素集合比左边元素集合大2, 取顶元素加入到左边
item = rightSet.remove();
leftSet.insert(item);
}
}
// 在元素取完之后要判断两边集合元素的情况
if(leftSet.size() == rightSet.size()) {
return (leftSet.top() + rightSet.top()) / 2;
} else if(leftSet.size() > rightSet.size()) {
return leftSet.top();
} else
return rightSet.top();
}
在这里,进一步细化到代码实现的时候,还有一个需要考虑的。就是刚开始的时候,两个集合都没有元素,该怎么来判断呢?这个时候,可以先取两个元素,比较一下两边,比较小的元素放到左边集合,大的元素放到右边集合。然后左边的实现是使用一个最大堆,而右边的实现是使用一个最小堆。按照前面伪码的提示,后面所要做的就是跟踪两边集合的个数,让它们不要超标了,并及时调整。这样整个问题就解决了。
具体最大堆和最小堆的实现可以参照我前面关于堆方面的文章,这里因为按照提示实现具体的代码已经很容易了,就不再提供详细的实现。
总结
动态的求一组数据的中位数,要点无非就是要找到最接近中间值的那个点。所以需要跟踪两边集合元素的个数和最接近中间值的那几个数。在本问题中,因为要求有其他数据操作的时间复杂度以及需要保存这些数据,所以左边采用了一个最大堆而右边采用了一个最小堆。这样左边堆顶和右边堆顶保存的永远是最接近中间值的元素。这里还有一个限制,就是对于大数据量的情况,因为要将数据保存在两个堆里,方便后面进行调整,所以这种方法就不适用了。
参考材料
http://stackoverflow.com/questions/10657503/find-running-median-from-a-stream-of-integers
http://shmilyaw-hotmail-com.iteye.com/blog/1775868
http://shmilyaw-hotmail-com.iteye.com/blog/1827136