数据结构与算法碎碎念(2)——递归常见运用
递归是算法一个十分经典的话题,递归的本质是函数调用其自身。
- 递归是把大规模的问题不断变小,再进行推导的过程
- 特点:可以使一个看似复杂的问题变得简洁和易于理解
下面我们通过几道题目来理解递归是如何实现的以及有哪些变化运用。
使用递归以相反序打印字符串
该题目比较简单,首先递归最重要的就是确定递归终止条件,在这里如果我们的字符串只剩一个,那么直接输出即可,即终止条件是:if len(s) < = 1。否则我们需要减小字符串的长度,具体实现如下:
In [1]: def reverse_print(s):
...: if len(s) <= 1:
...: return s
...: return reverse_print(s[1:]) + s[0]
...:
如上,当字符串长度 <= 1 时,我们直接返回即可,否则就需要首先返回当前字符串以后的字符串,才能实现反序,所以有 reverse_print(s[1:]) + s[0] 。
下面我们来测试一下:
In [2]: reverse_print('this is a test string')
Out[2]: 'gnirts tset a si siht'
如上,即实现了字符串反序打印。
使用递归:两两交换链表中的节点
给定链表,交换每两个相邻节点并返回其头节点。
例如:
# class Listnode:
# def __init__(self, val):
# self.val = val
# self.next = None
原链表: 1 -> 2 -> 3 -> 4
结果: 2 -> 1 -> 4 -> 3
使用递归求解的思路很简单,首先我们确定递归的终止条件,很显然当节点数小于2时,我们就无需进行节点的交换。
当当前节点数大于2,我们就需要对前两个节点进行交换,再处理剩余节点,最后将两部分链接起来即可,代码实现如下:
In [8]: def swapPairs(head):
...: if head is None or head.next is None:
...: return head
...: temp = head.next
...: r = swapPairs(temp.next)
...: temp.next = head
...: head.next = r
...: return temp
...:
接下来我们使用上面的例子来进行测试。首先我们建立的链表如下:
In [24]: p = head
In [25]: while p:
...: print(p.val)
...: p = p.next
...:
1
2
3
4
接下来进行两两交换节点
In [45]: p = swapPairs(head)
In [46]: while p:
...: print(p.val)
...: p = p.next
...:
2
1
4
3
如上,使用递归实现了对链表进行两两交换节点。
每次递归函数调用自身时,他都会将给定的问题拆分为子问题,递归调用继续进行,直到子问题无需进一步递归即可求解。
递归生成杨辉三角
给定一个非负整数 numRows ,生成杨辉三角的前 numRows 行。
首先我们要直到杨辉三角是什么,下面我们用一张图来说明:
(图片来源于网络)
如上图所示,通过观察我们发现,每一层除左右两个数字是1之外,每一个数字都是其上层的两个数字之和,这便是著名的杨辉三角。
所以我们知道了杨辉三角的第 i-1 行,生成第 i 行的代码如下:
[1] +
[yanghui[-1][i-1] + yanghui[-1][i] for i in range(1, numRows-1)] +
[1]
那么我们的递归终止条件是什么呢?很明显,当 numRows == 0 时,杨辉三角为空,返回 [] ,当 numRows == 1 时,杨辉三角返回其第一层 [[1]] ,否则我们需要递归向下求取杨辉三角。具体的代码如下:
In [47]: def yanghui(numRows):
...: if numRows == 0:
...: return []
...: elif numRows == 1:
...: return [[1]]
...: else:
...: # 调用自身生成 n-1 行的杨辉三角
...: yanghui_result = yanghui(numRows - 1)
...: # 根据倒数第二行生成当前行
...: pre_row = [1] + [yanghui_result[-1][i-1]+yanghui_result[-1][i] for i in range(1, numRows-1)] +[1]
...: yanghui_result.append(pre_row)
...: return yanghui_result
...:
下面我们生成一个5层的杨辉三角进行测试:
In [49]: test_yanghui = yanghui(5)
In [50]: for line in test_yanghui:
...: print(line)
...:
[1]
[1, 1]
[1, 2, 1]
[1, 3, 3, 1]
[1, 4, 6, 4, 1]
如上,实现了使用递归生成杨辉三角。
递归在树中的应用
递归的一个典型的应用场景就是树,树其实就是一种递归定义的结构。
在上面的杨辉三角的例子中,我们当前行的生成使用的是上一行的数据,即可以理解为从子问题得出当前问题的解,但有时候我们解决当前问题时,也需要用到其父问题的某些值,这个时候我们就需要边递归,边向下传递值。
让我们来看看下面问题:
给你一棵根为 root 的二叉树,请你返回二叉树中好节点的数目。
「好节点」X 定义为:从根到该节点 X 所经过的节点中,没有任何节点的值大于 X 的值。
示例 1:
输入:root = [3,1,4,3,null,1,5]
输出:4
解释:图中蓝色节点为好节点。
根节点 (3) 永远是个好节点。
节点 4 -> (3,4) 是路径中的最大值。
节点 5 -> (3,4,5) 是路径中的最大值。
节点 3 -> (3,1,3) 是路径中的最大值。
示例 2:
输入:root = [3,3,null,4,2]
输出:3
解释:节点 2 -> (3, 3, 2) 不是好节点,因为 "3" 比它大。
示例 3:
输入:root = [1]
输出:1
解释:根节点是好节点。
提示:
- 二叉树中节点数目范围是 [1, 10^5] 。
- 每个节点权值的范围是 [-10^4, 10^4] 。
来源:力扣(LeetCode)
链接:原题链接
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
如上题目,我们解决的思路可以是递归,但是有一个问题就是:好节点的判定,是与其上层路径上的节点的值有关的,所以无法从当前节点直接判断是否为好节点。
那么我们应该如何做呢?
此时我们就无法直接从子问题得出解,还需要递归地向下传递当前路径上的最大值,与当前节点的值进行比较,才能确定其是否为好节点,具体实现如下:
# Definition for a binary tree node.
# class TreeNode:
# def __init__(self, val=0, left=None, right=None):
# self.val = val
# self.left = left
# self.right = right
class Solution:
def goodNodes(self, root: TreeNode) -> int:
self.count = 0
self.best_node(root, root.val)
return self.count
def best_node(self, node, max_val):
if node == None:
return
if node.val >= max_val:
self.count += 1
max_val = node.val
self.best_node(node.left, max_val)
self.best_node(node.right, max_val)
如上代码所示,我们在进行递归向下求解时,同时传递了当前路径上的最大值 max_val 。
这是递归的另一个运用,有时我们仅通过当前子问题无法直接求解,还需要向下传递其父问题的值,这在与树相关的题目中有很多体现。
递归求斐波那契数列的第N项
通常情况下递归是一种直观而有效的实现算法的方法,但是如果运用不合理,会造成大量的重复计算。
例如对于求斐波那契数列的第N项的问题,如果我们使用递归来求解,直接递归的话,会重复对子问题进行计算,加大时间开销。
首先什么是斐波那契数列?
斐波那契数列(Fibonacci sequence),又称黄金分割数列、因数学家列昂纳多·斐波那契(Leonardoda Fibonacci)以兔子繁殖为例子而引入,故又称为“兔子数列”,指的是这样一个数列:1、1、2、3、5、8、13、21、34、……在数学上,斐波那契数列以如下被以递推的方法定义:F(1)=1,F(2)=1, F(n)=F(n - 1)+F(n - 2)(n ≥ 3,n ∈ N*)
——百度百科
以上是百度百科对斐波那契数列的定义,其实就是要满足第一第二项为1、1(有时也以0、1开头),其余后面项每一项的值为其前两项之和,这样生成的数列,就是斐波那契数列。
那么为何会产生重复计算的问题呢?我们知道,每一个值都要由其前面所有值所决定,这就是重复计算产生的原因,我们通过下图来更好地进行理解
如上图所示,要计算 f(4) 的值时,我们实际上重复计算了很多值。
那么有什么办法消除这些重复计算呢?很自然的一个想法,就是我们将每次计算的中间结果保存起来,遇到之前没有计算过的才进行计算,否则直接读出值即可。
如此做我们就可以减少大量的重复计算,这是一种经常与递归一起使用的技术。
针对该题,具体代码实现如下:
In [51]: def fib(N):
...: history = {}
...: def recur(N):
...: if N in history:
...: return history[N]
...: if N < 2:
...: result = N
...: else:
...: result = recur(N-1) + recur(N-2)
...: history[N] = result
...: return result
...: return recur(N)
...:
如上,我们将每次计算的中间结果使用一个字典保存,以避免重复计算(此处的斐波那契数列从0开始)。下面我们传入值来验证一下上面代码。
首先以 0 开始的斐波那契数列前几项的值如下:
0 1 1 2 3 5 8 13 21 34 55 89 ...
接下来传入几项进行验证
In [59]: fib(4)
Out[59]: 3
In [60]: fib(7)
Out[60]: 13
In [61]: fib(11)
Out[61]: 89
如上所示,结果计算正确。
在运用递归处理问题时,我们要学会分析有无重复计算,有的话消除重复计算将时算法效率大大提升。
复杂度分析
对于递归的时间复杂度分析,通常有两种方法:
- 迭代法
- 公式法
此处不做具体讲解,有兴趣的读者可以参考我的另外一篇博文运用递归处理问题
总结
以上就是递归在实际中的几个常见运用,理解透彻会给我们带来许多帮助。
参考资料:
知识星球:算法刷题日记
Leetcode题库