【问题标题】:Retrieving the top 100 numbers from one hundred million of numbers从一亿个数字中检索前 100 个数字
【发布时间】:2011-02-02 18:49:50
【问题描述】:

我的一个朋友被问到一个问题

从一亿个数字中检索最大的前 100 个数字

在最近的一次工作面试中。你有什么想法想出一个有效的方法来解决它吗?

【问题讨论】:

  • 有趣。我今天在一次采访中被问到一个非常相似的问题(措辞非常不同,但归结为一个类似的问题)。希望你昨天能发布这个! :p
  • 相关问题:“数百万个 3D 点:如何找到最接近给定点的 10 个?” stackoverflow.com/questions/2486093/… 相同的答案适用于堆、部分排序,甚至线性搜索。这个问题可能会受到 IO 的限制,因此任何算法都可以。
  • kd-tree 是否有助于一维中最近的邻居(如本例)?
  • 这样的问题几乎需要您询问更多信息。如果他们回答你可以对所有不清楚的事情做出你自己的假设,你可以假设数据是在一个排序列表中提供给你的,你可以得到前 100 个数字 :)
  • @eSKay:看代码stackoverflow.com/questions/2486093/…有基于堆、部分排序、线性搜索的C++程序,几乎可以直接使用(只需使用你的阅读和比较函数,而不是@987654323 @和less_distance)

标签: algorithm


【解决方案1】:

如果数据已经在您可以修改的数组中,您可以使用 Hoare 的 Select 算法的变体,它(反过来)是 Quicksort 的变体。

基本的想法很简单。在快速排序中,您将数组分成两部分,一个大于枢轴的项目,另一个小于枢轴的项目。然后递归地对每个分区进行排序。

在 Select 算法中,您执行的分区步骤与之前完全相同 - 但不是递归地对 两个 分区进行排序,而是查看哪个分区包含您想要的元素,然后仅在该分区中递归选择划分。例如,假设您的 1 亿个项目几乎分成了一半,那么前几次迭代您将查看上部分区。

最终,您可能会达到您想要“连接”两个分区的部分 - 例如,您有一个约 150 个数字的分区,当您进行分区时,您最终会得到两个约 75 个数字的分区一块。此时,只有一个小细节发生了变化:不是拒绝一个分区而只继续另一个分区,而是接受上分区的 75 个项目,然后继续在下分区中查找前 25 个.

如果您在 C++ 中执行此操作,您可以使用std::nth_element 执行此操作(通常按照上述方式实现)。平均而言,这具有线性复杂性,我相信这与您希望的一样好(由于没有一些预先存在的顺序,我看不出有任何方法可以在不查看所有元素的情况下找到前 N 个元素)。

如果数据的不是已经在一个数组中,并且你(例如)从一个文件中读取数据,你通常想要使用一个堆。您基本上读取一个项目,将其插入堆中,如果堆大于您的目标(在本例中为 100 个项目),则删除一个并重新堆。

可能不太明显(但实际上确实如此)的是,您通常不希望为此任务使用最大堆。乍一看,似乎很明显:如果你想获得最多的项目,你应该使用最大堆。

然而,考虑从堆中“移除”的项目会更简单。最大堆可让您快速找到堆中最大的一项。但是,它不是,已针对查找堆中的最小项进行了优化。

在这种情况下,我们主要对堆中的最小项感兴趣。特别是,当我们从文件中读取每个项目时,我们希望将其与堆中最小的项目进行比较。如果(且仅当)它大于堆中的最小项,我们希望将当前在堆中的最小项替换为新项。由于它(根据定义)比现有项目大,因此我们需要将其筛选到堆中的正确位置。

但请注意:如果文件中的项目是随机排序的,那么当我们读取文件时,我们会很快达到一个点,在该点我们读入文件的 大多数 项目将小于我们堆中最小的项目。由于我们可以轻松访问堆中最小的项目,因此进行比较相当快速和容易,并且对于较小的项目根本不会插入堆中。

【讨论】:

  • 这需要 O(n) 时间,但也需要 O(n) 空间。
  • @Darius:是的,确实如此。视情况而定,这可能是一个大问题,或者根本没有问题——特别是,问题表明你得到了数字;如果您已经在内存中提供了它们,那么它可以最有效地利用内存空间——几乎没有额外的空间。另一方面,如果您在文件中获得数据(例如),那么分配那么多空间可能是一个真正的问题(分配虚拟内存可能需要比您的解决方案运行时间更长的时间,仅举一个例子)。跨度>
【解决方案2】:

我将前 100 个数字存储在大小为 100 的 Max -Heap 中。

  • 在最后一级,我跟踪我插入的最小号码和新号码,并检查最小号码。传入号码是否是前 100 名的候选者。

    -- 我再次调用 reheapify,所以我总是拥有前 100 个的最大堆。

    所以它的复杂度是O(nlogn)。

【讨论】:

  • 最大堆还是最小堆?我想你的意思是最小堆
【解决方案3】:

在 O(n) 中堆积数组。然后取出前100个元素。

