【问题标题】:Efficient computation of n choose k in Node.jsNode.js 中 n 选择 k 的高效计算
【发布时间】:2016-06-07 12:54:03
【问题描述】:

我在需要计算组合的 Node.js 服务器上有一些性能敏感代码。从this SO answer,我用这个简单的递归函数来计算n选择k:

function choose(n, k) {
    if (k === 0) return 1;
    return (n * choose(n-1, k-1)) / k;
}

那么既然我们都知道迭代几乎总是比递归快,我就根据multiplicative formula写了这个函数:

function choosei(n,k){
    var result = 1;
    for(var i=1; i <= k; i++){
        result *= (n+1-i)/i;
    }
    return result;
}

我在我的机器上运行了几个benchmarks。以下是其中之一的结果:

Recursive x 178,836 ops/sec ±7.03% (60 runs sampled)
Iterative x 550,284 ops/sec ±5.10% (51 runs sampled)
Fastest is Iterative

结果一致表明,迭代方法确实比 Node.js 中的递归方法快大约 3 到 4 倍(至少在我的机器上)。

可能已经足够满足我的需要了,但是有什么方法可以让它更快?我的代码必须非常频繁地调用这个函数,有时nk 的值相当大,所以越快越好。

编辑

在使用 le_m 和 Mike 的解决方案运行了更多测试后,结果证明虽然两者都比我提出的迭代方法快得多,但使用 Pascal 三角形的 Mike 方法似乎比 le_m 的日志表方法快一些。

Recursive x 189,036 ops/sec ±8.83% (58 runs sampled)
Iterative x 538,655 ops/sec ±6.08% (51 runs sampled)
LogLUT x 14,048,513 ops/sec ±9.03% (50 runs sampled)
PascalsLUT x 26,538,429 ops/sec ±5.83% (62 runs sampled)
Fastest is PascalsLUT

在我的测试中,对数查找方法比迭代方法快大约 26-28 倍,使用帕斯卡三角形的方法比对数查找方法快大约 1.3 到 1.8 倍。

请注意,我遵循了 le_m 的建议,即使用 mathjs 预先计算更高精度的对数,然后将它们转换回常规 JavaScript Numbers(始终为 double-precision 64 bit floats)。

【问题讨论】:

  • 如果您不关心空间,记忆化可能是最快的选择。
  • 来自您链接的答案的评论和wikipedia pageif (k &gt; n/2) return choose(n, n-k); - 当nk 都很大时,这将有所帮助,但额外的分支可能会整体减慢执行。
  • @Godfather @NanoWizard 和如果你没有空间来 momoize 选择结果,你可以通过 momoizing 阶乘获得良好的性能提升。您甚至可以使用动态编程技术来计算阶乘 fact(n) = max_known_fact_value(n) * [i for evey int for max_known_fact_int(n) to n],从而节省大量时间
  • 是的@gbtimmon 那会很棒!
  • 不是出于实际目的。使用 UINT16,您可以在 380 字节 + 数组开销中存储最多 n=19 的 LUT。 UINT32 允许在超过 2kb 的部分中最多 n=35。 UINT64 最多允许 n=68,现在我们已经正确地超越了“你真的相信你需要这么高的二项式系数吗?”值超过 18kb。如果他们已经致力于进行二项式计算,那么这些 malloc 没有人会关心他们。尤其是在 Node.js 中,与基本 Node 的内存占用相比,这些 LUT 微不足道。

标签: javascript node.js algorithm performance


【解决方案1】:

在给定具有空间复杂度O(n)的对数阶乘线性查找表的情况下,以下算法的运行时间复杂度为O(1) .

nk 限制在 [0, 1000] 范围内是有意义的,因为 binomial(1000, 500) 已经危险地接近 Number.MAX_VALUE。因此,我们需要一个大小为 1000 的查找表。

在现代 JavaScript 引擎中,包含 n 个数字的紧凑数组的大小为 n * 8 字节。因此,一个完整的查找表将需要 8 KB 的内存。如果我们将输入限制在 [0, 100] 范围内,表格将只占用 800 个字节。

