递归算法的原理
一个递归函数的调用类似于多个函数的嵌套调用,只不过调用函数和被调函数都是同一个函数。在解决递归问题时,最重要的是不要陷入递归的逻辑中去,视角要以一个节点为根基来考虑整个问题。递归调用时内部的执行过程如下:
- 首先为递归调用建立一个工作栈,其结构包括值参、局部变量和返回地址;
- 每次执行递归调用前,把递归函数的值参、局部变量的当前值以及调用后的返回地址压栈;
- 每次递归调用结束后,将栈顶元素推出,使相应的值参和局部变量恢复为调用前的值,然后转向返回地址的位置继续执行。
在明确了递归函数的执行过程后,只要把握好如下3个步骤即可:
- 明确递归函数的作用;
- 明确终止条件和相应的解决办法;
- 找出函数的等价关系式,提取重复的逻辑缩小问题规模。
递归三步走
1.明确函数功能
首先要确定这个函数的具体功能是什么?它的参数有哪些?它的全局变量是什么?递归的时候要根据题目的要求设置函数功能,再根据函数功能来设置函数的参数。
方法参数:这个方法的参数最好由当前阶段的状态决定。
返回数据:返回数据是我们遇到递归出口之后,需要告诉前一步递归的信息数据。
注意:
- 递归函数的返回值最好设置为单个元素,比如说一个节点或者一个数值,告诉前一步递归我们现在的结果数据即可;
- 如果返回值是数组的话,我们将无法从中提取到任何有效信息来进行操作;
- 如果结果需要数组的话,我们可以将数组作为公共变量返回值为void,我们在方法体里面操作数组即可。
2.寻找递归出口
在递归函数的一开始,我们应该思考什么时候该结束递归。因此,递归一定要有结束条件,不然会永远的递归下去。递归出口一般为某深度或叶子结点,或非叶子结点(包括根节点)、所有节点等。决定递归出去后要执行的操作。由于我们的节点状态可能需要多个参数来表示,所以我们的递归出口可能并不唯一,可能需要为每一个转台参数安排一个递归出口,确保我们的递归能够确实有效地出去。
特别注意的:每次提交数组的集合(即list(dst())
)的时候,要创建一个新的数组copy()
来存放结果数组dst()
,不然后面操作的都是加入集合list()
的那个数组dst()
。
我们的递归出口并不一定都是在最开头的位置,我们一般在最开头设置递归出口是希望递归能以最快的速度出去;但是有时候我们在对当前节点进行一些相关处理操作之后我们就希望判断一下能不能递归出口,所以递归出口有可能是在代码中间的,大家需要灵活应用。在这一步,我们需要思考题目需要的解在哪里?是在某一具体的深度、还是在叶子结点、还是在非叶子结点(包括根节点)、还是在每个节点、还是在从跟结点到叶子结点的路径?
- 在某一具体深度:
if depth >= n
- 在每个节点:
if root != nil
3.找出递推关系
类比于数学归纳法。算n的阶乘:
- 初始条件:
f(1) = 1
- 递推关系式:
f(n) = f(n-1) * n
递归关系:
- 递:
f(n) = n * f(n-1)
,将f(n)→f(n-1)了。这样,问题就由n缩小为了n-1,并且为了原函数f(n)
不变,我们需要让f(n-1)
乘以n
。就这样慢慢从f(n)
,f(n-1)
“递”到f(1)
。 - 归:这样就可以从
n=1
,一步一步“归”到n=2,n=3,...
1 | func (n int) int { |
运用递归的二叉树算法题
二叉树的递归框架无外乎二叉树的三种遍历方式:前序遍历、中序遍历、后序遍历。在想好递归出口后,就要考虑采用何种方式来遍历整个二叉树有助于我们解决问题。解决这类问题的核心难点是不要深入进递归细节中,不要把自己的大脑当计算机来用!只要着眼于根节点,在设置好递归出口的前提下就能顺利地解题。
1.直接递归遍历整个二叉树后返回根节点
此类问题不需要引入一个用于记录和更新极值结果的中间全局变量,只需要对二叉树本身进行操作,所以在传入的方法参数上比较简单,只需要关注于节点本身(以根节点为视角和着眼点)。大多数题目都是针对树结构进行性重建,查找或删除等操作。
剑指 Offer 07. 重建二叉树
输入某二叉树的前序遍历和中序遍历的结果,请构建该二叉树并返回其根节点。假设输入的前序遍历和中序遍历的结果中都不含重复的数字。
示例 1:
1 | Input: preorder = [3,9,20,15,7], inorder = [9,3,15,20,7] |
示例 2:
1 | Input: preorder = [-1], inorder = [-1] |
限制:
1 | 0 <= 节点个数 <= 5000 |
解题思路:
对于任意一颗树而言,前序遍历的形式总是:
[ 根节点, [左子树的前序遍历结果], [右子树的前序遍历结果] ]
即根节点总是前序遍历中的第一个节点。而中序遍历的形式总是:
[ [左子树的中序遍历结果], 根节点, [右子树的中序遍历结果] ]
只要我们在中序遍历中定位到根节点,那么我们就可以分别知道左子树和右子树中的节点数目。由于同一颗子树的前序遍历和中序遍历的长度显然是相同的,因此我们就可以对应到前序遍历的结果中,对上述形式中的所有左右括号进行定位。这样以来,我们就知道了左子树的前序遍历和中序遍历结果,以及右子树的前序遍历和中序遍历结果,我们就可以递归地对构造出左子树和右子树,再将这两颗子树接到根节点的左右位置。
1 | /** |
106. 从中序与后序遍历序列构造二叉树
根据一棵树的中序遍历与后序遍历构造二叉树。
注意:
你可以假设树中没有重复的元素。
例如,给出
1 | 中序遍历 inorder = [9,3,15,20,7] |
返回如下的二叉树:
1 | 3 |
1 | /** |
226. 翻转二叉树
翻转一棵二叉树。
示例:
输入:
1 | 4 |
输出:
1 | 4 |
废话少说,直接看代码吧,简单题。
1 | /** |
104. 二叉树的最大深度
给定一个二叉树,找出其最大深度。
二叉树的深度为根节点到最远叶子节点的最长路径上的节点数。
说明: 叶子节点是指没有子节点的节点。
示例:
给定二叉树 [3,9,20,null,null,15,7]
,
1 | 3 |
返回它的最大深度 3 。
简单题,直接上代码。
1 | /** |
101. 对称二叉树
给定一个二叉树,检查它是否是镜像对称的。
例如,二叉树 [1,2,2,3,4,4,3] 是对称的。
1
/ \
2 2
/ \ / \
3 4 4 3
但是下面这个 [1,2,2,null,3,null,3]
则不是镜像对称的:
1 | 1 |
解题思路:
如果同时满足下面的条件,两个树互为镜像:
- 它们的两个根结点具有相同的值;
- 每个树的右子树都与另一个树的左子树镜像对称。
我们可以实现这样一个递归函数,通过「同步移动」两个指针的方法来遍历这棵树,p 指针和 q 指针一开始都指向这棵树的根,随后 p 右移时,q 左移;p 左移时,q 右移。每次检查当前 p 和 q 节点的值是否相等,如果相等再判断左右子树是否对称。
1 | /** |
236. 二叉树的最近公共祖先
给定一个二叉树, 找到该树中两个指定节点的最近公共祖先。
百度百科中最近公共祖先的定义为:“对于有根树 T 的两个节点 p、q,最近公共祖先表示为一个节点 x,满足 x 是 p、q 的祖先且 x 的深度尽可能大(一个节点也可以是它自己的祖先)。”
示例 1:
1 | 输入:root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 1 |
示例 2:
1 | 输入:root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 4 |
示例 3:
1 | 输入:root = [1,2], p = 1, q = 2 |
提示:
1 | 树中节点数目在范围 [2, 105] 内。 |
解题思路:
我们递归遍历整棵二叉树,定义 $f_{x}$ 表示 $x$ 节点的子树中是否包含 $p$ 节点或 $q$ 节点,如果包含为true
,否则为false
。那么符合条件的最近公共祖先 $x$ 一定满足如下条件:
$\left(f_{\text {lson }} & & f_{\text {rson }}\right) |\left((x=p | x=q) & &\left(f_{\text {lson }} | f_{\text {rson }}\right)\right)$
其中 ${\text{lson}}$ 和 ${\text{rson}}$ 分别代表 $x$ 节点的左孩子和右孩子。$\left(f_{\text {lson }} & & f_{\text {rson }}\right)$ 说明左子树和右子树均包含 $p$ 节点或 $q$ 节点,如果左子树包含的是 $p$ 节点,那么右子树只能包含 $q$ 节点,反之亦然,因为 $p$ 节点和 $q$ 节点都是不同且唯一的节点,因此如果满足这个判断条件即可说明 $x$ 就是我们要找的最近公共祖先。再来看第二条判断条件,这个判断条件即是考虑了 $x$ 恰好是 $p$ 节点或 $q$ 节点且它的左子树或右子树有一个包含了另一个节点的情况,因此如果满足这个判断条件亦可说明 $x$ 就是我们要找的最近公共祖先。
1 | /** |
617. 合并二叉树
给定两个二叉树,想象当你将它们中的一个覆盖到另一个上时,两个二叉树的一些节点便会重叠。
你需要将他们合并为一个新的二叉树。合并的规则是如果两个节点重叠,那么将他们的值相加作为节点合并后的新值,否则不为 NULL 的节点将直接作为新二叉树的节点。
示例 1:
1 | 输入: |
注意: 合并必须从两个树的根节点开始。
解题思路:
方法一:深度优先搜索
可以使用深度优先搜索合并两个二叉树。从根节点开始同时遍历两个二叉树,并将对应的节点进行合并。
两个二叉树的对应节点可能存在以下三种情况,对于每种情况使用不同的合并方式。
- 如果两个二叉树的对应节点都为空,则合并后的二叉树的对应节点也为空;
- 如果两个二叉树的对应节点只有一个为空,则合并后的二叉树的对应节点为其中的非空节点;
- 如果两个二叉树的对应节点都不为空,则合并后的二叉树的对应节点的值为两个二叉树的对应节点的值之和,此时需要显性合并两个节点。
对一个节点进行合并之后,还要对该节点的左右子树分别进行合并。这是一个递归的过程。
1 | func mergeTrees(root1 *TreeNode, root2 *TreeNode) *TreeNode { |
方法二:广度优先搜索
也可以使用广度优先搜索合并两个二叉树。首先判断两个二叉树是否为空,如果两个二叉树都为空,则合并后的二叉树也为空,如果只有一个二叉树为空,则合并后的二叉树为另一个非空的二叉树。
如果两个二叉树都不为空,则首先计算合并后的根节点的值,然后从合并后的二叉树与两个原始二叉树的根节点开始广度优先搜索,从根节点开始同时遍历每个二叉树,并将对应的节点进行合并。
使用三个队列分别存储合并后的二叉树的节点以及两个原始二叉树的节点。初始时将每个二叉树的根节点分别加入相应的队列。每次从每个队列中取出一个节点,判断两个原始二叉树的节点的左右子节点是否为空。如果两个原始二叉树的当前节点中至少有一个节点的左子节点不为空,则合并后的二叉树的对应节点的左子节点也不为空。对于右子节点同理。
如果合并后的二叉树的左子节点不为空,则需要根据两个原始二叉树的左子节点计算合并后的二叉树的左子节点以及整个左子树。考虑以下两种情况:
- 如果两个原始二叉树的左子节点都不为空,则合并后的二叉树的左子节点的值为两个原始二叉树的左子节点的值之和,在创建合并后的二叉树的左子节点之后,将每个二叉树中的左子节点都加入相应的队列;
- 如果两个原始二叉树的左子节点有一个为空,即有一个原始二叉树的左子树为空,则合并后的二叉树的左子树即为另一个原始二叉树的左子树,此时也不需要对非空左子树继续遍历,因此不需要将左子节点加入队列。
对于右子节点和右子树,处理方法与左子节点和左子树相同。
1 | func mergeTrees(root1 *TreeNode, root2 *TreeNode) *TreeNode { |
2.返回bool类型的结果(一般要求对二叉树是否符合某种性质做判别)
此类问题一般只在递归出口处可能返回true
,而在一般节点的判别时只返回false
的情形。返回true
表明递的过程结束,然而后续的节点不一定判别完了,会出现不应该的剪枝的情况。可以直接返回false
的情形是因为遇到了不符合要求的情况,下面的节点不用继续判断了,就直接返回false
。一般而言有下面这样的模板:
1 | func helper(root *TreeNode) bool { |
剑指 Offer 33. 二叉搜索树的后序遍历序列
输入一个整数数组,判断该数组是不是某二叉搜索树的后序遍历结果。如果是则返回 true
,否则返回 false
。假设输入的数组的任意两个数字都互不相同。
参考以下这颗二叉搜索树:
1 | 5 |
示例 1:
1 | 输入: [1,6,3,2,5] |
示例 2:
1 | 输入: [1,3,2,6,5] |
提示:
数组长度 <= 1000
解题思路:
二叉搜索树的后续遍历数组满足如下定义:
- 后序遍历:
[ 左子树 | 右子树 | 根节点 ]
,即遍历顺序为 “左、右、根” 。 - 二叉搜索树定义: 左子树中所有节点的值 < 根节点的值;右子树中所有节点的值 > 根节点的值;其左、右子树也分别为二叉搜索树。
递归解析:
- 递归出口:
len(postorder)==0
时,返回true
。 - 递:
划分左右子树: 遍历后序遍历的元素,寻找 第一个大于根节点 的节点,索引记为
flag
。此时,可划分出左子树区间[:flag]
、右子树区间[flag,len(postorder)−1]
、根节点索引为len(postorder)-1
。判断是否为二叉搜索树: 左子树区间
[0,flag−1]
内的所有节点都应 <postorder[len(postorder)-1]
。而第1.划分左右子树
步骤已经保证左子树区间的正确性,因此只需要判断右子树区间即可。右子树区间
[flag,len(postorder)-1]
内所有节点都应 >postorder[len(postorder)-1]
。实现方式为遍历,当遇到 <postorder[len(postorder)-1]
的节点则跳出。
- 归: 所有子树都需正确才可判定正确,因此使用 与逻辑符
&&
连接。
1 | func verifyPostorder(postorder []int) bool { |
剑指 Offer 26. 树的子结构
输入两棵二叉树A和B,判断B是不是A的子结构。(约定空树不是任意一个树的子结构)
B是A的子结构, 即 A中有出现和B相同的结构和节点值。
例如:
给定的树 A:
1 | 3 |
给定的树 B:
1 | 4 |
返回 true,因为 B 与 A 的一个子树拥有相同的结构和节点值。
示例 1:
1 | 输入:A = [1,2,3], B = [3,1] |
示例 2:
1 | 输入:A = [3,4,5,1,2], B = [4,1] |
限制:
1 | 0 <= 节点个数 <= 10000 |
解题思路:
两个函数
- isSubStructure()
- 用于递归遍历 A 中的所有节点,并判断当前节点 A 是否与 B 的根节点相同,相同则调用 helper( ) 进一步校验
- helper()
- 用于校验 B 是否与 A 的一个子树拥有相同的结构和节点值
- isSubStructure()
函数内容
isSubStructure()
- 如果当前节点 A == nil && B == nil ,返回true。
- 如果当前节点 A == nil | | B == nil ,返回 false。(由题目可知,空树不是任意一个树的子结构)
- 当在当前结点 A 中找到 B 的根节点时,进入helper () 递归校验
- ret == false,说明 B 的根节点不在当前 A 中,进入 A 的左子树进行递归查找
- ret 仍等于 false,则说明 B 的根节点不在当前 A 和左子树中,进入 A 的右子树进行递归查找。
helper()
如果 B == nil ,说明 B 已遍历完,返回 true
在 B != nil 的情况下,如果 A == nil ,说明 A 中节点不足以构成子结构 B ,返回 false
如果 A.Val != B.Val,不满足节点值相等条件,返回 false
A.Val == B.Val 继续递归校验 A B 左子树和右子树的结构和节点是否相同
1 | /** |
331. 验证二叉树的前序序列化
序列化二叉树的一种方法是使用前序遍历。当我们遇到一个非空节点时,我们可以记录下这个节点的值。如果它是一个空节点,我们可以使用一个标记值记录,例如 #
。
1 | _9_ |
例如,上面的二叉树可以被序列化为字符串 "9,3,4,#,#,1,#,#,2,#,6,#,#"
,其中 # 代表一个空节点。
给定一串以逗号分隔的序列,验证它是否是正确的二叉树的前序序列化。编写一个在不重构树的条件下的可行算法。
每个以逗号分隔的字符或为一个整数或为一个表示 null
指针的'#'
。
你可以认为输入格式总是有效的,例如它永远不会包含两个连续的逗号,比如 "1,,3"
。
示例 1:
1 | 输入: "9,3,4,#,#,1,#,#,2,#,6,#,#" |
示例 2:
1 | 输入: "1,#" |
示例 3:
1 | 输入: "9,#,#,1" |
解题思路:
我们可以定义一个概念,叫做槽位。一个槽位可以被看作「当前二叉树中正在等待被节点填充」的那些位置。
二叉树的建立也伴随着槽位数量的变化。每当遇到一个节点时:
- 如果遇到了空节点,则要消耗一个槽位;
- 如果遇到了非空节点,则除了消耗一个槽位外,还要再补充两个槽位。
此外,还需要将根节点作为特殊情况处理。
我们使用栈来维护槽位的变化。栈中的每个元素,代表了对应节点处剩余槽位的数量,而栈顶元素就对应着下一步可用的槽位数量。当遇到空节点时,仅将栈顶元素减 1;当遇到非空节点时,将栈顶元素减 1 后,再向栈中压入一个 2。无论何时,如果栈顶元素变为 0,就立刻将栈顶弹出。遍历结束后,若栈为空,说明没有待填充的槽位,因此是一个合法序列;否则若栈不为空,则序列不合法。此外,在遍历的过程中,若槽位数量不足,则序列不合法。
1 | func isValidSerialization(preorder string) bool { |
3.设置全局变量实时记录并更新递归过程中的极值
此类题目最终的输出是一个单一的值,我们需要维护一个全局变量来记录这一个单一值,并在递归遍历二叉树的过程中实时地更新这个值。用于递归的辅助函数的返回结果不必与这个值直接联系,要着眼于构建二叉树的节点间的关系。
124. 二叉树中的最大路径和
路径被定义为一条从树中任意节点出发,沿父节点-子节点连接,达到任意节点的序列。同一个节点在一条路径序列中 至多出现一次 。该路径 至少包含一个 节点,且不一定经过根节点。
路径和是路径中各节点值的总和。
给你一个二叉树的根节点 root ,返回其 最大路径和 。
示例 1:
1 | 输入:root = [1,2,3] |
示例 2:
1 | 输入:root = [-10,9,20,null,null,15,7] |
提示:
- 树中节点数目范围是
[1, 3 * 104]
-1000 <= Node.val <= 1000
解题思路:
- 首先考虑辅助函数的功能。考虑实现一个简化的函数
maxGain(node)
,该函数计算二叉树中的一个节点的最大贡献值,具体而言,就是在以该节点为根节点的子树中寻找以该节点为起点的一条路径,使得该路径上的节点值之和最大。具体而言,该函数的计算如下:
- 空节点的最大贡献值等于 0。
- 非空节点的最大贡献值等于节点值与其子节点中的最大贡献值之和(对于叶节点而言,最大贡献值等于节点值)。
例如,考虑如下二叉树。
1 | -10 |
叶节点 9、15、7 的最大贡献值分别为 9、15、7。得到叶节点的最大贡献值之后,再计算非叶节点的最大贡献值。节点 20 的最大贡献值等于 20+max(15,7)=35
。节点 −10 的最大贡献值等于 −10+max(9,35)=25
。上述计算过程是递归的过程,因此,对根节点调用函数 maxGain
,即可得到每个节点的最大贡献值。
根据函数 maxGain
得到每个节点的最大贡献值之后,如何得到二叉树的最大路径和?对于二叉树中的一个节点,该节点的最大路径和取决于该节点的值与该节点的左右子节点的最大贡献值,如果子节点的最大贡献值为正,则计入该节点的最大路径和,否则不计入该节点的最大路径和。维护一个全局变量 maxSum
存储最大路径和,在递归过程中更新 maxSum
的值,最后得到的 maxSum
的值即为二叉树中的最大路径和。
1 | /** |
543. 二叉树的直径
给定一棵二叉树,你需要计算它的直径长度。一棵二叉树的直径长度是任意两个结点路径长度中的最大值。这条路径可能穿过也可能不穿过根结点。
示例 :
给定二叉树
1 | 1 |
返回 3, 它的长度是路径 [4,2,1,3] 或者 [5,2,1,3]。
注意:两结点之间的路径长度是以它们之间边的数目表示。
解题思路:
任意一条路径均可以被看作由某个节点为起点,从其左儿子和右儿子向下遍历的路径拼接得到。假设我们知道对于某节点的左儿子向下遍历经过最多的节点数 $L$ (即以左儿子为根的子树的深度) 和其右儿子向下遍历经过最多的节点数 $R$ (即以右儿子为根的子树的深度),那么以该节点为起点的路径经过节点数的最大值即为 $L+R+1$ 。
最后的算法流程为:我们定义一个递归函数 depth(node)
计算 $d_{node}$,函数返回该节点为根的子树的深度。先递归调用左儿子和右儿子求得它们为根的子树的深度 $L$ 和 $R$ ,则该节点为根的子树的深度即为 $\max (L, R)+1$, 该节点的 $d_{node}$ 值为$L+R+1$。递归搜索每个节点并设一个全局变量 maxSum
来记录并随时更新最大直径。
1 | /** |
4.结果要求返回数组
由于递归算法本身在递归的过程中没法返回数组,所以可以通过设置全局变量的方式,在递归过程中动态的更新数组。
113. 路径总和 II
给你二叉树的根节点 root 和一个整数目标和 targetSum ,找出所有 从根节点到叶子节点 路径总和等于给定目标和的路径。
叶子节点 是指没有子节点的节点。
示例 1:
1 | 输入:root = [5,4,8,11,null,13,4,7,2,null,null,5,1], targetSum = 22 |
示例 2:
1 | 输入:root = [1,2,3], targetSum = 5 |
示例 3:
1 | 输入:root = [1,2], targetSum = 0 |
提示:
1 | 树中节点总数在范围 [0, 5000] 内 |
1 | /** |
129. 求根节点到叶节点数字之和
给你一个二叉树的根节点 root
,树中每个节点都存放有一个 0
到 9
之间的数字。
每条从根节点到叶节点的路径都代表一个数字:
- 例如,从根节点到叶节点的路径
1 -> 2 -> 3
表示数字123
。
计算从根节点到叶节点生成的 所有数字之和 。
叶节点 是指没有子节点的节点。
示例 1:
1 | 输入:root = [1,2,3] |
示例 2:
1 | 输入:root = [4,9,0,5,1] |
提示:
- 树中节点的数目在范围
[1, 1000]
内 0 <= Node.val <= 9
- 树的深度不超过
10
解题思路:
深度优先搜索是很直观的做法。从根节点开始,遍历每个节点,如果遇到叶子节点,则将叶子节点对应的数字加到数字之和。如果当前节点不是叶子节点,则计算其子节点对应的数字,然后对子节点递归遍历。
1 | /** |
257. 二叉树的所有路径
给你一个二叉树的根节点 root
,按 任意顺序 ,返回所有从根节点到叶子节点的路径。
叶子节点 是指没有子节点的节点。
示例 1:
1 | 输入:root = [1,2,3,null,5] |
示例 2:
1 | 输入:root = [1] |
提示:
- 树中节点的数目在范围
[1, 100]
内 -100 <= Node.val <= 100
解题思路:
- 当遍历到根节点时,将根节点的值转化成字符串后贴到路径串中,并将该条路径存入
ret
中; - 当不是根节点时,将根节点的值转化成字符串后贴到路径串中,并在其后加入符号
->
。
1 | /** |
863. 二叉树中所有距离为 K 的结点
给定一个二叉树(具有根结点 root
), 一个目标结点 target
,和一个整数值 K
。
返回到目标结点 target
距离为 K
的所有结点的值的列表。 答案可以以任何顺序返回。
示例 1:
1 | 输入:root = [3,5,1,6,2,0,8,null,null,7,4], target = 5, K = 2 |
提示:
- 给定的树是非空的。
- 树上的每个结点都具有唯一的值 0 <= node.val <= 500 。
- 目标结点 target 是树上的结点。
- 0 <= K <= 1000.
解题思路:
若将 target
当作树的根结点,我们就能从 target
出发,使用深度优先搜索去寻找与 target
距离为 k
的所有结点,即深度为 k
的所有结点。
由于输入的二叉树没有记录父结点,为此,我们从根结点 root
出发,使用深度优先搜索遍历整棵树,同时用一个哈希表记录每个结点的父结点。
然后从 target
出发,使用深度优先搜索遍历整棵树,除了搜索左右儿子外,还可以顺着父结点向上搜索。
代码实现时,由于每个结点值都是唯一的,哈希表的键可以用结点值代替。此外,为避免在深度优先搜索时重复访问结点,递归时额外传入来源结点 from
,在递归前比较目标结点是否与来源结点相同,不同的情况下才进行递归。通过标记源结点,如图结点2遍历到7时,由于存在7->2的边,可能会重复遍历。由于存在我们预先标记的源节点只有当cur != from
的时候才能继续遍历。
1 | /** |
二叉搜索树(BST)
二叉搜索树(BST)的特性
- 对于 BST 的每一个节点
node
,左子树节点的值都比node
的值要小,右子树节点的值都比node
的值大。 - 对于 BST 的每一个节点
node
,它的左侧子树和右侧子树都是 BST。 - 从做算法题的角度来看 BST,除了它的定义,还有一个重要的性质:BST 的中序遍历结果是有序的(升序)。
BST必知必会题
1. 判断BST的合法性(98. 验证二叉搜索树)
给定一个二叉树,判断其是否是一个有效的二叉搜索树。
假设一个二叉搜索树具有如下特征:
- 节点的左子树只包含小于当前节点的数。
- 节点的右子树只包含大于当前节点的数。
- 所有左子树和右子树自身必须也是二叉搜索树。
示例1:
1 | 输入: |
示例 2:
1 | 输入: |
解题思路:
对于每一个节点 **root
,代码值检查了它的左右孩子节点是否符合左小右大的原则;但是根据 BST 的定义,root
**的整个左子树都要小于 **root.val
**,整个右子树都要大于 **root.val
**。
问题是,对于某一个节点 root
,他只能管得了自己的左右子节点,怎么把 root
的约束传递给左右子树呢?我们通过使用辅助函数,增加函数参数列表,在参数中携带额外信息,将这种约束传递给子树的所有节点,这也是二叉树算法的一个小技巧吧。
1 | /** |
2. 在BST中搜索一个数
直接利用BST的性质前序遍历二叉搜索树,当当前节点值等于target
,直接返回true
,如果当前节点值大于target
,则在其左子树中寻找;如果小于target
,在其右子树中寻找。
1 | func isInBST(root *TreeNode, target int) bool { |
3. 在BST中插入一个数
涉及到“改”这一操作,就要返回TreeNode
类型了。
1 | func insertIntoBST(root *TreeNode, val int) *TreeNode { |
4. 在BST中删除一个数
这个问题稍微复杂,跟插入操作类似,先「找」再「改」。找到目标节点了,比方说是节点 A
,如何删除这个节点,这是难点。因为删除节点的同时不能破坏 BST 的性质。有三种情况,用图片来说明。
- 情况1:
A
恰好是末端节点,两个子节点都为空,那么可以直接将其删除。
- 情况2:
A
只有一个非空子节点,那么它要让这个孩子接替自己的位置。
- 情况 3:
A
有两个子节点,麻烦了,为了不破坏 BST 的性质,A
必须找到左子树中最大的那个节点,或者右子树中最小的那个节点来接替自己。我们以第二种方式讲解。
1 | func deleteNode(root *TreeNode, key int) *TreeNode { |
BST高频题
96. 不同的二叉搜索树
给你一个整数 n
,求恰由 n
个节点组成且节点值从 1
到 n
互不相同的 二叉搜索树 有多少种?返回满足题意的二叉搜索树的种数。
示例 1:
1 | 输入:n = 3 |
示例 2:
1 | 输入:n = 1 |
提示:
1 <= n <= 19
解题思路:(搬运自不同的二叉搜索树)
给定一个有序序列 $1⋯n$,为了构建出一棵二叉搜索树,我们可以遍历每个数字 $i$,将该数字作为树根,将 $1⋯(i−1)$ 序列作为左子树,将 $(i+1)⋯n$ 序列作为右子树。接着我们可以按照同样的方式递归构建左子树和右子树。
在上述构建的过程中,由于根的值不同,因此我们能保证每棵二叉搜索树是唯一的。由此可见,原问题可以分解成规模较小的两个子问题,且子问题的解可以复用。因此,我们可以想到使用动态规划来求解本题。
题目要求是计算不同二叉搜索树的个数。为此,我们可以定义两个函数:
- $G(n)$: 长度为 $n$ 的序列能构成的不同二叉搜索树的个数。
- $F(i,n)$: 以 $i$ 为根、序列长度为 $n$ 的不同二叉搜索树个数 $(1≤i≤n)$。
可见,$G(n)$ 是我们求解需要的函数。稍后我们将看到,$G(n)$ 可以从 $F(i,n)$ 得到,而 $F(i,n)$ 又会递归地依赖于 $G(n)$。
首先,根据上一节中的思路,不同的二叉搜索树的总数 $G(n)$,是对遍历所有 $(1≤i≤n)$ 的 $F(i,n)$ 之和。换言之:
$G(n)=\sum_{i=1}^{n} F(i, n)$
对于边界情况,当序列长度为 11(只有根)或为 00(空树)时,只有一种情况,即:
$G(0)=1, \quad G(1)=1$
给定序列 $1⋯n$,我们选择数字 $i$ 作为根,则根为 $i$ 的所有二叉搜索树的集合是左子树集合和右子树集合的笛卡尔积,对于笛卡尔积中的每个元素,加上根节点之后形成完整的二叉搜索树,如下图所示:
举例而言,创建以 3 为根、长度为 7 的不同二叉搜索树,整个序列是 $[1,2,3,4,5,6,7]$,我们需要从左子序列 $[1,2]$ 构建左子树,从右子序列 $[4,5,6,7]$ 构建右子树,然后将它们组合(即笛卡尔积)。对于这个例子,不同二叉搜索树的个数为 $F(3,7)$。我们将 $[1,2]$ 构建不同左子树的数量表示为 $G(2)$, 从 $[4,5,6,7]$ 构建不同右子树的数量表示为 $G(4)$,注意到 $G(n)$ 和序列的内容无关,只和序列的长度有关。于是,$F(3,7)=G(2)⋅G(4)$。 因此,我们可以得到以下公式:
$F(i, n)=G(i-1) \cdot G(n-i)$
将公式结合,可以得到 $G(n)$ 的递归表达式:
$G(n)=\sum_{i=1}^{n} G(i-1) \cdot G(n-i)$
至此,我们从小到大计算 $G$ 函数即可,因为 $G(n)$ 的值依赖于 $G(0)⋯G(n−1)$。
1 | func numTrees(n int) int { |
95. 不同的二叉搜索树 II
给你一个整数 n
,请你生成并返回所有由 n
个节点组成且节点值从 1
到 n
互不相同的不同 二叉搜索树 。可以按 任意顺序 返回答案。
示例 1:
1 | 输入:n = 3 |
示例 2:
1 | 输入:n = 1 |
提示:
1 <= n <= 8
解题思路:
二叉搜索树关键的性质是根节点的值大于左子树所有节点的值,小于右子树所有节点的值,且左子树和右子树也同样为二叉搜索树。因此在生成所有可行的二叉搜索树的时候,假设当前序列长度为 n,如果我们枚举根节点的值为 i,那么根据二叉搜索树的性质我们可以知道左子树的节点值的集合为 [1…i−1],右子树的节点值的集合为 [i+1…n]。而左子树和右子树的生成相较于原问题是一个序列长度缩小的子问题,因此我们可以想到用回溯的方法来解决这道题目。
我们定义 generateTrees(start, end)
函数表示当前值的集合为 [start,end]
,返回序列 [start,end]
生成的所有可行的二叉搜索树。按照上文的思路,我们考虑枚举 [start,end]
中的值 i 为当前二叉搜索树的根,那么序列划分为了 [start,i−1]
和 [i+1,end]
两部分。我们递归调用这两部分,即 generateTrees(start, i - 1)
和 generateTrees(i + 1, end)
,获得所有可行的左子树和可行的右子树,那么最后一步我们只要从可行左子树集合中选一棵,再从可行右子树集合中选一棵拼接到根节点上,并将生成的二叉搜索树放入答案数组即可。
递归的入口即为 generateTrees(1, n)
,出口为当 start>end 的时候,当前二叉搜索树为空,返回空节点即可。
1 | /** |
99. 恢复二叉搜索树
给你二叉搜索树的根节点 root ,该树中的两个节点被错误地交换。请在不改变其结构的情况下,恢复这棵树。
进阶:使用 O(n) 空间复杂度的解法很容易实现。你能想出一个只使用常数空间的解决方案吗?
示例 1:
1 | 输入:root = [1,3,null,null,2] |
示例 2:
1 | 输入:root = [3,1,4,null,null,2] |
提示:
- 树上节点的数目在范围
[2, 1000]
内 -231 <= Node.val <= 231 - 1
解题思路:
我们需要考虑两个节点被错误地交换后对原二叉搜索树造成了什么影响。对于二叉搜索树,我们知道如果对其进行中序遍历,得到的值序列是递增有序的,而如果我们错误地交换了两个节点,等价于在这个值序列中交换了两个值,破坏了值序列的递增性。
我们来看下如果在一个递增的序列中交换两个值会造成什么影响。假设有一个递增序列 a=[1,2,3,4,5,6,7]
。如果我们交换两个不相邻的数字,例如 2 和 6,原序列变成了 a=[1,6,3,4,5,2,7]
,那么显然序列中有两个位置不满足 $a_{i}$<$a_{i+1}$,在这个序列中体现为6>3,5>2,因此只要我们找到这两个位置,即可找到被错误交换的两个节点。如果我们交换两个相邻的数字,例如 2 和 3,此时交换后的序列只有一个位置不满足 $a_{i}$<$a_{i+1}$。因此整个值序列中不满足条件的位置或者有两个,或者有一个。
至此,解题方法已经呼之欲出了:
- 找到二叉搜索树中序遍历得到值序列的不满足条件的位置。
- 如果有两个,我们记为 i 和 j(i<j 且 $a_{i}$>$a_{i+1}$ && $a_{j}$>$a_{j+1}$,那么对应被错误交换的节点即为$a_{i}$对应的节点和$a_{i+1}$对应的节点,我们分别记为 x 和 y。
- 如果有一个,我们记为 i,那么对应被错误交换的节点即为$a_{i}$对应的节点和$a_{i+1}$对应的节点,我们分别记为 x 和 y。
- 交换 x 和 y 两个节点即可。
实现部分,本方法开辟一个新数组 nums
来记录中序遍历得到的值序列,然后线性遍历找到两个位置 i 和 j,并重新遍历原二叉搜索树修改对应节点的值完成修复,具体实现可以看下面的代码。
1 | /** |
- Post title:递归算法解决二叉树问题
- Post author:洪笳淏
- Create time:2021-08-18 18:00:00
- Post link:https://jiahaohong1997.github.io/2021/08/18/递归算法解决二叉树问题/
- Copyright Notice:All articles in this blog are licensed under BY-NC-SA unless stating additionally.