生成一个不在已有 40 亿个整数中的整数。

724

给我一个面试题:

假设有一个包含40亿个整数的输入文件,请提供一种算法,生成一个不包含在文件中的整数。假设你有1 GB内存。如果只有10 MB内存,你会怎么做?

我的分析:

文件的大小为4×109×4字节=16 GB。

我们可以进行外部排序,这样可以让我们知道整数的范围。

我的问题是:在排序后的大整数集中检测缺失的整数的最佳方法是什么?

我的理解(阅读所有答案后):

假设我们讨论的是32位整数,则有232= 40亿个不同的整数。

情况1:我们有1 GB = 109 * 8位 = 80亿位的内存。

解决方案:

如果使用一个比特表示一个不同的整数,则足够了。 我们不需要排序。

实现:

int radix = 8;
byte[] bitfield = new byte[0xffffffff/radix];
void F() throws FileNotFoundException{
    Scanner in = new Scanner(new FileReader("a.txt"));
    while(in.hasNextInt()){
        int n = in.nextInt();
        bitfield[n/radix] |= (1 << (n%radix));
    }

    for(int i = 0; i< bitfield.lenght; i++){
        for(int j =0; j<radix; j++){
            if( (bitfield[i] & (1<<j)) == 0) System.out.print(i*radix+j);
        }
    }
}

情形2: 10 MB内存 = 10 * 106 * 8位 = 8000万个比特位

解决方案:

对于所有可能的16位前缀,有216个整数=65536,我们需要216 * 4 * 8 = 200万个比特位。我们需要构建65536个桶。对于每个桶,我们需要4个字节来保存所有可能性,因为最坏的情况是所有40亿个整数属于同一个桶。

  1. 通过第一次文件遍历,建立每个桶的计数器。
  2. 扫描桶,找到第一个少于65536个命中的桶。
  3. 通过文件的第二次遍历,构建新的桶,其高16位前缀与步骤2中发现的相同。
  4. 扫描步骤3中构建的桶,找到第一个没有命中的桶。

代码与上面的代码非常相似。

结论: 通过增加文件遍历次数来减少内存使用。


针对晚来者的澄清:问题在提问时没有明确说明文件中只有一个整数没有出现。至少大多数人不是这样理解的。评论线程中的许多评论都涉及该任务的变体。不幸的是,介绍这个变体的评论后来被其作者删除了,所以现在看起来像是它的孤儿回复误解了一切。非常抱歉,这很令人困惑。


33
@trashgod,你错了。对于4294967295个唯一整数,你将剩下1个整数。为了找到它,你需要对所有整数求和,然后从所有可能整数的预计总和中减去它。 - Nakilon
58
这是来自《编程珠玑》的第二个“珠子”,我建议您阅读书中的整个讨论。请参阅http://books.google.com/books?id=kse_7qbWbjsC&lpg=PP1&pg=PA11#v=onepage&q&f=false。 - Alok Singhal
9
一个64位整数已经足够大了。 - cftarnas
82
获取缺失数字的函数:int getMissingNumber(File inputFile) { return 4; }(参考资料:http://xkcd.com/221/) - johnny
16
即使你无法在1到2的32次方之间存储所有整数的总和,在像C/C++这样的语言中,整数类型始终保留结合律和交换律等特性,这并不重要。这意味着,尽管总和可能不是正确的答案,但如果您计算了有溢出的期望值、有溢出的实际总和,然后相减,结果仍然是正确的(前提是本身没有溢出)。 - thedayturns
显示剩余34条评论
38个回答

547
假设“整数”表示32位:10 MB的空间已经足够你在一遍扫描输入文件时,用任何给定的16位前缀计算有多少个数字,在所有可能的16位前缀中进行计数。至少一个桶将被击中不到2^16次。再进行第二遍扫描以查找该桶中可能使用的数字中哪些已经被使用。
如果它的位数超过32位,但仍是有界大小:请按上述方法执行,忽略所有恰巧落在(有符号或无符号;由您选择)32位范围之外的输入数字。
如果“整数”表示数学整数:阅读一次输入,并记录您曾经看到的最大数字的长度。完成后,输出比最大值加1更大的随机数字。(文件中的某个数字可能是一个大数,需要超过10 MB才能完全表示它,但如果输入是文件,则至少可以表示适合其中的任何东西的长度)。

25
好的。你的第一个答案只需要通过文件进行2次处理! - corsiKa
48
一个10兆字节的大数?那相当极端了。 - Mark Ransom
12
@Legate,只需跳过太大的数字,不做任何处理。由于您不会输出太大的数字,因此无需跟踪您看到了哪些数字。 - hmakholm left over Monica
13
方案1的好处在于,您可以通过增加遍数来减少内存使用。 - Yousf
12
@Barry:上面的问题并没有表明确实有一个数字遗漏了。它也没有说文件中的数字不会重复。(在面试中,跟随实际提问可能是个好主意,对吧?;-)) - Christopher Creutzig
显示剩余40条评论

