【问题标题】:Given a 1 TB data set on disk with around 1 KB per data record, how can I find duplicates using 512 MB RAM and infinite disk space?给定磁盘上的 1 TB 数据集,每个数据记录大约 1 KB,我如何使用 512 MB RAM 和无限磁盘空间找到重复项?
【发布时间】:2011-02-04 03:27:18
【问题描述】:

磁盘上有 1 TB 数据,每条数据记录大约 1 KB。如何使用 512 MB RAM 和无限磁盘空间查找重复项?

【问题讨论】:

  • 对于 ~1.76e9 块,大小约为 7e8 位或 700MB 且具有 5 个哈希函数的布隆过滤器应该会给您大约 1/8.0e9 的误报概率,这应该是 more 足够了。
  • @Monomer:也许把它放在答案中?我没有看到其他人得到它。如果您希望其他人充实详细信息,请创建社区 wiki。
  • @Potatocorn:随时回答。顺便说一句,我建议关于只需要 2 个哈希函数的 mitzenmacher 论文,可能很有趣。
  • @Monomer 你确实意识到 1/8.0e9 的误报率加上 1e9 块意味着平均一个块将发出误报信号,因此你的算法会犯一个错误吗?这通常不是“绰绰有余”。
  • @Red:看我的回答;我计算出 5 个函数会导致 5100 万个误报。

标签: c++ algorithm data-structures


【解决方案1】:

目前提供的解决方案似乎过于复杂。 Bloom filter,虽然是过去几年的数据结构 du jour,但在这种情况下最好不要应用:因为没有数据可以与散列内容相关联,所以你不能只维护布隆过滤器,但您仍然必须记录每个(仅 6 位!)哈希值并记录到磁盘,破坏布隆过滤器的好处并具有高得离谱的碰撞率。

另一方面,对整个 TB 进行合并排序不仅会占用O(n log n) 比较,还会占用O(n log n) 磁盘流量,因为大多数中间文件必须从磁盘而不是内存中合并.任何真正的解决方案都应该尽量减少磁盘流量,因为这是我们的主要瓶颈。

我的解决方案很简单,做一个假设:TB 的数据实际上记录在一个文件中。

遍历 TB 文件的记录并对它们进行哈希处理。加密散列在这里是不必要的、昂贵的和太大的;相反,请使用64-bit version of murmurhash 之类的东西。它可以散列超过 2 GiB/秒(远远快于我们可能需要的速度,考虑到如今的存储速度),并且具有出色的(尽管不是加密安全的)抗碰撞性。使用 64 位哈希 we would expect our first collision at 2^32,因此我们大约 10 亿条记录很可能根本不会发生任何冲突。

将哈希值及其关联的记录偏移量写入另一个文件。由于记录包含任意二进制数据,我们不能依赖 Unix 的 sort(1) 对其进行排序,因为一些散列和偏移量可能包含 sort(1) 将解释为换行符的内容。我们将简单地将记录写为固定宽度(可能是 16 个字节:8 个字节用于 murmur2 64 位散列,8 个字节用于 TB 文件中的偏移量)记录。考虑到我们的记录数量,生成的文件应该大约 16 GB。

我们可以通过读取可以安全放入内存的记录数并对它们进行排序,然后将排序后的块刷新回磁盘,来对这个文件进行排序。与使用快速排序(使用O(log n) 内存作为调用堆栈)相比,我们可以使用堆排序(它使用O(1) 空间)将更多的记录放入内存,但在大多数实现中,快速排序凭借其内存局部性和更低的优势获胜指令计数。这些中间文件(应该有 35-40 个)将被写入磁盘。

最后一步是合并这些文件(在内存中;无需为此将结果存储在磁盘上)收集所有哈希冲突并在 TB 文件中查找相关记录,比较记录是否重复并发出以问题指定的任何方式记录(或它们的偏移量)。

据我所知,与其他任何提供的解决方案相比,此任务对磁盘的影响要小得多,而且它在概念上非常简单:散列记录,在散列中查找重复项,并在实际记录中进行验证。

