递归:深度优先搜索

总结记忆

  • 递归的本质:递归是一种函数调用自身的编程技术。它允许一个函数通过直接或间接地调用自身来解决复杂的问题,前提是必须有一个明确的终止条件,否则会导致无限循环。
  • 串行调用:在递归过程中,每次函数调用都是串行发生的,这意味着在一个函数调用完成之前,控制权不会回到上一层调用。例如,在深度优先搜索(DFS)中,如果从 dfs(1) 调用了 dfs(2) 和 dfs(3),程序会首先完全执行 dfs(2),然后才继续执行 dfs(3)。
  • 递归栈的展开:当 dfs(2) 被调用时,它可能会进一步调用其他节点。这个过程会一直持续到达到叶子节点为止。一旦所有子调用完成并返回到 dfs(2),dfs(2) 才会返回给 dfs(1),然后 dfs(1) 才能继续执行 dfs(3)。
  • 后序遍历:后序遍历是指先访问左右子树,最后访问根节点。在递归后序遍历中,我们从叶子节点开始向上逐步计算,直到整个树被遍历完毕。这种方法适用于需要累积结果的情况,如计算树的高度等。
  • 先序遍历:先序遍历则是先访问根节点,然后依次遍历左右子树。在先序遍历中,你可以通过不断向下传递路径信息,在到达叶子节点时进行必要的处理。路径可以通过复制传递(创建新的路径拷贝),也可以通过回溯的方式(修改路径然后恢复原状)来实现。

什么是递归

如果我们想要知道全国最大身高该怎么办呢?一种直觉的方法就是知道各个省最大的身高,然后取max就是全国最大的身高了。那么怎么知道全省最大的身高呢?知道省下面各个市的最大身高,取max就是这个省的最大身高了。怎么知道市的最大身高呢?知道市下面各个县的最大身高取max就行了。我们逐级往下一直到各个县的身高,假如已经知道了各个县的最大身高,就可以不断往上汇总取max就得到各个市的最大身高,市再进行汇总得到省的最大身高,省再进行汇总得到全国的最大身高。这就是一个非常非常标准的递归。
在这里插入图片描述
国是如何得到最大身高的,知道知道各个省的最大身高取max就可以了

def getCountryMaxHeight(blocks):
	val = 0
	for block in blocks: // 遍历每个省
		val = max(val, getProvinceMaxHeight(block))
	return val

再看看省是如何得到最大身高的,知道知道每个市的最大身高,取max就可以

def getProvinceMaxHeight(blocks):
	val = 0
	for block in blocks: // 遍历每个市
		val = max(val, getCityMaxHeight(block))
	return val

再看看市是如何得到最大身高的,知道知道每个乡镇的最大身高,取max就可以

def getCityMaxHeight(blocks):
	val = 0
	for block in blocks: // 遍历每个镇
		val = max(val, getCountyMaxHeight(block))
	return val

我们直接获取乡镇里面每个人身高,取max就可以是这个乡镇的最大身高了

def getCountyMaxHeight(blocks):
	val = 0
	for block in blocks: // 遍历每个镇
		val = max(val, getPeopleMaxHeight(block))
	return val

每个人已经是最小单位了,直接返回自己的结果就可了

def getPeopleMaxHeight(block):
	return random()

乡镇得到最终的结果,返回给城市,城市再最结果进行汇总,返回给省份,省份在对结果汇总返回给国家。上面的代码其实已经有递归的感觉了,可以看到国,省,市他们的函数时一模一样的,我们改成递归的形式

def getMaxHeight(blocks):
	val = 0
	for block in blocks:
		val = max(val, getMaxHeight(block))
	return val

但是有一点需要注意,当检索到每个人的身高的时候就无需继续递归调用了,直接返回结果,因此需要做一点小修改

def getMaxHeight(blocks):
	# 递归到最小单位,即叶子节点的时候就直接返回结果
	if blocks == 'people':
		return random()
	val = 0
	for block in blocks:
		val = max(val, getMaxHeight(block))
	return val

递归树

