最近我参加了一个面试,面试官要求我“编写一个程序,从一个包含10亿个数字的数组中找出100个最大的数字”。
我只能给出一个蛮力解决方案,即以O(nlogn)时间复杂度对数组进行排序,并取最后100个数字。
Arrays.sort(array);
面试官正在寻找一个更好的时间复杂度,我尝试了几个其他的解决方案,但都没有回答他。有没有更好的时间复杂度解决方案?
最近我参加了一个面试,面试官要求我“编写一个程序,从一个包含10亿个数字的数组中找出100个最大的数字”。
我只能给出一个蛮力解决方案,即以O(nlogn)时间复杂度对数组进行排序,并取最后100个数字。
Arrays.sort(array);
面试官正在寻找一个更好的时间复杂度,我尝试了几个其他的解决方案,但都没有回答他。有没有更好的时间复杂度解决方案?
当前回答
取十亿个数字中的前一百个,然后排序。现在只需遍历十亿,如果源数大于100中最小的数,则按排序顺序插入。你得到的结果更接近于O(n)除以集合的大小。
其他回答
你可以遍历这些数字,需要O(n)
只要发现一个大于当前最小值的值,就将新值添加到一个大小为100的循环队列中。
循环队列的最小值就是新的比较值。继续往队列中添加。如果已满,则从队列中提取最小值。
你可以保留一个最大的100个数字的优先队列,遍历10亿个数字。每当遇到大于队列中最小数字(队列头)的数字时,删除队列头并将新数字添加到队列中。
用堆实现的优先级队列的插入+删除复杂度为O(log K).(其中K = 100,要查找的元素数量。N = 10亿,数组中元素的总数)。
在最坏的情况下,你得到十亿*log2(100)这比十亿*log2(十亿)对于O(N log N)基于比较的排序要好。
一般来说,如果你需要一组N个数字中最大的K个数字,复杂度是O(N log K)而不是O(N log N),当K与N相比非常小时,这可能非常重要。
这种优先级队列算法的预期时间非常有趣,因为在每次迭代中可能会出现插入,也可能不会出现插入。
第i个数字插入队列的概率是一个随机变量大于同一分布中至少i- k个随机变量的概率(前k个数字自动添加到队列中)。我们可以使用顺序统计(见链接)来计算这个概率。
例如,假设这些数字是从{0,1}中均匀随机选择的,第(i-k)个数字(从i个数字中)的期望值为(i-k)/i,并且随机变量大于此值的概率为1-[(i-k)/i] = k/i。
因此,期望插入数为:
期望运行时间可表示为:
(k时间生成包含前k个元素的队列,然后是n-k个比较,以及如上所述的预期插入次数,每次插入的平均时间为log(k)/2)
注意,当N与K相比非常大时,这个表达式更接近于N而不是nlog K。这有点直观,就像在这个问题的情况下,即使经过10,000次迭代(与十亿次相比非常小),一个数字被插入队列的机会也非常小。
但是我们不知道数组的值是均匀分布的。它们可能趋向于增加,在这种情况下,大多数或所有数字将成为所见最大的100个数字集合的新候选数。这个算法的最坏情况是O(N log K)
或者如果它们呈递减的趋势,最大的100个数字中的大多数将会非常早,我们的最佳情况运行时间本质上是O(N + K log K)对于K比N小得多的K,它就是O(N)
脚注1:O(N)整数排序/直方图
计数排序或基数排序都是O(N),但通常有更大的常数因子,使它们在实践中比比较排序更差。在某些特殊情况下,它们实际上相当快,主要是对于窄整数类型。
例如,计数排序在数字很小的情况下表现良好。16位数字只需要2^16个计数器的数组。而不是实际展开到一个排序的数组,你可以扫描你建立的直方图作为计数排序的一部分。
在对数组进行直方图化之后,您可以快速回答任何顺序统计的查询,例如最大的99个数字,最大的200到100个数字)32位数字将计数分散到一个更大的数组或计数器哈希表中,可能需要16gib的内存(每个2^32个计数器4字节)。在真正的cpu上,可能会有很多TLB和缓存失误,不像2^16个元素的数组,L2缓存通常会命中。
类似地,Radix Sort可以在第一次传递后只查看顶部的桶。但常数因子仍然可能大于logk,这取决于K。
注意,每个计数器的大小足够大,即使所有N个整数都是重复的,也不会溢出。10亿略小于2^30,所以一个30位无符号计数器就足够了。32位有符号或无符号整数就可以了。
如果有更多的计数器,则可能需要64位计数器,初始化为零并随机访问需要占用两倍的内存。或者是少数溢出16或32位整数的计数器的哨兵值,以指示计数的其余部分在其他地方(在一个小字典中,例如映射到64位计数器的哈希表中)。
从十亿个数字中找到前100个最好使用包含100个元素的最小堆。
首先用遇到的前100个数字对最小堆进行质数。Min-heap将前100个数字中最小的存储在根(顶部)。
现在,当你继续计算其他数字时,只将它们与根数(100中最小的数)进行比较。
如果遇到的新数字大于最小堆的根,则将根替换为该数字,否则忽略它。
作为在最小堆中插入新数字的一部分,堆中最小的数字将移到顶部(根)。
一旦我们遍历了所有的数字,我们将得到最小堆中最大的100个数字。
虽然其他的quickselect解决方案已经被否决,但事实是quickselect将比使用大小为100的队列更快地找到解决方案。在比较方面,Quickselect的预期运行时间为2n + o(n)。一个非常简单的实现是
array = input array of length n
r = Quickselect(array,n-100)
result = array of length 100
for(i = 1 to n)
if(array[i]>r)
add array[i] to result
这平均需要3n + o(n)次比较。此外,quickselect将数组中最大的100个项保留在最右边的100个位置,这可以提高效率。所以实际上,运行时间可以提高到2n+o(n)。
有一个问题是,这是预期的运行时间,而不是最坏的情况,但通过使用一个不错的主元选择策略(例如,随机选择21个元素,并选择这21个元素的中位数作为主元),那么比较的数量可以保证高概率为(2+c)n对于任意小的常数c。
事实上,通过使用优化的抽样策略(例如随机抽样平方根(n)个元素,并选择第99百分位数),对于任意小的c(假设K,要选择的元素数量为o(n)),运行时间可以降至(1+c)n + o(n)。
另一方面,使用大小为100的队列将需要O(log(100)n)个比较,log以2为底100的对数大约等于6.6。
如果我们从更抽象的意义上考虑这个问题,即从大小为N的数组中选择最大的K个元素,其中K=o(N),但K和N都趋于无穷大,那么快速选择版本的运行时间将是o(N),队列版本的运行时间将是o(N log K),因此在这种意义上,快速选择也渐近地更好。
在注释中,提到队列解决方案将在随机输入的预期时间N + K log N内运行。当然,随机输入假设永远不会成立,除非问题明确地说明了这一点。队列解决方案可以以随机顺序遍历数组,但这将产生对随机数生成器的N次调用的额外成本,以及排列整个输入数组或分配一个长度为N的包含随机索引的新数组。
如果问题不允许您移动原始数组中的元素,并且分配内存的成本很高,因此不能复制数组,那就是另一回事了。但严格地从运行时间来看,这是最好的解决方案。
你可以在O(n)个时间内完成。只需遍历列表,并跟踪在任何给定点上看到的最大的100个数字,以及该组中的最小值。当你发现一个新的数字大于你的10个数字中的最小值,然后替换它并更新你的新的100的最小值(可能每次你都要花100的常数时间来确定,但这并不影响整体分析)。