JavaScript的尾递归优化?

10

抱歉之前的版本过于含糊。有人决定同情这位新手并帮助我重新修改问题——以下是我希望能够澄清问题的更新(感谢所有一直以来慷慨解囊的人):


问题

我是一名计算机科学专业的新生,正在大学的第一年。在我们的算法课程中,我们可以选择任何语言,并实现一种“优化”算法,这种算法在其他语言中具有本地实现(内部实现?),但在我们选择的语言中缺失。

我们最近刚学习了递归,我的教授简要提到 JavaScript 没有实现尾调用优化。根据我在网上的研究,新的 ECMAScript 6 规范包括这个特性,但目前没有在任何/大多数 JavaScript 版本/引擎中实现(如果我不确定哪个是哪个...我是新手)。

我的任务是提供两个解决方法以弥补这种缺失。因此,我的问题是... 是否有比我更聪明、更有经验的人对如何实现以下内容有任何想法或示例:

如何解决尾递归优化不足的问题?


1
如何修改JS引擎以包含TCO,或者如何解决缺乏TCO的问题? - user395760
1
@Swasa - 你的责任是提供一个好问题给我们。你会发现这个群体确实很强大,但如果你已经做了功课,他们也会非常乐意帮助你。 - Jamiec
@Aadit然后将函数作为参数传递给自身?除了使一切变得更加复杂之外,这会有什么改变吗?您仍然会拥有不需要的堆栈帧,而且它们甚至更大。 - Niklas B.
1
@AaditMShah 我明白了,这与georg的解决方案类似,只是没有显式解释器(我认为这更加简洁)。感谢提供链接。 - Niklas B.
我同意。@Swasa:坚持住,姐们! - user3613898
显示剩余11条评论
3个回答

16

递归的一种优化方式是进行惰性求值,也就是返回一个“计算”(=函数),该函数将返回一个值,而不是立即计算并返回它。

考虑一个将数字相加的函数(以相当愚蠢的方式实现):

function sum(n) {
    return n == 0 ? 0 : n + sum(n - 1)
}

如果您使用n = 100000 调用它,它会超出堆栈(至少在我的Chrome中是这样)。要应用所述的优化,首先将其转换为真正的尾递归,使函数仅返回对自身的调用而不再有其他内容:

如果您使用n = 100000 调用它,它会超出堆栈(至少在我的Chrome中是这样)。要应用所述的优化,首先将其转换为真正的尾递归,使函数仅返回对自身的调用而不再有其他内容:

function sum(n, acc) {
    return n == 0 ? acc : sum(n - 1, acc + n)
}

并将这个直接的自调用函数包装在一个“惰性”函数中:

function sum(n, acc) {
    return n == 0 ? acc : function() { return sum(n - 1, acc + n) }
}
现在,为了获得结果,我们重复计算,直到它返回一个非函数值:
f = sum(100000, 0)
while(typeof f == "function")
    f = f()

这个版本在 n = 100000、1000000 等情况下没有问题。


1
这很酷。它不仅可以防止堆栈溢出,如果我没记错的话,它实际上只使用O(1)的空间。 - Niklas B.
1
@NiklasB.:正确。它根本不消耗堆栈空间。 - gog
1
@NiklasB。分配1000000个函数对象对我来说似乎不是O(1) - Esailija
2
@Esailija O(1) 空间复杂度意味着在任何给定时间点上占用的空间量是恒定的(模GC的不确定性)。 - Niklas B.
3
补充一下这个很棒的解释,这在JavaScript中被称为“Trampoline”。 - Aditya Singh

6
正如我在评论中提到的那样,您可以将程序转换为延续传递风格,然后使用异步函数调用来实现真正的尾调用优化。为了强调这一点,请考虑以下示例:
function foldl(f, a, xs) {
    if (xs.length === 0) return a;
    else return foldl(f, f(a, xs[0]), xs.slice(1));
}

很明显,这是一个尾递归函数。所以我们需要做的第一件事是将其转换为续传风格,这非常简单:

function foldl(f, a, xs, k) {
    if (xs.length === 0) k(a);
    else foldl(f, f(a, xs[0]), xs.slice(1), k);
}

就这样。我们的函数现在已经采用了连续传递风格。然而,仍然存在一个大问题——没有尾调用优化。不过,可以通过使用异步函数轻松解决这个问题:

