尾递归还是伪递归,关于老赵blog的一点评论

threenewbee 2012-12-15 06:24:24
加精
老赵原文看这里:
http://blog.zhaojie.me/2009/03/tail-recursion-and-continuation.html

首先,老赵是我非常尊重的技术专家,并且他的这篇文章总体上没有什么问题。我只是以一个读者的视角,批评下文章某些引起争议的部分。

大家可以看下这篇文章。坦率地说,之前我不清楚什么是尾递归,请允许我才疏学浅,因为我并非一个经常使用函数式语言/编程风格的开发者,所以对这方面接触不太多,这也是看老赵文章的目的。

不知道大家看了这篇文章,是否会得出如下一些结论,我的讨论围绕这些结论展开,可能这不是老赵的观点,但是有理由相信,这应该是很多看了老赵的文章,很可能把它们当作老赵的观点。我整理了一下,大约有如下几条:

(1)链表求长度(不考虑其他算法,我说的是老赵的那种)以及Fibonacci程序可以“轻易地”修改成尾递归的实现。
(2)二叉树进行先序遍历的算法不能轻易地用尾递归实现,因为“先后调用了两次PreOrderTraversal,这意味着必然有一次调用没法放在末尾”。
(3)尾递归因为不需要保存堆栈,所以可以转化为循环,提高效率,并且不能存在“堆栈溢出”的问题。

当然文章还有其他观点,因为不涉及争议和误解,这里不谈。至少,我看了老赵的文章,“学习”到了这样一些知识。于是我就想学以致用,结果就发现问题了。

我的第一个疑问就是,链表求长度、Fibonacci程序在老赵的文章中优雅地摇身一变,就成了尾递归的实现,它转化的原理是什么?给定一个函数,假设它属于老赵说的可以转化为尾递归的那种,那么在只根据代码本身,而不关心算法的前提下,它到底是怎么转化的。我在weibo上试图问老赵,估计老赵事务繁杂,一直都没有回答我。

于是我对老赵的转化做法开始研究,我编写了如下程序:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace ConsoleApplication1
{
class Program
{
static int c1 = 0;
static int c2 = 0;

public static int FibonacciRecursively(int n)
{
c1++;
if (n < 2) return n;
return FibonacciRecursively(n - 1) + FibonacciRecursively(n - 2);
}

public static int FibonacciTailRecursively(int n, int acc1, int acc2)
{
c2++;
if (n == 0) return acc1;
return FibonacciTailRecursively(n - 1, acc2, acc1 + acc2);
}

static void Main(string[] args)
{
FibonacciRecursively(10);
FibonacciTailRecursively(10, 0, 1);
Console.WriteLine(c1);
Console.WriteLine(c2);
}
}
}


对不起,我很笨,我不喜欢用数学分析算法复杂度,我使用计数器来分析。程序输出是177和11。这显而易见了,两个算法连复杂度都不同,根本就不是一个算法!显然找不到简单的,将“递归算法”改写成“尾递归算法”的通用做法。

既然尾递归程序不能由递归程序直接变换出来,我又开始研究这个尾递归程序,究竟老赵是怎么得到这个程序呢?我们对这个程序做一个变换(老赵在另外一篇文章中自己也说了),似乎答案出来了:

public static int FibonacciTailRecursively(int n, int acc1, int acc2)
{
while (!(n == 0))
{
c2++;
n = n - 1;
int acc1_old = acc1;
acc1 = acc2;
acc2 = acc1_old + acc2;
}
c2++;
return acc1;
}


稍微整理,得到
public static int FibonacciTailRecursively(int n)
{
int acc1 = 0;
int acc2 = 1;
while (n != 0)
{
acc2 = acc1 + acc2;
acc1 = acc2 - acc1;
n--;
}
return acc1;
}

变量名重构下,再变换
public static int FibonacciTailRecursively(int n)
{
int a = 0;
int b = 1;
for (int i = n; i != 0; i--)
{
b = a + b;
a = b - a;
}
return a;
}

当然,你可以把循环再写成递增的形式。
这下恍然大悟了吧。我很有理由相信,老赵是先写出了上面这个程序,再一步一步变形,直到形成所谓“尾递归”版本的!

在这个过程中,我总结下将循环变成尾递归的一般步骤。不用这个例子了,我们看这样一个简单的需求,找到数组中最大的那个数:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace ConsoleApplication1
{
class Program
{
static int Max(int[] source)
{
int max = source[0];
for (int i = 1; i < source.Length; i++)
{
if (max < source[i]) max = source[i];
}
return max;
}
static void Main(string[] args)
{
int[] a = { 1, 4, 5, 9, 2, 0, 2, 3, 8, 5, 12, 7, 4, 6 };
Console.WriteLine(Max(a));
}
}
}

