尾调用递归

时间:2011-09-02 22:30:05

标签: c++ tail-recursion

我正在实现以下功能:

void Add(list* node)
{
    if(this->next == NULL)
        this->next = node;
    else
        this->next->Add(node);
}

看起来Add在递归的每一步都会被尾调用 我也可以实现它:

void Add(list *node)
{
    list *curr = this;
    while(curr->next != NULL) curr = curr->next;
    curr->next = node;
}

这根本不会使用递归 哪个版本更好? (堆栈大小或速度)
请不要给出“为什么不使用STL / Boost /其他?”评论/答案。

2 个答案:

答案 0 :(得分:7)

它们的性能可能相同,因为编译器可能会将它们优化为完全相同的代码。

但是,如果在Debug设置上进行编译,编译器将不会针对尾部递归进行优化,因此如果列表足够长,则可能会出现堆栈溢出。还有(非常小的)可能性,错误的编译器不会优化尾递归的递归版本。在迭代版本中没有风险。

选择哪一个更清晰,更容易让您考虑非优化的可能性。

答案 1 :(得分:5)

我试了一下,制作了三个文件来测试你的代码:

node.hh:

struct list {
  list *next;
  void Add(list *);
};

tail.cc:

#include "node.hh"

void list::Add(list* node)
{
    if(!this->next)
        this->next = node;
    else
        this->next->Add(node);
}

loop.cc:

#include "node.hh"

void list::Add(list *node)
{
    list *curr = this;
    while(curr->next) curr = curr->next;
    curr->next = node;
}

使用G ++ 4.3 for IA32编译这两个文件,使用-O3-S来提供程序集输出而不是目标文件

结果:

tail.s:

_ZN4list3AddEPS_:
.LFB0:
        .cfi_startproc
        .cfi_personality 0x0,__gxx_personality_v0
        pushl   %ebp
        .cfi_def_cfa_offset 8
        movl    %esp, %ebp
        .cfi_offset 5, -8
        .cfi_def_cfa_register 5
        movl    8(%ebp), %eax
        .p2align 4,,7
        .p2align 3
.L2:
        movl    %eax, %edx
        movl    (%eax), %eax
        testl   %eax, %eax
        jne     .L2
        movl    12(%ebp), %eax
        movl    %eax, (%edx)
        popl    %ebp
        ret
        .cfi_endproc

loop.s:

_ZN4list3AddEPS_:
.LFB0:
        .cfi_startproc
        .cfi_personality 0x0,__gxx_personality_v0
        pushl   %ebp
        .cfi_def_cfa_offset 8
        movl    %esp, %ebp
        .cfi_offset 5, -8
        .cfi_def_cfa_register 5
        movl    8(%ebp), %edx
        jmp     .L3
        .p2align 4,,7
        .p2align 3
.L6:
        movl    %eax, %edx
.L3:
        movl    (%edx), %eax
        testl   %eax, %eax
        jne     .L6
        movl    12(%ebp), %eax
        movl    %eax, (%edx)
        popl    %ebp
        ret
        .cfi_endproc

结论:输出基本相似(两者中的核心循环/递归变为movl, movl, testl, jne),它确实不值得担心。在递归版本中有一个不那么无条件的跳转,虽然我不想打赌哪个更快,如果它甚至可以测量的话。挑选哪些是最自然的表达手头的算法。即使如果你以后认为这是一个糟糕的选择,也不会太难切换。

-g添加到编译中也不会改变g ++的实际实现,尽管设置断点的行为不再像预期的那样会增加 - 在尾部调用行上断点无论递归的实际深度如何,我在GDB测试中最多会被击中一次(但对于1个元素列表根本不会被击中)。

时序:

出于好奇,我使用相同的g ++变体运行了一些时间。我用过:

#include <cstring>
#include "node.hh"

static const unsigned int size = 2048;
static const unsigned int count = 10000;

int main() {
   list nodes[size];
   for (unsigned int i = 0; i < count; ++i) {
      std::memset(nodes, 0, sizeof(nodes));
      for (unsigned int j = 1; j < size; ++j) {
        nodes[0].Add(&nodes[j]);
      }
   }
}

这是每次循环和尾部调用版本运行200次。结果在此平台上使用此编译器是相当确凿的。尾巴平均为40.52秒,而垂耳平均值为66.93。 (标准偏差分别为0.45和0.47)。

Box plot of results

所以我当然不会害怕使用尾调用递归,如果它似乎是表达算法的更好方式,但我可能也不会忘记使用它,因为我怀疑这些时序观察很可能与平台/编译器(版本)不同。