206

使用具有统计信息的算法比确定性方法需要更少的通道来解决此问题。

如果允许使用非常大的整数,则可以在O(1)时间内生成一个可能是唯一的数字。类似于GUID这样的128位伪随机整数仅会与现有集合中的四十亿个整数中的一个产生碰撞,每64亿亿亿次中只有不到一次发生碰撞。

如果整数被限制为32位,则可以使用远少于10 MB的空间,在单个通道中生成一个可能是唯一的数字。伪随机32位整数与4十亿现有整数之一发生碰撞的概率约为93%(4e9 / 2 ^ 32)。1000个伪随机整数全部发生碰撞的概率不到12,000亿亿亿分之一(一个碰撞的概率^ 1000)。因此,如果程序维护包含1000个伪随机候选项的数据结构,并通过已知的整数进行迭代,从候选项中消除匹配项,则几乎可以确定至少找到一个不在文件中的整数。


35
我很确定整数是有界的。如果它们没有被限制,那么即使是初学者程序员也会想到这个算法:“通过一次遍历数据来找到最大的数,并将其加1”。 - Adrian Petrescu
12
在面试中,仅仅随意猜测答案可能不会为您赢得太多分数。 - Rag
7
@Adrian,您的解决方案似乎很明显(对我来说就是这样,我在自己的答案中使用了它),但并不是对每个人都很明显。这是一个很好的测试,看看您是否能够发现明显的解决方案,或者您是否会让每件事情都过于复杂化。 - Mark Ransom
24
@Brian:我认为这个解决方案既富有想象力又实用。就我个人而言,我会为这个答案给予很高的赞赏。 - Richard H
6
啊,这就是工程师和科学家之间的界线。Ben给出了很棒的答案! - TrojanName
显示剩余7条评论

147

这个问题的详细讨论已经在Jon Bentley的《编程珠玑》Addison-Wesley pp.3-10中讨论了。

Bentley讨论了几种方法,包括使用外部排序、使用多个外部文件的合并排序等。但Bentley建议的最佳方法是使用位域的单次遍历算法,他幽默地称之为“Wonder Sort” :) 至于问题,40亿个数字可以用以下方式表示:

4 billion bits = (4000000000 / 8) bytes = about 0.466 GB