这里我们不考虑一些细节问题,比如数组没有元素应该丢出异常,或者算法是否高效。我们只考虑,将一个循环改写成尾递归的一般规律。
首先,提取局部变量
static int Max(int[] source, int max)
{
for (int i = 1; i < source.Length; i++)
{
if (max < source[i]) max = source[i];
}
return max;
}

然后将for循环改造成while循环,有利于下面拆开
static int Max(int[] source, int max)
{
int i = 1;
while (i < source.Length)
{
if (max < source[i]) max = source[i];
i++;
}
return max;
}

然后把循环改写成if...else...,在循环结尾,实现“尾递归”(又发现一个局部变量i,再提取)
static int Max(int[] source, int max, int i)
{
if (i < source.Length)
{
if (max < source[i]) max = source[i];
i++;
return Max(source, max, i);
}
else
{
return max;
}
}

还不是尾递归?把条件判断修改下。
static int Max(int[] source, int max, int i)
{
if (i >= source.Length) return max;
if (max < source[i]) max = source[i];
i++;
return Max(source, max, i);
}

哈哈,大功告成啦。

再补充一点,如果循环里面有break; continue;怎么处理?break变成return,continue变成尾递归调用。如果循环后面还有代码怎么办?也别着急,还是通过一个参数判断是应该执行循环段,还是return段(这里不是return,而是return段,所有放在循环后面的代码都属于这里,我们还是通过一个例子来看下)

static int Max(int[] source)
{
int max = source[0];
for (int i = 1; i < source.Length; i++)
{
if (max < source[i]) max = source[i];
}
Console.WriteLine("Hello World");
return max;
}

这次我们在循环后插入一条语句,为节省篇幅,我就直接给出最后形式了
static int Max(int[] source, int max, int i)
{
if (i >= source.Length)
{
Console.WriteLine("Hello World");
return max;
}
if (max < source[i]) max = source[i];
i++;
return Max(source, max, i);
}

循环前面的代码,可以放在调用者出,也可以用变量判断(其实就是状态机)。多重循环,里面的循环保持不变,当然也可以和外侧的循环合并后一起放在“递归”中。在后面,会有一个相当复杂的例子掩饰循环到尾递归的转换,这里不再赘述了。

总之,怎么样从递归变换到尾递归,没有机械的,等价的变换方法,你的诀窍就是,看懂递归算法本身,用递推/循环改写整个算法,再把循环机械地转换为尾递归。如果你掌握了这一点,你也可以像老赵那样信手拈来地举例子了。

谈下一个话题,文章的另一个观点(希望我没有断章取义,大家对照原文看)二叉树进行先序遍历的算法不能轻易地用尾递归实现,因为“先后调用了两次PreOrderTraversal,这意味着必然有一次调用没法放在末尾”。

这话奇怪了,还是老赵的代码
public static int FibonacciRecursively(int n)
{
if (n < 2) return n;
return FibonacciRecursively(n - 1) + FibonacciRecursively(n - 2);
}

FibonacciRecursively不是也调用了2次么?怎么就能轻易地用尾递归实现呢?

二叉树进行先序遍历的算法真的不能轻易实现么?

大家可以思考下。

我就“轻易地”实现了下,篇幅所限,我放在 http://bbs.csdn.net/topics/390317050 的2楼了。大家可以参考下。对于这个代码,我要指出的是,我给Tree增加了Parent属性,指向父节点,是出于代码简洁的考虑。在不考虑性能的情况下,即便没有Parent属性,算法也是成立的。只要有了循环算法,最后总能给出“尾递归”版本。

下面要谈一个问题,老赵的blog上说他的同学问他,什么样的递归,可以转化为尾递归,或者不用递归,他要好好解释解释的,可惜不知道什么缘故,解释的文章指向错误,我没有看到老赵的解释。这个问题我其实也想知道,老赵不说,只能自己思考了。

递归算法要想转化为不用递归的线性的递推算法,要求程序的状态不能回溯,也就是程序的状态一旦改变,就不能要求退回到之前的状态,不用递归可以看作递归的特例——程序的状态没有回溯(最大堆栈深度是1)。

因此,可以说,老赵的观点“先后调用了两次PreOrderTraversal,这意味着必然有一次调用没法放在末尾”是正确的,但是还可以推广——任何非尾递归的递归,都不能改写成尾递归,哪怕只调用了1次,而不一定是2次。而尾递归和循环则是等价的

