生成一个不在已有 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个回答

6

为了完整起见,这里提供另一种非常简单的解决方案,它很可能需要很长时间才能运行,但使用的内存非常少。

让所有可能的整数成为int_minint_max的范围,并且bool isNotInFile(integer)是一个函数,如果文件不包含某个整数,则返回true,否则返回false(通过将某个整数与文件中的每个整数进行比较)

for (integer i = int_min; i <= int_max; ++i)
{
    if (isNotInFile(i)) {
        return i;
    }
}

问题确切地是关于isNotInFile函数的算法。请确保在回答之前理解问题。 - Aleks G
2
不,问题是“文件中缺少哪个整数”,而不是“整数x是否在文件中”。例如,确定答案的函数可以将文件中的每个整数与所询问的整数进行比较,并在匹配时返回true。 - deg
我认为这是一个合法的答案。除了输入/输出之外,您只需要一个整数和一个布尔标志。 - Rag
@Aleks G - 我不明白为什么这个被标记为错误。我们都同意这是最慢的算法 :-),但它可以工作,并且只需要4个字节来读取文件。例如,原始问题并没有规定文件只能读取一次。 - Simon Mourier
@Simon,我从未说过文件需要被读取一次。我说过问题包括需要一个算法来检查文件中是否存在某个数字。 - Aleks G
1
@Aleks G - 对的,我也没说过你这么说。我们只是说 IsNotInFile 可以使用文件上的循环轻松实现:打开文件;当未到达文件结尾时{读取整数;如果整数=i,则返回 False;否则继续;};它只需要 4 个字节的内存。 - Simon Mourier

6
你可以使用位标志来标记一个整数是否存在。
在遍历整个文件后,扫描每一位以确定数字是否存在。
假设每个整数占32位,如果进行位标记,它们将方便地适合1GB的RAM。

0.5 Gb,除非您重新定义字节为4位。;-) - dty
2
@dty 我认为他的意思是“宽裕”,即1GB会有很多空间。 - corsiKa

5

我将回答1GB版本:

问题中缺乏足够的信息,因此我首先会陈述一些假设:

整数是32位的,范围为-2,147,483,648到2,147,483,647。

伪代码:

var bitArray = new bit[4294967296];  // 0.5 GB, initialized to all 0s.

foreach (var number in file) {
    bitArray[number + 2147483648] = 1;   // Shift all numbers so they start at 0.
}

for (var i = 0; i < 4294967296; i++) {
    if (bitArray[i] == 0) {
        return i - 2147483648;
    }
}

5
针对10 MB内存限制的解决方法如下:
  1. 将数字转为二进制表示。
  2. 创建一个二叉树,其中左子树为0,右子树为1。
  3. 使用其二进制表示将每个数字插入到树中。
  4. 如果一个数字已经被插入,则叶子节点已经被创建。
完成后,只需选择之前未创建过的路径来创建所需数字。
40亿个数字=2 ^ 32,这意味着10 MB可能不足够。
编辑:
如果两端叶子节点已经被创建,并且具有共同的父节点,则可以删除它们并标记父节点为非解决方案。 这样可以减少分支并减少内存需求。
第二次编辑:
也没有必要完全构建整棵树。 只有在数字相似的情况下,才需要构建深度分支。 如果我们也剪枝,那么这个解决方案实际上可能会起作用。

6
...那将如何适应10MB的空间? - hmakholm left over Monica
如何限制BTree的深度,使其适合于10MB;这意味着您将在集合{false positive | positive}中获得结果,并且您可以遍历该集合并使用其他技术查找值。 - Jonathan Dickinson

4

正如Ryan所说,基本上是对文件进行排序,然后遍历整数,当有一个值被跳过时,那么它就是我们要找的值:)

编辑面向downvoters(点踩者):OP提到了文件可以被排序,因此这是一种有效的方法。


一个关键的部分是你应该边做边学,这样你只需要读一次。访问物理内存很慢。 - Ryan Amos
@ryan 外部排序在大多数情况下是归并排序,因此在最后一次合并时可以进行检查 :) - ratchet freak
如果数据存储在磁盘上,它必须被加载到内存中。这由文件系统自动完成。如果我们只需要查找一个数字(题目没有另外说明),那么逐行读取已排序的文件是最有效的方法。它占用很少的内存,并且不比其他方法慢——文件必须被读取。 - Tony Ennis
当你只有1GB内存时,如何对40亿个整数进行排序?如果使用虚拟内存,由于内存块需要不断地被分页,所以会花费很长时间。 - Klas Lindbäck
4
"merge sort(归并排序)是为此设计的。" - ratchet freak