实现位集的代码很简单:(摘自解决方案页面

#define BITSPERWORD 32
#define SHIFT 5
#define MASK 0x1F
#define N 10000000
int a[1 + N/BITSPERWORD];

void set(int i) {        a[i>>SHIFT] |=  (1<<(i & MASK)); }
void clr(int i) {        a[i>>SHIFT] &= ~(1<<(i & MASK)); }
int  test(int i){ return a[i>>SHIFT] &   (1<<(i & MASK)); }

Bentley的算法对文件进行一次遍历,在数组中设置适当的位,然后使用上面的test宏检查该数组,以找到缺失的数字。

如果可用内存小于0.466 GB,则Bentley建议使用k-pass算法,根据可用内存将输入分成不同的范围。举个非常简单的例子,如果只有1字节(即内存可以处理8个数字)可用,而范围是从0到31,则我们将其分成0到7、8-15、16-22等范围,并在每个范围中处理此范围,需要32/8=4次操作。

希望对你有所帮助。


13
我不认识这本书,但没有理由称它为“奇妙排序”,因为它只是一个桶排序,带有1位计数器。 - flolo
3
虽然更加便携,但是这段代码将会被使用硬件支持的向量指令所摧毁。我认为在某些情况下gcc可以自动将代码转换为使用向量操作。 - Rag
3
我不认为Jon Bentley会允许这些内容出现在他关于算法的书中。 - David Heffernan
8
相较于读取文件所需的时间,RAM 中花费的时间可以忽略不计。不必优化它。请放心。 - Ian
1
@BrianGordon:或者你是在说最后的循环查找第一个未设置的位吗?是的,向量将加速这一过程,但在单个核心上运行,通过64位整数循环遍历位域,寻找一个!= -1的位仍会饱和内存带宽(这是SIMD-within-a-register,SWAR,以位作为元素)。 (适用于最近的Intel / AMD设计)。 找到包含它的64位位置后,您只需要弄清楚哪个位未设置。 (对于此,您可以使用not / lzcnt)。 有道理,仅对单个位测试进行循环可能无法得到良好的优化。 - Peter Cordes
显示剩余10条评论

124

由于问题没有明确要求我们找到不在文件中的最小可能数字,因此我们可以生成一个比输入文件本身更长的数字。 :)


6
除非文件中的最大数是 max int,否则你会发生数字溢出。 - KBusc
在一个可能需要生成一个新整数并将其附加到“已使用整数”文件100次的真实世界程序中,该文件的大小会是多少? - Michael
2
我在想,假设“int”是32位,只需输出“2^64-1”。完成。 - geometrian
2
如果每行只有一个整数:tr -d '\n' < nums.txt > new_num.txt :D - Shon

61

对于1GB RAM变体,您可以使用位向量。您需要分配40亿个比特==500 MB字节数组。对于从输入读取的每个数字,将相应的位设置为“1”。一旦完成,迭代位,找到仍然是“0”的第一个位。它的索引就是答案。


4
输入数字的范围没有说明。如果输入由80亿到160亿之间的所有偶数组成,这个算法会如何工作? - Mark Ransom
27
@Mark,忽略超出0..2 ^ 32范围的输入。你不会输出任何超出此范围的值,因此无需记住要避免哪些值。 - hmakholm left over Monica
@Mark,无论你使用什么算法来确定32位字符串如何映射到实数,都由你决定。过程仍然是相同的。唯一的区别在于你如何将其打印为实数输出到屏幕上。 - corsiKa
4
不必自己迭代,可以使用bitSet.nextClearBit(0):http://download.oracle.com/javase/6/docs/api/java/util/BitSet.html#nextClearBit%28int%29 - starblue
3
提及一下会很有用,无论整数的范围如何,在每次传递结束时至少保证有一个位是0。这是由于鸽笼原理所导致的。 - Rafał Dowgird
显示剩余2条评论

51
如果这些整数是32位整数(可能是因为选择了接近2的32次方的40亿个数字),则您的40亿个数字列表将占用最多93%的可能整数(4x10^9/2^32)。因此,如果您创建一个由2的32次方个比特组成的位数组,每个比特初始化为零(将占用2的29次方字节,大约500 MB的RAM;请记住,一个字节=2的3次方比特=8比特),读取整数列表,并对于每个整数将相应的位数组元素从0设置为1;然后读取位数组并返回仍为0的第一个比特。
在RAM较少(~10 MB)的情况下,需要略微修改此解决方案。 10 MB〜83886080个比特仍足以为0到83886079之间的所有数字做一个位数组。 因此,您可以阅读整数列表;并仅在您的位数组中记录介于0和83886079之间的数字。 如果数字是随机分布的,则以压倒性的概率(与10的-2592069次方差异100%),您将找到缺失的整数。 实际上,如果您只选择1到2048的数字(仅使用256字节的RAM),则在绝大多数情况下(99.99999999999999999999999999999999999999999999999999999999999995%)仍将找到缺失的数字。
但是,假设您不是拥有大约40亿个数字,而是拥有2的32次方减1个数字,并且RAM小于10 MB,则任何小范围的整数只有很小的可能不包含该数字。