对于磁盘 I/O,它会读取 TB 数据文件,将 16 GB 写入磁盘,从磁盘读取 16 GB 并将其写回排序,然后读取并返回副本。作为一种优化,对记录进行散列处理的过程可以在将它们刷新到磁盘之前将它们累积在内存中,在这样做之前对它们进行排序:这会减少 16 GB 的中间文件,并允许该过程从直接散列转移到合并和报告重复项.

【讨论】:

  • 你有 10^9 块,你预计你的第一次碰撞是在 2^32 (这真的是真的吗?我相信你低估了 murmurhash 的碰撞率,但我没有检查这个) .你怎么能断定你根本不会发生碰撞?事实上,你有四分之一的碰撞机会:2^32 ≈ 4e9。
  • @Red-nosed unicorn:你看过我链接的关于生日悖论的文章吗?这涵盖了我的主张中涉及的数学,并有一个专门讨论哈希函数冲突的部分。作为一般规则,给定具有 N 位的均匀分布散列,您应该期望(我的意思是“有大于 50% 的机会)在第 2^(N/2) 个对象周围发生碰撞。所以使用 64位哈希,我建议,你很可能不会在十亿个哈希值中看到冲突(除了真正的重复)。无论如何,冲突都不太可能发生,我的算法可以解决这些问题。
  • @jemfinch 没看过,但我知道生日悖论,我知道数学。但是,我的评论仍然有效,因为这 2^(N/2) 恰好与 1e9 元素处于同一数量级,所以很有可能,您 可能 会看到碰撞(尽管我的数字1/4 当然是错误的——它是 50% 的 1/4,即 1/8)。如果我们想保证算法正常工作,那么 8 分之一的碰撞几率是非常糟糕的。
  • 这个算法的效率很大程度上取决于实际重复是不常见的假设。如果重复很常见(我不确定收支平衡点),那么使用像这样的基于哈希的解决方案验证它们所必需的随机读取将完全淹没这里涉及的计算时间。如果提前知道重复很常见,则应将此算法切换为加密安全散列,并且应不经验证就报告重复散列,或者应简单地对数据进行合并排序,最后一次通过报告重复。
  • @Potatocorn 不过,请记住,合并排序的文件不必一次处理两个文件;我们可以通过使用(头记录,文件描述符)对的最小堆来合并 O(number_of_files) 内存中的任意数量的文件。我们发出最小的记录,从给定文件中读取一条新记录,并在堆中适当地筛选(新记录,文件描述符)对。该合并仍然需要 O(n log n) 时间,但不需要对磁盘进行任何额外的写入。
【解决方案2】:

使用Bloom filter:同时哈希表。根据 Wikipedia,最佳哈希数是ln(2) * 2^32 / 2^30 ≈ 2.77 ≈ 3。 (嗯,插入 4 会减少误报,但对于此应用程序来说,插入 3 仍然更好。)这意味着您有一个 512 兆字节或 4 千兆位的表,并且处理每条记录会在广阔的海洋中设置三个新位。如果所有三个位都已设置,则可能是匹配项。将三个哈希值记录到文件中。否则,将它们记录到另一个文件中。请注意记录索引以及每个匹配项。

(如果可以容忍 5% 的错误率,请忽略大文件并使用小文件作为结果。)

完成后,您应该有一个大约 49M 可能正匹配的文件和一个可能与正匹配的 975M 负文件。将前者读入vector<pair<vector<uint32_t>,vector<uint32_t> > >(索引在后者vector,前者可以是array)并排序。将索引放入另一个vector<uint32_t>;他们已经排序了。读取大文件,但不是在表中设置位,而是在 vector 中查找哈希值。 (例如,使用equal_range。)使用正文件索引列表来跟踪负文件中当前记录的索引。如果未找到匹配项,则忽略。否则,追加记录的索引match->second.push_back(current_negative_record_index)

最后,遍历地图和记录索引的向量。任何包含多个条目的存储桶“几乎”肯定会包含一组重复项,但是您已经走到了这一步,因此请查找并完全比较它们以确保确定。