【讨论】:

    【解决方案4】:

    假设 mylist 是一个包含亿万数据的列表。这样我们就可以对列表进行排序并从 mylist 中获取最后一百个数据。

    mylist.sort()

    我的列表[-100:]

    第二种方式:

    导入堆

    heapq.nlargest(100, mylist)

    【讨论】:

    • 效率不高。
    • @DJClayworth:是否愿意通过一些分析来支持这一点?
    【解决方案5】:

    第一次迭代:

    快速排序,取前 100 名。O(n log n)。简单,易于编码。很明显。

    更好?我们正在处理数字,对前 100 名进行基数排序(线性时间)。我希望这就是面试官正在寻找的。

    还有其他注意事项吗?嗯,一百万个数字并不是很多内存,但如果你想最小化内存,你最多保留到目前为止遇到的 100 个数字,然后扫描这些数字。最好的方法是什么?

    有些人提到了堆,但更好的解决方案可能是双向链表,您可以在其中将指针保持在迄今为止找到的前 100 个中的最小值。如果您遇到一个大于列表中当前最小值的数字 a,则与下一个元素进行比较,然后将该数字从旁边移到当前位置,直到找到新数字的位置。 (这基本上只是针对这种情况的专门堆)。通过一些调整(如果数字大于当前最小值,则与当前最大值比较以查看遍历列表以找到插入点的方向)这将相对有效,并且只需要大约 1.5k 的内存。

    【讨论】:

    • 这不是堆。扫描已排序的双向链表以进行插入是 O(n),而在堆中保留排序顺序的插入是 O(log n)(=树结构的深度)。尝试用二分搜索优化它并没有帮助,因为双向链表没有随机访问,而是迫使你遍历整个列表才能到达下一个中间点。
    • @JensRoland 我没有描述插入排序,带有指向最小元素的额外指针的双链表可以让您通过交换 100 个元素中的最小元素来保持 100 个元素的运行。这个将较大的元素推向顶部。这与 heapify 的功能非常接近。这是线性时间,一次通过对元素进行恒定操作的列表。可能描述得不是很好。请记住,堆有很多种。
    • 啊-但事实仍然是保持最小元素指针更新仍然是线性操作,因此对于一般的“Top m”情况,您的算法将是 O(n*m),而 minheap 解决方案达到 O(n log m)
    • @JensRoland 我没有清楚/正确地描述算法。无法在评论中放置伪代码,并且答案太旧而无法添加。
    【解决方案6】:

    通过大小为 100 的 min-heap 运行它们:对于每个输入数字 k,将当前最小 m 替换为 max(k, m)。之后堆会保存 100 个最大的输入。

    像 Lucene 这样的搜索引擎可以使用这种方法进行优化,以选择最相关的搜索答案。

    编辑:我没有通过面试——我两次弄错了细节(在之前做过这个之后,在生产中)。这是检查它的代码;和 Python 的标准 heapq.nlargest() 几乎一样:

    import heapq
    
    def funnel(n, numbers):
        if n == 0: return []
        heap = numbers[:n]
        heapq.heapify(heap)
        for k in numbers[n:]:
            if heap[0] < k:
                heapq.heapreplace(heap, k)
        return heap
    
    >>> funnel(4, [3,1,4,1,5,9,2,6,5,3,5,8])
    [5, 8, 6, 9]
    

    【讨论】:

    • +1:但仅当新数字为 >min 时才替换 min,但...(或使用 101 个元素的最小堆)
    • 不应该是“用max(k, m)替换当前的min m”。我是不是理解错了?
    • +1 一个优雅的解决方案,因为您正确地利用了一个非常漂亮的数据结构。干得好。
    • 使用heapq.heappushpop(heap, k)而不是if heap[0] &lt; k: heapq.heapreplace(heap, k)缩短一行
    【解决方案7】:

    @darius 实际上可以改进!!!
    通过“修剪”或根据需要推迟堆替换操作

    假设我们在堆顶有 a=1000
    它有 c,b 个兄弟姐妹
    我们知道 c,b>1000

          a=1000
      +-----|-----+
     b>a         c>a
    
    
    
    
    We now read the next number x=1035
    Since x>a we should discard a.
    Instead we store (x=1035, a=1000) at the root
    We do not (yet) bubble down the new value of 1035 
    Note that we still know that b,c<a but possibly b,c>x
    Now, we get the next number y
    when y<a<x then obviously we can discard it 
    
    when y>x>a then we replace x with y (the root now has (y, a=1000))
    => we saved log(m) steps here, since x will never have to bubble down
    
    when a>y>x then we need to bubble down y recursively as required
    
    Worst run time is still O(n log m) 
    But average run time i think might be O(n log log m) or something
    In any case, it is obviously a faster implementation
    

    【讨论】:

    • -1 这不像写的那样工作。假设当前堆中的前 100 个数字是 1000 到 1198 之间的偶数。因此,您添加了 x=1035 并在根目录中有 (x=1035, a=1000)。现在,y=1037 出现了,所以 y>x>a 并且你说保持根为 (y=1037, a=1000),然后删除 x。但是 x=1035 仍然比 1002 好(仍然保留)。 (如果你放弃a,或者如果x>y>a,则执行这样的步骤,它也不起作用)。此外,您没有理由声称“可能是 O(n log log m)”。您的方案(如果可行的话)只会将 lg M 步数减少一半;所以会保持 O(n lg m)。
    【解决方案8】:

    没有理由对整个列表进行排序。这应该在 O(n) 时间内是可行的。在伪代码中:

    List top = new List
    
    for each num in entireList
        for i = 0 to top.Length
            if num > top[i] then
                top.InsertBefore(num, i)
                if top.Length > 100 then
                    top.Remove(top.Length - 1)
                end if
                exit for
            else
                if i = top.Length - 1 and i < 100 then
                    top.Add(num)
                end if
            end if
        next
    next
    

    【讨论】:

    • 为此,您必须保持“顶部”列表的排序。
    • 是的,已修复,我忘记了那部分。我认为仍然是 O(n)。
    • 您的算法只有 O(n),因为您被要求找到前 100 个,而不是顶部 k,其中 k &lt; n。在最坏的情况下,原始列表从小到大排序,因此,对于原始列表中的每个数字,您将始终扫描到top 的末尾。相反,如果你被要求找到顶部的k,你的算法运行在 O(kn) 中,这对于大的 kn 可能很差。
    • 另外,if i = top.Length - 1 应该是 if **==**` top.Length - 1`。
    • “你的算法只有 O(n),因为你被要求找到前 100 个。” - 那是正确的。当然,如果问题不同,则需要不同的算法。但是,与 1 亿相比,100 不是问题。还有,我的代码是伪代码,只要你懂,语法无关。
    【解决方案9】:
    int numbers[100000000000] = {...};
    int result[100] = {0};
    for( int i = 0 ; i < 100000000000 ; i++ )
    {
        for( int j = 0 ; j < 100 ; j++ )
        {
             if( numbers[i] > result[j] )
             {
                  if( j < 99 )
                  {
                      memcpy(result+j+1, result+j, (100-j)*sizeof(int));
                  }
                  result[j] = numbers[i];
                  break;
             }
        }
    }
    

    【讨论】:

      【解决方案10】:

      好的,这是一个非常愚蠢的答案,但它是一个有效的答案:

      • 将所有 1 亿个条目加载到数组中
      • 调用一些快速排序实现
      • 取最后 100 个项目(按升序排序),如果可以降序排序,则取前 100 个。

      推理:

      • 这个问题没有上下文,所以可以争论效率 - 什么是有效的?计算机时间还是程序员时间?
      • 此方法实施速度非常快。
      • 1 亿个条目 - 数字,只有几百 mb,所以每个体面的工作站都可以简单地运行它。

      对于某种一次性操作来说,这是一个不错的解决方案。每秒运行 x 次或其他东西会很糟糕。但是,我们需要更多的上下文——正如 mclientk 也有他的简单 SQL 语句——假设内存中不存在 1 亿个数字是一个可行的问题,因为......它们可能来自数据库,并且大部分时间会在谈话时关于业务相关的数字。

      因此,这个问题真的很难回答——首先必须定义效率。

      【讨论】:

      • 是的,但正如我在回答中指出的那样(至少如果您使用正确的语言),更有效地完成这项工作同样容易。
      【解决方案11】:

      以 100 个为一组进行合并排序,然后只保留前 100 个。

      顺便说一句,您可以在各种方向上扩展它,包括同时进行。

      【讨论】:

      • 如果前100名是同一批呢?
      • @nikhil,如果它们都在同一个批次中,不会有什么不同。当然,当我说“以 100 个为一组进行合并排序”时,我的意思是合并排序两个集合,每个集合包含 100 个数字。
      【解决方案12】:

      TOP 100 是指最大的 100 个吗?如果是这样:

      SELECT TOP 100 Number FROM RidiculouslyLargeTable ORDER BY Number DESC
      

      确保您告诉面试官您认为该表已正确编入索引。

      【讨论】:

      • 谁说过数据库?
      • 谁说不在数据库中?拥有 1 亿个数据点,假设它们不在文本文件中是明智的 - 毕竟,这是典型的 OLTP / 数据仓库类型的查询。
      • 如果我在采访中问了这个问题并得到了答案,我可能会说,“太好了!现在假设您正在编写 RDBMS,您将如何实现该查询?”
      • @hemp,“当我作为 Perl Web 开发人员申请时,你到底为什么要问我如何开发 RDBMS?”
      • 因为即使是一个低级的 Perl Web 开发人员也应该是一个足够扎实的工程师,您可以清楚地推断出您可能如何实现该查询。我不指望你以前做过,或者将来你会这样做。我不根据经验招聘;我根据可证明的智力和使用基本计算机科学基础解决问题的能力来招聘。
      猜你喜欢
      • 2022-10-13
      • 1970-01-01
      • 1970-01-01
      • 2020-08-14
      • 2018-01-05
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多