var logf = [0, 0, 0.6931471805599453, 1.791759469228055, 3.1780538303479458, 4.787491742782046, 6.579251212010101, 8.525161361065415, 10.60460290274525, 12.801827480081469, 15.104412573075516, 17.502307845873887, 19.987214495661885, 22.552163853123425, 25.19122118273868, 27.89927138384089, 30.671860106080672, 33.50507345013689, 36.39544520803305, 39.339884187199495, 42.335616460753485, 45.38013889847691, 48.47118135183523, 51.60667556776438, 54.78472939811232, 58.00360522298052, 61.261701761002, 64.55753862700634, 67.88974313718154, 71.25703896716801, 74.65823634883016, 78.0922235533153, 81.55795945611504, 85.05446701758152, 88.58082754219768, 92.1361756036871, 95.7196945421432, 99.33061245478743, 102.96819861451381, 106.63176026064346, 110.32063971475739, 114.0342117814617, 117.77188139974507, 121.53308151543864, 125.3172711493569, 129.12393363912722, 132.95257503561632, 136.80272263732635, 140.67392364823425, 144.5657439463449, 148.47776695177302, 152.40959258449735, 156.3608363030788, 160.3311282166309, 164.32011226319517, 168.32744544842765,  172.3527971391628, 176.39584840699735, 180.45629141754378, 184.53382886144948, 188.6281734236716, 192.7390472878449, 196.86618167289, 201.00931639928152, 205.1681994826412, 209.34258675253685, 213.53224149456327, 217.73693411395422, 221.95644181913033, 226.1905483237276, 230.43904356577696, 234.70172344281826, 238.97838956183432, 243.2688490029827, 247.57291409618688, 251.8904022097232, 256.22113555000954, 260.5649409718632, 264.9216497985528, 269.2910976510198, 273.6731242856937, 278.0675734403661, 282.4742926876304, 286.893133295427, 291.3239500942703, 295.76660135076065, 300.22094864701415, 304.6868567656687, 309.1641935801469, 313.65282994987905, 318.1526396202093, 322.66349912672615, 327.1852877037752, 331.7178871969285, 336.26118197919845, 340.815058870799, 345.37940706226686, 349.95411804077025, 354.5390855194408, 359.1342053695754, 363.73937555556347];

function binomial(n, k) {
    return Math.exp(logf[n] - logf[n-k] - logf[k]);
}

console.log(binomial(5, 3));

说明

从原来的迭代算法开始,我们先将乘积替换为对数之和:

function binomial(n, k) {
    var logresult = 0;
    for (var i = 1; i <= k; i++) {
        logresult += Math.log(n + 1 - i) - Math.log(i);
    }
    return Math.exp(logresult);
}

我们的循环现在对 k 项求和。如果我们重新排列总和,我们可以很容易地看到我们对连续对数进行求和 log(1) + log(2) + ... + log(k) 等,我们可以将其替换为实际上与 log(k!) 相同的 sum_of_logs(k)。预先计算这些值并将它们存储在我们的查找表logf 中,然后导致上述单线算法。

计算查找表:

我建议以更高的精度预先计算查找表并将结果元素转换为 64 位浮点数。如果您不需要额外的精度或想在客户端运行此代码,请使用:

var size = 1000, logf = new Array(size);
logf[0] = 0;
for (var i = 1; i <= size; ++i) logf[i] = logf[i-1] + Math.log(i);

数值精度:

通过使用对数阶乘,我们避免了存储原始阶乘所固有的精度问题。

我们甚至可以将Stirling's approximation 用于log(n!) 而不是查找表,并且在运行时和空间复杂度上仍然可以得到 12 位有效数字O(1)

function logf(n) {
    return n === 0 ? 0 : (n + .5) * Math.log(n) - n + 0.9189385332046728 + 0.08333333333333333 / n - 0.002777777777777778 * Math.pow(n, -3);
}

function binomial(n , k) {
    return Math.exp(logf(n) - logf(n - k) - logf(k));
}

console.log(binomial(1000, 500)); // 2.7028824094539536e+299

【讨论】:

  • 算术运算是在 64 位浮点值上执行的,因此您需要 e. G。在打印前通过截断无意义的小数来格式化结果。
  • 我认为这是一个错字 - log(1) + log(2) + ... + log(k) 应该是 log(k!) 而不是 log(n!)
【解决方案2】:

永远不要计算阶乘,它们增长太快。而是计算您想要的结果。在这种情况下,您需要二项式数字,它具有非常简单的几何构造:您可以根据需要构建pascal's triangle,并使用简单的算术来完成。