有了这个结论,我想我可以好好梳理网上搜到的一些观点了:
(1)编译器可以把递归优化成尾递归——明显错误的,人都办不到的事情,编译器更不能办到。
(2)编译器可以把尾递归优化为循环——这个可行。
(3)尾递归比递归有很多优势,比如xxx,xxx,xxx。这个观点(在非函数式编程情况下)是错的。因为递归根本不能改写成尾递归,尾递归和递归没有可比性,谈什么优势呢?倒是尾递归和循环等价,所以,尾递归的本质就是“伪递归”,将非递归版本的算法中的循环包装成了递归的形式。(在非函数式编程情况下,这个前提很重要!)尾递归的可读性不如循环,还不如直接写成循环。换一句话说,不是尾递归比递归相比有哪些优势,而是撕下尾递归的面具,循环形式的递推算法比递归算法有那些优势。

最后一个观点,尾递归因为不需要保存堆栈,所以可以转化为循环,提高效率,并且不能存在“堆栈溢出”的问题。这里我着重要说的是,尾递归同样可能存在“堆栈溢出”的问题,在什么情况下呢?老赵后面介绍的Continuation。Continuation可以把任意递归包装成尾递归形式,这似乎和我前面说的“任何非尾递归的递归,都不能改写成尾递归”矛盾了。我前面说错了么?注意前提,Continuation在不改变算法本身的情况下,还是无法解决程序状态回溯的问题,但是它做了一个掩饰,就是利用这个Continuation callback,把状态作为它的参数转移了——主程序看上去果然不需要状态了,所以它也就轻松地变成尾递归了。

说到这里,就明白了,Continuation包装的“尾递归”还是逃不过使用堆栈和堆栈溢出的问题。好吧,让人失望了,尾递归没有那么夸大的“疗效”。

前面说了,递归需要使用堆栈,那么如果允许模拟堆栈,是否可以找到不用递归的算法呢?答案是肯定的,稍微有一点计算机原理知识,知道堆栈用途的人都可以尝试改写,我也写了一遍,还是放在http://bbs.csdn.net/topics/390317050里面,大家可以参考。

哦,对了,那些龌龊的goto语句在下面的版本中怎么没了?这些代码不会是像“老赵变魔术”那样变出来的吧?确实那些goto看着实在头痛,神奇就一下变成循环的确不是我直接转换的,但是我保证这个过程的“无意识性”——很简单,你可以用ILSpy反编译goto代码,呵呵,反编译器比我强一些。

好了,这个代码才是真正直接由递归变换而来的尾递归——同样它需要额外的堆栈,同时,它的算法复杂度和递归版本一样——它们算法是等价的。

好了,递归不能直接转换成尾递归,但是允许模拟堆栈,或者把调用用Continuation包装,除此之外别无他法,本文可以结束了。且慢,我们还有其它方式,我把这样的方式大致分为两种,a)使用非堆栈的数据结构保存回溯的状态 b) 通过额外的计算(时间换空间),再得到回溯的状态。

我们又回到了二叉树遍历的例子,我们使用某种算法遍历二叉树,而且只需要当前节点作为状态,改写了算法,成功将递归转换为了尾递归,同时也没有用到堆栈模拟。本质上说,我们利用二叉树本身作为状态回溯存储的数据结构——所以我们不需要额外的数据结构,也不需要堆栈了。

那么二叉树遍历的这个程序给我们将递归转化为尾递归又有什么启示呢?先卖个关子,以后再说吧。
...全文
3470 91 打赏 收藏 转发到动态 举报
写回复
用AI写文章
91 条回复
切换为时间正序
请发表友善的回复…
发表回复
zylsky 2014-04-30
  • 打赏
  • 举报
回复
好长,高手讨论的话题,路过一下...
於黾 2014-04-30
  • 打赏
  • 举报
回复
好深奥,完全没看懂. 想不明白为什么把在中间调用的自身放到return里面,就可以不用堆栈了 即使放到return里面,不还是先调用了自身,等函数都执行完了再return么 难道可以先return,再往下继续调用...
Greaitm 2014-04-30
  • 打赏
  • 举报
回复
Continuation其实并不是保存在堆栈中,楼主属于没事找事。提出尾递归,主要是把能优化成尾递归的函数,尽量优化成尾递归。不是专门把自己写好的循环改写成尾递归。C#中对尾递归没有优化。你不用F#折腾个毛。
qiufozhell 2012-12-26
  • 打赏
  • 举报
回复
从上面一系列的改进中,基本就知道了算法优化的思路跟过程了,好好学习这种分析方法,谢谢!
mbugaifc 2012-12-20
  • 打赏
  • 举报
回复
老王爱上猫 2012-12-19
  • 打赏
  • 举报
回复
mark....
liduoduo 2012-12-19
  • 打赏
  • 举报
回复
学习啦,都是高手
WizardOz 2012-12-19
  • 打赏
  • 举报
回复
递归分为形式上的递归和算法上的递归。 基本上能用循环实现的算法,都能实现为形式上的尾递归。 真正的递归算法,直接用循环是做不了的,要用栈辅助。这种是形式上的循环,算法上的递归。
mrsupersky 2012-12-19
  • 打赏
  • 举报
