文章目录
摘要
对新手而言,递归及其衍生的动态规划可以说是最难理解的几个算法。在看别人的代码的时候又会发现别人几行代码就用递归解决了一个难题,但让自己写却死活写不出来。
为什么会有这种反差呢?经过长时间的递归学习和代码练习后,我得出的结论是:因为递归算法的实现思想与我们正常思维观念不太一致。
接下来就来详细说明:
一、递归算法原理
1、先举一个例子说明一下递归的作用
有一天你找到一个藏宝箱,你用娴熟的开锁技巧打开了它(请不要在意为什么你会有娴熟的开锁技巧),发现里面是一个小一点藏宝箱,于是你又用娴熟的开锁技巧打开,发现里面又是更小一点的藏宝箱…于是你一层层打开,最后发现最里面是一张纸条,写着“哈哈”二字。这个时候你数了数地上的箱子,发现一共开了233个箱子,从此悟出了233的真谛,过上了乐观的生活。
所谓递归,就是有递出有归还,即有去有回。
如果你开完箱子就走了,你就不知道一共开了多少个箱子,你就会活在无限循环的烦恼之中。所以这种开完箱子不数的行为,我们就称作循环,即有去无回。
2、递归算法的思想
正如上面所描述的场景,递归就是有去有回。
如下图所示,
“有去”是指:递归问题必须可以分解为若干个规模较小,与原问题形式相同的子问题,这些子问题可以用相同的解题思路来解决,这点在上面例子的体现就是:所有的藏宝箱都可以用同样的开锁技巧打开;
“有回”是指 : 这些问题的演化过程是一个从大到小,由近及远的过程,并且会有一个明确的终点(临界点),一旦到达了这个临界点,就不用再往更小、更远的地方走下去。最后,从这个临界点开始,原路返回到原点,原问题解决。上文例子的临界点就是“哈哈”纸条。最后的箱子数目就是返回到原点的答案。
(图片来源于https://blog.csdn.net/weixin_43025071/article/details/89149695)
3、为什么递归难理解
上面的例子不是很好理解吗,为什么做题的时候辣么难呢?
因为递归的思维方式与正常的思维方式是反着的。
在判断递归计算是否正确的时候,Paul Graham提到一种方法,即,
如果下面这两点是成立的,我们就知道这个递归对于所有的 n 都是正确的。
a、当 n=0,1 时,结果正确;
b、假设递归对于 n 是正确的,同时对于 n+1 也正确。
咦,这不就是数学归纳法吗,这有啥难的?
在数学归纳法中,我们往往先知道a,再去验证b,是从数目小的问题开始,从下往上验证。
但在递归算法中,是先从最大的问题开始逐渐分解为小问题处理,是从上往下进行验证。
我们用一个计算阶乘n!的代码示例看一下
def factorial(n) :
if n == 1 :
return 1 #递归结束
return n * factorial(n - 1) #问题规模减1,递归调用
正常思维习惯是1x2x3…这样从小往大乘起来,每一步就算都能得到一个实打实的数字。
但在用递归的时候,我们是直接求factorial(n),在求解factorial(n)时,我们又要用factorial(n-1),接着我们就会再用factorial(n-2)去验证…我们始终带着一种巨大的未知去往下验证,直到factorial(0)。
那么这该怎么处理呢?
这是一种思维习惯的不适应,只要适应了就没事了,说白了就是多练习。
但是一开始练习的时候还是不适应咋办呢?
好办!直接当成一个函数去调用,调用的多了,你也就会了。
4、递归的应用场景
符合以下两个条件即可应用:
1、大问题可以分解为小问题,并有相同的解法;
2、有终止条件,不会无限循环下去。
二、几个典型问题的python实现
1、计算阶乘
#coding=utf-8
def factorial(n) :
if n == 1 :
return 1 #递归结束
return n * factorial(n - 1) #问题规模减1,递归调用
print(factorial(3))
得到输出结果为:
2、汉诺塔问题
有三座塔 A,B,C。A 塔上有 N 个穿孔圆盘,盘的尺寸由上到下依次变大,B,C 塔为空。要求按下列规则将所有圆盘移至 C 塔,且要求每次只能移动一个圆盘;大盘不能叠在小盘上面。
问:如何移?最少要移动多少次?
i = 1
def move(n, mfrom, mto) :
global i
print("第%d步:将%d号盘子从%s -> %s" %(i, n, mfrom, mto))
i += 1
def hanoi(n, A, B, C) :
if n == 1 :
move(1, A, C)
else :
hanoi(n - 1, A, C, B)
move(n, A, C)
hanoi(n - 1, B, A, C)
print( "移动步骤如下:")
hanoi(3, 'A', 'B', 'C')
输出结果为
3、斐波拉切数列问题
(看完问题先别急着看代码,先自己写写试试,看到这里其实你也已经能写出大概的代码了)
斐波拉契数列,是这样的一个数列:0、1、1、2、3、5、8、13、21、……。
斐波拉契数列的核心思想是:
从第三项起,每一项都等于前两项的和,即F(N) = F(N - 1) + F(N - 2) (N >= 2)
并且规定F(0) = 0,F(1) = 1
要求:利用递归算法获得指定项的斐波拉契数列。
代码为:
def function(n):
if (n == 1 or n == 2):
return 1
else:
m = function(n-1) + function(n-2)
return m
a = int(input("请输入一个整数:"))
print("数列第%s项为"%a)
print(function(10))
lista = [0]
temp = 1
while (temp <= a):
lista.append(function(temp))
temp += 1
print("数列前%s项为"%a)
print(lista)
输出结果为:
4、约瑟夫环类似问题
约瑟夫问题是个著名的问题:N个人围成一圈,第一个人从1开始报数,报M的将被杀掉,下一个人接着从1开始报。如此反复,最后剩下一个,求最后的胜利者。
现在就来处理一道约瑟夫环的类似问题(题目来自leecode),题目描述如下:
0,1,n-1这n个数字排成一个圆圈,从数字0开始,每次从这个圆圈里删除第m个数字。求出这个圆圈里剩下的最后一个数字。
例如,0、1、2、3、4这5个数字组成一个圆圈,从数字0开始每次删除第3个数字,则删除的前4个数字依次是2、0、4、1,因此最后剩下的数字是3。
这道题可以很好的检验是否理解了递归,因为其代码十分简洁,但思路却很考验对递归的理解。
先来看一下官方思路:
先思考,思考之后如果还是不明白,就继续看下面的详细思路过程:
我们有n个数,下标从0到n-1,然后从0开始数,每次数m个数,最后看能剩下谁。我们假设能剩下的数的下标为y,则我们把这件事表示为f(n,m) = y
这个y到底表示了啥呢?注意,y是下标,所以就意味着你从0开始数,数y+1个数,然后就停,停谁身上谁就是结果。
所以我们假设f(n-1,m)=x,然后来找一找f(n,m)和f(n-1,m)到底啥关系(这就是写出递归关系式,也是解题的关键)。
于是我们来思考f(n,m)时考虑以下两个问题:
问题一:有n个数的时候,要划掉一个数,然后就剩n-1个数了呗,那划掉的这个数,下标是多少?
因为要从0数m个数,那最后肯定落到了下标为m-1的数身上了,但这个下标可能超过我们有的最大下标(n-1)了。所以攒满n个就归零接着数,逢n归零,所以要模n。
所以有n个数的时候,我们划掉了下标为(m-1)%n的数字,也就是第m%n个数。
问题二:我们划完了这个数,往后数x+1下,能落到谁身上呢,它的下标是几?
你往后数x+1,它下标肯定变成了(m-1)%n +x+1,和第一步的想法一样,你肯定还是得取模(为了避免超出长度),所以答案为[(m-1)%n+x+1]%n,则
f(n,m)=[(m-1)%n+x+1]%n ,其中x=f(n-1,m)
我们利用两个定理化简它!
定理一:两个正整数a,b的和,模另外一个数c,就等于它俩分别模c,模完之后加起来再模。
(a+b)%c=((a%c)+(b%c))%c
定理二:一个正整数a,模c,模一遍和模两遍是一样的。
a%c=(a%c)%c
所以
f(n,m)=[(m-1)%n+x+1]%n
=[(m-1)%n%n+(x+1)%n]%n
=[(m-1)%n+(x+1)%n]%n
=(m-1+x+1)%n
=(m+x)%n
于是我们就就得到了关键点递归公式,进而可以得到如下代码:
def f(n, m):
if n == 0:
return 0
x = f(n - 1, m)
return (m + x) % n #这就是我们分析那一大堆想得到的式子
怎么样,现在再提起递归算法,是不是没以前那么懵了?
不要松懈,继续去练习更多习题吧。
参考:
https://blog.csdn.net/SeeTheWorld518/article/details/47957183
https://blog.csdn.net/qmdweb/article/details/80537602
https://leetcode-cn.com/problems/yuan-quan-zhong-zui-hou-sheng-xia-de-shu-zi-lcof