如果你确信列表中每个int都是唯一的,那么你可以将数字求和并从完整和(½)中减去缺失的数字之和来得到缺失的int,计算公式为(232 - 1) = 9223372034707292160。然而,如果有一个int出现了两次,这种方法就会失败。

但是,你总是可以采用分治的方法。一种朴素的方法是遍历数组并计算出前半部分(0到231-1)和后半部分(231,232)中数字的数量。然后选择数字更少的范围并重复将该范围分成两半的步骤。(假设在(231,232)中有两个数字不在列表中,则下一次搜索将计算范围(231,3*230-1),(3*230,232)。不断重复直到找到数字为零的范围,那么你就找到了缺失的数字。这个过程应该只需要O(lg N) ~ 32次读取数组即可。

这个方法效率低下。在每个步骤中我们只使用了两个整数(或约8字节的RAM和4字节(32位)整数)。更好的方法是将其分成sqrt(232) = 216 = 65536 个箱子,每个箱子中有 65536 个数字。每个箱子需要4个字节来存储其计数,因此您需要218字节= 256 kB。所以箱子0是(0到65535 = 216 -1),箱子1是(216 = 65536到2 * 216 -1 = 131071),箱子2是(2 * 216 = 131072到3 * 216 -1 = 196607)。在Python中,您可以编写类似以下内容的代码:

import numpy as np
nums_in_bin = np.zeros(65536, dtype=np.uint32)
for N in four_billion_int_array:
    nums_in_bin[N // 65536] += 1
for bin_num, bin_count in enumerate(nums_in_bin):
    if bin_count < 65536:
        break # we have found an incomplete bin with missing ints (bin_num)

遍历约 40 亿个整数列表,计算每个 216 个区间内的整数数量,并找到一个不完整的区间,该区间没有所有 65536 个数字。然后再次遍历这些整数列表;但是这一次只关注处于该范围内的整数,并在找到它们时翻转一个位。

del nums_in_bin # allow gc to free old 256kB array
from bitarray import bitarray
my_bit_array = bitarray(65536) # 32 kB
my_bit_array.setall(0)
for N in four_billion_int_array:
    if N // 65536 == bin_num:
        my_bit_array[N % 65536] = 1
for i, bit in enumerate(my_bit_array):
    if not bit:
        print bin_num*65536 + i
        break

3
非常棒的答案。这个方法确实可行,并且能够保证结果。 - Jonathan Dickinson
@dr jimbob,如果一个 bin 中只有一个数字,并且这个数字有 65535 个副本,那么该 bin 仍然会计算为 65536,但所有的 65536 个数字都是相同的。 - Alcott
@Alcott - 我假设你有2^32-1(或更少)个数字,因此根据鸽笼原理,你保证有一个箱子的计数少于65536个,需要更详细地检查。我们只是试图找到一个缺失的整数,而不是所有整数。如果您有2^32或更多个数字,则无法保证缺少整数,并且将无法使用此方法(或从一开始就保证存在缺少整数)。那么您最好的选择是暴力破解(例如,通过数组读取32次;第一次检查前65536个数字;并在找到答案后停止)。 - dr jimbob
聪明的上16位/下16位方法早在Henning之前就已经发布了:https://dev59.com/TWw05IYBdhLWcg3wmCwo#7153822。不过,我喜欢将它们相加的想法,用于一个唯一的整数集合,恰好缺少一个成员。 - Peter Cordes
3
@PeterCordes - 是的,Henning的解决方案早于我的,但我认为我的答案仍然有用(更详细地解决了几个问题)。话虽如此,Jon Bentley在他的书《编程珠玑》中,在stackoverflow存在之前就提出了这个问题的多遍选项(请参见vine'th's的答案)(我不是在声称我们中的任何一个有意从那里窃取,也不是Bentley第一个分析这个问题-开发这个相当自然的解决方案)。当限制是您没有足够的内存来进行一次带有巨大位数组的1次通行解决方案时,两次通行似乎是最自然的选择。 - dr jimbob
是的,我同意这是一个很好的分析/解释。与其他答案相比,我认为将它们加起来的想法与全部异或的答案相似。 - Peter Cordes

39

为什么要这么复杂?你是在要求文件中不存在的整数吗?

根据规定,你只需要存储到目前为止在文件中遇到的最大整数。一旦读取完整个文件,返回比那个数大1的数字。

因为根据规则,整数或算法返回的数字的大小没有任何限制,所以不会有超过maxint或其他什么风险。


4
除非文件中包含最大整数,否则这将有效......这是完全可能的。 - PearsonArtPhoto
13
规则没有指定是32位还是64位或任何其他的东西,所以根据规则来看,没有最大整数。"Integer"不是计算机术语,而是一个数学术语,用来识别正数或负数的整数。 - Pete
足够正确,但不能假设它是一个64位数字,也不能假设有人不会只是为了混淆这样的算法而悄悄地插入最大整数。 - PearsonArtPhoto
25
如果未指定任何编程语言,"max int" 的整个概念就不成立。例如,看看 Python 中长整数的定义,它是无限制的,没有上限,您可以一直增加。您假设它正在被实现在一个具有整数最大允许值的语言中。 - Pete
1
规则确实指定了“这是一道面试题”。因此,提出你的解决方案非常好,但你需要准备好,因为面试官预计会跟进:“非常好,现在同样的问题,但使用32位整数。”另外,如果这不是一道面试题而是一个实际问题,那么你需要从“首先我们需要检查是否有maxint”开始,而不是从“我假装没有maxint并希望没问题”开始。 - Stef

35

可以使用二分查找的变体来在非常小的空间内解决这个问题。

  1. 从允许的数字范围 04294967295 开始。

  2. 计算中点。

  3. 循环遍历文件,计算等于、小于或大于中点值的数字数量。

  4. 如果没有相等的数字,则完成。中点数字就是答案。

  5. 否则,选择具有最少数字的范围,并使用此新范围从步骤2开始重复。

这将需要最多32次线性扫描文件,但仅使用少量字节的内存来存储范围和计数。

本质上,这与Henning的解决方案基本相同,只是它使用两个箱子而不是16k。


2
这是我开始优化给定参数之前的内容。 - hmakholm left over Monica
@Henning:很棒。这是一个很好的算法示例,可以轻松调整空间和时间的权衡。 - hammar
@hammar,如果有一些数字出现了多次怎么办? - Alcott
@Alcott:那么算法将选择密集的箱子而不是稀疏的箱子,但根据鸽笼原理,它永远不会选择一个完全满的箱子。(两个计数中较小的总是小于箱子范围。) - Peter Cordes
这更像是快速排序而不是二分查找。但是,虽然在文件中进行NLgN次操作并不是什么大问题,但它在RAM中进行多次操作就很糟糕了。因此,一个好的解决方案最多只需要O(1)次文件操作。否则,你真的会被拖垮。 - AminM

27

编辑 好吧,这并没有很好地考虑到文件中的整数遵循某些静态分布。显然他们不需要,但即使如此,我们应该尝试这样做:


有 ≈43亿个32位整数。我们不知道它们在文件中是如何分布的,但最坏的情况是熵最高的情况: 均等分布。在这种情况下,任何一个整数不出现在文件中的概率为

( (2³²-1)/2³² )⁴ ⁰⁰⁰ ⁰⁰⁰ ⁰⁰⁰ ≈ .4

香农熵越低,平均上述概率就越高,但即使对于这个最坏的情况,我们有90%的几率在随机猜测5次后找到一个未出现的数字。只需使用伪随机生成器创建这样的数字,将它们存储在列表中。然后逐个读取 int 并将其与所有猜测进行比较。当匹配时,删除这个列表条目。经过整个文件后,很可能还剩下多个猜测。使用其中任何一个。在极少数的情况下(即使在最坏的情况下仍为10%),没有猜测剩余,请获取一组新的随机整数,也许这次更多些(10->99%)。

内存消耗: 几十个字节,复杂度: O(n),开销: 可忽略,因为大部分时间都用于不可避免的硬盘访问,而不是比较整数。


实际的最坏情况是,在我们不假设静态分布的情况下,每个整数最多只出现一次,因为此时仅有 1 - 4000000000/2³² ≈ 6% 的整数不在文件中出现。所以你需要更多的猜测,但这仍然不会占用过多的内存。


5
很高兴看到有其他人也考虑到这个问题,但为什么它在底部?这是一种一次扫描算法......10 MB足以进行2.5 M次猜测,而93%^2.5M≈ 10^-79000 真的非常小,需要第二次扫描的几率可以忽略不计。由于二分搜索的开销,如果使用更少的猜测,它会更快!这在时间和空间上都是最优的。 - Potatoswatter
1
@Potatoswatter:很好你提到了二分查找。当只使用5次猜测时,这可能不值得开销,但是在10次或更多次时肯定是值得的。你甚至可以进行2M次猜测,但是那时你应该将它们存储在哈希集中以获得O(1)的搜索效率。 - leftaroundabout
1
我喜欢这种方法,但我建议一个节省内存的改进:如果有N位索引存储可用,再加上一些常量存储,定义一个可配置的可逆32位混淆函数(置换),选择一个任意的置换,并清除所有索引位。然后从文件中读取每个数字,对其进行混淆,如果结果小于N,则设置相应的位。如果在文件结束时有任何位未设置,则对其索引执行反向混淆函数。使用64KB的内存,可以在单次遍历中有效地测试超过512,000个数字的可用性。 - supercat
2
当然,使用这个算法的最坏情况是数字是由您正在使用的相同随机数生成器创建的。假设您可以保证不是这种情况,您最好的策略是使用线性同余随机数生成器来生成列表,以便您以伪随机方式遍历数字空间。这意味着如果您某种方式失败了,您可以继续生成数字,直到覆盖整个int范围(或找到间隙),而不会重复努力。 - Dewi Morgan
1
@DewiMorgan:没错,不过那是一个非常糟糕的最坏情况(也就是说,运气不好)。 - leftaroundabout
显示剩余3条评论

24

如果你在范围 [0, 2^x - 1] 中缺少一个整数,那么只需要对它们进行异或操作。例如:

>>> 0 ^ 1 ^ 3
2
>>> 0 ^ 1 ^ 2 ^ 3 ^ 4 ^ 6 ^ 7
5

(我知道这并不是完全回答了问题,但它是对一个非常相似的问题的很好回答。)


1
是的,证明只有一个整数缺失时它是有效的很容易,但如果缺失的数字超过一个,它经常会失败。例如,0 ^ 1 ^ 3 ^ 4 ^ 6 ^ 7 的结果是0。[使用2的x次方表示2的x次幂,a^b表示a异或b,所有k<2*x的异或值为零-- k ^ ~k = (2^x)-1,对于k < 2^(x-1),而且k ^ ~k ^ j ^ ~j = 0当j=k+2(x-2) -- 因此除了一个数字之外的所有数字的异或值就是缺失数字的值] - James Waldby - jwpat7
2
正如我在ircmaxell的回复评论中提到的:问题并没有说“缺失一个数字”,而是要找到一个在4十亿个数字文件中未包含的数字。如果我们假设32位整数,则该文件中可能缺少约3亿个数字。存在的数字的异或匹配缺失数字的概率仅约为7%。 - James Waldby - jwpat7
这是我最初阅读问题时所考虑的答案,但仔细检查后我认为问题比这更加模糊不清。FYI,这是我考虑的问题:https://dev59.com/E3VD5IYBdhLWcg3wQZYg - Lee Netherton

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接