我正在实现以下功能:
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 /其他?”评论/答案。
答案 0 :(得分:7)
它们的性能可能相同,因为编译器可能会将它们优化为完全相同的代码。
但是,如果在Debug设置上进行编译,编译器将不会针对尾部递归进行优化,因此如果列表足够长,则可能会出现堆栈溢出。还有(非常小的)可能性,错误的编译器不会优化尾递归的递归版本。在迭代版本中没有风险。
选择哪一个更清晰,更容易让您考虑非优化的可能性。
答案 1 :(得分:5)
我试了一下,制作了三个文件来测试你的代码:
struct list {
list *next;
void Add(list *);
};
#include "node.hh"
void list::Add(list* node)
{
if(!this->next)
this->next = node;
else
this->next->Add(node);
}
#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
来提供程序集输出而不是目标文件
_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
_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)。
所以我当然不会害怕使用尾调用递归,如果它似乎是表达算法的更好方式,但我可能也不会忘记使用它,因为我怀疑这些时序观察很可能与平台/编译器(版本)不同。