递归调用(一)

学习编程的时候老师总是不建议使用递归,一些书上至今也是这么建议的。但是递归在二叉树上的应用即优美又稍显复杂。所以弄清楚递归的本质是分治是很有必要的。将大规模问题缩小。

为什么要用递归

编程里面估计最让人摸不着头脑的基本算法就是递归了。很多时候我们看明白一个复杂的递归都有点费时间,尤其对模型所描述的问题概念不清的时候,想要自己设计一个递归那么就更是有难度了。

很多不理解递归的人(今天在csdn里面看到一个初学者的留言),总认为递归完全没必要,用循环就可以实现,其实这是一种很肤浅的理解。因为递归之所以在程序中能风靡并不是因为他的循环,大家都知道递归分两步,递和归,那么可以知道递归对于空间性能来说,简直就是造孽,这对于追求时空完美的人来说,简直无法接接受,如果递归仅仅是循环,估计现在我们就看不到递归了。递归之所以现在还存在是因为递归可以产生无限循环体,也就是说有可能产生100层也可能10000层for循环。例如对于一个字符串进行全排列,字符串长度不定,那么如果你用循环来实现,你会发现你根本写不出来,这个时候就要调用递归,而且在递归模型里面还可以使用分支递归,例如for循环与递归嵌套,或者这节枚举几个递归步进表达式,每一个形成一个递归。

用归纳法来理解递归

数学都不差的我们,第一反应就是递归在数学上的模型是什么。毕竟我们对于问题进行数学建模比起代码建模拿手多了。 (当然如果对于问题很清楚的人也可以直接简历递归模型了,运用数模做中介的是针对对于那些问题还不是很清楚的人)

自己观察递归,我们会发现,递归的数学模型其实就是归纳法,这个在高中的数列里面是最常用的了。回忆一下归纳法。

归纳法适用于想解决一个问题转化为解决他的子问题,而他的子问题又变成子问题的子问题,而且我们发现这些问题其实都是一个模型,也就是说存在相同的逻辑归纳处理项。当然有一个是例外的,也就是递归结束的哪一个处理方法不适用于我们的归纳处理项,当然也不能适用,否则我们就无穷递归了。这里又引出了一个归纳终结点以及直接求解的表达式。如果运用列表来形容归纳法就是:

  • 步进表达式:问题蜕变成子问题的表达式
  • 结束条件:什么时候可以不再是用步进表达式
  • 直接求解表达式:在结束条件下能够直接计算返回值的表达式
  • 逻辑归纳项:适用于一切非适用于结束条件的子问题的处理,当然上面的步进表达式其实就是包含在这里面了。

这样其实就结束了,递归也就出来了。递归算法的一般形式:

01 void func( mode)
02 {
03     if(endCondition)
04     {
05         constExpression        
//基本项
06     }
07     else
08     {
09         accumrateExpreesion    
//归纳项
10         mode=expression        
//步进表达式
11             func(mode)         
//调用本身,递归
12     }
13 }

最典型的就是N!算法,这个最具有说服力。理解了递归的思想以及使用场景,基本就能自己设计了,当然要想和其他算法结合起来使用,还需要不断实践与总结了。

01 #include "stdio.h"
02 #include "math.h"
03  
04 int main(void)
05 {
06     int
n, rs;
07  
08     printf("请输入需要计算阶乘的数n:");
09     scanf("%d",&n);
10  
11     rs = factorial(n);
12     printf("%d ", rs);
13 }
14  
15 // 递归计算过程
16 int factorial(n){
17      if(n == 1) {
18           return
1;
19      }
20      return
n * factorial(n-1);
21 }

求阶乘的递归比较简单,这里就不展开了。

再来两个递归的例子

返回一个二叉树的深度:

1 int depth(Tree t){
2       if(!t)
return 0;
3     else
{
4         int
a=depth(t.right);
5         int
b=depth(t.left);
6         return
(a>b)?(a+1):(b+1);
7     }
8 }

判断一个二叉树是否平衡:

1 int isB(Tree t){
2       if(!t)
return 0;
3     int
left=isB(t.left);
4     int
right=isB(t.right);
5     if( left >=0 && right >=0 && left - right <= 1 || left -right >=-1)
6         return
(left < right)? (right +1) : (left + 1);
7     else
return -1;
8 }