function async(f, args) {
    setTimeout(function () {
        f.apply(null, args);
    }, 0);
}

我们的尾调用优化的foldl函数现在可以写成:
function foldl(f, a, xs, k) {
    if (xs.length === 0) k(a);
    else async(foldl, [f, f(a, xs[0]), xs.slice(1), k]);
}

现在你只需要使用它。例如,如果您想查找数组中数字的总和:
foldl(function (a, b) {
    return a + b;
}, 0, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], function (sum) {
    alert(sum); // 55
});

将所有内容组合起来:

function async(f, args) {
    setTimeout(function () {
        f.apply(null, args);
    }, 0);
}

function foldl(f, a, xs, k) {
    if (xs.length === 0) k(a);
    else async(foldl, [f, f(a, xs[0]), xs.slice(1), k]);
}

foldl(function (a, b) {
    return a + b;
}, 0, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], function (sum) {
    alert(sum); // 55
});

当然,在JavaScript中使用继续传递样式是很麻烦的。幸运的是,有一种非常好的语言叫做LiveScript,它可以让回调变得更加有趣。下面是用LiveScript编写的相同函数:
async = (f, args) ->
    setTimeout ->
        f.apply null, args
    , 0

foldl = (f, a, xs, k) ->
    if xs.length == 0 then k a
    else async foldl, [f, (f a, xs.0), (xs.slice 1), k]

do
    sum <- foldl (+), 0, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
    alert sum

是的,这是一种编译成JavaScript的新语言,但它值得学习。特别是因为回调函数(即<-)使您可以轻松地编写不需要嵌套函数的回调。


1
哇,LiveScript很酷。它给JS带来了比CoffeeScript更多的Haskell感觉。 - Niklas B.
再次感谢你... 你真的帮了我很多!我已经编辑了问题... 希望现在对大家更清晰了。 - user3606304

0
许多常见的编程语言缺乏尾递归优化,因为它们根本不希望你使用递归来解决线性问题。
只有当递归调用是函数执行的最后一件事情时,尾递归优化才适用,这意味着没有任何需要查看当前堆栈内容的东西,因此不需要通过添加另一个堆栈帧来保留它。
任何这样的算法都可以转换成迭代形式。例如(伪代码):
 int factorial(int x) {
      return factTail(x,1);
 }

 int factTail(int x, int accum) {
      if(x == 0) {
          return accum;
      } else {
          return(factTail (x-1, x * accum);
      }
 }

...是一个对factorial()进行了定制的实现,以确保最后一条语句返回递归调用的结果。如果引擎了解尾递归优化(TCO),它将对此进行优化。

一个按照相同顺序执行操作的迭代版本:

  int factorial(int x) {
      int accum = 1;
      for(int i=x; i>0; i--) {
          accum *= i;
      }
      return accum;
 }

我让它倒数以近似递归版本的执行顺序 -- 实际上,对于阶乘,你可能不会这样做。

如果您知道递归深度不会很大(在此示例中,x 的值很大),则可以使用递归调用。

通常,递归会导致解决方案非常优雅的规范。为了获得尾调用而摆弄算法会削弱这一点。请看上面的 factorial 比经典的难以理解的例子:

 int factorial(int x) {
     if(x == 1) {
         return 1;
     } else {
         return factorial(x-1) * x;
     }
 }

...然而这种经典形式需要大量的堆栈空间,对于不需要堆栈的任务来说,这是不必要的。因此,可以认为迭代形式是解决这个特定问题最清晰的方法。

由于编程教学的方式,今天大多数程序员比起递归方法更喜欢迭代形式。你是否有特定困难的递归算法?


1
另一个尾调用的重要用途是实现状态机,使用尾调用比使用“goto”(或其近亲“switch”)更清晰,特别是当数据从状态到状态传递时。我顺便提一下,在Unix编程中,尾调用优化非常常见,尽管我怀疑许多程序员并不认识它们的使用,因为他们将其拼写成类似于“exec”的东西。 - rici
希望这是一个改进,谢谢大家迄今为止的所有帮助!!! - user3606304
1
@rici,有很多种状态机的实现方式,其中许多不涉及递归。switch是其中之一;另一种是在设计模式中描述的面向对象方法。 - slim

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接