上面的例子其实就是一个递归树,我们在dfs(1)中调用了两个子问题dfs(2)和dfs(3),而dfs(2)中呢调用了dfs(4)和dfs(5)两个子问题,依次类推,最终的函数调用过程如上图所示。dfs4的左右子树节点都是None,可以认为执行了dfs(None)和dfs(None),dfs(None)可以认为是终止条件了,直接返回结果。
dfs(4)dfs(5)执行完成之后结果,在dfs(2)中可以直接拿到,dfs(6)和dfs(7)的结果则在dfs(3)中可以拿到,dfs(2)的结果和dfs(3)的结果可以在dfs(1)中拿到。从底开始将结果逐层返回,一直到根节点。
在这里插入图片描述
之前一直有个疑惑,为什么二叉树的遍历复杂度是o(n)。一直觉得一个节点会被多次访问,例如下面有两条路径[1,2,3]和[1,2,5],当访问节点2之后,再去访问节点4,然后返回到节点2,再去访问节点5,然后再返回到节点2,节点2不是会被多次访问吗?这是一个误区,我们看右边的递归树,dfs(4)和dfs(5)都是在函数dfs(2)中的,dfs(4)执行完了返回,就又跳到dfs(2)中了,然后执行dfs(5),执行完成之后再返回到dfs(2)中,最终dfs(2)执行完成返回到dfs(1)中,dfs(2)执行完成后再执行dfs(3),dfs(3)中又包含了dfs(6)和dfs(7),整个递归其实就是串行在执行,后序所有的函数其实就是包在了dfs(1)中。

def run6():
	print(6)
def run7():
	print(7)

def run4():
	print(4)
def run5():
	print(5)

def run3():
	print(3)
	run4()
	run5()

def run2():
	print(2)
	run4()
	run5()

def run1():
	print(1)
	run2()
	run3()

run1() # 执行函数

# 把run1函数展开调用
run1():
	print(1)
	#run2()
	print(2)
	#run4()
	print(4)
	#run5()
	print(5)
	#run3()
	print(3)
	#run6()
	print(6)
	#run7()
	print(7)	

可以看到所有的函数都展开了,并且是串行执行的,也就是所谓的前序遍历。
在这里插入图片描述

二叉树的最大深度

看一个leetcode求解二叉树最大深度的问题,深度是说从根节点到叶子节点的最长路径。如果我们想知道最大深度,只要知道左子树的最大深度和右子树的最大深度,那么root节点的深度就是左右子树最大的值加1
d e p t h ( r o o t ) = m a x ( d e p t h ( r o o t . l e f t ) , d e p t h ( r o o t . r i g h t ) ) + 1 depth(root)=max(depth(root.left),depth(root.right))+1 depth(root)=max(depth(root.left),depth(root.right))+1
很明显这也是一个递归的式子,所以直接使用递归即可

class Solution:
    def maxDepth(self, root: Optional[TreeNode]) -> int:
        def dfs(root):
            if not root: # base case
                return 0
            l = dfs(root.left) # 先求左子树的深度
            r = dfs(root.right) # 然后求右子树的深度
            return max(l,r)+1 # 取最大,然后加上root节点就是+1
        return dfs(root)

先序遍历

对于先序遍历来说,先处理了根节点的数据,然后再递归的处理子节点,在处理的过程中,我们可以将根节点的数据逐层往下传递,当递归到子节点的时候,整条路径上的数据就全部传递下去了。比如我们想要获取二叉树的所有路径,二叉树的路径也就是从根节点一路到叶子节点

# 先序遍历
def dfs(root):
	stop_condition()
	process(root.val)
	dfs(root.left)
	dfs(root.right)

在这里插入图片描述

res = []
def dfs(root, path):
	if root is None:
		return
	# process()
	if root.left is None and root.right is None:
		res.append(path)
	
	# 根节点的数据添加到path中,并将path传递下去,下游的节点就可以拿到之前的信息了
	dfs(root.left, path+[root.val])
	dfs(root.right, paht+[root.val])

可以看到,path是一路传递下来,一直到了叶子节点,叶子节点接受到path也就是之前全部的节点路径了。有一个点需要注意一下,上面的例子中path是直接拷贝传递下去的,如果是直接传递path到后面的节点,相当于是引用,我们直接展开函数,可以发现path一直在添加节点

res = []
def dfs(a, path):
	path.append(a)
	dfs(b, path)
		path.append(b)
	dfs(c, path)
		path.append(c)

