文章目录
引子:
很多APP,公众号的商品服务有注册返佣金功能。例如 A推荐B,B推荐C…。这里A是“最终推荐人”,怎么根据这条链中的任意一个人来找到A这个“最终推荐人”呢?
递归基础
1. 什么叫递归?怎么理解它?
举个假想的例子:咱来到CBA上海队球馆看一场激烈的常规赛,赛场座位都坐满了,这时候女朋友/老婆大人问你我们现在坐的是第几排?人这么多,个子矮,看不清咋办?递归派上用场了:问前面的朋友他是第几排?在他的排数上 +1 就知道自己的排数了。那前面的朋友一脸萌比也不知道怎么办呢?没事,告诉他让他也按照你这个办法问下去。这样一直下去,到了第一排的朋友他们肯定知道自己是第一排,然后依次返回,最后我们就知道我们是第几排了。哈哈,是不是很有趣?(严重声明:小心被人当作傻子~哈哈)。
在这个场景中,依次问过去的这个展开过程叫“递”;后面依次返回答案的这个过程叫“归”。
递推公式表示:
f(n) = f(n-1) +1
f(1) = 1
其中,f(n) 表示我的排数,f(n-1) 表示前面一位的排数,f(1)表示第一排的排数。
递推公式转换为递归代码:
def f(n):
if n == 1:
return 1
return f(n-1) + 1
最后,贴一个维基百科对递归的解释:递归(英语:Recursion),又译为递回,在数学与计算机科学中,是指在函数的定义中使用函数自身的方法。可以理解为自我复制的过程。
递归应用非常广泛,是后续很多复杂数据结构和算法的基础,而且平时编程设计中也常常会运用到递归的思想,所以一定要拿下他!
2. 什么情况下可以用递归?–递归的三个条件
- 原问题的解可以分解为几个数据规模更小的子问题的解
- 原问题与分解后的子问题除了数据规模不同外,求解思路完全一致
- 存在递归终止条件
递归不可能无限深入,总归是存在终止条件的。上面的例子中 f(1) = 1 就是递归的终止条件。
3. 如何编写递归代码?
- 分析找出将原始问题分解为小问题的规律,并找出终止条件(
关键
) - 依据规律写出递推公式,仔细考虑终止条件(
关键
)
注意,有时候终止条件可能比较复杂,需要仔细思考多推敲并验证,比如f(1), f(2) 两个终止条件等等。 - 转换成代码实现
例子:爬楼梯问题。一次可以跨 1 阶或 2 阶楼梯,总共有 10 阶楼梯,请问走完这 10 阶楼梯总共有多少种可能的走法?
思考,
- 首先(除最后1阶外)每一阶台阶都有两种走法–跨 1 阶或者 2 阶;
- 其次第 n 阶的走法相当于跨1阶后剩下n-1阶的走法f(n-1),再加上跨2阶后剩下 n-2 阶的走法f(n-2),即 f(n) = f(n-1) + f(n-2);
- 最后一阶情况有点特殊,这里把最后2阶拿出来单独看。最后一阶只有跨1阶一个爬法,即f(1) = 1;倒数第二阶有跨 1 阶或 2 阶楼梯共2种爬法,即 f(2)=2。
递推公式为:
f(n) = f(n-1) + f(n-2)
f(2) = 2
f(1) = 1
代码:
def f(n):
if n == 1:
return 1
if n == 2:
return 2
return f(n-1) + f(n-2)
深入理解递归
1. 分解为单个子问题与多个子问题
人脑属于线性思维,分析线性的问题还比较擅长,人脑不太适合同一时刻分析多条线,在递归中也是如此。
上面球馆作为的问题属于“原始问题分解为一个子问题”,这是比较容易理解的;有时还会遇到“原始问题分解为多个子问题”的情况,比如前面的爬楼梯问题。这种情况就不好像分解为一个子问题那样在脑海中一步一步一层一层将每个字问题都捋清楚了,如果想这样把递归的每一步都搞清楚就陷入了思维误区。
分析递归的正确思路:只需要将 A 问题分解为 B,C 问题,并假设B和C问题已经解决,在此基础上分析 A 和 B, C的关系即可,不要试图在脑海中去分解递归的每个步骤。
2. 警惕堆栈溢出
由前面栈的知识,我们都知道,函数调用的时候会将上一个函数作为栈帧入栈。递归本质上就是深层次的函数调用,那么会存在一个问题,就是当递归的深度很深时,入栈的函数栈帧就会增多,而每个线程的栈内存空间是有限的,极端情况下如果递归深度无限深,那么肯定会出现栈溢出,这可是会引起系统死机等严重问题的。
递归栈溢出的解决办法:
- 限制递归的深度
对于不深的递归我们没有必要做这个限制;对于那种递归深度很深的情况下,而且线程栈资源又明确有限的情况下,就有必要在递归到一定深度时直接返回,结束递归,从而避免堆栈溢出。 - 将递归代码改成非递归代码实现
上面提到的限制递归深度从而避免递归堆栈溢出的做法适合数据规模比较小的情况,数据规模大了并不合适。递归的本质是函数栈,那么为了避免入栈的函数栈帧增多或者不可控,我们可以干脆将递归代码改写成非递归实现(往往是迭代循环),这样就不会涉及到大量的函数调用,也就规避掉了递归导致的堆栈溢出问题。
例如上面的爬楼梯代码可以改写成如下非递归形式:
def f(n):
if n == 1:
return 1
if n == 2:
return 2
pre = 2
ppre = 1
for i in range(3, n+1):
cur = pre + ppre
ppre = pre
pre = cur
return cur
if __name__ == "__main__":
print(f(5))
3. 警惕重复计算
还是拿前面的爬楼梯问题来看。
f(5) = f(4) + f(3)
f(4) = f(3) + f(2)
==> f(5) = (f(3) + f(2)) + f(3)
这里发现会出现重复计算 f(3)的情况,数据量增大时重复计算的情况也会大大增多,这就造成了资源的浪费和效率的降低。为了解决这个问题,可以将已经计算过的情况先保存起来(比如 hashtable),后面判断遇到计算过的值时直接拿出来用即可。
4. 递归的其他问题
从时间复杂度来看,递归调用越深,耗费的时间会越多;
从空间复杂度来看,递归每次调用函数都会栈帧入栈,意味着运行内存的增加,空间复杂度达到O(n)(注意不是O(1))。
本质上来说,解决办法都是合理设置递归深度或者改为非递归实现。