回复
引用 28 楼 conmajia 的回复:
我赶脚。。仅限赶脚。。 尾递归啥的就它本身来讲就是个无意义的笑话,就是个“理论叫”。。因为在把一般递归转成尾递归的过程中,你已经完成了从“递归->迭代”的这个过程,所以你压根就不需要再来个“画蛇添足”的尾递归。。 就我的理解,尾递归,tial这个名词的意义在于指出了“递归->迭代”的一种可行的方法:想尽一切办法把递归调用往最后面挤。。其他的什么XXOO的。。别想太多。……
我不太了解,但是,我看到了一条,尾递归可以避免发生堆栈溢出。。。 节约函数调用的开销和资源。。。
zhxtaoo 2012-12-19
  • 打赏
  • 举报
回复
blackkettle 2012-12-19
  • 打赏
  • 举报
回复
不错,有达人在讨论算法
YHL27 2012-12-19
  • 打赏
  • 举报
回复
顶一个。。。
JustSteps 2012-12-19
  • 打赏
  • 举报
回复
路过
sanae 2012-12-19
  • 打赏
  • 举报
回复
引用 72 楼 kinado 的回复:
C/C++ code?123456 public static int FibonacciTailRecursively(int n, int acc1, int acc2) { c2++; if (n == 0) return acc1; return FibonacciTailRecu……
与强行凑循环没关系。。如果你通过循环这个概念到达递归的形式,但不能说就必须通过循环这个概念到达递归的形式, 直接从问题到递归不经过循环也是可以的,见40楼的讨论。。
Karl_S 2012-12-19
  • 打赏
  • 举报
回复
射了
wangquangaobudong 2012-12-18
  • 打赏
  • 举报
回复
哇 都是牛人啊
taoliye 2012-12-18
  • 打赏
  • 举报
回复
太牛了,看来这个网站只适合你们。
lichengji121 2012-12-18
  • 打赏
  • 举报
回复
虽然看不懂,支持一下
天天技术宅 2012-12-18
  • 打赏
  • 举报
回复

   public static int FibonacciTailRecursively(int n, int acc1, int acc2)
        {
            c2++;
            if (n == 0) return acc1;
            return FibonacciTailRecursively(n - 1, acc2, acc1 + acc2);
        }
为了计算出N的裴波那切数,必须先计算出N-1和N-2的裴波那契数,然而从上面代码的参数分配上,完全无法理解acc1和acc2与n的关系,acc1原意是想说的N-1的值,acc2是N-2的值,从调用中FibonacciTailRecursively(10,0,1)来看,acc1和acc2的参数使人无法理解。这段代码的理解是:为了求Fib(N),必须先求fib(0)和fib(1),因为参数传递的就是fib(0)和fib(1)的值,然后再求fib(1)和fib(2),最后求fib(N-1)和fib(N-2),这里根本就是把一个for循环的变量强行放置到函数参数的位置上而已,n的值就是个循环控制变量。
static int Max(int[] source, int max, int i)
{
    if (i >= source.Length) return max;
    if (max < source[i]) max = source[i];
    i++;
    return Max(source, max, i);
}
上面这段代码依然使人无法理解,很显然这里应该这么调用Max函数,Max(source,0,0);这里应该是这么理解这段代码的:为了求出source[N]的最大值,不得不先求出source[0-0]的最大值,然后再求出source[0-1]的最大值,然后再求出source[0-2]的最大值,最后才求出source[0-N]的最大值。以及函数参数max的意图是为了存储一个变量。 可以说,这样的代码不是一个严格的递归调用,只是强行使用递归而凑的一个循环而已。 假设递归求取多重指针的值,char ********p的值,********p这个该怎么求取。 这样的求法是,为了求N重指针的值,必须先求出N-1重指针的值*(*******p),这里的递归其本质就是一个自右向左结合的一个表达式,递归的本质是倒退求解问题的,把递归使用成正向前进的一个循环是说不通的。
sanae 2012-12-18
  • 打赏
  • 举报
回复
引用 70 楼 shencb 的回复:
习惯函数式的编程方式,有些算法想将之转化为尾递归,或者取消递归用循环实现, 基本等同于重写一次····还不一定写的好。 我不用递归写出来的东西感觉可读性很糟糕,可能是自己还没适应吧,努力学习···
用协程式的消除递归,和普通的递归写法很像,只是return的地方做了处理,大可不必每个递归程序都用独特的改写方法,可以用同一种通用的方法的,见http://www.chiark.greenend.org.uk/~sgtatham/coroutines.html
加载更多回复(65)

110,501

社区成员

发帖
与我相关
我的任务
社区描述
.NET技术 C#
社区管理员
  • C#
  • Web++
  • by_封爱
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告

让您成为最强悍的C#开发者

试试用AI创作助手写篇文章吧