第一个算法还是比较好理解的,但第二个就不那么好理解了。第一个算法的思想是:如果这个树是空,则返回0;否则先求左边树的深度,再求右边数的深度,然后对这两个值进行比较哪个大就取哪个值+1。而第二个算法,首先应该明白isB函数的功能,它对于空树返回0,对于平衡树返回树的深度,对于不平衡树返回-1。明白了函数的功能再看代码就明白多了,只要有一个函数返回了-1,则整个函数就会返回-1。(具体过程只要认真看下就明白了)

对于递归,最好的理解方式便是从函数的功能意义的层面来理解。了解一个问题如何被分解为它的子问题,这样对于递归函数代码也就理解了。这里有一个误区(我也曾深陷其中),就是通过分析堆栈,分析一个一个函数的调用过程、输出结果来分析递归的算法。这是十分要不得的,这样只会把自己弄晕,其实递归本质上也是函数的调用,调用的函数是自己或者不是自己其实没什么区别。在函数调用时总会把一些临时信息保存到堆栈,堆栈只是为了函数能正确的返回,仅此而已。我们只要知道递归会导致大量的函数调用,大量的堆栈操作就可以了。

小结

递归的基本思想是把规模大的问题转化为规模小的相似的子问题来解决。在函数实现时,因为解决大问题的方法和解决小问题的方法往往是同一个方法,所以就产生了函数调用它自身的情况。另外这个解决问题的函数必须有明显的结束条件,这样就不会产生无限递归的情况了。

递归需要的两个条件

很多人对递归的理解不太深刻。一直就停留在“自己调用自己”的程度上。这其实这只是递归的表象(严格来说连表象都概括得不全面,因为除了“自己调用自己”的递归外,还有交互调用的递归)。而递归的思想远不止这么简单。

递归,并不是简单的“自己调用自己”,也不是简单的“交互调用”。它是一种分析和解决问题的方法和思想。简单来说,递归的思想就是:把问题分解成为规模更小的、具有与原问题有着相同解法的问题。比如二分查找算法,就是不断地把问题的规模变小(变成原问题的一半),而新问题与原问题有着相同的解法。

有些问题使用传统的迭代算法是很难求解甚至无解的,而使用递归却可以很容易的解决。比如汉诺塔问题。但递归的使用也是有它的劣势的,因为它要进行多层函数调用,所以会消耗很多堆栈空间和函数调用时间。

既然递归的思想是把问题分解成为规模更小且与原问题有着相同解法的问题,那么是不是这样的问题都能用递归来解决呢?答案是否定的。并不是所有问题都能用递归来解决。那么什么样的问题可以用递归来解决呢?一般来讲,能用递归来解决的问题必须满足两个条件:

  • 可以通过递归调用来缩小问题规模,且新问题与原问题有着相同的形式。
  • 存在一种简单情境,可以使递归在简单情境下退出。

如果一个问题不满足以上两个条件,那么它就不能用递归来解决。

为了方便理解,还是拿斐波那契数列来说下:求斐波那契数列的第N项的值。

这是一个经典的问题,说到递归一定要提到这个问题。斐波那契数列这样定义:f(0) = 0, f(1) = 1, 对n > 1, f(n) = f(n-1) + f(n-2)

这是一个明显的可以用递归解决的问题。让我们来看看它是如何满足递归的两个条件的:

  1. 对于一个n>2, 求f(n)只需求出f(n-1)和f(n-2),也就是说规模为n的问题,转化成了规模更小的问题;
  2. 对于n=0和n=1,存在着简单情境:f(0) = 0, f(1) = 1。

因此,我们可以很容易的写出计算费波纳契数列的第n项的递归程序:

1 int fib(n){
2     if(n == 0)
3         return
0;
4     else
if(n == 1)
5         return
1;
6     else
7         return
f(n-1) + f(n-2);
8 }

在编写递归调用的函数的时候,一定要把对简单情境的判断写在最前面,以保证函数调用在检查到简单情境的时候能够及时地中止递归,否则,你的函数可能会永不停息的在那里递归调用了。

字符串回文的现象

前面谈到了递归的一些思想,还有概念上的一些理解,这里试着用递归解决一些问题。比如回文。

