更麻烦:在固定大小的数组上有效地实现二进制搜索

时间:2011-03-08 22:18:44

标签: c optimization

再次,我有一个问题,我想减少纳秒。我有一个小的,常量的数组,我想搜索它以查看给定的数字是否是成员*。

输入:64位数 n

输出:如果 n 在数组中,则为True,如果不是 n ,则为false。

考虑到针对特定元素及其分布进行优化的可能性,快速进行二进制搜索的有哪些好方法。

具体细节

我有一个大约136名成员的阵列(虽然见下文:有一些灵活性)来搜索。成员在范围内的分布不均匀:它们聚集在范围的开头和结尾。输入数可以假设为具有均匀概率的选择。利用这种不规则性可能是值得的。

这是136元素阵列分布的示例图片。请注意, 136个元素中只有12个在范围的1%到99%之间;余额低于1%或超过99%。

http://math.crg4.com/distribution.png

我认为分支错误预测将是任何实施的最大成本。我很高兴被证明是错的。

备注

* 实际上,我有两个数组。实际上,我可以选择使用哪些数组:效率表明第一个应该有10-40个成员,而第二个可以有不超过(确切地)136个成员。我的问题在选择尺寸方面提供了真正的灵活性,并且有限地自由决定使用哪些成员。如果方法在某些尺寸或限制下表现更好,请提及此因为我可以使用它。在所有条件相同的情况下,我宁愿让第二个数组尽可能大。由于与二进制搜索无关的原因,我可能需要将第二个数组的大小减小到< = 135或< = 66(这与确定输入号的难度有关,这取决于在选中的阵列上。)

这是可能的数组之一,如果它有助于测试想法。 (这很好地揭示了我的目的......!)不要在前几个成员的基础上跳到毫无根据的结论。

0, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, 6765, 10946, 17711, 28657, 46368, 75025, 121393, 196418, 317811, 514229, 832040, 1346269, 2178309, 3524578, 5702887, 9227465, 14930352, 24157817, 39088169, 63245986, 102334155, 165580141, 267914296, 433494437, 701408733, 1134903170, 1836311903, 2971215073, 4807526976, 7778742049, 12586269025, 20365011074, 32951280099, 53316291173, 86267571272, 139583862445, 225851433717, 365435296162, 591286729879, 956722026041, 1548008755920, 2504730781961, 4052739537881, 6557470319842, 10610209857723, 17167680177565, 27777890035288, 44945570212853, 72723460248141, 117669030460994, 190392490709135, 308061521170129, 498454011879264, 806515533049393, 1304969544928657, 2111485077978050, 3416454622906707, 5527939700884757, 8944394323791464, 14472334024676221, 23416728348467685, 37889062373143906, 61305790721611591, 99194853094755497, 160500643816367088, 259695496911122585, 420196140727489673, 679891637638612258, 1100087778366101931, 1779979416004714189, 2880067194370816120, 4660046610375530309, 7540113804746346429, 9320093220751060618, 9999984858389672876, 10259680355300795461, 10358875208395550958, 10396764270768694864, 10411236604793371085, 10416764544494255842, 10418876029572233892, 10419682545105283285, 10419990606626453414, 10420108275656914408, 10420153221227127261, 10420170388907304826, 10420176946377624668, 10420179451108406629, 10420180407830432670, 10420180773265728832, 10420180912849591277, 10420180966165882450, 10420180986530893524, 10420180994309635573, 10420180997280850646, 10420180998415753816, 10420180998849248253, 10420180999014828394, 10420180999078074380, 10420180999102232197, 10420180999111459662, 10420180999114984240, 10420180999116330509, 10420180999116844738, 10420180999117041156, 10420180999117116181, 10420180999117144838, 10420180999117155784, 10420180999117159965, 10420180999117161562, 10420180999117162172, 10420180999117162405, 10420180999117162494, 10420180999117162528, 10420180999117162541, 10420180999117162546, 10420180999117162548

我最初将在Phenom II x4上运行该程序,但欢迎对其他架构进行优化。

7 个答案:

答案 0 :(得分:3)

由于您在编译时知道数组数据,因此可以考虑使用hash而不是二进制搜索。精心选择的哈希可能会更快,特别是如果你能找到一个对你的数据没有冲突的简单哈希函数,并且符合你的内存约束。

