学习编程的时候老师总是不建议使用递归,一些书上至今也是这么建议的。但是递归在二叉树上的应用即优美又稍显复杂。所以弄清楚递归的本质是分治是很有必要的。将大规模问题缩小。
为什么要用递归
编程里面估计最让人摸不着头脑的基本算法就是递归了。很多时候我们看明白一个复杂的递归都有点费时间,尤其对模型所描述的问题概念不清的时候,想要自己设计一个递归那么就更是有难度了。
很多不理解递归的人(今天在csdn里面看到一个初学者的留言),总认为递归完全没必要,用循环就可以实现,其实这是一种很肤浅的理解。因为递归之所以在程序中能风靡并不是因为他的循环,大家都知道递归分两步,递和归,那么可以知道递归对于空间性能来说,简直就是造孽,这对于追求时空完美的人来说,简直无法接接受,如果递归仅仅是循环,估计现在我们就看不到递归了。递归之所以现在还存在是因为递归可以产生无限循环体,也就是说有可能产生100层也可能10000层for循环。例如对于一个字符串进行全排列,字符串长度不定,那么如果你用循环来实现,你会发现你根本写不出来,这个时候就要调用递归,而且在递归模型里面还可以使用分支递归,例如for循环与递归嵌套,或者这节枚举几个递归步进表达式,每一个形成一个递归。
用归纳法来理解递归
数学都不差的我们,第一反应就是递归在数学上的模型是什么。毕竟我们对于问题进行数学建模比起代码建模拿手多了。 (当然如果对于问题很清楚的人也可以直接简历递归模型了,运用数模做中介的是针对对于那些问题还不是很清楚的人)
自己观察递归,我们会发现,递归的数学模型其实就是归纳法,这个在高中的数列里面是最常用的了。回忆一下归纳法。
归纳法适用于想解决一个问题转化为解决他的子问题,而他的子问题又变成子问题的子问题,而且我们发现这些问题其实都是一个模型,也就是说存在相同的逻辑归纳处理项。当然有一个是例外的,也就是递归结束的哪一个处理方法不适用于我们的归纳处理项,当然也不能适用,否则我们就无穷递归了。这里又引出了一个归纳终结点以及直接求解的表达式。如果运用列表来形容归纳法就是:
- 步进表达式:问题蜕变成子问题的表达式
- 结束条件:什么时候可以不再是用步进表达式
- 直接求解表达式:在结束条件下能够直接计算返回值的表达式
- 逻辑归纳项:适用于一切非适用于结束条件的子问题的处理,当然上面的步进表达式其实就是包含在这里面了。
这样其实就结束了,递归也就出来了。递归算法的一般形式:
09 |
accumrateExpreesion
//归纳项 |
10 |
mode=expression
//步进表达式 |
最典型的就是N!算法,这个最具有说服力。理解了递归的思想以及使用场景,基本就能自己设计了,当然要想和其他算法结合起来使用,还需要不断实践与总结了。
08 |
printf ( "请输入需要计算阶乘的数n:" ); |
20 |
return
n * factorial(n-1); |
求阶乘的递归比较简单,这里就不展开了。
再来两个递归的例子
返回一个二叉树的深度:
6 |
return
(a>b)?(a+1):(b+1); |
判断一个二叉树是否平衡:
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); |
第一个算法还是比较好理解的,但第二个就不那么好理解了。第一个算法的思想是:如果这个树是空,则返回0;否则先求左边树的深度,再求右边数的深度,然后对这两个值进行比较哪个大就取哪个值+1。而第二个算法,首先应该明白isB函数的功能,它对于空树返回0,对于平衡树返回树的深度,对于不平衡树返回-1。明白了函数的功能再看代码就明白多了,只要有一个函数返回了-1,则整个函数就会返回-1。(具体过程只要认真看下就明白了)
对于递归,最好的理解方式便是从函数的功能意义的层面来理解。了解一个问题如何被分解为它的子问题,这样对于递归函数代码也就理解了。这里有一个误区(我也曾深陷其中),就是通过分析堆栈,分析一个一个函数的调用过程、输出结果来分析递归的算法。这是十分要不得的,这样只会把自己弄晕,其实递归本质上也是函数的调用,调用的函数是自己或者不是自己其实没什么区别。在函数调用时总会把一些临时信息保存到堆栈,堆栈只是为了函数能正确的返回,仅此而已。我们只要知道递归会导致大量的函数调用,大量的堆栈操作就可以了。
小结
递归的基本思想是把规模大的问题转化为规模小的相似的子问题来解决。在函数实现时,因为解决大问题的方法和解决小问题的方法往往是同一个方法,所以就产生了函数调用它自身的情况。另外这个解决问题的函数必须有明显的结束条件,这样就不会产生无限递归的情况了。
递归需要的两个条件
很多人对递归的理解不太深刻。一直就停留在“自己调用自己”的程度上。这其实这只是递归的表象(严格来说连表象都概括得不全面,因为除了“自己调用自己”的递归外,还有交互调用的递归)。而递归的思想远不止这么简单。
递归,并不是简单的“自己调用自己”,也不是简单的“交互调用”。它是一种分析和解决问题的方法和思想。简单来说,递归的思想就是:把问题分解成为规模更小的、具有与原问题有着相同解法的问题。比如二分查找算法,就是不断地把问题的规模变小(变成原问题的一半),而新问题与原问题有着相同的解法。
有些问题使用传统的迭代算法是很难求解甚至无解的,而使用递归却可以很容易的解决。比如汉诺塔问题。但递归的使用也是有它的劣势的,因为它要进行多层函数调用,所以会消耗很多堆栈空间和函数调用时间。
既然递归的思想是把问题分解成为规模更小且与原问题有着相同解法的问题,那么是不是这样的问题都能用递归来解决呢?答案是否定的。并不是所有问题都能用递归来解决。那么什么样的问题可以用递归来解决呢?一般来讲,能用递归来解决的问题必须满足两个条件:
- 可以通过递归调用来缩小问题规模,且新问题与原问题有着相同的形式。
- 存在一种简单情境,可以使递归在简单情境下退出。
如果一个问题不满足以上两个条件,那么它就不能用递归来解决。
为了方便理解,还是拿斐波那契数列来说下:求斐波那契数列的第N项的值。
这是一个经典的问题,说到递归一定要提到这个问题。斐波那契数列这样定义:f(0) = 0, f(1) = 1, 对n > 1, f(n) = f(n-1) + f(n-2)
这是一个明显的可以用递归解决的问题。让我们来看看它是如何满足递归的两个条件的:
- 对于一个n>2, 求f(n)只需求出f(n-1)和f(n-2),也就是说规模为n的问题,转化成了规模更小的问题;
- 对于n=0和n=1,存在着简单情境:f(0) = 0, f(1) = 1。
因此,我们可以很容易的写出计算费波纳契数列的第n项的递归程序:
7 |
return
f(n-1) + f(n-2); |
在编写递归调用的函数的时候,一定要把对简单情境的判断写在最前面,以保证函数调用在检查到简单情境的时候能够及时地中止递归,否则,你的函数可能会永不停息的在那里递归调用了。
字符串回文的现象
前面谈到了递归的一些思想,还有概念上的一些理解,这里试着用递归解决一些问题。比如回文。
回文是一种字符串,它正着读和反着读都是一样的。比如level,eye都是回文。用迭代的方法可以很快地判断一个字符串是否为回文。用递归的方法如何来实现呢?
首先我们要考虑使用递归的两个条件:
- 第一:这个问题是否可以分解为形式相同但规模更小的问题?
- 第二:如果存在这样一种分解,那么这种分解是否存在一种简单情境?
先来看第一点,是否存在一种符合条件的分解。容易发现,如果一个字符串是回文,那么在它的内部一定存在着更小的回文。 比如level里面的eve也是回文。 而且,我们注意到,一个回文的第一个字符和最后一个字符一定是相同的。
所以我们很自然的有这样的方法:
先判断给定字符串的首尾字符是否相等,若相等,则判断去掉首尾字符后的字符串是否为回文,若不相等,则该字符串不是回文。
注意,我们已经成功地把问题的规模缩小了,去掉首尾字符的字符串当然比原字符串小。
接着再来看第二点, 这种分解是否存在一种简单情境呢?简单情境在使用递归的时候是必须的,否则你的递归程序可能会进入无止境的调用。
对于回文问题,我们容易发现,一个只有一个字符的字符串一定是回文,所以,只有一个字符是一个简单情境,但它不是唯一的简单情境,因为空字符串也是回文。这样,我们就得到了回文问题的两个简单情境:字符数为1和字符数为0。
好了,两个条件都满足了,基于以上分析,我们可以很容易的编写出解决回文问题的递归实现方式:
09 |
printf ( "请输入需要判断回文的字符串:" ); |
13 |
rs = is_palindereme(str, n); |
17 |
int is_palindereme( char
*str, int
n)
|
19 |
printf ( "Length: %d \n" ,n); |
20 |
printf ( "%c ----- %c\n" , str[0], str[n-1]); |
24 |
//printf("%d, %d\n", str[0], str[n-1]); |
25 |
return
((str[0] == str[n-1]) ? is_palindereme(str+1, n-2) : 0); |
程序运行结果为:
二分查找法递归实现
还有一个典型的递归例子是对已排序数组的二分查找算法。
现在有一个已经排序好的数组,要在这个数组中查找一个元素,以确定它是否在这个数组中,很一般的想法是顺序检查每个元素,看它是否与待查找元素相同。这个方法很容易想到,但它的效率不能让人满意,它的复杂度是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