本文是递归系列的第三篇, 第一篇介绍了递归的形式以及递归设计方法(迭代改递归),;第二篇以递归为引子, 详细介绍了快排和归排以及堆排的核心思想; 本篇主要通过几个题, 从递推, 归纳法的角度, 深入了介绍了递归的本质和具体应用.
往期回顾:
回顾我们在第一篇文章讨论的递归中, 下面是我们能够看到现象形式:
f(n) -> f(n - 1) -> f(n-2) -> ... -> f(1)
但实际本质是: 为了解决/完成 f(n), 必先完成f(n- 1); 为解决f(n-1),必先解决f(n-2) … 那么最先要解决f(1)
f(1) -> f(2) -> f(3) -> ... ->f(n)
回顾以前学过的数学归纳法:
1. 证明当k=1时,条件成立
2. 假设k=n(n>=1)时,条件成立
3. 证明k=n+1,条件成立
得到结论: k取任意正整数时,条件成立
如果没记错的话这叫第一数学归纳法, 往往我们用来证明构造的某些式子在给定自然数集合(全体或局部)的正确性. 而数学归纳法本质是什么呢? 通俗来看, 就是首先证明了k=1时的正确性, 然后证明k = n 成立可以推导出k=n+1成立. 根据上述两个条件可以得出k=2也就成立了… 然后k=3也就成立… 本质是递推.
recursion一词既可以翻译为递推,也可以翻译为递归, 这里的归应该是是规约的意思. 注意这里的递归和编程形式中的 递归调用 是有点区别的, 编程中谈到的形式化更多一些, 而数学本质还是和递归递推没有区别.
递归, 递推, 数学归纳法本质正是同一种东西.
好了,现在看来知道了这些似乎作用不大. 我们还是举个例子, 搞懂递归, 看这篇就够了 !! 递归设计思路 + 经典例题层层递进 中的青蛙上楼梯问题.
楼梯有n个台阶, 一个青蛙一次可以上1 , 2 或3 阶 , 实现一个方法, 计算该青蛙有多少种上完楼梯的方法
文中给出了递归的解法:
(回忆找重复,找变化,找出口)
假如青蛙上10 阶, 那么其实相当于要么 站在第9 阶向上走1步,要么 站在第8 阶向上走两步, 要么在第7阶向上走3步. 每个大于3阶楼梯的问题都可以看成几个子问题的堆叠
变化:令f(n) 为 青蛙上n阶的方法数. 则f(n) = f(n -1) +f(n - 2) + f(n -3) , 当n >= 3
出口: 当n = 0 时 ,青蛙不动 , f(0) = 0; n = 1时 ,有1种方法 , n = 2 时 有2 种方法
def f(n):
if n == 0 :
return 1 #站着不动也得返回1的, 因为实际上0种方法的是没意义
if n == 1:
return 1
if n == 2:
return 2
return f(n - 1) +f(n - 2) +f(n -3)
显然这样的递归方法不是很直观的, 其实一开始拿到这题 , 普通地想, 应该是拿出张白纸来, 左边起名一列: 阶数 , 右边起名一列: 走法
阶数 走法
1 1 0->1
2 2 0->1->2 0->2
3 4 0->1->2->3 0->1->3 0->2->3 0->3
4 7 ...
... ...
详细康康阶数为4时的走法:
注意我分成了三列写, 如果不看红色部分的话, 三列分别代表了上第1,2,3阶的方法. 现在带着红色的 ->4 一起看:
显然上到第四阶的方法刚好就是这三列的和了 …
到这里, 有兴趣的同学可以在写出阶数为5的走法. 但其实也会得到下面的结论:
显然上到第五阶的方法刚好就是这三列的和了 …
…想一想, 规律也就可以得出了
阶数为n的走法. 但其实也会得到下面的结论:
所以 f(n) = f(n -1) +f(n - 2) + f(n -3) 不是凭空产生, 而真是一步一步的像上面一样推出来 – 递归表达也是如此
下面就可以自然的得到递归法实现了
def go_stairs(n):
if n <= 1:
return 1
if n == 2 :
return 2
if n == 3 :
return 4
return go_stairs(n - 1) + go_stairs(n - 2) + go_stairs(n - 3)
写出了出口条件, 写出了递推式, 计算机不就帮我们像上面一样, 一步一步地推下去了么…
同样的, 我们也可以按照我们的推理演算的顺序, 用一个长度为3的数组, 保存每次得到的f(n -1) ,f(n - 2) ,f(n -3), 下一轮再更新…这就是我们递推的迭代法实现 :
def go_stairs_ite(n):
#声明一个长度为4的数组保存每次计算得到值, 用于存储每次计算所需的三个值和一个结果值
arr =[]
if n <= 1:
return 1
if n == 2 :
return 2
if n == 3 :
return 4
arr[0] = 1
arr[1] = 2
arr[2] = 4 #1 2 4 ()
for i in range(4, n+1):
arr[3] = arr[0] #1 2 4 1 不断地空出来一个固定位置,存结果
arr[0] = arr[1] #2 2 4 1
arr[1] = arr[2] #2 3 4 1
arr[2] = arr[3] + arr[0] + arr[1] #2 4 7 1
return arr[2]
有一个 X*Y 的方格, 一个机器人只能走格点且只能向右或者向右走, 要从左上角走到左下角
请设计一个算法, 计算机器人有多少种走法
给定两个个正整数X , Y, 返回机器人走法的数目.
分析如下:
得到递推公式和出口条件就可以写出递归形式代码:
'''
递归形式
'''
def robot_go_grim(x, y):
if (x == 1 or y == 1):
return 1
return robot_go_grim(x - 1 , y ) +robot_go_grim(x , y - 1 )
想清楚了, 代码看上去是不是异常简洁呢?
现在考虑迭代形式: 我们知道,
在对应格子中填上从此处到右下角的走法, 目前可得到:
然后就可以填格子, 根据就是f(n,m) = f(n - 1,m) +f(n, m -1)
.
这其实也就相当于:当前的方法数 = 自己下方格子处的方法数 + 右边格子处的方法数
6
的格子处, 也就得到了f(3,3)
的解5
的格子处, 也就得到了f(2,5)
的解'''
迭代形式
'''
def robot_go_grim_ite(x,y):
dp = [[0 for i in range(0, y)] for j in range(0, x)]
#出口条件(边界条件)
for j in range(0 , y):
dp[x - 1][j] = 1
for i in range(0, x):
dp[i][y - 1] = 1
# print_matrix(dp)
for i in range(x - 2 , -1 , -1):
for j in range(y - 2 , -1 , -1):
dp[i][j] = dp[i +1] [j] + dp[i][j +1]
return dp[0][0]
编写一个方法,打印n对括号的全部有效组合(即左右括号正确匹配)
示例
输入:3
输出:()()(),((())),(())(),()(()),(()())
按照前两道的思路, 我们依然从最初开始逐步递推: 寻找每次大规模问题和其小一号问题的关系. 同时出口条件又是已知的
def proper_bracket(n):
'''
:param n: 输入括号对数
:return:
'''
#声明一个set用于存放结果
sn = set()
#出口条件
if n == 1 :
sn.add("()")
return sn
sn_1 = proper_bracket(n-1) #声明小一号规模的子问题,上一次求得的sn作为下一次的sn_1
for e in sn_1: #以下全是归回来的副作用, 阐明子问题与父问题的关系
sn.add("()"+e)
sn.add(e+"()")
sn.add("("+e+")")
return sn
print(proper_bracket(3))
稍微解释下上述代码,
下面的迭代形式正是递推过程的正向体现
'''
迭代形式
'''
def proper_bracket_ite(n):
sn = set()
sn.add("()")
if n ==1 :
return sn
for i in range(2 , n+1 ):
sn_new = set() #从n=2开始每次创建一个新集合set_new, 从sn推出set_new
for e in sn:
sn_new.add("()" +e)
sn_new.add(e + "()")
sn_new.add("(" + e + ")")
sn = sn_new #set_new变sn,周而复始
return sn
编写一个方法,返回int集合的所有子集
# 例如:
# 输入: [1,2,3]
# 输出: [],[1],[1,2],[1,2,3],[2,3],[3],[1,3],[2]
此题我们同样按照小规模往大规模进行推理,
当只有一个元素时, 只用考虑有这个元素(子集1),或者没有这个元素(子集2),
当有两个元素时,可以这样考虑:
当有多个元素时, 对于每个元素,都有试探放入或者不放人集合中两个选择:
选择该元素放入,递归地进行后续元素的选择,完成放入该元素后续所有元素的试探;
之后将其拿出
再进行一次选择不放入该元素,递归地进行后续元素的选择,完成不放入该元素时对后续元素的试探
设arr传入的数组, item为每一个子集, res为最终的结果集, i 表示当前arr的下标
下图演示递归求解的调用思路:
def get_subset(arr):
item = list()
res = list(list())
generate(0 , arr, item, res)
print(res)
def generate(i , arr , item, res):
'''
:param i: 表示当前操作的arr下标
:param arr: 初始传入的int集合
:param item: 存放每个子集的set
:param res: 存放最终结果的set
:return:
'''
if(i >= len(arr)):
return
item.append(arr[i])
temp_item=list(item) #这里不能直接res.append(item),否则下一次更新res中的item会跟着变化,这里只需要其元素
res.append(temp_item)
#重点
generate(i +1, arr, item ,res)
item.pop() #将当前元素拿出
generate(i + 1, arr, item ,res) #递归调用不考虑当前元素的情况
对于这种放或不放的01事件,还可以用二进制表示的方法。。具体来看,就是就是是和否可能性的组合问题.以原始集合{1,2,3}为例,下图可以很好的表示子集的所有可能性:
因此,我们可以用当前位置上1或0表示选或不选当前位置上的元素, 数组长度即为二进制数的位数, 即可用一个3位二进制数保存{A,B.C}的所有可能性.
而在寻找这种可能时, 可从0遍历到2^(len(arr))-1, 其中的每一个二进制数,刚好表达的是一种可能性. 比如:110,即为{A,B}.
def get_subset_ite(arr):
res= list() #最终结果集
for i in range(2**len(arr) - 1 ,-1 , - 1):
item = list() #d当前子集
for j in range(len(arr) - 1 , -1 ,- 1): #j是遍历每一位,当该为为1,则对应的元素加入
if (i>>j) & 1 == 1: #若该二进制位为1,则加入item
item.append(arr[j])
res.append(item)
return(res)
写一个方法,返回一个字符串数组的全排列
例如
输入:"ABC"
返回:"ABC","ACB","BAC","BCA","CAB","CBA"
这个问题和刚刚的那个子集问题结合起来看
那么如何用递归思考方式着手解决呢? 还从小规模逐渐推吧
由此推而广之到n时:
令 S(n-1) = {前n-1的子串全排列集合}, 则S(n)与S(n-1)关系为:
for each item in S(n-1):
for each empty between str[i] and str[i+1]:
item.append(str[n])
res.append(item)
上面的方法, 其实并不是我们平时所一下想到的, 那么我们平时是怎么想的呢?
看文字感觉不好表述, 那么还是看图好了:
蓝色数字为调用回溯顺序
代码:
res = list() #全局: 最终结果list
def get_all_array(str):
arr = list(str)
arr.sort() #先排好序
generate(arr, 0)
return res
def generate(arr ,k ):
#递归走到底了 表示排好了
if k == len(arr):
item = "".join(arr)
res.append(item)
#从第k位开始的每个字符都尝试放在新排列的第k个位置
for i in range(k , len(arr)):
swap(arr , k ,i) #交换, 形成新的顺序, 比如 bc=>cb
generate(arr , k+1) #递归调用
swap(arr , k ,i) #这是返回时的副作用, 再次交换, 复原 == >回溯
# 辅助函数swap
def swap(arr , i ,j):
if i <0 or j < 0 or i > len(arr) or j > len(arr):
return "i or j is out of indedx"
tem = arr[i]
arr[i] = arr[j]
arr[j] = tem
['abc', 'acb', 'bac', 'bca', 'cba', 'cab']
上面这个交换-回溯法很简洁, 但是并不能按照字典序打印, 下面这个方法就可以将其字典序打印了
伪代码如下:
res = list() #存放最终结果
generate("" , str) #初始时前缀为空
generate(prefix, str) :
if prefix.length == str.length:
res.add(prefix) #结果集中放入prefix
return
for each ch in str:
# 这个字符可用: 在pre中出现的次数 < 在字符集中出现的次数 (这是关键)
if prefix.count(ch) < str.count(ch)
generate(prefix + ch ,str) #将ch加入prefix中,继续递归调用
好了, 本次介绍就到这里, 下面来小结一下:
本文是递归系列的第三篇, 第一篇介绍了递归的形式以及递归设计方法(迭代改递归),;第二篇以递归为引子, 详细介绍了快排和归排以及堆排的核心思想; 本篇主要通过几个题, 从递推, 归纳法的角度, 深入了介绍了递归的本质和具体应用.
本文所谈递归的"本质",是数学角度上的,且并未继续深入(比如所谓的封闭式计算方法,直接求通项等). 同时,关于计算机中的递归(比如栈开辟,函数存储等问题)并未涉及, 待以后补充学习后一定补上.
前两个题是数值类问题, 后三个题为非数值型问题. 他们的核心在这里都是: 逐步生成, 以此类推 .
递归设计的方法依然还是 搞懂递归, 看这篇就够了 !! 递归设计思路 + 经典例题层层递进 中详细介绍的:
本篇介绍的一些东西将在后续对回溯, dfs ,动态规划的介绍中有所体现. 这里主要强调的是:
如何观察问题 ==> 从小规模开始递推 ==> 找出本质(递推公式) ==> 按照方法,设计算法(递归, 迭代)
接下来的文章将对递归的一些应用: dfs, dp等进行介绍
下一篇:[算法系列] 搞懂DFS——设计思路+经典例题(数独游戏, 部分和, 水洼数目)图文详解