【问题标题】:Finding minimum in a very small array在一个非常小的数组中找到最小值
【发布时间】:2015-10-09 05:16:35
【问题描述】:

我正在处理 long int 数据,并试图确定数组中的最小元素。我知道循环遍历数组以找到最小值的传统方式。这个问题是要检查是否有其他方法可以加快速度。

这个数组的一些属性可能会帮助我们加快速度,但我不确定如何。

该数组正好有 8 个 long int 整数。每次调用该函数时,我们都会从数组中找到一个最小值,并将该数字替换为另一个数字,然后重复此步骤。 (至少 80 亿次)

我正在考虑以某种方式为下一次迭代记住第二大数字(因为我们将在当前迭代中比较它们)。与遍历数组的线性实现相比,这是否有用?

也允许排序,但我们必须使用临时数组以某种方式记住原始位置。这样会更有效吗?

还有可能使用 SIMD 来确定长整数的最小值吗?即使是毫秒级的加速也很有用,因为我正在执行此操作数十亿次。

【问题讨论】:

  • long int是32位整数对吗?
  • @stgatilov 这在很大程度上取决于所使用的系统。可以是 32、64 甚至 128 位
  • 8 个元素太少了。重要的是要完全了解您的最内层循环,以便了解如何对其进行矢量化。否则,矢量化版本将一直花费在外部代码的各种开销上。
  • 整个过程的目的是什么?这个八元素数组最终期望包含什么?可能在更高级别上提供更积极的优化,可能使用不同的数据结构。
  • 我闻到过早的优化!因此,让我直截了当地说,您将通过网络传输约 64GB(8000000000 * 8 字节),并且您担心运行此算法的 CPU 跟不上?咕咕咕!

标签: c++ c arrays algorithm performance


【解决方案1】:

具有 8 个元素数组的算法的理论复杂度几乎无关紧要。考虑到缓存的局部性,线性搜索很可能是您的最佳选择。

另一种选择是将数组按降序排序一次,然后每次简单地替换第一个元素,并最终将新数字移到右边。

无论如何,请尝试分析一下。

【讨论】:

  • 如果有新数字增加的趋势。我希望这种方法中的瓶颈是移位操作(或者如果您愿意,可以重新排序元素)一种缓解这种情况的方法是让缓冲区成为循环缓冲区,这样您只需移动元素严格大于新插入的元素。 (除非它仍然是最小的c)
  • 移动两个不同的数组(一个保存数字,另一个保存原始位置)不会比简单循环更昂贵吗?
  • @chettyharish 我从来没有建议过。这里的“移位”与插入排序实现中的“移位”是一样的。
  • 除了“算法的复杂性无关紧要,缓存位置很重要”部分(我非常同意),我建议考虑找到minimum without using conditionals。这很容易出现上溢和下溢,但很可能您实际上并没有利用每个整数的整个 2^64 范围,在这种情况下,它没关系,避免管道停顿实际上也可能加快速度。
  • @StianV.Svedenborg 实际上,这只是包括网络在内的更大图景的一小部分。这肯定不会是瓶颈!
【解决方案2】:

使用 SIMD 可以做到这一点,因为您最多可以并行化 4 个比较。遍历数组的普通算法不能向量化,因为每次比较都依赖于之前比较的结​​果,例如

x = min(array[0], array[1])
x = min(x, array[2])
x = min(x, array[3))
...

如果您将其更改为一种淘汰赛的方法,如果您将值 0-3 加载到一个向量中并将值 4-7 加载到另一个向量中,您可以一次进行多次比较:

// these 4 ops can be done at once using SIMD
x[0] = min(array[0], array[4])
x[1] = min(array[1], array[5])
x[2] = min(array[2], array[6])
x[3] = min(array[3], array[7])

// so can these 2 ops:
y[0] = min(x[0], x[2])
y[1] = min(x[1], x[3])

z[0] = min(y[0], y[1])

这意味着理论上只需要进行 3 次矢量化比较。

例如,在 ARM NEON SIMD 中,它看起来像这样(比较 8 个 32 位值):

vldm     r1!, {d0-d3}
vmin.32  q0, q0, q1    // first vectorized comparison
vpmin.32 d0, d0, d1    // second comparison
vpmin.32 d0, d0, d1    // third comparison
// min value is now in d0[0]

在最后一次比较中,您最终会进行不需要的额外比较,因为它是矢量化的,但这并不重要。

我以 ARM NEON 为例,因为我对 x86 SIMD 不是很熟悉,但同样的方法应该可以工作,并且可以扩展到 64 位值,如 related question

和往常一样,确保你的配置文件,不要过早优化,yadda yadda yadda

