关于语言不可知论:这个搜索算法是最优的吗?

Is this searching algorithm optimal?

我有两个列表,L 和 M,每个列表包含数千个 64 位无符号整数。我需要找出 L 的任意两个成员的总和是否本身就是 M 的成员。

是否可以改进以下算法的性能?

1
2
3
4
Sort(M)
for i = 0 to Length(L)
    for j = i + 1 to Length(L)
        BinarySearch(M, L[i] + L[j])


(我假设你的目标是找到 L 中所有与 M 相加的对)

忘记哈希表!

对两个列表进行排序。

然后执行算法的外循环:遍历 L 中的每个元素 i,然后遍历 L 中的每个较大元素 j。在进行过程中,形成总和并检查它是否在 M 中。

但不要使用二分搜索:只需从您最后查看的位置进行线性扫描。假设您正在处理某个值 i,并且您有某个值 j,然后是某个值 j'。搜索 (i j) 时,您将到达 M 中找到该值的点,或第一个最大值。您现在正在寻找 (i j');因为 j' > j,你知道 (i j') > (i j),所以它在 M 中不能比你得到的最后一个位置更早。如果 L 和 M 都平滑分布,则很有可能在 M 中找到 (i j') 的点只有一点距离。

如果数组不是平滑分布的,那么比线性扫描更好的可能是某种跳跃扫描 - 一次向前看 N 个元素,如果跳跃太远,则将 N 减半。

我相信这个算法是 O(n^2),它和任何提出的散列算法一样快(它有一个 O(1) 的原始操作,但仍然需要 O(n**2) 个。这也意味着您不必担心 O(n log n) 进行排序。它具有比散列算法更好的数据局部性 - 它基本上由数组上的成对流式读取组成,重复 n 次。

编辑:我已经编写了 Paul Baker 的原始算法、Nick Larsen 的哈希表算法和我的算法的实现,以及一个简单的基准测试框架。实现很简单(哈希表中的线性探测,线性搜索中没有跳过),我不得不猜测各种大小参数。有关代码,请参见 http://urchin.earth.li/~twic/Code/SumTest/。我欢迎对任何实现、框架和参数提出更正或建议。

对于每个包含 3438 个项目的 L 和 M,值范围从 1 到 34380,并且 Larsen 的哈希表的负载因子为 0.75,运行的中位时间为:

  • 贝克(二进制搜索):423 716 646 ns
  • 拉森(哈希表):733 479 121 ns
  • Anderson(线性搜索):62 077 597 ns

差异比我预期的要大得多(而且,我承认,不是我预期的方向)。我怀疑我在实施过程中犯了一个或多个重大错误。如果有人发现了,我真的很想听听!

有一件事是我在定时方法中分配了拉森的哈希表。因此,它付出了分配和(一些)垃圾收集的成本。我认为这是公平的,因为它只是算法需要的临时结构。如果您认为它是可以重用的东西,那么将它移动到一个实例字段中并只分配一次(并且 Arrays.fill 它在定时方法中用零填充)就足够简单了,看看它如何影响性能。


问题中示例代码的复杂度为 O(m log m l2 log m) 其中 l=|L|和 m=|M|因为它对 L (O(l2)) 中的每一对元素运行二进制搜索 (O(log m)),并且首先对 M 进行排序。

假设哈希表插入和查找是 O(1) 操作,用哈希表替换二进制搜索将复杂度降低到 O(l2)。

这是渐近最优的,只要你假设你需要处理列表 L 上的每一对数字,因为有 O(l2) 个这样的对。如果 L 上有几千个数字,并且它们是随机的 64 位整数,那么您肯定需要处理所有对。


您可以以 n 为代价创建散列集,而不是以 n * log(n) 为代价对 M 进行排序。

您还可以在迭代时将所有总和存储在另一个哈希集中,并添加检查以确保您不会两次执行相同的搜索。


或者,将 L 的所有成员添加到哈希集 lSet,然后迭代 M,对 M 中的每个 m 执行以下步骤:

  • 将 m 添加到 hashset mSet - 如果 m 已经在 mSet 中,则跳过此迭代;如果 m 在 hashset dSet 中,也跳过此迭代。
  • 从m中减去L中小于m的每个成员l得到d,并测试d是否也在lSet中;
  • 如果是,则将 (l, d) 添加到某个集合 rSet;将 d 添加到哈希集 dSet。
  • 这将需要更少的迭代,但会消耗更多的内存。如果这是为了提高速度,您将需要为结构预先分配内存。


    您可以通过使用除已排序 M 数组之外的哈希表来避免二进制搜索。