总结记忆
- 递归的本质:递归是一种函数调用自身的编程技术。它允许一个函数通过直接或间接地调用自身来解决复杂的问题,前提是必须有一个明确的终止条件,否则会导致无限循环。
- 串行调用:在递归过程中,每次函数调用都是串行发生的,这意味着在一个函数调用完成之前,控制权不会回到上一层调用。例如,在深度优先搜索(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肯定也不是
- 当子问题定义的不够好的时候,就无法还原出原问题
- 我们通过将问题转换成其他递归问题,并引入外部变量来解决原问题