同步磁盘 I/O 总量:(一次传递 = 1 TiB)+(每条记录 96 个哈希位 = 12 GiB)+(每个正数 32 个索引位 = ~200 MiB)。

最终编辑(认真地):仔细想想,Bloom Filter 方面可能在这里并没有真正的帮助。哈希数据的数量比误报的数量更多是一个限制因素。仅使用一个哈希函数,哈希数据总量将为 4 GiB,1.24 亿个预期误报的索引约为 500 MiB。这应该会在全球范围内优化此策略。

澄清(投反对票):Bloom 过滤器的误报和哈希冲突之间存在区别。除非返回原始记录并进行比较,否则无法解决哈希冲突,这是昂贵的。 Bloom 误报可以通过返回原始哈希值并比较它们来解决,这是该算法的第二遍所做的。所以再想一想,“最终”编辑中描述的单哈希过滤器会过度导致磁盘寻道。两个哈希布隆过滤器会增加最终出现在 match 映射的单个存储桶中的误报数量,并将误报数量降低到数千万。

【讨论】:

  • @Potatocom:请记住,如果布隆过滤器的大小正确,那么最坏的误报概率只会在插入最终唯一元素时发生。在那之前,查询的误报概率可能会降低几个数量级 - 但随着更多元素的添加会逐渐增加。
  • @Monomer:这有什么改变吗?
  • @Potatocom:确实可以,只需要通过一次。想象一下,您构建了一个误报概率为 1/2n 的 BF,其中 n 是您将插入 BF 的项目数。您查询的第二项将看到 ~ 1/(2n^edelta_0) 的假阳性概率,第三项将看到 1/(2n^edelta_1) 其中 delta_i = m*i+c 和限制在 1/e。关键是只有当您插入/查询最后一个唯一元素时,您才会真正看到预期的误报概率,所有其他查询都乐于误报概率至少低一个数量级......跨度>
  • @Monomer:我明白这一点(尽管“所有其他查询”并不完全正确)。但这不影响我的回答。我说“第一次通过非常确定地告诉您除了'第一次'之外的所有重复。”如果您想要绝对确定或识别 all 重复,您需要一次通过加上 24 GiB 第二次通过( 20 GiB 和五个散列函数;如果巧妙地组合更少的散列,则更少)。我认为我的回答的主要问题是map 的存储......假设它的大小可以忽略不计太冒险了。
  • 最后一次插入单独的误报率约为 22%。预计误报总数约为 5800 万或 5.8%。这足以需要考虑存储map,并且足以严重限制单通道版本的实用性。
【解决方案3】:

这是很多记录 ;-) 大约 1,000,000,000 条。最好聪明点……

记录的性质是不确定的:我们是否只是通过顺序读取它们来发现它们,或者是否有某种索引,或者它们是否作为文件存储在不同的目录中?问题中还没有指定 dbms 的可用性,我们可以将其用于类似索引的数据(而不是必须用我们自己的代码对其进行排序)。此外,对重复数量的 [甚至粗略] 概念将有助于将一些选择导向一个有效的过程。

如果不存在索引,我们可以/应该创建一个;这可以在第一次通过数据时完成。相同的传递将用于为每条记录(或可能,出于效率目的,为记录的前几百个字节)生成各种类型的消息摘要(散列)。

总体思路是快速生成可用于识别可能重复的索引,并最终确定实际重复的列表,可能通过并行处理

索引中有用的信息是:

  • 记录长度
  • 文本的前几个字节
  • 哈希码(更多内容见下文)
  • 还有文件中的偏移量或任何指向数据的指针,但当然与上述 3 个元素不同,这不能用于识别潜在的匹配项。

哈希的选择很关键:应该以牺牲完美分布的算法为代价来支持快速算法;每个记录的散列字节数也是一个折衷方案,可能 100 到 200 个字节(即平均记录大小的大约 10% 到 20%)是一个不错的值,具体取决于预期的重复率和节省的时间这提供了(与散列整个记录相比)。 (见下面的编辑)