4
只要我们想出创意答案,这里就有另一种方法。
使用外部排序程序按数字对输入文件进行排序。这适用于您可能拥有的任何内存量(如果需要,它将使用文件存储)。 阅读排序后的文件并输出第一个缺失的数字。

3

我认为这是一个已解决的问题(见上文),但有一个有趣的特殊情况需要考虑,因为它可能会被问到:

如果存在确切的 4,294,967,295(2^32 - 1)个 32 位整数没有重复,因此只缺少一个,那么就有一个简单的解决方案。

从零开始运行总数,并对文件中的每个整数进行操作,使用 32 位溢出加法(实际上,runningTotal = (runningTotal + nextInteger) % 4294967296)。完成后,再使用 32 位溢出加法将 4294967296/2 添加到运行总数中。从 4294967296 中减去这个和,结果就是缺失的整数。

"仅缺少一个整数" 的问题可以在一次运行中解决,且仅需要 64 位 RAM 来存储数据(32 位用于计算运行总数,32 位用于读取下一个整数)。

推论:如果我们不关心整数结果需要具有多少位,则更通用的规范非常简单。我们只需生成足够大的整数,使其不能包含在我们获得的文件中。同样,这占用的 RAM 绝对最少。请参见伪代码。

# Grab the file size
fseek(fp, 0L, SEEK_END);
sz = ftell(fp);
# Print a '2' for every bit of the file.
for (c=0; c<sz; c++) {
  for (b=0; b<4; b++) {
    print "2";
  }
}

@Nakilon和TheDayTurns在原问题的评论中指出了这一点。 - Rag

3

位数消除

一种方法是通过消除位数来减小数据量,但这并不一定会产生结果(很可能不会)。伪代码:

long val = 0xFFFFFFFFFFFFFFFF; // (all bits set)
foreach long fileVal in file
{
    val = val & ~fileVal;
    if (val == 0) error;
}

位数计数

跟踪位数计数;使用数量最少的位来生成值。但是这并不能保证生成正确的值。

区间逻辑

跟踪按起始顺序排序的区间列表。一个区间由以下结构定义:

struct Range
{
  long Start, End; // Inclusive.
}
Range startRange = new Range { Start = 0x0, End = 0xFFFFFFFFFFFFFFFF };

遍历文件中的每个值,尝试将其从当前范围中移除。该方法没有内存保证,但应该表现相当不错。


3

2128*1018 + 1(即(2816*1018 + 1)- 这难道不可能是今天的普遍答案吗?这代表一个数字,无法在任何当前文件系统中的16 EB文件中保存。


那么你要如何打印结果呢?你不能将它放在文件中,而在屏幕上打印需要数十亿年的时间。这是今天的计算机无法实现的可靠运行时间。 - vsz
从来没有说过我们需要在任何地方打印结果,只是“生成”它。所以这取决于你所说的“生成”的意思。无论如何,我的答案只是一个避免计算真正算法的技巧 :) - Michael Sagalovich

2
给定一个包含 40 亿个整数的输入文件,提供一种算法生成一个不在该文件中的整数。假设你有 1 GiB 的内存。如果你只有 10 MiB 的内存,你会怎么做?文件大小为 16 GiB(4 * 109 * 4 字节)。针对 32 位无符号整数。
0 <= Number < 2^32
0 <= Number < 4,294,967,296

我的解决方案:没有错误检查的C++。
#include <vector>
#include <fstream>
#include <iostream>
using namespace std;

int main ()
{
    const long SIZE = 1L << 32;

    std::vector<bool> checker(SIZE, false);

    std::ifstream infile("file.txt");  // TODO: error checking

    unsigned int num = 0;

    while (infile >> num)
    {
        checker[num] = true ;
    }

    infile.close();

    // print missing numbers

    for (long i = 0; i < SIZE; i++)
    {
        if (!checker[i])
            cout << i << endl ;
    }

    return 0;
}

复杂度

  • 空间 ~ 232 位 = 229 字节 = 219 千字节 = 29 兆字节 = 1/2 千兆字节
  • 时间 ~ 单次遍历
  • 完备性 ~ 是

这个操作会将所有年龄大于指定年龄的位图答案复制一份。另外,std::vector<bool> 没有一个快速的方式来扫描未设置的位。 std::bitset 也没有,尽管如此。 (每次只检测64位与(long)-1相比较要快得多,除非编译器真的很聪明并且能够看穿一个逐位操作的循环。) - Peter Cordes
在x86上测试过了;gcc 4.9.2生成可怕的逐位循环。clang为设置一系列位生成更糟糕的循环,但对于逐位测试使用bt r, r生成稍微好一些的循环。两者仍然比使用cmp r, -1一次检查64位要慢约100倍。 - Peter Cordes

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