从 [1] 和 [1,1] 开始。下一行的开头为 [1],中间为 [1+1],结尾为 [1]:[1,2,1]。下一行:开头的 [1],点 2 中的前两项之和,点 3 中的后两项之和,以及结尾的 [1]:[1,3,3,1]。下一行:[1],然后是 1+3=4,然后是 3+3=6,然后是 3+1=4,最后是 [1],以此类推。如您所见,没有阶乘、对数甚至乘法:只需使用干净的整数进行超快速加法。如此简单,您可以手动构建一个庞大的查找表。

你应该这样做。

永远不要在代码中计算您可以手动计算的内容,而只是将其作为常量包含在内以便立即查找;在这种情况下,写出 n=20 左右的表格绝对是微不足道的,然后您可以将其用作“起始 LUT”,甚至可能永远不会访问高行。

但是,如果您确实需要它们,或者更多,那么因为您无法构建无限查找表,所以您会妥协:您从预先指定的 LUT 和一个可以“填充”到某个术语的函数开始你需要的还没有:

module.exports = (function() {
  // step 1: a basic LUT with a few steps of Pascal's triangle
  var binomials = [
    [1],
    [1,1],
    [1,2,1],
    [1,3,3,1],
    [1,4,6,4,1],
    [1,5,10,10,5,1],
    [1,6,15,20,15,6,1],
    [1,7,21,35,35,21,7,1],
    [1,8,28,56,70,56,28,8,1],
    ...
  ];

  // step 2: a function that builds out the LUT if it needs to.
  function binomial(n,k) {
    while(n >= binomials.length) {
      let s = binomials.length;
      let nextRow = [];
      nextRow[0] = 1;
      for(let i=1, prev=s-1; i<s; i++) {
        nextRow[i] = binomials[prev][i-1] + binomials[prev][i];
      }
      nextRow[s] = 1;
      binomials.push(nextRow);
    }
    return binomials[n][k];
  }

  return binomial;
}());

由于这是一个整数数组,因此内存占用很小。对于很多涉及二项式的工作,实际上我们每个整数甚至不需要超过两个字节,这使得这是一个 分钟 查找表:在您需要更高的二项式之前,我们不需要超过 2 个字节比 n=19,并且直到 n=19 的完整查找表仅占用 380 个字节。与您的程序的其余部分相比,这算不了什么。即使我们允许 32 位整数,我们也可以在仅 2380 个字节中得到 n=35。

因此查找速度很快:对于先前计算的值,要么 O(constant),要么如果我们根本没有 LUT,则为 (n*(n+1))/2 步(在大 O 表示法中,这将是 O(n² ),但大 O 表示法几乎从来都不是正确的复杂性度量),对于我们需要的尚未在 LUT 中的术语,介于两者之间。为您的应用程序运行一些基准测试,它会告诉您初始 LUT 应该有多大,只需硬编码(说真的。这些是常量,它们是 完全 应该是硬编码的),并保留生成器以防万一。

但是,请记住,您处于 JavaScript 领域,并且受到 JavaScript 数字类型的约束:integers only go up to 2^53,除此之外还有整数属性(每个 n 都有一个不同的 m=n+1,因此 m-n=1 ) 不保证。不过,这几乎不应该成为问题:一旦我们达到了这个限制,我们就会处理你甚至不应该使用的二项式系数。

【讨论】:

  • lut数组最后一行有错误。对于任何复制粘贴的人,将 54 替换为 56 或删除该行。懒人证明:google.com/search?q=8+choose+3
  • 我没有意识到你会看到评论,但是你修好了它。
  • 对于那些喜欢一点 JS 疯狂的人:这是 while 循环中 let s = ... 之后的部分,作为单行:binomials.push(new Array(s+1).fill(0).map((_, i) =&gt; i == 0 || i == s ? 1 : binomials[s-1][i-1] + binomials[s-1][i]));
  • @sigalor 不需要new Array(s+1).fill(0).map(),只需使用现代JS:[...Array(s+1)].map()。如果你试图以一种实际上并没有更快执行的方式进行微优化 =)
  • 作为几年后的评论,我们现在有 Uint8Array 和 Uint16Array,它们是 大小的数组,因此您现在可以强制您的使用new Uint8Array(rowsize) 或(完全矫枉过正)new Uint16Array(rowsize) 的内存占用
猜你喜欢
  • 2013-02-24
  • 2012-06-23
  • 1970-01-01
  • 1970-01-01
  • 2013-01-17
  • 2016-05-27
  • 1970-01-01
  • 2019-10-09
  • 2018-10-10
相关资源
最近更新 更多