一旦有了这样的索引,我们就可以[相对快速/毫不费力地]获得可能重复的计数;基于此结果,如果认为选择性不够,可以进行旨在提高索引质量的第二次遍历(忽略容易被视为唯一的记录)。第二遍可以在整个记录(不包括第一个散列的前 x 个字节)或记录的另一个子集上计算另一个散列。请注意,多亏了索引,如果可能,第二次传递可以是多线程的。

第二次或最后一次通过需要对一组可能匹配(相同长度、相同哈希码、相同的前 x 个字节)中的记录进行排序。正如 Pax Diablo 所描述的那样,这可以实现,索引的优点是这种操作可以再次是多线程的,并且涉及更小的集合(其中很多)。 已添加Nick Johnson 再次强调,如果我们使用长哈希码(他建议使用 128 字节长的 SHA1),可能不需要第二遍。假设对记录进行部分散列没有任何好处,这是一个非常合理的解决方案,因为索引可以驻留在磁盘上,但比我们排序/存储整个记录时更快地排序和存储。


编辑Nick Johnson 提出了一个很好的观点,即磁盘存储中的寻道延迟可能会导致普通顺序读取更快,而瓶颈是磁盘 I /O 限制,并发运行的快速哈希函数可能实际上比顺序读取更快,因此不会添加到整个过程中。这是一种可能的可能性(特别是如果有效地需要顺序读取来检测每条记录的开始/结束等),这就是为什么我通过写“取决于这提供的时间节省...”。这表示磁盘上记录的实际结构是问题的开放参数之一(例如,如果我们只是从目录中的单个文件中读取,因此会强制执行非顺序读取),并且 TeraByte 大小的存储也很可能由花哨的 RAID 支持,在这种情况下,通常会大大改善寻道延迟,同时保持关注。
我坚持我的建议,即两遍方法可能比每条记录完全散列的方法更有效,但我希望我强调了可能性和好处的单程方法。与许多面试问题一样,当前情况的几个特征没有明确说明;与其说是看到申请人提供绝对正确的答案(尽管有些答案可能完全错误!),不如说是为了深入了解他/她的思维过程以及识别选项和决策点的能力。

【讨论】:

  • 通常是一个不错的策略,但我认为您低估了 CPU 时间与磁盘寻道延迟的比率。鉴于 spin-rust 存储的特性,只对每条记录中的一些记录进行散列绝对没有意义——无论如何,你必须读取整条记录才能到达下一条记录——而且因为你的 CPU 比磁盘快得多,你也可以使用像 SHA1 这样分布良好的散列函数。
  • 另外,如果您要不厌其烦地将这样的索引写入磁盘,您还可以包含一个足够长的哈希值,以确保如果 H(a) = = H(b) 然后 a == b - 例如,至少 128 个字节 - 从而允许您跳过后续通道。
  • @Alex:如果你能限定你的评论,我会很感兴趣,据我所知,这个特定的解决方案和其他解决方案一样糟糕(除了马铃薯),我似乎不能了解人们为什么投票赞成这个答案。
  • @Monomer,它似乎相当有效(从高级阅读)。尽管我确实同意@Nick 的观点,即仅通过计算部分哈希来节省 CPU 周期可能不值得。愿意指出您在其中发现的主要问题吗?
  • @Nick Johnson:你提出了两个很好的观点。请参阅我的编辑。我当然没有强调一次性方法的合理性。我支持在多通道方法中同样合理的部分散列方法。对解决方案的最终决定将由许多未说明的参数驱动,其中一些我在整个答案中暗示,一些我什至没有改变(估计重复的百分比,记录集的结构:一个大的连续 blob,磁盘上的文件......,操作的频率,是一次性的,我们是否会添加到这个存储库中......等等)
【解决方案4】:

找到一个合适的散列函数并对每条记录进行散列,将带有索引的散列列表存储到一个文件中。现在按哈希对哈希文件进行排序。最后检查匹配哈希的全部记录是否有真正的重复。

当然,这取决于您希望找到多少重复项以及之后您将如何处理这些信息。

