【问题标题】:Find median value from a growing set从不断增长的集合中查找中值
【发布时间】:2009-09-07 03:57:16
【问题描述】:

我在一次采访中遇到了一个有趣的算法问题。我给出了答案,但不确定是否有更好的主意。所以我欢迎大家写一些关于他/她的想法的东西。

你有一个空集。现在元素被一个一个地放入集合中。我们假设所有元素都是整数并且它们是不同的(根据集合的定义,我们不考虑具有相同值的两个元素)。

每次向集合中添加新元素时,都会询问集合的中值。中值的定义与数学中的相同:排序列表中的中间元素。在这里,特别是当集合的大小是偶数时,假设集合的大小=2*x,中间元素是集合的第x个元素。

一个例子: 从一个空集开始, 当添加 12 时,中位数为 12, 当加 7 时,中位数为 7, 加 8 时,中位数为 8, 加 11 时,中位数为 8, 加 5 时,中位数为 8, 加 16 时,中位数为 8, ...

请注意,首先,元素被一个一个添加到集合中,第二个,我们不知道要添加的元素。

我的回答。

既然是求中位数的问题,就需要排序。最简单的解决方案是使用普通数组并保持数组排序。当一个新元素到来时,使用二分搜索找到元素的位置 (log_n) 并将该元素添加到数组中。由于它是一个普通数组,因此需要移动数组的其余部分,其时间复杂度为 n。当插入元素时,我们可以立即得到中位数,使用实例时间。

最差的时间复杂度是:log_n + n + 1。

另一种解决方案是使用链接列表。使用链表的原因是不需要移动数组。但是找到新元素的位置需要线性搜索。添加元素需要即时时间,然后我们需要通过数组的一半找到中位数,这总是需要 n/2 时间。

最差的时间复杂度是:n + 1 + n/2。

第三种解决方案是使用二叉搜索树。使用树,我们避免了移动数组。但是使用二叉搜索树来寻找中位数并不是很有吸引力。所以我改变了二叉搜索树,使左子树和右子树始终保持平衡。这意味着在任何时候,要么左子树和右子树具有相同数量的节点,要么右子树比左子树多一个节点。换句话说,确保在任何时候,根元素都是中位数。当然,这需要改变树的构建方式。技术细节类似于旋转红黑树。

如果树维护得当,保证WORST时间复杂度为O(n)。

所以这三种算法都与集合的大小成线性关系。如果不存在次线性算法,则可以认为这三种算法是最优解。由于它们之间并没有太大区别,因此最好是最容易实现的,这是第二个,使用链接列表。

所以我真正想知道的是,是否会有针对这个问题的亚线性算法,如果有,它会是什么样子。有什么想法吗?

史蒂夫。

【问题讨论】:

  • en.wikipedia.org/wiki/Self-balancing_binary_search_tree我不确定找到中位数是否有用或其复杂度低于O(n)
  • 目前还不清楚这个问题到底是什么。您想要插入集合 + 查找中位数的复杂性,还是只是在集合的各种实现中查找中位数?
  • 您的第一个算法只是插入排序。如果您可以在 O(log(n)+n+1) 中实现插入排序(这只是 O(n)),那么我鼓励您发布您的代码...
  • 致 John Fouhy:是的,它实际上是一种插入排序。请注意,时间复杂度 O(log(n)+n+1) 仅用于添加一个元素,而不是对 n 个元素进行排序。
  • 致亚当·巴特金:很抱歉让您感到困惑。我想知道是否有更好/有趣的算法来解决这个问题:从不断增长的集合中找到中位数。除此之外,其余的只是我的想法,供参考。

标签: algorithm


【解决方案1】:

您的复杂性分析令人困惑。假设总共添加了 n 个项目;我们想要高效地输出 n 个中位数的流(其中流中的第 i 个是前 i 个项目的中位数)。

我相信这可以使用两个优先级队列(例如二进制或斐波那契堆)在 O(n*lg n) 时间内完成;一个队列用于当前中位数以下的项目(因此最大的元素位于顶部),另一个用于其上方的项目(在此堆中,最小的位于底部)。请注意,在斐波那契(和其他)堆中,插入是 O(1) 摊销的;它只会弹出一个 O(lg n) 的元素。

这将被称为“在线中值选择”算法,尽管Wikipedia 只讨论在线最小/最大选择。这是一个approximate algorithm 和一个lower bound,用于确定性和近似在线中值选择(下限意味着不可能有更快的算法!)

如果与 n 相比存在少量可能的值,您可能可以像排序一样打破基于比较的下限。

