可能存在无限递归_算法与数据结构(六) 递归

? 工欲善其事,必先利其器。

递归 - Recursion

递归是一种应用非常广泛的算法(或者编程技巧)。

后续一些复杂的算法和数据结构都用到了递归,所以,搞懂递归非常重要。

递归例子

周末你在排队买东西,队伍太长了,于是你想知道自己排在第几位,但是你又不想自己数,于是你问前面的人是第几位,但是他也不知道,于是一个一个往前询问,到了第一个人时,他知道自己是第一个,于是告诉后面的人,后面的人加一就是他的位数,一层层传递到你这边来,你也就知道自己是第几位了(现实中不太可能,这里举个例子)

这就是一个非常标准的递归求解问题的分解过程,去的过程叫“递”,回来的过程叫“归”。基本上,所有的递归问题都可以用递推公式来表示。刚刚这个生活中的例子,我们用递推公式将它表示出来就是这样的:

f(n)=f(n-1)+1 其中,f(1)=1

f(n) 表示你想知道自己在哪一位,f(n-1) 表示前面一位所在的位数,f(1)=1 表示第一位的人知道自己在第一位。有了这个递推公式,我们就可以很轻松地将它改为递归代码,如下:

int f(int n) {
  if (n == 1) return 1;
  return f(n-1) + 1;
}

递归需要满足的三个条件

刚才的例子就是一个典型的递归,那么满足什么条件的问题可以用递归来解决呢?

一个问题的解可以分解为几个子问题的解

何为子问题?子问题就是数据规模更小的问题。比如,前面讲的排队的例子,你要知道,“自己在哪一位”的问题,可以分解为“前一位的人在哪一位”这样一个子问题。

这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样

比如排队那个例子,你求解 “自己在哪一位” 的思路,和前面一位人求解“自己在哪一位”的思路,是一模一样的。

存在递归终止条件

把问题分解为子问题,把子问题再分解为子子问题,一层一层分解下去,不能存在无限循环,这就需要有终止条件。还是排队的例子,第一位的人不需要再继续询问任何人,就知道自己在哪一位,也就是 f(1)=1,这就是递归的终止条件。

如何编写递归代码呢?

刚刚说了很多递归有关的内容,那么在实际运用中如何去写一个递归呢?

写递归代码最关键就是:

  • 找到规律
  • 写出通用公式
  • 找到截止条件

学习算法和数据结构,需要多练多写,通过下面几个例子说明下递归。

1. Fibonacci 数列

关于递归,有些人可以想到的是斐波那契数列。

什么是斐波那契数列呢?

0, 1, 1, 2, 3, 5, 8, 13... 总之,就是第N(N>2)个数是等于第(N-1)数和第(N-2)数的和。

9363b00650fa026c442bf6a9a4c7523a.png

05_递归-斐波那契数列

那么通过上面关键分析:

首先找到规律

从第三个数开始,每个数的值都是它前两项的和。

通用公式

f(n) = f(n-1) + f(n-2)

截止条件

当n小于等于2时,就结束递归。

通过上面的综合来看,写出的代码如下:

public static int Fibonacci(int n)
{
    if (n == 1) return 0;
    if (n == 2) return 1;
    return Fibonacci(n - 1) + Fibonacci(n - 2);
}

2. Factorial 阶乘

数学中常见的阶乘,也是可以使用递归进行计算。

阶乘:n! = 1 * 2 * ··· * (n-1) * n

首先找到规律

求解阶乘,将从1到n的数字相互进行乘法运算

通用公式

f(n) = n * f(n-1)

截止条件

当n等于1时,就结束递归。

代码如下:

public static int Factorial(int n)
{
    if (n == 1) return 1;
    return n * Factorial(n - 1);
}

总结

写递归代码的关键就是找到如何将大问题分解为小问题的规律,并且基于此写出递推公式,然后再推敲截止条件,最后将递推公式和终止条件翻译成代码。

学习递归过程中,如果面对的是一个问题要分解成多个子问题的情况下,递归代码就没有那么好理解了。

人脑几乎没办法把整个“递”和“归”的过程一步一步都想清楚,而计算机擅长做重复的事情,所以递归正合它的胃口。而我们人脑更喜欢平铺直叙的思维方式。当我们看到递归时,我们总想把递归平铺展开,脑子里就会循环,一层一层往下调,然后再一层一层返回,试图想搞清楚计算机每一步都是怎么执行的,这样就很容易被绕进去。

对于递归代码,这种试图想清楚整个递归过程的做法,实际上是进入了一个思维误区。很多时候,我们理解起来比较吃力,主要原因就是自己给自己制造了这种理解障碍。那正确的思维方式应该是怎样的呢?

如果一个问题 A 可以分解为若干子问题 B、C、D,你可以假设子问题 B、C、D 已经解决,在此基础上思考如何解决问题 A。而且,你只需要思考问题 A 与子问题 B、C、D 两层之间的关系即可,不需要一层一层往下思考子问题与子子问题,子子问题与子子子问题之间的关系。屏蔽掉递归细节,这样子理解起来就简单多了。

因此,编写递归代码的关键是,只要遇到递归,我们就把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤

而且在进行递归计算时,需要注意下面几点:

  • 递归代码要警惕堆栈溢出
  • 递归代码要警惕重复计算

对于递归中的堆栈溢出,则可以通过判断进行最大数据处理拦截,比如设置数据规模大于 100000 时,则抛出错误即可;但是这种方式并不能完全解决问题,因为最大允许的递归深度跟当前线程剩余的栈空间大小有关,事先无法计算。如果实时计算,代码过于复杂,就会影响代码的可读性。所以,如果最大深度比较小,比如 10、50,就可以用这种方法,否则这种方法并不是很实用。

而针对于重复计算问题,其实可以在后续算法与数据结构中的散列表来进行操作和处理,使用散列表保存已经计算过的数据,当递归调用到 f(k) 时,则先看下是否已经求解过了。如果是,则直接从散列表中取值返回,不需要重复计算,就可以避免重复计算问题。

递归有利有弊,利是递归代码的表达力很强,写起来非常简洁;而弊就是空间复杂度高、有堆栈溢出的风险、存在重复计算、过多的函数调用会耗时较多等问题。所以在实际使用中,需要根据需求来选定方式,可以将递归改为循环来处理。

本文作者: SpiritLing (公众号同名)

版权声明: 本公众号所有文章除特别声明外,均采用 BY-NC-SA 许可协议。

授权转载: 转载请获取授权并在醒目处注明出处!

邮箱联系: spirit_ling@yeah.net

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值