从整数流中查找运行中位数


223

可能重复:
C语言中的滚动中值算法

假定从数据流中读取整数。查找迄今为止有效读取的元素的中位数。

我已经读过的解决方案:我们可以在左侧使用最大堆表示小于有效中位数的元素,在右侧使用最小堆表示大于有效中位数的元素。

处理传入的元素后,堆中的元素数量最多相差1个元素。当两个堆包含相同数量的元素时,我们发现堆根数据的平均值为有效中位数。当堆不平衡时,我们从包含更多元素的堆根中选择有效中位数。

但是我们将如何构造最大堆和最小堆,即我们如何知道有效中位数?我认为我们将在max-heap中插入1个元素,然后在min-heap中插入下一个1个元素,以此类推。纠正我,如果我在这里错了。


10
巧妙的算法,使用堆。从标题上,我无法立即想到解决方案。
Mooing Duck 2012年

1
vizier的解决方案对我来说看起来不错,只是我假设(尽管您没有声明)此流可以任意长,所以您无法将所有内容都保留在内存中。是这样吗
野兽

2
@RunningWild对于任意长的流,您可以通过使用Fibonacci堆(最后得到log(N)删除)并按顺序存储指向插入元素的指针(例如双端队列)来获取最后N个元素的中值堆满后,可以在每个步骤中使用元素(也可以将内容从一个堆移到另一个堆)。通过存储重复元素的数量(如果有很多重复),您可能会比N更好一些,但是总的来说,如果您想获得整个数据流的中位数,则我必须做出某种分布假设。
Dougal 2012年

2
您可以从两个堆都为空开始。第一个int进入一个堆;第二个在另一个中进行,或者您将第一个项目移到另一个堆中然后插入。这可以概括为“不允许一个堆大于另一个堆+1”,并且不需要特殊的大小写(空堆的“根值”可以定义为0)
乔恩·瓦特

我只是在MSFT采访中遇到了这个问题。感谢您的发布
R Claven '16

Answers:


383

从流数据中查找运行中位数有很多不同的解决方案,我将在答案的最后简要讨论它们。

问题是有关特定解决方案(最大堆/最小堆解决方案)的详细信息,下面说明基于堆的解决方案如何工作:

对于前两个元素,在左侧的maxHeap中添加较小的元素,在右侧的minHeap中添加较大的元素。然后一一处理流数据

Step 1: Add next item to one of the heaps

   if next item is smaller than maxHeap root add it to maxHeap,
   else add it to minHeap

Step 2: Balance the heaps (after this step heaps will be either balanced or
   one of them will contain 1 more item)

   if number of elements in one of the heaps is greater than the other by
   more than 1, remove the root element from the one containing more elements and
   add to the other one

然后,在任何给定时间,您都可以像这样计算中位数:

   If the heaps contain equal amount of elements;
     median = (root of maxHeap + root of minHeap)/2
   Else
     median = root of the heap with more elements

现在,我将按照答案开头所承诺的一般性地讨论这个问题。从数据流中找到运行中位数是一个难题,对于一般情况而言,有效地找到具有内存限制的精确解决方案可能是不可能的。另一方面,如果数据具有我们可以利用的某些特征,那么我们可以开发有效的专业解决方案。例如,如果我们知道数据是整数类型,则可以使用计数排序,这可以为您提供恒定的内存恒定时间算法。基于堆的解决方案是一种更通用的解决方案,因为它也可以用于其他数据类型(双精度)。最后,如果不需要精确的中位数并且近似值足够,则可以尝试估计数据的概率密度函数,然后使用该函数估计中位数。


6
这些堆无限制地增长(即,一个100个元素的窗口滑过1000万个元素将需要将​​1000万个元素全部存储在内存中)。有关使用可索引跳转列表的另一个答案,请参见下文,该列表仅要求将最近看到的100个元素保留在内存中。
雷蒙德·海廷格

1
您也可以使用堆来实现有限内存解决方案,如问题本身的注释之一所述。
哈坎·塞斯

1
您可以在c中找到
AShelly 2014年

1
哇,这不仅帮助我解决了这个特定问题,而且还帮助我学习了堆。这是我在python中的基本实现:github.com/PythonAlgo/DataStruct
swati saoji

