[算法系列] 深入递归本质+经典例题解析——如何逐步生成, 以此类推,步步为营

[算法系列] 深入递归本质+经典例题解析——如何逐步生成, 以此类推,步步为营

本文是递归系列的第三篇, 第一篇介绍了递归的形式以及递归设计方法(迭代改递归),;第二篇以递归为引子, 详细介绍了快排和归排以及堆排的核心思想; 本篇主要通过几个题, 从递推, 归纳法的角度, 深入了介绍了递归的本质和具体应用.

往期回顾:

  1. 搞懂递归, 看这篇就够了 !! 递归设计思路 + 经典例题层层递进
  2. 递归应用: 快速排序+归并排序算法及其核心思想与拓展 … 附赠 堆排序算法

回顾我们在第一篇文章讨论的递归中, 下面是我们能够看到现象形式:

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也就成立… 本质是递推.

  • 递归解决的本质是先从f(1)->f(2)->…->f(n), 小问题解决了,再解决大问题
  • 数学归纳法式从k = 1 逐层证明, 或者说证明k=n和k=n+1的关系,然后递推
  • 递推, 就是按照前一个(或几个)的关系推理出下一个 …

recursion一词既可以翻译为递推,也可以翻译为递归, 这里的归应该是是规约的意思. 注意这里的递归和编程形式中的 递归调用 是有点区别的, 编程中谈到的形式化更多一些, 而数学本质还是和递归递推没有区别.

递归, 递推, 数学归纳法本质正是同一种东西.

好了,现在看来知道了这些似乎作用不大. 我们还是举个例子, 搞懂递归, 看这篇就够了 !! 递归设计思路 + 经典例题层层递进 中的青蛙上楼梯问题.

1. 再谈青蛙上楼梯

楼梯有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张图片

注意我分成了三列写, 如果不看红色部分的话, 三列分别代表了上第1,2,3阶的方法. 现在带着红色的 ->4 一起看:

  • 第一列: 相当于先上到第1阶再一次上到4 (因为最大可以跨3阶嘛)
  • 第二列: 相当于先上到第2阶再一次上到4 (相当于最后一次跨2阶嘛)
  • 第三列:相当于先上到第3阶再一次上到4(最后一次跨1阶即可)

显然上到第四阶的方法刚好就是这三列的和了 …

到这里, 有兴趣的同学可以在写出阶数为5的走法. 但其实也会得到下面的结论:

  • 第一列: 相当于先上到第2阶再一次上到5 (因为最大可以跨3阶嘛)
  • 第二列: 相当于先上到第3阶再一次上到5 (相当于最后一次跨2阶嘛)
  • 第三列:相当于先上到第4阶再一次上到5(最后一次跨1阶即可)

显然上到第五阶的方法刚好就是这三列的和了 …

…想一想, 规律也就可以得出了

阶数为n的走法. 但其实也会得到下面的结论:

  • 或者先上到第n-3阶再一次上到n (因为最大可以跨3阶嘛)
  • 或者先上到第n-2阶再一次上到n (相当于最后一次跨2阶嘛)
  • 或者于先上到第n-1阶再一次上到n(最后一次跨1阶即可)

所以 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]

2. 机器人走方格 cc150 9.2

有一个 X*Y 的方格, 一个机器人只能走格点且只能向右或者向右走, 要从左上角走到左下角
请设计一个算法, 计算机器人有多少种走法
给定两个个正整数X , Y, 返回机器人走法的数目.

分析如下:

[算法系列] 深入递归本质+经典例题解析——如何逐步生成, 以此类推,步步为营_第2张图片

得到递推公式和出口条件就可以写出递归形式代码:

'''
递归形式
'''
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 )

想清楚了, 代码看上去是不是异常简洁呢?

现在考虑迭代形式: 我们知道,

  • 如果只有一个格子, 那么终点即为起点, 结果为1
  • n * 1 或 1 * m 的情况, 总是只有一种走法

在对应格子中填上从此处到右下角的走法, 目前可得到:

[算法系列] 深入递归本质+经典例题解析——如何逐步生成, 以此类推,步步为营_第3张图片

然后就可以填格子, 根据就是f(n,m) = f(n - 1,m) +f(n, m -1) .

这其实也就相当于:当前的方法数 = 自己下方格子处的方法数 + 右边格子处的方法数

[算法系列] 深入递归本质+经典例题解析——如何逐步生成, 以此类推,步步为营_第4张图片

  • 填到图中值为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]

3.输出合法括号cc9.6

编写一个方法,打印n对括号的全部有效组合(即左右括号正确匹配)
示例
输入:3
输出:()()(),((())),(())(),()(()),(()())

按照前两道的思路, 我们依然从最初开始逐步递推: 寻找每次大规模问题和其小一号问题的关系. 同时出口条件又是已知的

[算法系列] 深入递归本质+经典例题解析——如何逐步生成, 以此类推,步步为营_第5张图片

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))

稍微解释下上述代码,

  • n=1时为出口条件,答案明确
  • n>1时依次调用n-1, 因此首先求得的是n=2时, sn_1="()",针对它的每一项进行加左,加右,加外三个操作得到sn, 再逐次返回

下面的迭代形式正是递推过程的正向体现

'''
迭代形式
'''
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

4.集合的所有子集cc9.4