【讨论】:

  • 是的,很抱歉让您感到困惑。时间复杂度为一次迭代,即添加一个元素并返回当前集合的中位数。时间复杂度不是为了添加总共 n 个元素并输出 n 个中位数。
  • "插入是 O(1) 摊销的;它只是弹出一个 O(lg n) 的元素" - 你有时不得不弹出元素,不是吗?因为如果有很多“大”元素进来,那么之前大于中值的中型元素最终会小于中值,所以你必须将它们弹出并推送到另一个堆上。跨度>
  • 是的,当然。这就是为什么我说 O(n*lg n) 而不是 O(n)。无论如何,斐波那契堆对于小尺寸是不实用的。如果我想要 O(1) 操作,我可能会使用 cs.tau.ac.il/~zwick/papers/meld-talg.pdf
【解决方案2】:

我收到了同样的面试问题,并在 wrang-wrang 的帖子中提出了两堆解决方案。正如他所说,每次操作的时间是 O(log n) 最坏情况。 预期时间也是 O(log n),因为假设随机输入,您必须“弹出一个元素”1/4 的时间。

随后我进一步思考并想出了如何获得恒定的预期时间;实际上,每个元素的预期比较次数变为 2+o(1)。你可以在http://denenberg.com/omf.pdf 看到我的文章。

顺便说一句,这里讨论的解决方案都需要空间 O(n),因为您必须保存所有元素。一种完全不同的方法,只需要 O(log n) 空间,可以为您提供中位数的近似值(而不是确切的中位数)。抱歉,我无法发布链接(每篇帖子仅限一个链接),但我的论文有指针。