回文是一种字符串,它正着读和反着读都是一样的。比如level,eye都是回文。用迭代的方法可以很快地判断一个字符串是否为回文。用递归的方法如何来实现呢?

首先我们要考虑使用递归的两个条件:

  • 第一:这个问题是否可以分解为形式相同但规模更小的问题?
  • 第二:如果存在这样一种分解,那么这种分解是否存在一种简单情境?

先来看第一点,是否存在一种符合条件的分解。容易发现,如果一个字符串是回文,那么在它的内部一定存在着更小的回文。 比如level里面的eve也是回文。 而且,我们注意到,一个回文的第一个字符和最后一个字符一定是相同的。

所以我们很自然的有这样的方法:

先判断给定字符串的首尾字符是否相等,若相等,则判断去掉首尾字符后的字符串是否为回文,若不相等,则该字符串不是回文。

注意,我们已经成功地把问题的规模缩小了,去掉首尾字符的字符串当然比原字符串小。

接着再来看第二点, 这种分解是否存在一种简单情境呢?简单情境在使用递归的时候是必须的,否则你的递归程序可能会进入无止境的调用。

对于回文问题,我们容易发现,一个只有一个字符的字符串一定是回文,所以,只有一个字符是一个简单情境,但它不是唯一的简单情境,因为空字符串也是回文。这样,我们就得到了回文问题的两个简单情境:字符数为1和字符数为0。

好了,两个条件都满足了,基于以上分析,我们可以很容易的编写出解决回文问题的递归实现方式:

01 #include "stdio.h"
02 #include "string.h"
03  
04 int main(void)
05 {
06     int
n, rs;
07     char
str[50];
08  
09     printf("请输入需要判断回文的字符串:");
10     scanf("%s",&str);
11  
12     n = (int)strlen(str);
13     rs = is_palindereme(str, n);
14     printf("%d ", rs);
15 }
16  
17 int is_palindereme(char
*str, int
n)
18 {
19     printf("Length: %d \n",n);
20     printf("%c ----- %c\n", str[0], str[n-1]);
21     if(n == 0 || n == 1)
22         return
1;
23     else{
24         //printf("%d, %d\n", str[0], str[n-1]);
25         return
((str[0] == str[n-1]) ? is_palindereme(str+1, n-2) : 0);
26     }
27 }

程序运行结果为:

1 请输入需要判断回文的字符串:level
2 Length: 5
3 l ----- l
4 Length: 3
5 e ----- e
6 Length: 1
7 v ----- v
8 1

二分查找法递归实现

还有一个典型的递归例子是对已排序数组的二分查找算法。

现在有一个已经排序好的数组,要在这个数组中查找一个元素,以确定它是否在这个数组中,很一般的想法是顺序检查每个元素,看它是否与待查找元素相同。这个方法很容易想到,但它的效率不能让人满意,它的复杂度是O(n)的。现在我们来看看递归在这里能不能更有效。

还是考虑上面的两个条件:

  • 第一:这个问题是否可以分解为形式相同但规模更小的问题?
  • 第二:如果存在这样一种分解,那么这种分解是否存在一种简单情境?

考虑条件一:我们可以这样想,如果想把问题的规模缩小,我们应该做什么?

可以的做法是:我们先确定数组中的某些元素与待查元素不同,然后再在剩下的元素中查找,这样就缩小了问题的规模。那么如何确定数组中的某些元素与待查元素不同呢? 考虑到我们的数组是已经排序的,我们可以通过比较数组的中值元素和待查元素来确定待查元素是在数组的前半段还是后半段。这样我们就得到了一种把问题规模缩小的方法。

接着考虑条件二:简单情境是什么呢?

容易发现,如果中值元素和待查元素相等,就可以确定待查元素是否在数组中了,这是一种简单情境,那么它是不是唯一的简单情境呢? 考虑元素始终不与中值元素相等,那么我们最终可能得到了一个无法再分的小规模的数组,它只有一个元素,那么我们就可以通过比较这个元素和待查元素来确定最后的结果。这也是一种简单情境。

好了,基于以上的分析,我们发现这个问题可以用递归来解决,二分法的代码如下:

#include "stdio.h"
#include "stdlib.h"

