有人能用C ++向我展示一个简单的尾递归函数吗?
为什么尾递归更好,如果它甚至是?
除了尾递归之外还有哪些其他类型的递归?
答案 0 :(得分:58)
一个简单的尾递归函数:
unsigned int f( unsigned int a ) {
if ( a == 0 ) {
return a;
}
return f( a - 1 ); // tail recursion
}
尾递归基本上是在:
并不是“更好”,除非好的编译器可以删除递归,将其转换为循环。这可能更快,并且肯定会节省堆栈使用。 GCC编译器可以进行这种优化。
答案 1 :(得分:41)
C ++中的尾部重复与C或任何其他语言相同。
void countdown( int count ) {
if ( count ) return countdown( count - 1 );
}
尾递归(和一般的尾调用)需要在执行尾调用之前清除调用者的堆栈帧。对于程序员来说,尾递归类似于循环,return
简化为goto first_line;
。但是,编译器需要检测您正在执行的操作,如果没有,则仍会有一个额外的堆栈帧。大多数编译器都支持它,但写一个循环或goto
通常更容易,风险也更小。
非递归尾调用可以启用随机分支(如goto
到其他函数的第一行),这是一个更独特的工具。
请注意,在C ++中,return
语句的范围内不能有任何具有重要析构函数的对象。函数结束清理将要求被调用者返回调用者,从而消除尾调用。
还要注意(在任何语言中)尾递归要求算法的整个状态在每一步都通过函数参数列表传递。 (从下一个调用开始之前消除函数的堆栈帧的要求可以清楚地看出......你不能在局部变量中保存任何数据。)此外,在函数尾部返回之前,不能对函数的返回值应用任何操作。
int factorial( int n, int acc = 1 ) {
if ( n == 0 ) return acc;
else return factorial( n-1, acc * n );
}
答案 2 :(得分:27)
尾递归是尾调用的特例。尾部调用是编译器可以看到在从被调用函数返回时不需要执行的操作的地方 - 实质上是将被调用函数的返回转换为它自己的函数。编译器通常可以执行一些堆栈修复操作,然后跳转(而不是调用)到被调用函数的第一条指令的地址。
除了消除一些回复调用之外,其中一个很棒的事情是你还减少了堆栈的使用。在某些平台或操作系统代码中,堆栈可能非常有限,而在高级机器上,例如我们台式机中的x86 CPU,降低堆栈使用率会提高数据缓存性能。
尾递归是被调用函数与调用函数相同的地方。这可以转换为循环,这与上面提到的尾调用优化中的跳转完全相同。由于这是相同的函数(被调用者和调用者),因此在跳转之前需要完成更少的堆栈修复。
以下显示了执行递归调用的常用方法,编译器转换为循环会更加困难:
int sum(int a[], unsigned len) {
if (len==0) {
return 0;
}
return a[0] + sum(a+1,len-1);
}
这很简单,许多编译器无论如何都可能想出来,但正如你所看到的那样,在从被调用的sum返回一个数字之后需要进行一次添加,所以不可能进行简单的尾调用优化
如果你这样做了:
static int sum_helper(int acc, unsigned len, int a[]) {
if (len == 0) {
return acc;
}
return sum_helper(acc+a[0], len-1, a+1);
}
int sum(int a[], unsigned len) {
return sum_helper(0, len, a);
}
您可以利用尾部调用这两个函数中的调用。 sum函数的主要工作是移动一个值并清除寄存器或堆栈位置。 sum_helper完成所有数学运算。
既然你在问题中提到了C ++,我会提到一些特殊的事情。 C ++隐藏了一些你不喜欢的东西。这些析构函数中的主要因素是尾部调用优化。
int boo(yin * x, yang *y) {
dharma z = x->foo() + y->bar();
return z.baz();
}
在这个例子中,对baz的调用实际上并不是尾调用,因为z需要在从baz返回后被破坏。我相信即使在调用期间不需要变量的情况下,C ++规则也可能使优化更加困难,例如:
int boo(yin * x, yang *y) {
dharma z = x->foo() + y->bar();
int u = z.baz();
return qwerty(u);
}
z可能必须在qwerty从这里返回后被破坏。
另一件事是隐式类型转换,它也可以在C中发生,但在C ++中可能更复杂和常见。 例如:
static double sum_helper(double acc, unsigned len, double a[]) {
if (len == 0) {
return acc;
}
return sum_helper(acc+a[0], len-1, a+1);
}
int sum(double a[], unsigned len) {
return sum_helper(0.0, len, a);
}
这里sum对sum_helper的调用不是尾调用,因为sum_helper返回一个double,sum需要将它转换为int。
在C ++中,返回一个可能有各种不同解释的对象引用是很常见的,每个解释都可以是不同的类型转换, 例如:
bool write_it(int it) {
return cout << it;
}
这里有一个对cout.operator的调用&lt;&lt;作为最后的陈述。 cout将返回对自身的引用(这就是为什么你可以在由&lt;&lt;分隔的列表中将大量事物串在一起),然后强制将其作为bool进行评估,最终调用另一个cout的方法,运算符布尔()。在这种情况下,这个cout.operator bool()可以被称为尾调用,但是运算符&lt;&lt;不能。
值得一提的是,C中尾部调用优化的一个主要原因是编译器知道被调用函数会将它的返回值存储在调用函数必须确保它的相同位置。返回值存储在。
中答案 3 :(得分:2)
尾递归是实现同时处理两个问题的一个技巧。第一个是在很难知道要执行的迭代次数时执行循环。
虽然这可以通过简单的递归来解决,但第二个问题是由于递归调用被执行太多次而导致的堆栈溢出问题。尾部调用是解决方案,当伴随着&#34;计算和携带&#34;技术。
在基本的CS中,您了解到计算机算法需要具有不变量和终止条件。这是构建尾递归的基础。
简单地说, 不得对函数的返回值进行计算 。
例如,计算10的幂,这是微不足道的,可以通过循环写入。
应该看起来像
template<typename T> T pow10(T const p, T const res =1)
{
return p ? res: pow10(--p,10*res);
}
这给出了执行,例如4:
RET,P,RES
- ,4,1
- ,3,10
- ,2100
- ,1,1000
- ,0,10000
10000, - , -
很明显,编译器只需复制值而不更改堆栈指针,并且当尾调用发生时只是为了返回结果。
尾递归非常重要,因为它可以提供现成的编译时评估,例如:以上可以是。
template<int N,int R=1> struct powc10
{
int operator()() const
{
return powc10<N-1, 10*R>()();
}
};
template<int R> struct powc10<0,R>
{
int operator()() const
{
return R;
}
};
这可以用作powc10<10>()()
来计算编译时的第10个幂。
大多数编译器都有嵌套调用的限制,所以尾调用技巧有帮助。显然,没有元编程循环,所以必须使用递归。
答案 4 :(得分:1)
在C ++中,编译器级别不存在尾递归。 击>
虽然您可以编写使用尾递归的程序,但是您不会通过支持编译器/解释器/语言来实现尾递归的继承优势。例如,Scheme支持尾递归优化,因此它基本上会将递归更改为迭代。这使得堆栈溢出更快,更无懈可击。 C ++没有这样的东西。 (至少不是我见过的任何编译器)
显然,MSVC ++和GCC都存在尾递归优化。有关详细信息,请参阅this question。
答案 5 :(得分:0)
Wikipedia has a decent article on tail recursion。基本上,尾递归优于常规递归,因为将它优化为迭代循环是微不足道的,并且迭代循环通常比递归函数调用更有效。这在没有循环的函数式语言中尤为重要。
对于C ++,如果你可以用尾递归编写递归循环仍然很好,因为它们可以更好地优化,但在这种情况下,你通常可以首先迭代地进行,所以增益不是那么好因为它是一种函数式语言。