【讨论】:

    【解决方案3】:

    虽然 wrang-wrang 已经回答了,但我希望描述一个对您的二叉搜索树方法的修改,它是次线性的。

    • 我们使用平衡的二叉搜索树(AVL/Red-Black/etc),但不像您描述的那样超级平衡。所以添加一个项目是 O(log n)
    • 对树的一个修改:对于每个节点,我们还存储其子树中的节点数。这不会改变复杂性。 (对于叶子,此计数为 1,对于具有两个叶子子节点的节点,此计数为 3,依此类推)

    我们现在可以使用这些计数访问 O(log n) 中的第 K 个最小元素:

    def get_kth_item(subtree, k):
      left_size = 0 if subtree.left is None else subtree.left.size
      if k < left_size:
        return get_kth_item(subtree.left, k)
      elif k == left_size:
        return subtree.value
      else: # k > left_size
        return get_kth_item(subtree.right, k-1-left_size)
    

    中位数是第 K 个最小元素的特例(假设您知道集合的大小)。

    所以总而言之,这是另一个 O(log n) 解决方案。

    【讨论】:

      【解决方案4】:

      我们可以定义一个最小和最大堆来存储数字。此外,我们为数字集定义了一个类 DynamicArray,它有两个函数:Insert 和 Getmedian。插入一个新数的时间是 O(lgn),而得到中位数的时间是 O(1)。

      此解决方案在 C++ 中实现如下:

      template<typename T> class DynamicArray
      {
      public:
          void Insert(T num)
          {
              if(((minHeap.size() + maxHeap.size()) & 1) == 0)
              {
                  if(maxHeap.size() > 0 && num < maxHeap[0])
                  {
                      maxHeap.push_back(num);
                      push_heap(maxHeap.begin(), maxHeap.end(), less<T>());
      
                      num = maxHeap[0];
      
                      pop_heap(maxHeap.begin(), maxHeap.end(), less<T>());
                      maxHeap.pop_back();
                  }
      
                  minHeap.push_back(num);
                  push_heap(minHeap.begin(), minHeap.end(), greater<T>());
              }
              else
              {
                  if(minHeap.size() > 0 && minHeap[0] < num)
                  {
                      minHeap.push_back(num);
                      push_heap(minHeap.begin(), minHeap.end(), greater<T>());
      
                      num = minHeap[0];
      
                      pop_heap(minHeap.begin(), minHeap.end(), greater<T>());
                      minHeap.pop_back();
                  }
      
                  maxHeap.push_back(num);
                  push_heap(maxHeap.begin(), maxHeap.end(), less<T>());
              }
          }
      
          int GetMedian()
          {
              int size = minHeap.size() + maxHeap.size();
              if(size == 0)
                  throw exception("No numbers are available");
      
              T median = 0;
              if(size & 1 == 1)
                  median = minHeap[0];
              else
                  median = (minHeap[0] + maxHeap[0]) / 2;
      
              return median;
          }
      
      private:
          vector<T> minHeap;
          vector<T> maxHeap;
      };
      

      更详细的分析请参考我的博客:http://codercareer.blogspot.com/2012/01/no-30-median-in-stream.html

      【讨论】:

      • 假设我要计算最后 N 个元素的中位数而不是所有元素,我该怎么做?
      【解决方案5】:

      1) 与前面的建议一样,保留两个堆并缓存它们各自的大小。左堆保持低于中位数的值,右堆保持高于中位数的值。如果您只是对右堆中的值取反,则最小值将位于根处,因此无需创建特殊的数据结构。

      2) 当您添加一个新数字时,您可以根据两个堆的大小、当前的中值以及 L&R 堆的两个根来确定新的中值,这只需要恒定的时间。

      3) 调用私有线程方法执行插入和更新的实际工作,但立即返回新的中间值。你只需要阻塞直到堆根被更新。然后,执行插入的线程只需要在遍历树时保持对遍历祖父节点的锁定;这将确保您可以插入和重新平衡,而不会阻塞在其他子分支上工作的其他插入线程。

      获取中位数成为一个恒定时间过程,当然现在您可能不得不等待进一步添加的同步。

      罗伯

      【讨论】:

        【解决方案6】:

        具有增强 size 字段的平衡树(例如 R/B 树)应该在最坏的情况下找到 lg(n) 时间的中值。我认为它在经典算法教科书的第 14 章中。

        【讨论】:

          【解决方案7】:

          为了保持解释的简洁,您可以通过让每个节点存储其左子树中的节点数来有效地扩充 BST 以在 O(h) 中选择指定等级的键。如果可以保证树是平衡的,则可以将其减少到 O(log(n))。考虑使用高度平衡的 AVL(或大致平衡的红黑树),然后您可以选择 O(log(n)) 中的任何键。当您在 AVL 中插入或删除一个节点时,您可以增加或减少一个变量,该变量跟踪树中的节点总数以确定您可以在 O(log(n)) 中选择的中位数的排名。

          【讨论】:

            【解决方案8】:

            为了找到线性时间的中位数,你可以试试这个(我刚想到它)。每次将数字添加到集合时都需要存储一些值,并且不需要排序。就这样吧。

            typedef struct
            {
                    int number;
                    int lesser;
                    int greater;
            } record;
            
            int median(record numbers[], int count, int n)
            {
                    int i;
                    int m = VERY_BIG_NUMBER;
            
                    int a, b;
            
                    numbers[count + 1].number = n:
                    for (i = 0; i < count + 1; i++)
                    {
                            if (n < numbers[i].number)
                            {
                                    numbers[i].lesser++;
                                    numbers[count + 1].greater++;
                            }
                            else
                            {
                                    numbers[i].greater++;
                                    numbers[count + 1].lesser++;
                            }
                            if (numbers[i].greater - numbers[i].lesser == 0)
                                    m = numbers[i].number;
                    }
            
                    if (m == VERY_BIG_NUMBER)
                    for (i = 0; i < count + 1; i++)
                    { 
                            if (numbers[i].greater - numbers[i].lesser == -1)
                                    a = numbers[i].number;
                            if (numbers[i].greater - numbers[i].lesser == 1)
                                    b = numbers[i].number;
            
                            m = (a + b) / 2;
                    }
            
                    return m;
            }
            

            这样做的目的是,每次您向集合中添加一个数字时,您现在必须有多少个“小于您的数字”的数字,以及多少个“大于您的数字”的数字。因此,如果您的数字具有相同的“小于”和“大于”,则意味着您的数字位于集合的中间,无需对其进行排序。如果您有偶数数量的数字,您可能有两个中位数选择,因此您只需返回这两个的平均值。顺便说一句,这是 C 代码,希望对您有所帮助。

            【讨论】:

            • 感谢代码级别的描述。据我了解,在 median() 函数中,numbers 是保存集合的数组,n 是添加到集合中的新元素,count 是添加 n 之前集合的当前长度,m 是中位数。添加一个元素的时间复杂度是线性的。请注意,我们不能假设 numbers 数组足够大,因此我们需要检查并可能扩展 numbers 数组。您的方法不需要对数组进行排序,因此可以始终将新元素插入到末尾。但是你需要线性扫描,这比保持数组排序更昂贵。
            • 他说他想要亚线性算法
            猜你喜欢
            • 2017-07-08
            • 2013-06-08
            • 1970-01-01
            • 2018-10-26
            • 2015-12-16
            • 2020-12-21
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            相关资源
            最近更新 更多