void selectionSort(int data[], int count);
int binary_search(int *a, int n, int key);

void main()
{
    int i, key, rs;
    int arr[10];
    int count;

    printf("排序前数组为:");
    srand((int)time(0));
	for(i=0; i < 10; i++)
	{
	    arr[i] = rand()%100;
	    printf("%d ",arr[i]);
	}

    count = sizeof(arr)/sizeof(arr[0]);
    selectionSort(arr, count);

    printf("\n排序后数组为:");
    for(i=0; i < 10; i++)
	{
	    printf("%d ", arr[i]);
	}

    printf("\n请输入要查找的数字:");
    scanf("%d",&key);

    rs = binary_search(arr, 10, key);
    printf("%d ", rs);
}

void selectionSort(int data[], int count)
{
    int i, j, min, temp;
    for(i = 0; i < count; i ++) {
        /*find the minimum*/
        min = i;
        for(j = i + 1; j < count; j ++)
            if(data[j] < data[min])
                min = j;
        temp = data[i];
        data[i] = data[min];
        data[min] = temp;
    }
}

int binary_search(int *data, int n, int key)
{
    int mid;
    if(n == 1){
        return (data[0] == key);
    }else{
        mid = n/2;
        printf("mid=%d\n", data[mid]);
        if(data[mid-1] == key)
            return 1;
        else if(data[mid-1] > key)
        {
            printf("key %d 比 data[mid-1] %d 小,取前半段 \n", key, data[mid-1]);
            return binary_search(&data[0], mid, key);
        }
        else
        {
            printf("key %d 比 data[mid-1] %d 大,取后半段 \n", key, data[mid-1]);
            return binary_search(&data[mid], n - mid, key);
        }
    }
}

程序运行结果:

排序前数组为:53 27 26 99 20 17 15 25 23 63
排序后数组为:15 17 20 23 25 26 27 53 63 99
请输入要查找的数字:20
mid=26
key 20 比 data[mid-1] 25 小,取前半段
mid=20
key 20 比 data[mid-1] 17 大,取后半段
mid=23
1

这个算法的复杂度是O(logn)的,显然要优于先前提到的朴素的顺序查找法。

为什么递归是低效的

还是拿斐波那契(Fibonacci)数列来做例子。在很多教科书或文章中涉及到递归或计算复杂性的地方都会将计算斐波那契数列的程序作为经典示例。如果现在让你以最快的速度用C#写出一个计算斐波那契数列第n个数的函数(不考虑参数小于1或结果溢出等异常情况),我不知你的程序是否会和下列代码类似:

public static ulong Fib(ulong n)
{
    return (n == 1 || n == 2) ? 1 : Fib(n - 1) + Fib(n - 2);
}

这段代码应该算是短小精悍(执行代码只有一行),直观清晰,而且非常符合许多程序员的代码美学,许多人在面试时写出这样的代码可能心里还会暗爽。但是如果用这段代码试试计算Fib(1000)我想就再也爽不起来了,它的运行时间也许会让你抓狂。

看来好看的代码未必中用,如果程序在效率不能接受那美观神马的就都是浮云了。如果简单分析一下程序的执行流,就会发现问题在哪,以计算Fibonacci(5)为例:

从上图可以看出,在计算Fib(5)的过程中,Fib(1)计算了两次、Fib(2)计算了3次,Fib(3)计算了两次,本来只需要5次计算就可以完成的任务却计算了9次。这个问题随着规模的增加会愈发凸显,以至于Fib(1000)已经无法再可接受的时间内算出。

我们当时使用的是简单的用定义来求 fib(n),也就是使用公式 fib(n) = fib(n-1) + fib(n-2)。这样的想法是很容易想到的,可是仔细分析一下我们发现,当调用fib(n-1)的时候,还要调用fib(n-2),也就是说fib(n-2)调用了两次,同样的道理,调用f(n-2)时f(n-3)也调用了两次,而这些冗余的调用是完全没有必要的。可以计算这个算法的复杂度是指数级的。

改进的斐波那契递归算法

那么计算斐波那契数列是否有更好的递归算法呢? 当然有。让我们来观察一下斐波那契数列的前几项:

那么计算斐波那契数列是否有更好的递归算法呢? 当然有。让我们来观察一下斐波那契数列的前几项:

1 1, 1, 2, 3, 5, 8, 13, 21, 34, 55 …

注意到没有,如果我们去掉前面一项,得到的数列依然满足f(n) = f(n-1) – f(n-2), (n>2),而我们得到的数列是以1,2开头的。很容易发现这个数列的第n-1项就是原数列的第n项。怎么样,知道我们该怎么设计算法了吧?我们可以写这样的一个函数,它接受三个参数,前两个是数列的开头两项,第三个是我们想求的以前两个参数开头的数列的第几项。

1 int fib_i(int
a, int
b,
int n);

在函数内部我们先检查n的值,如果n为3则我们只需返回a+b即可,这是简单情境。如果n>3,那么我们就调用f(b, a+b, n-1),这样我们就缩小了问题的规模(从求第n项变成求第n-1项)。好了,最终代码如下:

int fib_i(int a, int b , int n)
{
    if(n == 3)
        return a+b;
    else
        return fib_i(b, a+b, n-1);
}

这样得到的算法复杂度是O(n)的。已经是线性的了。它的效率已经可以与迭代算法的效率相比了,但由于还是要反复的进行函数调用,还是不够经济。

递归与迭代的效率比较

我们知道,递归调用实际上是函数自己在调用自己,而函数的调用开销是很大的,系统要为每次函数调用分配存储空间,并将调用点压栈予以记录。而在函数调用结束后,还要释放空间,弹栈恢复断点。所以说,函数调用不仅浪费空间,还浪费时间。

这样,我们发现,同一个问题,如果递归解决方案的复杂度不明显优于其它解决方案的话,那么使用递归是不划算的。因为它的很多时间浪费在对函数调用的处理上。在C++中引入了内联函数的概念,其实就是为了避免简单函数内部语句的执行时间小于函数调用的时间而造成效率降低的情况出现。在这里也是一个道理,如果过多的时间用于了函数调用的处理,那么效率显然高不起来。

举例来说,对于求阶乘的函数来说,其迭代算法的时间复杂度为O(n):

int fact(n)
{
    int i;
    int r = 1;
    for(i = 1; i < = n; i++)
    {
        r *= i;
    }
    return r;
}

而其递归函数的时间复杂度也是O(n):

int fact_r(n)
{
    if(n == 0)
        return 1;
    else
        return n * f(n);
}

但是递归算法要进行n次函数调用,而迭代算法则只需要进行n次迭代而已。其效率上的差异是很显著的。

小结

由以上分析我们可以看到,递归在处理问题时要反复调用函数,这增大了它的空间和时间开销,所以在使用迭代可以很容易解决的问题中,使用递归虽然可以简化思维过程,但效率上并不合算。效率和开销问题是递归最大的缺点。

虽然有这样的缺点,但是递归的力量仍然是巨大而不可忽视的,因为有些问题使用迭代算法是很难甚至无法解决的(比如汉诺塔问题)。这时递归的作用就显示出来了。

递归的效率问题暂时讨论到这里。后面会介绍到递归计算过程与迭代计算过程,讲解得更详细点。

时间: 2024-10-12 16:50:53

递归调用(一)的相关文章

浅谈递归调用的个人领悟

从大一开始学c,就不是挺理解递归的,最近突然有所体会: 递归调用中递归调用的函数可以把它想象成为一个树的结点,在函数中调用自身就是一个分支,直到出口条件时就是这棵树的叶子结点.叶子的值便是出口返回的值.最后从叶子结点按照你所调用的方法向上返回值,最终结束递归调用.

案例------递归调用

1  什么是递归: 实现某些功能不用递归可能要几十行代码,用递归可能几行就搞定了,而且代码清晰简洁.一直以为递归也就是自己调用自己,有一个出口条件,让他停止递归,退出函数,其实的特点并非就这些. 递归还有一个非常重要的特点:先进后出,跟栈类似,先递进去的后递出来.由于递归一直在自己调用自己,有时候我们很难清楚的看出,他的返回值到底是哪个,只要你理解了先进后出这个特点,你就会明白,第一次调用时,作为返回值的那个变量的值就是递归函数的返回值.先进后出吗,他是第一个进来,也就是最后出去的那个,当然就是