2
@HakanSerce您能否解释为什么我们做了我们所做的事情?我的意思是我可以看到它的工作原理,但是我无法直观地理解它。
shiva

51

如果无法一次将所有项目保存在内存中,则此问题将变得更加棘手。堆解决方案要求您一次将所有元素保存在内存中。在此问题的大多数实际应用中,这是不可能的。

相反,当您看到数字时,请跟踪看到每个整数的次数计数。假设有4个字节的整数,即2 ^ 32个存储桶,或最多2 ^ 33个整数(每个int的键和计数),即2 ^ 35个字节或32GB。它可能远不止于此,因为您不需要存储键或为0的那些条目计数(即像python中的defaultdict一样)。这需要固定的时间来插入每个新的整数。

然后,在任何时候要找到中位数,只需使用计数即可确定哪个整数是中间元素。这需要恒定的时间(尽管常数很大,但是仍然是常数)。


3
如果几乎所有数字一次都看不到,那么稀疏列表将占用更多的内存。而且,如果您有太多的数字,而数字又不太适合,那么大多数数字似乎会出现一次。尽管如此,这对于大量数字来说是一个聪明的解决方案。
Mooing Duck 2012年

1
我同意,对于稀疏列表,这在内存方面会更糟。尽管如果整数是随机分布的,那么您将比直觉所暗示的要早得多地获得重复项。参见mathworld.wolfram.com/BirthdayProblem.html。因此,我非常确定,只要有几GB的数据,此方法就会立即生效。
安德鲁C

4
@AndrewC您能解释一下如何持续花费时间才能找到中位数吗?如果我看过n种不同的整数,那么在最坏的情况下,最后一个元素可能是中位数。这使得发现O(n)活动的中位数。
shshnk

@shshnk在这种情况下不是n个元素的总数>>> 2 ^ 35吗?
VishAmdi '17

@shshnk是正确的,正如您看到的不同整数的数量一样,它仍然是线性的,就像VishAmdi所说的那样,我为此解决方案所做的假设是n是您看到的数字的数量,大于2 ^ 33。如果您没有看到那么多数字,那么maxheap解决方案肯定会更好。
安德鲁·C

49

如果输入的方差在统计上是分布的(例如,正态,对数正态等),则储层采样是一种从任意长的数字流中估计百分位数/中位数的合理方法。

int n = 0;  // Running count of elements observed so far  
#define SIZE 10000
int reservoir[SIZE];  

while(streamHasData())
{
  int x = readNumberFromStream();

  if (n < SIZE)
  {
       reservoir[n++] = x;
  }         
  else 
  {
      int p = random(++n); // Choose a random number 0 >= p < n
      if (p < SIZE)
      {
           reservoir[p] = x;
      }
  }
}

这样,“水库”便是所有输入的连续,均匀(公平)的样本-不论大小。因此,找到中位数(或任何百分位数)是对储层进行排序并轮询有趣点的直接方法。

由于存储库的大小是固定的,因此可以认为排序有效为O(1)-此方法的运行时间和内存消耗都是恒定的。


出于好奇,为什么需要差异?
LazyCat

流返回的大小可能少于SIZE个元素,从而使存储库一半为空。计算中位数时应考虑这一点。
亚历克斯

有没有一种方法可以通过计算差异而不是中位数来使速度更快?删除并添加的样本以及先前的中位数是否足以满足此要求?
inf3rno

30

我发现的计算流的百分位数的最有效方法是算法:Raj Jain,Imrich Chlamtac:无需存储观测值即可动态计算量线和直方图的P²算法。公社 ACM 28(10):1076-1085(1985)

该算法很容易实现,效果很好。但是,这是一个估计,因此请记住这一点。从摘要:

提出了一种启发式算法,用于中位数和其他分位数的动态计算。随着生成观察值,动态生成估算值。观测值不存储;因此,无论观察次数多少,该算法的存储需求都非常小且固定。这使其非常适合在可用于工业控制器和记录器的分位数芯片中实现。该算法进一步扩展到直方图绘图。分析了算法的准确性。


2
Count-Min Sketch比P ^ 2更好,因为它还给出了误差范围,而后者没有误差范围。
sinoTrinity

1
还可以考虑Greenwald和Khanna撰写的“分位数摘要的空间高效在线计算”,它还提供了误差范围并且具有良好的内存要求。
Paul Chernoch 2015年