编辑:进一步解释......

将64位值散列为较小的值,该值是数组的索引。在你的情况下,你的目标是拥有一个无冲突的哈希,所以你的数组只是一个数组a)用于命中,1个有效值哈希到该数组索引,或b)未命中,一个无效值。

您选择适合您目的的哈希函数。在您的情况下,主要参数是:

  • 散列输出的大小,它决定了数组的大小,也影响了碰撞的概率。
  • 尽可能简单(快速)的功能。
  • 不产生碰撞的功能。

假设没有冲突,您可以在运行时使用它,如下所示:

  1. 哈希你的意见。
  2. 使用生成的哈希值索引到数组中。
  3. 测试数组值是否与您的输入匹配。
  4. 如果您的哈希函数产生冲突,您的选择是:

    • 使您的哈希输出更大,以减少冲突的可能性。
    • 尝试查找不会与您的特定数据集产生冲突的其他哈希函数。
    • 创建一个稍微复杂的数组,就像散列到该值的有效64位输入的链表一样。但是这会减慢你的速度,因为上面的第3步变得更加复杂:你必须扫描一个链表,而不是只测试一个值。

答案 1 :(得分:3)

如果你感兴趣的只是会员/非会员,而不是地点,你可以通过以下安排消除一些条件分支:

bool b = false;
b |= (n == x[i]);
b |= (n == x[i+1]);
// ... etc. ...

显然,您可能不希望对所有136个条目执行此操作。但是可能有一个最佳位置,你可以混合粗粒度二进制搜索,以首先找到哪一批例如可以使用4个元素n,然后切换到上述方法。

答案 2 :(得分:2)

作为一种非常简单的可能优化,为64位值的最高8位创建256项查找表。表的每一行都存储值的下限和上限的实际数组中的索引,其中最重要的是8位。您只需要搜索数组的这个区域。

如果您的数组值均匀分布,则所有区域的长度大致相同,并且这不会提供太多的增益(如果有的话),它与插值搜索没有太大区别。由于您的值是如此偏斜,因此256个条目中的大多数将指向非常短的区域(靠近中间),这些区域快速到二进制搜索,甚至是0大小的区域。每端的2或3个条目将指向阵列的更大的区域,然后搜索将花费相对更长的时间(几乎与整个阵列的二进制搜索一样长)。由于您的输入是均匀分布的,因此搜索的平均时间将减少,并且希望这种减少大于初始查找的成本。不过,你的最坏情况可能最终会变慢。

为了优化这一点,您可能一次有4位的2级查找表。第一级要么是“在这些索引之间搜索”,要么“在这个二级表中查找下4个有效位”。前者适用于中等值,其中16倍的值范围仍然对应于非常小的索引范围,因此仍然可以快速搜索。后者将用于搜索空间较大的范围的末端。表的总大小会更小,由于可以更好地缓存较少的数据,因此可能会或可能不会提供更好的性能。表格本身可以在运行时生成,也可以在编译时生成,如果您愿意在数组值已知时生成C代码。您甚至可以将查找表编码为来自地狱的巨型switch语句,只是为了查看它是否加快了速度。

如果您还没有,那么一旦开始在数组中搜索,您还应该对插值搜索进行基准测试而不是简单的二进制搜索。

请注意,我已经努力减少二进制搜索中的比较次数,而不是特别是分支错误预测的次数。无论如何,这两者都是成比例的 - 你无法避免每次将二分法搜索中的可能性减半时,你会在50%的情况下得到错误的预测。如果你真的想最大限度地减少错误预测,那么线性搜索只保证每次查找一次错误预测(打破循环的错误预测)。这通常不会更快,但您可以尝试查看是否有剩余阵列的大小要搜索,在此之下您应该切换到线性搜索,可能已展开,可能已完全展开。可能还有一些其他更聪明的混合线性/二元搜索可以针对成功与不成功比较的相对成本进行调整,但如果是这样,我就不知道了。

答案 3 :(得分:1)

正常的二进制搜索最多会迭代log_2(n)次。每次迭代通常都会进行三次比较(我们完成了吗?数字是否更高?是否更低?)。这是每次迭代失败分支预测的三次机会。