方法的创建、重载及递归调用

-----------siwuxie095 1.方法的定义 方法就是一段可重复调用的代码段 定义格式: 「方法的返回值类型为 void 时,不需要返回值,小括号 () 里可以有参数」 2.方法的重载: 方法名称相同,但是参数的类型和个数不同(即参数可辨), 通过传递参数的个数和类型不同来完成不同的功能 调用时系统自动匹配 3.方法的递归调用 递归调用是一种特殊的调用形式,就是方法自己调自己 常用于遍历(如:文件夹等) 如:从 1 加到 100 代码: package com.siwuxie095

python 3 递归调用与二分法

递归调用与二分法 1.递归调用 递归调用:在调用一个函数的过程中,直接或间接地调用了函数本身. 示例: def age(n): if n == 1: return 18 # 结束条件 return age(n-1)+2 # 调用函数本身 print(age(5)) 打印结果 26 递归的执行分为两个阶段: 1 递推 2 回溯 示例图 递归特性: 1.必须有一个明确的结束条件 2.每次进入更深一层递归时,问题规模相比上次递归都应有所减少 3.递归效率不高,因为每次调用自身时,都会在内存中创建一个新

1113 递归调用的次数统计

题目来源:https://acm.zzuli.edu.cn/zzuliacm/problem.php?id=1113Description如下程序的功能是计算 Fibonacci数列的第n项.函数fib()是一个递归函数.请你改写该程序,计算第n项的同时,统计调用了多少次函数fib(包括main()对fib()的调用).#include<stdio.h>int fib(int k); int main(void ){    int n;    scanf("%d", &am

[java基础]递归调用

递归调用:通过调用或间接调用程序自身 递归调用最重要的一点是,一定要有个头,要是没有头,一直调用下去,就成了死循环了. 代码示例: /** * 递归调用代码示例<br> * 说明:一个方法,自己直接或间接的调用自己.<br> * @author 冲出地球 * */ public class Recursion { /** * 示例程序:阶乘<br> * 一个数的阶乘,就是从1一直乘到那个数<br> * 示例:2! = 1*2 5! = 1*2*3*4*5&l

python-day5-生成器迭代器及递归调用

生成器是一个可迭代的对象,它的执行会记住上一次返回时在函数体中的位置.对生成器第二次(或第 n 次)调用跳转至该函数上次执行位置继续往下执行,而上次调用的所有局部变量都保持不变. 生成器的特点:1.生成器是一个函数,而且函数的参数都会保留.2.迭代到下一次的调用时,所使用的参数都是第一次所保留下的,即是说,在整个所有函数调用的参数都是第一次所调用时保留的,而不是新创建的.3.函数中yield就是个生成器,多次调用时,根据调用位置依此往下执行,而无法返回 1 #__next__方法会将生成器依此调

编程题:用递归调用实现,求N!(!阶乘)。

#include<stdio.h> long fac(int n) { if(n==1) return 1L;             /*"1L"为长整型常量*/ else return n*fac(n-1); } void main() {int m; scanf("%d",&m); printf("%2d!=%d\n",m,fac(m)); } 算法解析: 运行结果: 编程题:用递归调用实现,求N!(!阶乘).,布布扣,

函数的递归调用

递归调用即在定义函数的时候,在函数内部再调用自己,也就是函数自己调用自己,通常用于计算阶乘 注意一点的是,如果函数一直调用自己,那就成了死循环了,因此我们通常会设一个条件,当条件为假时函数就终止了 In [15]: def factorial(n): ....: if n == 0: ....: return 1 ....: else: ....: return n + factorial(n-1) ....: In [16]: factorial(5) # 计算 5+4+3+2+1 Out[1

Python-函数的递归调用

递归调用顾名思义即在函数内部调用函数(自己调用自己),通常用它来计算阶乘,累加等 注意: - 必须有最后的默认结果 if n ==0,(不能一直调用自己,如果没有可能会造成死循环) - 递归参数必须向默认的结果收敛 func(n-1) 例子1:计算5的阶乘 #!/usr/bin/env python def func(n): if n == 0: return 1 else: return n * func(n-1) print func(5) 例子2:计算1到100的和 #!/usr/bin/