1
另外,有关概率方法的信息,请参见此博客文章:research.neustar.biz/2013/09/16/… ,其所引用的论文位于: arxiv.org/pdf/1407.1121v1.pdf 这称为“节俭”。流媒体”
Paul Chernoch

27

如果我们想找到最近看到的n个元素的中位数,则此问题有一个精确的解决方案,只需要n个要将最近见元素保留在内存中即可。它速度快,扩展性好。

可转位skiplist支撑O(LN n)的插入,删除,和同时保持排序的顺序索引的搜索任意的元素。与跟踪第n个最旧条目的FIFO队列结合使用时,解决方案很简单:

class RunningMedian:
    'Fast running median with O(lg n) updates where n is the window size'

    def __init__(self, n, iterable):
        self.it = iter(iterable)
        self.queue = deque(islice(self.it, n))
        self.skiplist = IndexableSkiplist(n)
        for elem in self.queue:
            self.skiplist.insert(elem)

    def __iter__(self):
        queue = self.queue
        skiplist = self.skiplist
        midpoint = len(queue) // 2
        yield skiplist[midpoint]
        for newelem in self.it:
            oldelem = queue.popleft()
            skiplist.remove(oldelem)
            queue.append(newelem)
            skiplist.insert(newelem)
            yield skiplist[midpoint]

以下是完整工作代码的链接(一个易于理解的类版本和一个优化的生成器版本,内嵌可索引的跳过列表代码):


7
如果我正确理解的话,这只会为您提供最近N个元素的中位数,而不是到目前为止的所有元素。不过,对于该操作而言,这似乎是一个非常不错的解决方案。
安德鲁C

16
对。答案听起来似乎可以通过仅将最后n个元素保留在内存中来找到所有元素的中位数-通常这是不可能的。该算法只是找到最后n个元素的中位数。
汉斯·彼得·斯托尔

8
术语“运行中位数”通常用于表示数据子集的中位数。OP以非标准方式使用通用术语。
Rachel Hettinger 2014年

18

考虑这一点的一种直观方法是,如果您有一个完全平衡的二叉搜索树,则根将是中值元素,因为会有相同数量的较小和较大元素。现在,如果树未满,情况就不会如此,因为最后一级将缺少元素。

因此,我们可以做的是拥有中位数,以及两棵平衡的二叉树,一棵表示小于中位数的元素,一棵表示大于中位数的元素。两棵树必须保持相同大小。

当我们从数据流中获得一个新的整数时,我们会将其与中位数进行比较。如果它大于中位数,则将其添加到正确的树中。如果两棵树的大小之差大于1,我们将删除右树的min元素,将其设为新的中位数,然后将旧的中值放入左树中。较小的情况类似。


你打算怎么做?“我们删除了右树的min元素”
Hengameh 2015年

2
我的意思是二进制搜索树,因此min元素始终从根开始。
Irene Papakonstantinou

7

高效是一个取决于上下文的词。该问题的解决方案取决于相对于插入量执行的查询量。假设您对中位数感兴趣的最后插入N个数字和K次。基于堆的算法的复杂度为O(N log N + K)。

考虑以下替代方案。将数字插入数组,然后为每个查询运行线性选择算法(例如,使用quicksort枢轴)。现在您有了运行时间为O(KN)的算法。

现在,如果K足够小(不频繁查询),则后一种算法实际上更有效,反之亦然。


1
在堆示例中,查找是恒定时间,因此我认为应为O(N log N + K),但您的观点仍然成立。
Andrew C

是的,很好,请对此进行编辑。您是对的N log N仍然是主导术语。
彼得斯(Peteris),2012年

-2

您不能只用一个堆来做吗?更新:否。查看评论。

不变式:读取2*n输入后,最小堆将保留n最大的输入。

循环:读取2个输入。将它们都添加到堆中,并删除堆的分钟数。这将重新建立不变性。

因此,2n在读取输入后,堆的min为第n大。需要有一些额外的复杂性,才能对中间位置附近的两个元素求平均,并在输入数量奇数后处理查询。


1
不起作用:您可以放下后来变成顶部附近的东西。例如,尝试你的算法与数字1到100,但以相反的顺序:100,99,...,1
zellyn

谢谢,zellyn。我很傻,说服自己不变了。
达里乌斯·培根
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.