为什么这个尾递归不比非尾递归实现快?

时间:2018-01-13 14:42:17

标签: elixir

免责声明:我对elixir很新,以下内容可能非常简单。可能只是一个我不知道的语言细节。

TL; DR :以下2段代码只是实现了一个列表映射。函数在这里被称为累积,但它们只是遍历列表并将给定函数应用于每个方法。在我看来,我认为版本2应该更快,因为它的尾部递归(或者至少我认为它是。我可能是错的)。不是。它比版本1慢得多,我不明白为什么。我想要一些帮助。

问题

我来自java世界,其中只讨论了尾部调用优化,但您可能知道该语言中没有。我想在其他几个原因中,没有人真正关心在命令式语言中实现这样的功能,其中大多数递归方法可以以迭代方式编写。在任何情况下,我都开始学习灵丹妙药,而我收到的其中一件事是Exercism中的问题,称为累积。

这很容易理解。我们要求实现一个列表映射函数 - 遍历给定的列表并对每个元素应用给定的函数,最后吐出映射列表。

这是 1st 解决问题:

def accumulate([], _) do
  []
end

def accumulate([head | tail], fun) do
  [fun.(head) | accumulate(tail, fun)]
end

在我提交代码时,我收到了一条评论,指出这种方法对大型输入的效果不佳。实际上,调用堆栈将随输入线性增长。评论的人也向我提出挑战,想出一个不会成为问题的方法。我的大脑立刻跳进了尾部递归的优化(不确定这是否是正确的方法,但这就是我的目的)。所以我做了第二次实施:

defp accumulate([], _, acc) do
  acc
end

defp accumulate([head | tail], fun, acc) do
  accumulate(tail, fun, acc ++ [fun.(head)])
end

def accumulate(l, fun) do
  accumulate(l, fun, [])
end

同样,如果我甚至理解尾递归是什么,这整个情况让我有疑问。意思是,我可能做了一些与我想的完全不同的事情。无论如何,我决定运行一个非常简单的基准。我在网上搜索并使用了这个功能:

def measure(function) do
  function
  |> :timer.tc
  |> elem(0)
  |> Kernel./(1_000_000)
end

我用100000(十万)5s的列表运行它。是的,我确实启动了一个python shell并发出[5] * 100000并将输出复制粘贴到一个elixir文件中。映射函数只是对数字进行平方。整件事情就这样结束了:

def test() do
  IO.inspect Benchmark.measure(fn ->
    accumulate(Data.data, fn x -> x*x end)
  end)
end

(Data.data是提到的100000 5s的列表)

在我的机器上,第一次实施约为0.006秒,第二次约为28秒。就像我说的那样,这不是我所期待的。我当时正好相反。所以这是我的问题:

1。我是否正确掌握了这种尾递归优化的事情?

2。两种方法之间的关键区别是什么使整个事情在时间上有如此大的差异?

PS:我已经看过Erlang列表模块,看到方法图实际上和第一种方法一样实现(至少看起来像这样),所以我猜测这有什么原因吗?

2 个答案:

答案 0 :(得分:3)

第二个版本尾递归,但++需要复制整个LHS。由于LHS是累加器,因此您的函数变为O(n^2)而不是O(n)。解决方案是以相反的顺序累积列表,然后在结束时调用:lists.reverse/1。这将是O(n),因为在列表中添加元素为O(1),并且反转列表为O(n)。这个成语在Elixir和Erlang代码中很常见。

defp accumulate([], _, acc) do
  :lists.reverse(acc)
end

defp accumulate([head | tail], fun, acc) do
  accumulate(tail, fun, [fun.(head) | acc])
end

def accumulate(l, fun) do
  accumulate(l, fun, [])
end

这可能仍然不会比天真的非尾递归函数更快,因为Erlang优化这些情况的速度与尾部递归版本一样快in this myth

答案 1 :(得分:1)

问题不是尾递归与非尾递归,而是附加到列表的事实。附加到列表时,需要遍历并复制整个列表以在末尾添加元素。列表越大,越慢。当你在循环中执行此操作时,它将变得非常慢。

这就是为什么尾递归函数通常总是在列表前面,然后在最后调用Enum.reverse

defp accumulate([], _, acc) do
  Enum.reverse(acc)
end

defp accumulate([head | tail], fun, acc) do
  accumulate(tail, fun, [fun.(head) | acc])
end

def accumulate(l, fun) do
  accumulate(l, fun, [])
end

我们也谈到了in the list vs tuple section of Elixir's getting started guide

回到尾递归主题,根据我的经验,它在性能方面没有明显的差异。 Erlang VM限制了堆栈跟踪大小,因此您不必强制以尾递归格式编写它,因为您永远不会获得堆栈溢出。 Here is some discussion on this topic for those interested