【讨论】:

  • 这类似于 Rabin-Karp 算法,但用于记录,而不是字符串搜索。除了,你为什么要对哈希文件进行排序?您已经有一组包含可能重复项的存储桶。为什么不只检查存储桶?
  • @Alex 您认为我将记录放入哈希表中,这可能是一种选择。
【解决方案5】:

一次将数据加载到 512M 内存中,然后对该块进行排序并将其写入磁盘(作为自己的文件)。以这种方式完成整个 1T 后,将各个文件合并排序为一个大 honkin' 文件,然后按顺序读取该大(排序)文件,将其写入最终文件,同时删除重复记录。

1T,一次 512M,将是大约 210 万个文件(假设 SI 单位的二进制定义而不是十进制)。 512M 的 1K 记录一次只允许 524,288 条记录在内存中,因此您可能必须分两个阶段进行合并排序。换句话说,将 210 万个文件分成四组进行合并排序以创建四个更大的文件,然后将这四个合并排序到已排序的大文件中。然后就是您按顺序处理以删除重复项。

合并排序只是通过从每个文件中选择第一个剩余记录并选择“最低”来合并多个已排序的文件。比如ab这两个文件:

a   b
7   6
3   5
1   4
    2
 \_/
  1 (a)
  2 (b)
  3 (a)
  4 (b)
  5 (b)
  6 (b)
  7 (a)

【讨论】:

  • 如果您只是想查找重复项,那么对整个 1TB 数据进行排序就太过分了。
  • 真的吗?在我看来,未排序数据集的重复测试是 O(n*n),而任何体面的排序都更像 O(n log n)。如果你有更好的方法,我想看看。
  • @Ants,我认为不清楚内存限制是针对所有内容还是仅针对数据。无论如何,如果您的内存较少,则只需以较小的块进行。这个理论仍然成立。
  • 查找重复项是具有哈希集的 O(n) 操作。
  • 如果足够大,常量很重要。
【解决方案6】:

为每条记录生成一个哈希值;在内存中记录记录号和哈希,必要时溢出到文件(在写入文件之前按哈希顺序对数据进行排序)。当你想出一个新的哈希值时,检查它是否已经存在于内存中——这是一个早期检测。 (这可能是也可能不是主要好处。)。

当您读取所有数据后,您将拥有多个哈希文件和记录编号,并且已经排序。将这些文件合并在一起,随时发现重复文件。您甚至只需要记录此应用程序的重复项,因此您可以在它们被证明是唯一的后丢弃哈希。

考虑到大小 - 0.5 GB 内存、1000 GB 数据、每条记录 1 KB,因此大约有 10 亿条记录 - 假设 256 位散列(尽管 128 位可能就足够了),我们将使用 32 字节对于散列和 4 个字节的记录号,大约 10 亿条记录,我们需要大约 36 GB 的排序文件,生成 500 MB 文件(对应于可用内存),所以会有 70-80 个文件最后合并,这似乎很容易管理。该列表将为您提供记录编号 - 然后您必须访问 1 TB 文件才能读取记录。您需要考虑如何处理重复项;您是否需要有关初始记录的信息附加信息,保留哪些重复项和拒绝哪些重复项是否重要。等等。

【讨论】:

  • 对。我的观点是,对于 128 位的散列长度,没有足够大的合理数据集合来使冲突发生的可能性很小。
【解决方案7】:

首先,在无限大的驱动器上配置一个无限大的交换文件...

【讨论】:

    【解决方案8】:

    您可以使用散列来减少问题的大小。例如,如果您有 1TB 的数据,那么您定义一个哈希函数并将数据分成十个文件(每个文件的大小小于 1TB)。之后,如果一个文件仍然太大,重复该过程,直到该文件可以存储在内存中。最后,您可以按排序计算出现次数。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2017-07-18
      • 1970-01-01
      • 2011-05-17
      • 2012-12-30
      • 1970-01-01
      • 2019-04-12
      • 2019-12-12
      • 2021-11-28
      相关资源
      最近更新 更多