要回答这个问题,我们需要以某种方式解释 OP 的假设:need not worry about overflow。在这个答案的大部分中,它被解释为“忽略溢出”。但我从一些关于其他解释的想法开始(“使用多精度算术”)。在这种情况下,乘法过程可能大致分为 3 个阶段:
- 将一组小的小数相乘,得到一大组不那么小的数。此处可能会使用此答案第二部分中的一些想法。
- 将这些数字相乘得到一组大数字。可以使用平凡(二次时间)算法或Toom–Cook/Karatsuba(次二次时间)方法。
- 将大数相乘。可以使用Fürer's 或 Schönhage–Strassen 算法。这使得整个过程的时间复杂度为 O(N polylog N)。
二进制取幂可能会带来一些(不是很显着)的速度提升,因为这里提到的大多数(如果不是全部)复杂乘法算法的平方比两个不等数的乘法要快。我们也可以分解每个“小”数并仅对素因数使用二进制取幂。对于均匀分布的“小”数,这将减少乘方次数log(number_of_values),并略微改善平方/乘法的平衡。
当数字均匀分布时,分而治之是可以的。否则(例如,当输入数组被排序或使用二进制求幂时)我们可以通过将所有被乘数放入优先级队列中做得更好,按数字长度排序(可能近似排序)。然后我们可以将两个最短的数相乘并将结果放回队列中(这个过程非常类似于霍夫曼编码)。无需使用此队列进行平方。此外,我们不应该在数字不够长时使用它。
更多信息请访问the answer by A. Webb。
如果可以忽略溢出,我们可以将数字与线性时间或更好的算法相乘。
如果输入数组已排序或输入数据以元组集合 {值,出现次数} 的形式呈现,则亚线性时间算法是可能的。在后一种情况下,我们可以对每个值执行二进制取幂并将结果相乘。时间复杂度为 O(C log(N/C)),其中C 是数组中不同值的数量。 (另见this answer)。
如果输入数组已排序,我们可以使用二进制搜索来查找值发生变化的位置。这允许确定每个值在数组中出现的次数。然后我们可以对每个值执行二进制取幂并将结果相乘。时间复杂度为 O(C log N)。我们可以在这里使用单向二分搜索做得更好。在这种情况下,时间复杂度为 O(C log(N/C))。
但是如果输入数组没有排序,我们必须检查每个元素,所以 O(N) 时间复杂度是我们能做的最好的。我们仍然可以使用并行(多线程、SIMD、字级并行)来提高速度。在这里我比较了几种这样的方法。
为了比较这些方法,我选择了非常小的(3 位)值,这些值非常紧凑(每个 8 位整数一个值)。并以低级语言 (C++11) 实现它们,以便更轻松地访问位操作、特定 CPU 指令和 SIMD。
以下是所有算法:
-
accumulate 来自标准库。
- 使用 4 个累加器的简单实现。
- 乘法的字级并行性,如the answer by templatetypedef 中所述。对于 64 位字长,这种方法最多允许 10 位值(只有 3 次乘法而不是每次 4 次),或者它可以应用两次(我在测试中这样做)最多 5 位值(只需要每 8 次乘法中的 5 次)。
- 表查找。在测试中,每 8 个乘法中有 7 个被单表查找代替。如果值大于这些测试中的值,替代乘法的数量会减少,从而减慢算法速度。大于 11-12 位的值使这种方法毫无用处。
- 二进制取幂(请参阅下面的详细信息)。大于 4 位的值使这种方法无用。
- SIMD (AVX2)。此实现最多可以使用 8 位值。
Here are sources for all tests on Ideone。请注意,SIMD 测试需要英特尔的 AVX2 指令集。查表测试需要 BMI2 指令集。其他测试不依赖于任何特定的硬件(我希望如此)。我在 64 位 Linux 上运行这些测试,使用 gcc 4.8.1 编译,优化级别 -O2。
以下是二进制取幂测试的更多细节:
for (size_t i = 0; i < size / 8; i += 2)
{
auto compr = (qwords[i] << 4) | qwords[i + 1];
constexpr uint64_t lsb = 0x1111111111111111;
if ((compr & lsb) != lsb) // if there is at least one even value
{
auto b = reinterpret_cast<uint8_t*>(qwords + i);
acc1 *= accumulate(b, b + 16, acc1, multiplies<unsigned>{});
if (!acc1)
break;
}
else
{
const auto b2 = compr & 0x2222222222222222;
const auto b4 = compr & 0x4444444444444444;
const auto b24 = b4 & (b2 * 2);
const unsigned c7 = __builtin_popcountll(b24);
acc3 += __builtin_popcountll(b2) - c7;
acc5 += __builtin_popcountll(b4) - c7;
acc7 += c7;
}
}
const auto prod4 = acc1 * bexp<3>(acc3) * bexp<5>(acc5) * bexp<7>(acc7);
此代码比输入数组更密集地打包值:每个字节两个值。每个值的低位处理方式不同:由于我们可以在此处找到 32 个零位(结果为“零”)后停止,因此这种情况不会对性能产生太大影响,因此通过最简单的(库)算法处理。
在剩余的 4 个值中,“1”没有意义,因此我们只需要计算“3”、“5”和“7”的出现次数,并使用按位操作和固有的“人口计数”。
结果如下:
source size: 4 Mb: 400 Mb:
1. accumulate: 0.835392 ns 0.849199 ns
2. accum * 4: 0.290373 ns 0.286915 ns
3. 2 mul in 1: 0.178556 ns 0.182606 ns
4. mult table: 0.130707 ns 0.176102 ns
5. binary exp: 0.128484 ns 0.119241 ns
6. AVX2: 0.0607049 ns 0.0683234 ns
在这里我们可以看到accumulate 库算法非常慢:由于某种原因,gcc 无法展开循环并使用 4 个独立的累加器。
“手动”进行这种优化并不难。结果不是特别快。但如果我们为此任务分配 4 个线程,CPU 将大致匹配内存带宽(2 个通道,DDR3-1600)。
乘法的字级并行性几乎快两倍。 (我们只需要 3 个线程来匹配内存带宽)。
查表速度更快。但是当输入数组无法放入 L3 缓存时,它的性能会下降。 (我们需要 3 个线程来匹配内存带宽)。
二进制取幂的速度大致相同。但是对于较大的输入,这种性能不会降低,甚至会略有提高,因为与值计数相比,求幂本身使用的时间更少。 (我们只需要 2 个线程来匹配内存带宽)。
正如预期的那样,SIMD 是最快的。当输入数组无法放入 L3 缓存时,其性能会略有下降。这意味着我们接近单线程的内存带宽。