在这里插入图片描述
一种简单的办法就是回溯,每次添加完成节点之后再将其弹出,这样执行完dfs(b)之后,path中依然只有a节点,然后再执行dfs©添加c节点,完成后再pop出去,此时路径中就只剩下a节点了

res = []
def dfs(a, path):
	path.append(a) # path = [a]
	dfs(b, path) 
		path.append(b) # path=[a,b]
	path.pop() # b节点pop出去,path = [a]
	dfs(c, paht)
		path.append(c) # path = [a,c]
	path.pop() # c节点pop出去 path=[a]
	# path中只有a节点了

通过回溯的方式保证当前的path中只有当前节点之前的路径。

后序遍历

#后序遍历
def dfs(root):
	stop_condition()
	l = dfs(root.left)
	r = dfs(root.right)
	process(root,l,r)

**后续遍历是一直遍历到子问题,拿到子问题的结果之后,再根据子问题的结果得到原问题的解。**从后序遍历的代码可以看出来
dfs(1)是停止条件吗,不是所以接着递归调用dfs(2)和dfs(3),先执行dfs(2),判断2是停止条件吗,不是,所以接着递归,这里需要注意,由于是串行执行的,当dfs(2)全部递归完成之后,才会执行dfs(3)。
在这里插入图片描述
例如我们想要求解二叉树的最大深度,先得到左右子树的最大深度,然后根据子问题的解和当前结点的输入得到最终的结果。
在这里插入图片描述

def height(root):
	
	l = height(root.left)
	r = height(root.right)
	
	v = max(l,r)+1
	return v

这里想要注意的是停止条件,是到叶子结点就停止呢,还是一直到None才截止。当然是都可以,如果是叶子结点结束

def height(root):
	# 叶子结点就递归结束,并且叶子结点的高度就是1
	if root.left is None and root.right is None:
		return 1
	l = height(root.left)
	r = height(root.right)
	
	v = max(l,r)+1
	return v

如果是到None结束呢,返回到结果就是0

def height(root):
	# 叶子结点就递归结束,并且叶子结点的高度就是1
	if root is None:
		return 0
	l = height(root.left)
	r = height(root.right)
	
	v = max(l,r)+1
	return v

在写停止条件的时候,一定要记住能否还原出上一层的结果,显然None和叶子结点都可以还原出来,所以都是OK的。

通过前序遍历我们也可以得到二叉树的最大深度


ans = 0
def depth(root, height):
	if root is None:
		return
	# 上之前节点树加上当前节点树
	ans = max(ans, height+1)
	depth(root.left, height+1)
	depth(root.right, height+1)

但是前序遍历是从上往下的,我们知道的是这个节点到根节点的高度,二叉树的高度是到叶节点的高度。为什么前序遍历也可以呢?因为这个问题只关注整个树的最大值,如果我们想要知道每个节点的最大深度,那么使用后序遍历是非常方便的,因为后序遍历是从叶子节点往上返回的。

独立子问题

为什么觉得上面的方案非常清晰,很简单的就画出了递归树。这是因为上面的子问题非常清晰。我们可以明显的看出每个子问题是相互独立的,只要分别求出每个子问题,然后就可以得到原问题的解。如果子问题并不互相独立,那就非常迷惑了。当我们使用递归的时候,一定要想清楚子问题是什么,如果子问题解决了是不是就可以得到原问题的求解。

现在我想要知道全国最长的铁路有多长?是不是只要知道各个省份的最长铁路,然后取max或者取sum就可以了?当然不是,全国的铁路交互纵横,有些铁路横跨了多个省份,也就是说需要省份协同处理才可以知道全国最长的铁路,子问题并不独立。
在这里插入图片描述
leetcode有一个二叉树的直径的问题,直径是指从一个点到另一个节点的最长路径。一个简单的直觉是求出左右子树的最长路径,然后取max就是整颗树的最长路径。

def diameterOfBinaryTree(root):
	if root is None:
		return 0
	
	l = diameterOfBinaryTree(root.left) # 左子树直径
	r = diameterOfBinaryTree(root.right) # 右子树直径
	
	return l + r + 1 # 左右子树深度加上根节点就是最后的结果了

在这里插入图片描述
左子树的直径是[8,4,2,5,9],而右子树的直径是[12,10,6,3,7,11],但是整棵树的直径却是[8,4,2,1,3,6,10,12],很容易发现一个问题,左子树最长路径和右子树的最长路径没办法通过根节点连接在一起。也就是说我们无法通过子问题来解原问题。