【讨论】:

  • 我会尝试这个想法,并会回复它的效果。
  • 我试过了,它无法击败顺序算法,因为 avx2 还不支持 8 字节整数。还注意到 gcc 默认尝试执行 SSE(至少它使用 xmm 寄存器)。从逻辑上讲,这是未来 avx-512 最快的方法,因为将支持 8 字节整数(并且可能还会提供水平最小-最大功能)
【解决方案3】:

您可以以最小堆的形式组织数组。搜索为O(1),替换为O(logn)。这将改善从O(n)O(logn) 的时间复杂度,这应该很重要。

【讨论】:

  • 一个小评论:由于数组的大小很小且恒定,因此无法确定在这种情况下会加速。这可能是一种加速,但除非您进行一些测试,否则您无法确定。我希望保持堆排序所需的堆操作比每次都简单地循环遍历数组更昂贵。
  • @StianV.Svedenborg:你说得有道理。我没有那样想。我把它留给 OP 来运行基准测试。堆可能有帮助,也可能没有帮助。
  • N 值如此之小,渐近分析不再适用。您必须准确记录所有操作。在这种情况下,蛮力需要七次比较,而堆重组每级需要一两次比较,有四个级别;不计算额外的内存移动,也不计算分支可能不太可预测的事实。我不会对最小堆持乐观态度。
  • @YvesDaoust:这是看待它的好方法。感谢您的见解。
【解决方案4】:

因为只有八个整数,所以如下:

  1. 第一次对八个数字进行排序,保留它们的原始索引
  2. 以“展开方式”实现二分搜索:代码中有 8 行 if/else
  3. 使用二分法查找最大数
  4. 使用二分查找找到插入新整数的正确位置

【讨论】:

  • 使用二分搜索查找插入位置似乎不是一个可行的选择,因为您必须移动一些元素才能插入到数组中。我可能只是更快地循环遍历数组。同样,只有基准测试才能确认会发生什么。
【解决方案5】:

尝试使用最小堆。例如

#include <iostream>
#include <algorithm>
#include <array>
using namespace std;

int main() {
    array<int, 8> arr { 3, 1, 4, 6, 5, 9, 2, 7 };
    make_heap(arr.begin(), arr.end(), greater<int>());
    pop_heap(arr.begin(), arr.end());
    cout << "Min Element: " << arr.back() << endl;
    return 0;
}

输出

1

这里的幼稚方式是

*min_element(arr.begin(), arr.end());

或者你可以使用multiset

std::multiset<long int> ms { 3, 1, 4, 6, 5, 8, 2, 7 };

for every new_element
    ms.erase(ms.begin());    // ms.begin() is the iterator to min element
    ms.insert(new_element);

【讨论】:

    【解决方案6】:

    我会保留一些信息并更新它。

    您有八个值 x0 到 x7。

    保持值 a0 = max (x0, x1), a2 = max (x2, x3), a4 = max (x4, x5), a6 = max (x6, x7),并记住哪一个是最大的一对。

    保持值 b0 = max (a0, a2), b4 = max (a4, a6),并记住哪一个是每个集合中最大的。

    现在获得最大的元素是微不足道的。当您拥有它并插入一个新元素时,您需要准确地更新值 a0、a2、a4 和 a6 之一,以及准确地更新 b0 和 b4 之一。

    (刚刚注意到您正在寻找最小值 - 应该没有太大区别)。

    【讨论】:

    • 确实如此,但是除了更新之外,您还会进行一些比较(当然是 2+1 来决定哪些值必须更新,以及其他)。不确定余额是否有利于最小堆方法。
    【解决方案7】:

    鉴于 N 非常小,并且替换过程本质上是顺序的,因此很难在此操作上获得显着的加速。虽然理论上最小堆是完美的工具,但由于开销,我不会赌它。

    我的建议是保持数组的递增顺序,并在替换最小值时使用 InsertionSort 的插入步骤,即将元素一个接一个地移到前面,直到找到插入槽。您可以完全展开代码以避免检查数组结束条件。

    保持元素排序的好处是,一旦找到插入点,就可以停止搜索。平均而言,您可以预期比较次数会有所改善(但内存移动次数会增加:-( )

    您也可以考虑使用二分搜索来查找插入点,进行 3 或 4 次比较,但我怀疑它会明显优于线性搜索。


    如果您的值适合 16 位无符号整数,您会对 _mm_minpos_epu16 指令感到非常满意。


    在完全偏执的版本中,您可以通过对将原始数组转换为排序序列的排列进行编号来避免不必要的内存移动。总共有 40320 个(!)。安排一个巨大的硬编码 switch 语句,其中根据手头的排列,按照相关顺序执行线性搜索;然后替换最大值并更新排列索引。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2013-04-08
      • 2022-08-10
      • 2013-03-30
      • 2017-01-10
      • 1970-01-01
      • 2018-07-04
      • 2011-03-30
      相关资源
      最近更新 更多