如果你展开二进制搜索(这是可行的,因为你的数组很小并且值是提前知道的),你可以消除“我们完成了吗?”比较,你的典型基数将从3 * log_2(n)变为2 * log_2(n)。这是执行的指令更少,错过分支预测的机会更少。但它也是更多的总指令(因此缓存更少)。您需要进行分析,看看这是否有助于平衡。

您可以编写一个快速程序来生成展开的搜索功能,而不是手动展开它。

对于展开的搜索,轮廓引导优化也许可以通过利用不均匀的分布来进一步帮助。

答案 4 :(得分:0)

有趣的问题。起初我认为这是红黑树可以最有效地处理的许多问题之一,这些问题将上升值映射到索引。

然而,你已经指出分布是如此不均匀。如果我们首先从人的角度来看这个:人类做什么,他/她可能首先检查给定值是否低于0.01分位数,然后是否高于0.99分位数,并且通过这种策略已经将搜索空间限制在49 / 50th,只进行了2次比较。

  • 0.01-0.99范围内的进一步迭代很少(这些数字是例如64位值空间的0 ... 1映射)

  • 0.0-0.01和0.99-1.0范围内的进一步迭代不需要深入,因为它们已经接近正确的值。

那么,我们如何概括呢?我们不需要。您已经注意到0.0-0.01是频繁的,价值空间的频率是0.99-1.0; 这可能不是第二次迭代中的情况,并且绝对不是第三次迭代中的情况(您不会在0.0-0.01范围内找到与完成时相同的分布范围)。

我会这样做:一个跳跃状态,其中3个目标取决于值所在的3个区域中的哪个,然后是每个区域的红黑树。

答案 5 :(得分:0)

由于只有136个成员,在新PC上我会使用128位指令和预取进行强力搜索。

如果有成千上万的成员,我会回应Steve Jessop关于上面256条目查找表的想法,但是将函数f(x)应用于查找值,其目的是将成员均匀地分配到256个桶中。

f(x)可能是某种多项式,类似于图形世界的“smoothstep”,但可能不那么平滑。

答案 6 :(得分:0)

一个有趣的替代方案是使用线性搜索的修改(双向)版本。

伪代码比代码更容易:

if the value is greater than or equal to 2^63
    linear search from middle to end
otherwise (if the value is less than 2^63)
    linear search from middle to beginning (must go in reverse direction)

我的代码非常酷,但你可以让它看起来更优雅:

int in_set(unsigned long long value)
{
    const unsigned long long max_bit_mask = (1 << 63);

    if(value & max_bit_mask) //if max bit is set, use linear search from middle (50% probability)
    {
        unsigned long long *ullp = data + 91; //WARNING: ullp should point to data + 92 on first dereference due to prefix increment
        while(*++ullp < value); //FIXME: array must be capped with ULLONG_MAX on the right
        return *ullp == value; //&& ullp != right_sentinel
    }

    //otherwise use reverse linear search from middle

    unsigned long long *ullp = data + 92; //WARNING: ullp should point to data + 91 on first dereference due to prefix decrement
    while(*--ullp > value); //WARNING: array must be capped with zero on the left
    return *ullp == value; //&& ullp != left_sentinel
}

对于此特定Fibonacci分布数组的随机输入,代码平均为O(1)算法(在最坏的情况下为O(k),其中k为表大小)。

您可以使用Steve Jessop的查找表策略来缓存具有特定8位签名的最接近数组中心的元素的索引,但是它可能会使代码变慢,因为您要添加额外的O(1)具有额外分支误预测的时间因素。

const int lookup_table[256] = {/*...*/};

unsigned long long *ullp = data + lookup[value >> 56];

//if, while, and returns as before

此策略的一个实现问题是您必须在数组的左侧和右侧填充最小值和最大值,否则最终会发生错误(在本例中为0和ULLONG_MAX)。对于零,这不是问题,但是对于ULLONG_MAX,该值不在集合中,因此需要额外的逻辑。在这种特殊情况下,必须使用前哨值并进行适当考虑。

编辑:时间复杂度在平均情况下为O(1),因为光标的每次前进都有~40%(1 / phi ^ 2)返回的机会,预期成本是n / phi ^的总和(n + 1)n> = 1,相当于1 /(phi-1)^ 2(~2.6光标前进)

edit2:这个代码应该比二进制搜索具有更少的分支误预测,并且只要查询具有&#34;统一整数分布&#34;就应该更快。