我们递归到了叶子节点,可以得到叶子节点的直径,但是由于子问题无法还原出原问题,因此叶子节点的结果也无法还原出上一层节点的结果,从底层开始结果就已经不对了

我们将子问题做一些修改求出左子树的最大深度,右子树的最大深度,那么树的直径就是左子树的深度+右子树的深度+1。

def deepth(root):
	if root is None:
		return 0
	
	l = deepth(root.left) # 左子树的深度
	r = deepth(root.right) # 右子树的深度
	
	return max(l,r)+1 # 树的最大深度

def diameterOfBinaryTree(root):
	if root is None:
		return 0
	
	l = deepth(root.left) # 左子树的深度
	r = deepth(root.right) # 右子树的深度
	
	return l + r + 1 # 左右子树深度加上根节点就是最后的结果了

现在的问题是,上面求解的是过根节点的最长路径,但是整棵树的最长路径并一定过根节点
在这里插入图片描述

self.ans = 0
def deepth(root):
	if root is None:
		return 0
	
	l = deepth(root.left) # 左子树的深度
	r = deepth(root.right) # 右子树的深度
	
	dim = l + r + 1 # root节点的直径
	self.ans = max(self.ans, dim)
	return max(l,r)+1 # 树的最大深度

在求解树的深度的时候,递归的时候其实会计算每个节点的最大深度,这个时候其实也就可以知道每个节点的直径了,我们用一个变量记录下来。

终止条件

再看一个小例子,求二叉树的最小深度,一开始觉得最小深度和最大深度是一样的,不动脑子的想

def minDepth(root):
	if root is None:
		return 0
	l = minDepth(root.left)
	r = minDepth(root.right)
	
	return min(l,r)+1

但是发现直接写min是有问题的,因为深度是根节点到叶子节点的,对于a节点来说,它的左子树是None,没有叶子节点,因此不能参与递归计算。
在这里插入图片描述正确写法需要考虑某个子树为None的情况,这个时候需要递归另一边的子树作为最小深度。

def minDepth(root):
    if root is None:
        return 0
    elif root.left is None and root.right is None: # 叶子节点
        return 1
    elif root.left is None:
        return self.minDepth(root.right)+1
    elif root.right is None:
        return  self.minDepth(root.left) + 1
    else:
        return min(self.minDepth(root.right),self.minDepth(root.left))+1

如何思考终止条件呢?求全国最大身高的例子中,每个人就是终止条件了,不能继续往下走了,最重要的是能否根据终止条件正确还原出上一层的结果,在上面的例子中,判断叶子节点是一种终止情况,叶子节点返回值是1,而None也是一种情况,None的返回结果是0,都可以还原出原问题。

平衡二叉树

给定一个二叉树,判断它是否是高度平衡的二叉树。
本题中,一棵高度平衡二叉树定义为:
一个二叉树每个节点 的左右两个子树的高度差的绝对值不超过 1

一种简单的子问题是判断左子树是平衡树,右子树也是平衡树,那么整棵树是否是平衡树呢?

def isBlacne(root):
	if root is None:
		return True
	
	l = isBlance(root.left)
	r = isBlance(root.right)
	
	return l and r

很明显不是,即使左右子树都是平衡二叉树,但是高度差可能会超过1,整棵树也不是平衡二叉树,所以这个子问题不太行。子问题就只能转变成左子树和右子树的差值,同时判断差值是否是大于1,一旦有一个大于1,那么肯定就不是平衡二叉树了。

self.ans = True
def dfs(root):
	if root is None:
		return 0
	l = dfs(root.left)
	r = dfs(root.right)
	
	if abs(l-r) > 1:
		self.ans = False
	return max(l,r)+1

这个问题本质也是求解树的深度,只不过要求左右子树的深度差值不超过1,如果超过一就会直接返回False。同样的理解,我们想要知道root节点是平衡的,那么就需要计算左右子树的高度,计算完高度之后,还需要判断左右子树是否也是平衡二叉树,如果有一个不是的话,那么root肯定也不是

  1. 当子问题定义的不够好的时候,就无法还原出原问题
  2. 我们通过将问题转换成其他递归问题,并引入外部变量来解决原问题
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值