编写一个方法,返回int集合的所有子集
# 例如:
# 输入: [1,2,3]
# 输出: [],[1],[1,2],[1,2,3],[2,3],[3],[1,3],[2]

此题我们同样按照小规模往大规模进行推理,

  • 当只有一个元素时, 只用考虑有这个元素(子集1),或者没有这个元素(子集2),

  • 当有两个元素时,可以这样考虑:

    • 加入第一个元素 =>形成子集1
    • 加入第二个元素=>形成子集2
    • 弹出第二个元素
    • 弹出第一个元素
    • 加入第二个元素=>形成子集3
  • 当有多个元素时, 对于每个元素,都有试探放入或者不放人集合中两个选择:

    • 选择该元素放入,递归地进行后续元素的选择,完成放入该元素后续所有元素的试探;

    • 之后将其拿出

    • 再进行一次选择不放入该元素,递归地进行后续元素的选择,完成不放入该元素时对后续元素的试探

设arr传入的数组, item为每一个子集, res为最终的结果集, i 表示当前arr的下标

下图演示递归求解的调用思路:

[算法系列] 深入递归本质+经典例题解析——如何逐步生成, 以此类推,步步为营_第6张图片
代码如下

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}为例,下图可以很好的表示子集的所有可能性:

[算法系列] 深入递归本质+经典例题解析——如何逐步生成, 以此类推,步步为营_第7张图片

因此,我们可以用当前位置上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)

5.全排列cc9.5

写一个方法,返回一个字符串数组的全排列
例如
输入:"ABC"
返回:"ABC","ACB","BAC","BCA","CAB","CBA"

这个问题和刚刚的那个子集问题结合起来看

  • 子集问题是:针对某一位上的元素,选还是不选这个元素的问题(0或1). 对每一位来说均有两种可能, 总计为2^n个情况(子集)
  • 全排列问题是: 每个位置都要选,但是是选n个当中哪一个的问题. 其次,当前选定一个了,下一个可选情况就少1了.因此情况个数为n!

那么如何用递归思考方式着手解决呢? 还从小规模逐渐推吧

  1. 当串长度为1时:“a”, 只有一种情况
  2. 当串长度为2时,比如"ab": 初始"a", 加一个"b":
    • “b"可以放在"a"的前面形成"ab”
    • 也可以放在"a"后面形成"ba"
  3. 当串长度为3时,“ab"或"ba”, 加一个"c":
    • 对于"ab",有a左,ab中间,b右三个位置可加入, 分别形成三个串
    • 对于"ba",同样有三个为加如c,同样形成三个新串

由此推而广之到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)

[算法系列] 深入递归本质+经典例题解析——如何逐步生成, 以此类推,步步为营_第8张图片

上面的方法, 其实并不是我们平时所一下想到的, 那么我们平时是怎么想的呢?

  • 先以a开头, b开头 , c开头写…abcd
  • 调换最后两位顺序…
  • 逐渐从后面向前面调换顺序, 写完所有a打头的item
  • 接下来交换a和b, 以b打头, a第二个写… 写完为止
  • 然后依然b打头, c第二个写…
  • 接下来交换a和c,c打头,a第二个…

看文字感觉不好表述, 那么还是看图好了:

[算法系列] 深入递归本质+经典例题解析——如何逐步生成, 以此类推,步步为营_第9张图片

蓝色数字为调用回溯顺序

代码:

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中,继续递归调用
            

[算法系列] 深入递归本质+经典例题解析——如何逐步生成, 以此类推,步步为营_第10张图片

好了, 本次介绍就到这里, 下面来小结一下:

  • 本文是递归系列的第三篇, 第一篇介绍了递归的形式以及递归设计方法(迭代改递归),;第二篇以递归为引子, 详细介绍了快排和归排以及堆排的核心思想; 本篇主要通过几个题, 从递推, 归纳法的角度, 深入了介绍了递归的本质和具体应用.

  • 本文所谈递归的"本质",是数学角度上的,且并未继续深入(比如所谓的封闭式计算方法,直接求通项等). 同时,关于计算机中的递归(比如栈开辟,函数存储等问题)并未涉及, 待以后补充学习后一定补上.

  • 前两个题是数值类问题, 后三个题为非数值型问题. 他们的核心在这里都是: 逐步生成, 以此类推 .

  • 递归设计的方法依然还是 搞懂递归, 看这篇就够了 !! 递归设计思路 + 经典例题层层递进 中详细介绍的:

    • 找出口条件 ==> 边界, 最小规模的问题 ==> 初始情况
    • 找不变 ==> 解决问题的方法不应变化, f(n) 与 f(n-m) 才能表述成父问题与子问题的关系(回忆前面的汉诺塔问题)
    • 找变化 ==> n规模的问题与n-m规模问题之间的关系(考虑走格子, 全排列问题) ==> 递推公式中的n
  • 本篇介绍的一些东西将在后续对回溯, dfs ,动态规划的介绍中有所体现. 这里主要强调的是:

    如何观察问题 ==> 从小规模开始递推 ==> 找出本质(递推公式) ==> 按照方法,设计算法(递归, 迭代)
    

接下来的文章将对递归的一些应用: dfs, dp等进行介绍
下一篇:[算法系列] 搞懂DFS——设计思路+经典例题(数独游戏, 部分和, 水洼数目)图文详解

你可能感兴趣的:(数据结构/算法)