漫谈递归和迭代

先讲个故事吧。

从前有座山,山里有座庙,庙里有个老和尚,正在给小和尚讲故事呢!故事是什么呢?“从前有座山,山里有座庙,庙里有个老和尚,正在给小和尚讲故事呢!故事是什么呢?‘从前有座山,山里有座庙,庙里有个老和尚,正在给小和尚讲故事呢!故事是什么呢?……’”。

这个故事永远也讲不完,因为没有递归结束条件。老师讲递归时总是说,递归很简单,一个递归结束条件,一个自己调用自己。如果递归没有结束条件,那么就会无限递归下去。在编程的时候,没有递归结束条件或者递归过深,一般会造成栈溢出。

下面这个函数,可以利用栈溢出来估测栈的大小:

1 void stack_size()
2 {
3     static int call_time = 0;
4     char dummy[1024*1024];
5     call_time++;
6     printf("call time: %d\n",call_time);
7     stack_size();
8 }

 

递归算法一般用于解决三类问题:这个函数定义了1M的局部变量,然后调用自己。栈溢出时会崩溃,根据最后打印出的数字可以算一下栈的大小。

(1)数据的定义是按递归定义的。(Fibonacci函数)

(2)问题解法按递归算法实现。(回溯)

(3)数据的结构形式是按递归定义的。(树的遍历,图的搜索)

对于求1+2+3+…+n这种问题,大部分人不会用递归方式求解:

int sum1(int n)
{
    if(n == 0)
        return 0;
    else
        return n+sum1(n-1);
}

而是使用迭代的方式:

int sum2(int n)
{
    int ret = 0;
    for(int i = 1;  i <= n; i++)
              ret += i;
    return ret;
}

 

很明显,使用递归时每调用一次,就需要在栈上开辟一块空间,而使用递归就不需要了,因此,很多时候设计出了递归算法,还要想法设法修改成迭代算法。迭代算法是用计算机解决问题的一种基本方法。它利用计算机运算速度快、适合做重复性操作的特点,让计算机对一组指令(或一定步骤)进行重复执行,在每次执行这组指令(或这些步骤)时,都从变量的原值推出它的一个新值。

为什么使用迭代而不用递归呢?

多数人对递归和迭代的理解就是这些。

假如现在我们不考虑编程,我们仅仅看一下上面使用递归和迭代求1+2+3…+n的过程。

使用递归:

sum(5)
5+sum(4)
5+4+sum(3)
5+4+3+sum(2)
5+4+3+2+sum(1)
5+4+3+2+1+sum(0)
5+4+3+2+1+0
5+4+3+2+1
5+4+3+3
5+4+6
5+10
15

使用迭代

0+1=1
1+2=3
3+3=6
6+4=10
10+5=15

上面两个计算过程所需的步骤都是O(n)。但是两个计算过程的形状不一样。

递归过程是一个先逐步展开而后收缩的形状,在展开阶段,这一计算过程构造起一个推迟进行的操作所形成的的链条(这里是+),在收缩阶段才会实际执行这些操作。这种类型的计算过程由一个推迟执行的运算链条刻画,称为一个递归计算过程。要执行这种计算过程,就需要维护以后将要执行的操作的轨迹。在计算1+2+3+…+n时,推迟执行的加法链条的长度就是为了保存其轨迹需要保存的信息量,这个长度随着n值而线性增长,这样的过程称为线性递归过程。

迭代过程的形成没有任何增长或收缩。对于任意一个n,在计算的每一步,我们需要保存的就只有i,ret,这个过程就是一个迭代计算过程。一般来说,迭代计算过程就是那种其状态可以用固定数目的状态变量描述的结算过程。在计算1+2+…+n时,所需的计算步骤与n成正比,这种过程称为线性迭代过程。

现在再回到编程语言中。

上面提到的推迟执行的运算链条就存在栈里,由于栈很小,如果链条太长,就会溢出了。

那我们再来看下面的函数

int sum3(int n, int acc)
{
    if(n == 0)
        return acc;
    else
        return sum3(n-1,acc+n);
}

调用的时候acc=0,以sum(5,0)为例这是一个递归函数,我们来看看它的计算过程。

sum(5,0)
sum(4,5)
sum(3,9)
sum(2,12)
sum(1,14)
sum(0,15)
15

这个计算过程是递归的还是迭代的呢?

是迭代的!

但是命名函数sum又调用了自己。

我们需要将递归计算过程与递归过程分隔开。

当我们说一个过程(函数)是递归的时候,论述的是一个语法形式上的事实,说明这个过程的定义中(直接或间接的)调用了自己。我们说一个计算过程具有某种模式时(例如线性递归),我们说的是这一计算过程的进展方式,而不是过程说些上的语法形式。

一个递归过程,如果它的计算过程是迭代的,那么我们称这种递归为尾递归。尾递归不需要保存递归的推迟计算链,那么是不是就意味着不会造成栈溢出了?

我们来试一下

int sum(int n, int acc)
{
    if(n == 0)
        return acc;
    else
        return sum(n-1,acc+n);
}
int main()
{
    int n;
    scanf("%d",&n);
    printf("%d\n",sum(n,0));
    return 0;
}

 

运行结果如下:

