如何从 4,000,000,000 个号码中获取最频繁的 100 个号码?

阿尔曼*_*阿尔曼 80 java algorithm

昨天在一次编码面试中,我被问到如何从 4,000,000,000 个整数(可能包含重复项)中获取最频繁的 100 个数字,例如:

813972066
908187460
365175040
120428932
908187460
504108776
Run Code Online (Sandbox Code Playgroud)

我想到的第一种方法是使用 HashMap:

static void printMostFrequent100Numbers() throws FileNotFoundException {
    
    // Group unique numbers, key=number, value=frequency
    Map<String, Integer> unsorted = new HashMap<>();
    try (Scanner scanner = new Scanner(new File("numbers.txt"))) {
        while (scanner.hasNextLine()) {
            String number = scanner.nextLine();
            unsorted.put(number, unsorted.getOrDefault(number, 0) + 1);
        }
    }

    // Sort by frequency in descending order
    List<Map.Entry<String, Integer>> sorted = new LinkedList<>(unsorted.entrySet());
    sorted.sort((o1, o2) -> o2.getValue().compareTo(o1.getValue()));

    // Print first 100 numbers
    int count = 0;
    for (Map.Entry<String, Integer> entry : sorted) {
        System.out.println(entry.getKey());
        if (++count == 100) {
            return;
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

但它可能会为 4,000,000,000 个数字的数据集抛出 OutOfMemory 异常。此外,由于 4,000,000,000 超过了 Java 数组的最大长度,假设数字在文本文件中并且没有排序。我认为多线程或 Map Reduce 更适合大数据集?

当数据不适合可用内存时,如何计算前 100 个值?

Dav*_*oko 40

如果数据进行排序,你可以收集进入前100 O(n),其中n是数据的大小。由于数据已排序,不同的值是连续的。在遍历数据一次时对它们进行计数为您提供全局频率,当数据未排序时,您无法使用该频率。

请参阅下面的示例代码,了解如何做到这一点。在GitHub 上也有整个方法的实现(在 Kotlin 中)

注意:排序不是必需的。什么需要的是不同的值是连续的,所以没有必要顺序进行定义-我们得到这个从分拣但也许还有更有效地这样做的方式。

您可以粗略地使用(外部)合并排序对数据文件进行排序,方法O(n log n)是将输入数据文件拆分为适合您内存的较小文件,将它们排序并写入已排序的文件中,然后合并它们。



关于此代码示例:

  • 排序后的数据由 a 表示long[]。因为逻辑一个一个地读取值,所以它是从排序文件中读取数据的近似值。

  • OP 没有指定如何处理具有相同频率的多个值;因此,除了确保结果是没有特定顺序的前 N ​​个值并且不暗示没有其他值具有相同的频率之外,代码不会做任何事情。

import java.util.*;
import java.util.Map.Entry;

class TopN {
    private final int maxSize;
    private Map<Long, Long> countMap;

    public TopN(int maxSize) {
        this.maxSize = maxSize;
        this.countMap = new HashMap(maxSize);
    }

    private void addOrReplace(long value, long count) {
        if (countMap.size() < maxSize) {
            countMap.put(value, count);
        } else {
            Optional<Entry<Long, Long>> opt = countMap.entrySet().stream().min(Entry.comparingByValue());
            Entry<Long, Long> minEntry = opt.get();
            if (minEntry.getValue() < count) {
                countMap.remove(minEntry.getKey());
                countMap.put(value, count);
            }
        }
    }

    public Set<Long> get() {
        return countMap.keySet();
    }

    public void process(long[] data) {
        long value = data[0];
        long count = 0;

        for (long current : data) {
            if (current == value) {
                ++count;
            } else {
                addOrReplace(value, count);
                value = current;
                count = 1;
            }
        }
        addOrReplace(value, count);
    }

    public static void main(String[] args) {
        long[] data = {0, 2, 3, 3, 4, 5, 5, 5, 5, 6, 6, 6, 7};
        TopN topMap = new TopN(2);

        topMap.process(data);
        System.out.println(topMap.get()); // [5, 6]
    }
}

Run Code Online (Sandbox Code Playgroud)

  • @zwol(和其他人)是正确的,而我不是,在排序后,如果没有平局可以突破获胜者之外,则只需要 1 遍 - 只需跟踪到目前为止发现的前 100 个值及其频率,取代如果遇到更好的获胜者,则频率最低 (17认同)
  • 当你遍历排序后的数据时,你只需保留前 100 个数字,如果你已经有 100 个数字,则用计数较高的数字淘汰计数最低的数字。在您的示例 [0,1,2,.... 100, 100] 中,当您达到 100 时,您将得到一个如下所示的集合: {0 - 1, 1 - 1, 2 -1, . .., 99 - 1} 现在你有 {100 - 2 },所以你可以剔除一些计数为 1 的数字并添加 {100 - 2} (16认同)
  • @Bohemian 如果输入已排序,则出现两次的 100 个值中的每一个都将在一行中出现两次。这有很大的不同。 (16认同)
  • 当多个值具有相同频率时该怎么办实际上在原始帖子中没有定义。最极端的情况是所有 40 亿个值都是不同的。我的看法是,在这种情况下,这 40 亿个值中的哪一个显示为前 100 名并没有什么区别。 (11认同)
  • @David 最极端的边缘情况是 3999999900 个不同的值,其次是已经出现的 100 个不同的值。结果是最后 100 个值,因为它们每个出现两次,而所有其他值出现一次。没有任何平局可打破 - 前 100 名是明显的赢家。但是,直到处理整个输入后才能确定它们,这意味着您需要跟踪 3999999900 个值的出现次数以及这些值是什么。这并不是要打破关系。排序或拆分然后合并也没有帮助(但两者都可能!) (3认同)

Sjo*_*erd 24

整数是有符号的 32 位,所以如果只出现正整数,我们会查看最多 2^31 个不同的条目。2^31 字节的数组应保持在最大数组大小以下。

但这不能保持高于 255 的频率,你会说?你是对的。

因此,我们为超出数组中可能的最大值的所有条目添加一个哈希映射(255 - 如果它已签名,则从 -128 开始计数)。这个哈希图中最多有 1600 万个条目(40 亿除以 255),这应该是可能的。


我们有两种数据结构:

  • 一个大数组,由读取的字节数 (0..2^31) 索引。
  • (读取的数字,频率)的哈希图

算法:

 在阅读下一个数字“x”时
 {
   如果 (hashmap.contains(x))
   {
     哈希图[x]++;
   }
   别的
   {
     bigarray[x]++;
     如果(大数组 [x] > 250)
     {
       hashmap[x] = bigarray[x];
     }
   }
 }

 // 完成后:
 // 在 hashmap 中查找前 100 个
 // 如果还不是 100,从 bigarray 添加更多,跳过那些已经从 hashmap 中取出的

我对 Java 不流利,所以无法给出更好的代码示例。


请注意,此算法是单次通过的,适用于未排序的输入,并且不使用外部预处理步骤。

它所做的只是假设读取的数字有最大值。如果输入是最大为 2^31 的非负整数,它应该可以工作。样本输入满足该约束。


上面的算法应该可以满足大多数问这个问题的面试官。你是否可以用 Java 编码应该由一个不同的问题来确定。这个问题是关于设计数据结构和高效算法的。

  • 我没有看到问题中的任何内容保证数字都适合 32 位“int”作为正数,即 31 位。对其他答案的评论提出了一些极端情况,例如 3999999900 个不同的值,其中必须包含许多负数或更大的类型。它们在文件中采用 ASCII 十进制字符串格式,这对其大小没有任何限制。不过,如果你*可以*做出这个假设,那么这是一个很好的算法。 (5认同)
  • 如果您可以假设为 32 位,则可以使用 4 位存储桶,每个 uint8_t 两个存储桶或任何 Java 调用的存储桶。这使得递增涉及位域提取/插入,或者在递增低半部分时阻止或取消进位传播到高半部分的一些位黑客。但确实将缓存/内存占用量减少了 2 倍。(假设均匀分布,31 位数字的计数器桶在 4096 中仍然只有 1 次机会位于 256kiB L2 缓存中热的缓存行中,所以这不是有帮助,甚至对于 32MiB L3 也没有帮助)。4 位计数器使最坏情况下的哈希图大小*大*大。 (2认同)

Boh*_*ian 18

在伪代码中:

  1. 执行外部排序
  2. 做一次传递以收集前 100 个频率(不是哪个值有它们)
  3. 再通过一次以收集具有这些频率的值

假设:有明显的赢家 - 没有平局(前 100 名之外)。

时间复杂度:O(n log n)(大约)由于排序。空间复杂度:可用内存,同样是由于排序。

第 2 步和第 3 步都是 O(n) 时间和 O(1) 空间。


如果没有平局(在前 100 名之外),可以将步骤 2 和步骤 3 合并为一个 pass,这不会提高时间复杂度,但会稍微提高运行时间。

如果有平局会使获胜者的数量很大,则您无法发现并采取特殊行动(例如,抛出错误或放弃所有平局)而无需两次通过。但是,您可以通过一次从关系中找到最小的 100 个值。

  • 我学到了新东西,[外部排序](https://en.wikipedia.org/wiki/External_sorting) (2认同)

gus*_*to2 10

但它可能会为 4000000000 个数字的数据集抛出 OutOfMemory 异常。此外,由于 4000000000 超过了 Java 数组的最大长度,假设数字在文本文件中并且没有排序。

这取决于价值分布。如果您有 4E9 数字,但数字是 1-1000 的整数,那么您最终将得到一个包含 1000 个条目的映射。如果数字是双精度数或值空间不受限制,那么您可能会遇到问题。

正如在以前的答案-有一个bug

unsorted.put(number, unsorted.getOrDefault(number, 0) + 1);
Run Code Online (Sandbox Code Playgroud)

我个人会使用“AtomicLong”作为值,它允许在不更新 HashMap 条目的情况下增加值。

我认为多线程或 Map Reduce 更适合大数据集?这个问题最有效的解决方案是什么?

这是一个典型的 map-reduce 练习示例,因此理论上您可以使用多线程或 MR 方法。也许这是你练习的目标,你想实现多线程的 map-reduce 任务,不管它是否是最有效的方法。

实际上,您应该计算一下是否值得付出努力。如果您正在连续读取输入(因为它在您的代码中使用Scanner),那么绝对不是。如果可以拆分输入文件并并行读取多个部分,考虑到 I/O 吞吐量,则可能是这种情况。

或者,如果值空间太大而无法放入内存并且您需要缩小数据集的规模,您可以考虑不同的方法。


小智 6

由于数据集可能对内存来说太大了,我会做一个十六进制基数排序。因此,数据集将在每次传递中拆分为 16 个文件,并根据需要使用尽可能多的传递来获得最大整数。

第二部分是将文件组合成一个大数据集。

第三部分是按编号读取文件编号并计算每个编号的出现次数。将出现次数和出现次数保存到按大小排序的二维数组(列表)中。如果文件中的下一个数字出现次数多于列表中出现次数最少的数字,则替换该数字。


Dan*_*ant 5

一种选择是一种二分搜索。考虑一棵二叉树,其中每个分裂对应于 32 位整数中的一个位。所以从概念上讲,我们有一个深度为 32 的二叉树。在每个节点,我们可以计算集合中以该节点的位序列开头的数字的计数。这个计数是一个 O(n) 操作,所以找到我们最常见的序列的总成本将是 O(n * f(n)),其中函数取决于我们需要枚举的节点数量。

让我们从考虑深度优先搜索开始。这为枚举期间的堆栈大小提供了合理的上限。对所有节点进行蛮力搜索显然很糟糕(在这种情况下,您可以完全忽略树的概念而仅枚举所有整数),但是我们有两件事可以阻止我们需要搜索所有节点:

  1. 如果我们曾经到达一个分支,在该分支中以该位序列开始的集合中有 0 个数字,我们可以修剪该分支并停止枚举。

  2. 一旦我们到达终端节点,我们就知道该特定数字出现了多少次。我们将此添加到我们的“前 100 名”列表中,必要时删除最低的。一旦此列表填满,我们就可以开始修剪总计数低于“前 100”计数中最低值的任何分支。

我不确定这的平均和最坏情况的性能是什么。对于具有较少不同数字的集合,它往往会表现得更好,而对于接近均匀分布的集合,它的表现可能最差,因为这意味着需要搜索更多的节点。

一些观察:

  1. 最多有 N 个非零计数的终端节点,但由于在这种特定情况下 N > 2^32,这无关紧要。

  2. M 个叶节点(M = 2^32)的节点总数为 2M-1。这在 M 中仍然是线性的,因此最坏情况的运行时间限制在 O(N*M) 以上。

  3. 在某些情况下,这将比仅通过线性标量因子搜索所有整数表现更差。这是否平均表现更好取决于预期数据。对于均匀随机数据集,我的直觉猜测是,一旦前 100 个列表填满,您将能够修剪足够多的分支,而您往往需要少于 M 个计数,但这需要根据经验进行评估或证明。

  4. 实际上,该算法只需要对数据集进行只读访问(它只执行以特定位模式开始的数字计数)这一事实意味着它可以通过跨多个数组存储数据来进行并行化,并行计算子集,然后将计数加在一起。在实际实现中,这可能是一个相当大的加速,而需要排序的方法更难做到。


这是如何执行的一个具体示例,对于一组更简单的 3 位数字,并且只找到最频繁的一个。假设集合是 '000, 001, 100, 001, 100, 010'。

  1. 计算所有以“0”开头的数字。这个数是 4。

  2. 更深入地计算所有以“00”开头的数字。这个数是3。

  3. 计算所有为“000”的数字。这个计数是 1。这是我们新的最频繁的。

  4. 计算所有为“001”的数字。这个计数是 2。这是我们新的最频繁的。

  5. 进行下一个深分支并计算以“01”开头的所有数字。这个计数是 1,比我们最频繁的要少,所以我们可以停止枚举这个分支。

  6. 计算所有以“1”开头的数字。这个计数是 1,比我们最频繁的要少,所以我们可以停止枚举这个分支。

  7. 我们没有分支,所以我们完成了,“001”是最常见的。


小智 5

Linux工具

这只是在 Linux/Mac 上的 shell 脚本中完成的:

sort inputfile | uniq -c | sort -nr | head -n 100
Run Code Online (Sandbox Code Playgroud)

如果数据已经排序,你只需使用

uniq -c inputfile | sort -nr | head -n 100
Run Code Online (Sandbox Code Playgroud)

文件系统

另一个想法是使用数字作为文件名并增加每次命中的文件大小

while read number;
do
  echo -n "." >> number
done <<< inputfile
Run Code Online (Sandbox Code Playgroud)

文件系统限制可能会导致大量文件出现问题,因此您可以创建一个包含前几位数字的目录树并将文件存储在那里。

完成后,您遍历树并记住文件大小的 100 个最高可见值。

数据库

您可以对数据库使用相同的方法,因此您不需要在那里实际存储 GB 的数据(也可以),只需要存储计数器(需要更少的空间)。

面试

一个有趣的问题是你如何处理边缘情况,所以如果第 100 个、第 101 个……数字具有相同的频率,会发生什么。整数只有正数吗?

他们需要什么样的输出,只是数字还是频率?只需像工作中的一项真正任务一样仔细考虑它,并询问您需要知道的一切来解决它。更多的是关于你如何思考和分析问题。

  • 不过警告,sort 和 uniq 可能会分别使用超过 36GB 的 RAM,这里 sort 和 uniq 的组合可能会使用超过 90GB 的 RAM (3认同)