浅议递归
0.写在最前面
递归(recursion)这个词在计算机的世界经常被使用,但是很少有人能真正的理解。早就想写点东西,第一个必然拿递归开刀。写,是希望更多的人去理解她(不是它)。
欢迎转载,转载需表明出处。遵从知识共享协议。
1. 递归的定义
1.1 定义
也许很多人理解的递归,就是自己调用自己。百度百科
[1]
中写到 程序调用自身的编程技巧称为递归。但是大家总是忽略后面的话:
一个过程或函数在其定义或说明中有直接或间接调用自身的一种方法,它通常把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解,递归策略只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量。递归的能力在于用有限的语句来定义对象的无限集合。一般来说,递归需要有边界条件、递归前进段和递归返回段。当边界条件不满足时,递归前进;当边界条件满足时,递归返回。
也就正如知乎
[2]
上一个问题中写的那样,递归需要满足:
- 基准情形。你必须有某种基准情形,不用递归就能求解。如x=0时,y=1,这是固定的,不需要求f(x-1)。
- 不断推进。对于那些递归求解的情形,递归调用必须总能够朝着产生基准情形的方向推进。如,接上条,你的x的变化必须是向x=0推进,而不是向x=正无穷推进,后者不叫递归。
1.2 举例
“你猜?”“你猜我猜不猜?”“你猜我猜你猜不猜?”…这种是否算递归?
答:这种不算递归。因为回答和提问是问题变得更为复杂,所以不满足推进需朝向基准情形方向进行的条件,所以不算递归。从前有座山,山里有座庙,庙里有个老和尚和一个小和尚,老和尚在给小和尚讲故事,讲的是:”从前有座山,山里有座庙,庙里有个老和尚和一个小和尚,老和尚在给小和尚讲故事,讲的是:”从前有座山,山里有座庙… 算不算递归?
答: 这种不算递归,原因同上。有如下代码
// C++ code 字符串反转
#include<string.h>
void strrev(char str[])
{
strrev(str, (int)strlen(str));
}
void strrev(char str[], int len)
{
int i;
for(i = 0; i < len/2 ; ++i)
{
// 交换
str[i] ^= str[len-1-i];
str[len-1-i] ^= str[i];
str[i] ^= str[len-1-i];
}
}
...
是否算递归?
答:这种不算递归。C++支持函数重载,这里strrev是两个同名函数,并非自己调用自己,不算递归
- 有如下代码
// C++ code 角谷猜想
bool fun(int n)
{
if (n == 1)
return true;
else
{
if (n % 2)
{
// 奇数
return fun(3 * n + 1);
}
else
{
// 偶数
return fun(n / 2);
}
}
}
...
是否算递归?
答:这种不算递归。为什么不是,我给出以下理由:这个函数fun确实有调用自身,但是,递归出口只有一个,也就是当n==1的情况。且不论这个函数输入n<=0导致栈溢出的问题,也不考虑n*3+1过大,超出int的表示的问题(这个是函数健壮性的问题),角谷猜想毕竟只是猜想,没有证明是否能收敛到1;如果你认定角谷猜想一定能收敛,那么函数必然返回true,这个函数的意义也就不存在了。也就是说,递归函数的两个条件不能完全满足。所以这个函数不能叫递归。
2. 递归的应用
刚才在定义上强调那么多,想必读者对递归也有了一定的了解。那么递归有什么应用呢?
2.1 应用1: 10以内的阶乘
阶乘怎么求?
按照递归的思路来。
首先,要有一个基准情形,也就是不用递归调用就可以得到的计算结果,我们可以认为0!=1是基准情形。(当然,你认为0!,1!都算基准情形也是无所谓的)。
然后,我们要找推进关系。如果我们想要求n!,是不是需要知道(n-1)!再乘n,就是n的阶乘了呢?显然,n-1的阶乘问题比n的阶乘问题更简单,满足向基准情形推进的条件,而且自己调用了自己。
把上面的思路整理成代码:
// C code 递归写法 10 以内阶乘
int factorial(int n)
{
if(n == 0)
return 1;
else if(n <= 10)
return n * factorial(n-1);
else
return -1;// ERROR CODE
}
对比一下我们用普通循环写的代码:
// C code 递推写法 10 以内阶乘
int factorial(int n)
{
int i, res = 1;
if(n <= 10 && n >= 0)
{
for(i = 1; i <= n; ++i)
{
res *= i;
}
return res;
}
else
return -1;// ERROR CODE
}
比较一下两种写法,是不是第一种写法更炫呢?
2.2 应用2: 字符串反转
咦?这个是不是写过?刚才不是说那个不是递归吗?
那么你看下下面的代码,相信你会有感觉:
// C code 字符串反转
void strrev(char* s, int len)
{
if(len > 1)
{
strrev(s+1, len-2);
s[0]^=s[len-1];
s[len-1]^=s[0];
s[0]^=s[len-1];
}
}
首先,这个函数没有显式说明递归的边界条件(基本情形),但是,一条if语句已经“出卖”了自己。当len<=1的时候,显然是基本情形了。顾名思义,s是传入的字符数组的首地址,len显然是要传一个长度。len==0的时候,字符数组中没有字符需要反转;len==1的时候,字符数组中只有一个字符,反转是其本身。这显然是属于不需要处理的情况,直接返回。
了解了基本情形,我们来说程序如何不断推进的。在一串字符中,去掉第一个字符,去掉最后一个字符,你会发现,字符的长度变小了,然而问题依然是与刚才类似的问题(字符串反转问题),如此,我们先处理去掉首尾字符的新的字符数组,再把首尾字符交换,就是我们的字符串反转了。
抓住一上两点,字符串反转理解起来就不那么难了。
2.3 其他应用
递归的应用真的很多
比如:汉诺塔问题(递归求法
[3]
, 非递归求法
[4]
),8皇后问题(递归求法
[5]
, 非递归求法
[6]
),等等。在工程上,文件遍历一般绕不开递归。有兴趣的可以了解一下。
3. 我对递归的认识
3.1 快速上手
想要快速上手,只需要抓住两点,我在前面也多次强调了,一个是递归边界(基准情形),一个是状态转移(不断推进),抓住这两点,再去理解递归,很多想不通的就迎刃而解。
3.2 递归不是算法,而是写法
枚举、搜索这些,可以叫算法,但是递归,只是一种实现的方式。我可以用递归的方式实现搜索,同样可以由递推实现。递归在调试上真的会把人闹晕,所以大量使用递归的程序并不是我所推荐的。而且,毕竟有些程序员对递归并不熟悉,虽然写起来简单,读起来也还好,真的要出了bug,是件很头痛的问题。我很欣赏递归这种简单粗暴的逻辑美,但是在使用中要小心使用才好。
3.3 递归的效率问题
相比较于递推,实现同样的功能,递归耗时要多的多。我不想从语言执行的角度谈这个问题,我想谈的是优化。一是,你可以实现栈,自己来把程序改写成非递归方式–这种方式有些繁琐,对能力也是很大的考验;二是,实现备忘录。在递归执行过程中可能存在重复的运算,如果我们把他存起来,会节省一笔不小的时间开支。
参考文献
[1] 百度百科 https://baike.baidu.com/item/%E9%80%92%E5%BD%92/1740695?fr=aladdin 2017-11-25
[2] 知乎 https://www.zhihu.com/question/51903950 2017-11-25
[3] csdn http://blog.csdn.net/liujian20150808/article/details/50793101 2017-11-25
[4] csdn http://blog.csdn.net/hdy007/article/details/1522142 2017-11-25
[5] csdn http://blog.csdn.net/livelylittlefish/article/details/2141142 2017-11-25
[6] csdn http://blog.csdn.net/ye_xiao_yu/article/details/53471851 2017-11-25