localhost:~ niuxinli$ gcc test.c
localhost:~ niuxinli$ ./a.out 1000 Segmentation fault: 11

看来还是会栈溢出。

为啥呢?因为c语言默认不会对尾递归进行优化,即使你的程序是尾递归的,它还是按一般的递归进行编译。加上优化选项就可以对尾递归进行优化。

localhost:~ niuxinli$ gcc -O3 test.c
localhost:~ niuxinli$ ./a.out 
1000
500500

下面哪些是尾递归呢?

int fib(int n)
{
    if(n == 0 || n == 1)
        return 1;
    else
        return fib(n-1) + fib(n-2);
}
void qsort(int A, int p, int q)
{
    r = partition(A,p,q);
    qsort(A,p,r-1);
    qsort(A,r+1,q);
}
int gcd(int a, int b)
{
    if(b == 0)
        return a;
    else
        gcd(b, a%b);
}

 

在函数式编程语言中,不存在变量,因此任何的循环都需要用递归实现。如果递归使用了尾递归,那么编译器或解释器能自动优化,如果不是尾递归,那么就存在栈溢出的风险。前面两个不是尾递归,第三个是尾递归。

任何递归都可以转化成迭代,那么任何递归都可以转化成尾递归。

斐波那契数列改成尾递归后如下

int fib(int n,int count, int a , int b)
{
    if(n == 0 || n == 1)
        return 1;
    else if (count > n)
        return b;
    else
        return fib(n,count+1,b,a+b);
}
int FIB(int n)
{
    return fib(n,2,1,1);
}

 

下面这段代码

i = 1, ret = 0
for(;i <= n; i++)
        ret += i;

 

 对应的递归形式就是

int fun(int i, int ret) { 
    if(i > n)
        return ret;
    else
        return fun(ret+i,i+1);
}

 

fun(1,0)相当于给i和ret赋初值。

如果将快速排序改成迭代的话,那么需要一个栈!它的变量个数是有限的吗?我们可以把栈看成一个变量就可以了。

先修改成迭代形式

void qsort_iterate(int a[],int p,int q)
{
        stack s;
        s.push(p);
        s.push(q);
        while(!s.empty())
        {
                int high = s.top();
                s.pop();
                int low = s.top();
                s.pop();
                if(high > low)
                {
                        int r = partition(a,low,high);
                        s.push(low);
                        s.push(r-1);
                        s.push(r+1);
                        s.push(high);
                }
        }
}

 

上面的迭代形式可以很容易的改成尾递归:

void qsort_tail(int a[],stack s)
{
        if(!s.empty())
        {
                int high = s.top();
                s.pop();
                int low = s.top();
                s.pop();
                if(high > low)
                {
                        int r = partition(a,low,high);
                        s.push(low);
                        s.push(r-1);
                        s.push(r+1);
                        s.push(high);
                }
                qsort_tail(a,s);
        }
}

 

那么在函数式编程语言里,快排是不是就是这样实现的?答案是No。函数式编程为什么不能用循环?就是因为没有变量,所以在函数式编程语言里不能进行原地排序的。

(define (qsort s)
  (cond ((null? s) s)
        ((null? (cdr s)) s)
        (else
         (let ((h (car s))
               (left (filter (lambda (x) (<= x (car s))) (cdr s)))
               (right (filter (lambda (x) (> x (car s))) (cdr s))))
           (append (qsort left) (list h) (qsort right))))))

 

我们把这段代码翻译成Python(翻译成C或者C++挺啰嗦的)上面这段代码是用Lisp的方言Scheme实现的,不是尾递归的。

def qsort_lisp(A):
    if len(A) == 0 or len(A) == 1:
        return A
    left = []
    right = []
    pivot = A[0]
    for i in range(1,len(A)):
        if A[i]             left.append(A[i]);
        else:
            right.append(A[i]);
    return qsort_lisp(left) + [pivot] + qsort_lisp(right)
x = [3,4,5,6,2,34,6,2,2,5,7,2,7]
print qsort_lisp(x)

其实刚才我说谎了,大部分函数式编程语言,例如Scheme,Erlang,Clojure等都提供可变的变量,数据库里有上G的数据,不能把它拷贝一份在写回去,这时候就需要使用真正的变量了。函数式编程语言都是比较高级的语言,排序时一般使用自带的sort函数就行了。上面这段代码没有对变量做修改的操作,所以可以看做是函数式编程。这个函数能改成尾递归吗?应该是可以的,但是挺麻烦的,我是没找到好办法。到网上找了找也没找到好的方法。

总结一下尾递归:
(1)计算过程是迭代的
(2)在函数最后一步调用自己,而且是仅有调用语句,或者是一句fun(),或者是return fun(),不存在x = fun()这样的情况
(3)函数执行最后一句调用自己的语句时,将状态变量以参数形式传递给下一次调用,自己的栈没用了,形象的说,它告诉下一次被调用的函数,我已经死了,你干完活后直接向我的上级报告就行了,不需要和我说了
(4)gcc开启优化选项后可以对尾递归进行优化,大部分函数式编程语言会对尾递归进行优化

 

posted @ 2019-10-11 19:46  牛哥的博客  阅读(2094)  评论(0编辑  收藏  举报