构建二叉树问题(前序和中序)

question

给定一棵二叉树的前序遍历 `preorder` 和中序遍历 `inorder` ,请从中构建二叉树,返回二叉树的根节点。假设二叉树中没有值重复的节点(如下图所示)。

构建二叉树的示例数据

判断是否为分治问题

原问题定义为从 preorderinorder 构建二叉树,是一个典型的分治问题。

  • 问题可以分解:从分治的角度切入,我们可以将原问题划分为两个子问题:构建左子树、构建右子树,加上一步操作:初始化根节点。而对于每棵子树(子问题),我们仍然可以复用以上划分方法,将其划分为更小的子树(子问题),直至达到最小子问题(空子树)时终止。
  • 子问题是独立的:左子树和右子树是相互独立的,它们之间没有交集。在构建左子树时,我们只需关注中序遍历和前序遍历中与左子树对应的部分。右子树同理。
  • 子问题的解可以合并:一旦得到了左子树和右子树(子问题的解),我们就可以将它们链接到根节点上,得到原问题的解。

如何划分子树

根据以上分析,这道题可以使用分治来求解,但如何通过前序遍历 preorder 和中序遍历 inorder 来划分左子树和右子树呢

根据定义,preorderinorder 都可以划分为三个部分。

  • 前序遍历:[ 根节点 | 左子树 | 右子树 ] ,例如上图的树对应 [ 3 | 9 | 2 1 7 ]
  • 中序遍历:[ 左子树 | 根节点 | 右子树 ] ,例如上图的树对应 [ 9 | 3 | 1 2 7 ]

以上图数据为例,我们可以通过下图所示的步骤得到划分结果。

  1. 前序遍历的首元素 3 是根节点的值。
  2. 查找根节点 3 在 inorder 中的索引,利用该索引可将 inorder 划分为 [ 9 | 3 | 1 2 7 ]
  3. 根据 inorder 的划分结果,易得左子树和右子树的节点数量分别为 1 和 3 ,从而可将 preorder 划分为 [ 3 | 9 | 2 1 7 ]

在前序遍历和中序遍历中划分子树

基于变量描述子树区间

根据以上划分方法,我们已经得到根节点、左子树、右子树在 preorderinorder 中的索引区间。而为了描述这些索引区间,我们需要借助几个指针变量。

  • 将当前树的根节点在 preorder 中的索引记为 ii
  • 将当前树的根节点在 inorder 中的索引记为 mm
  • 将当前树在 inorder 中的索引区间记为 [l,r][l, r]

如下表所示,通过以上变量即可表示根节点在 preorder 中的索引,以及子树在 inorder 中的索引区间。

  根节点和子树在前序遍历和中序遍历中的索引

根节点在 preorder 中的索引 子树在 inorder 中的索引区间
当前树 ii [l,r][l, r]
左子树 i+1i + 1 [l,m1][l, m-1]
右子树 i+1+(ml)i + 1 + (m - l) [m+1,r][m+1, r]

请注意,右子树根节点索引中的 (ml)(m-l) 的含义是“左子树的节点数量”,建议结合下图理解。

根节点和左右子树的索引区间表示

代码实现

为了提升查询 mm 的效率,我们借助一个哈希表 hmap 来存储数组 inorder 中元素到索引的映射:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def dfs(
preorder: list[int],
inorder_map: dict[int, int],
# i为根节点在前序的索引,[l,r]为子树在中序的索引区间
i: int,
l: int,
r: int,
) -> TreeNode | None:
"""构建二叉树:分治"""
# 子树区间为空时终止
if r - l < 0:
return None
# 初始化根节点
root = TreeNode(preorder[i])
# 查询 m ,从而划分左右子树
m = inorder_map[preorder[i]]
# 子问题:构建左子树,左子树的根节点在前序的索引为i+1,左子树在中序的索引区间为[l,m-1]
root.left = dfs(preorder, inorder_map, i + 1, l, m - 1)
# 子问题:构建右子树,右子树的根节点在前序的索引为i+1+m-l(m-l为左子树的节点数量),右子树在中序的索引区间为[m+1,r]
root.right = dfs(preorder, inorder_map, i + 1 + m - l, m + 1, r)
# 返回根节点
return root
1
2
3
4
5
6
def build_tree(preorder: list[int], inorder: list[int]) -> TreeNode | None:
"""构建二叉树"""
# 初始化哈希表,存储 inorder 元素到索引的映射
inorder_map = {val: i for i, val in enumerate(inorder)}
root = dfs(preorder, inorder_map, 0, 0, len(inorder) - 1)
return root

下图展示了构建二叉树的递归过程,各个节点是在向下“递”的过程中建立的,而各条边(引用)是在向上“归”的过程中建立的。

构建二叉树的递归过程

built_tree_step2

built_tree_step3

built_tree_step4

built_tree_step5

built_tree_step6

built_tree_step7

built_tree_step8

built_tree_step9

每个递归函数内的前序遍历 preorder 和中序遍历 inorder 的划分结果如下图所示。

每个递归函数中的划分结果

设树的节点数量为 nn ,初始化每一个节点(执行一个递归函数 dfs() )使用 O(1)O(1) 时间。因此总体时间复杂度为 O(n)O(n)

哈希表存储 inorder 元素到索引的映射,空间复杂度为 O(n)O(n) 。在最差情况下,即二叉树退化为链表时,递归深度达到 nn ,使用 O(n)O(n) 的栈帧空间。因此总体空间复杂度为 O(n)O(n)

前序和后序构建树(没有中序构建出的树就是不唯一)

给定一棵二叉树的前序遍历 preorder 和后序遍历 postorder ,请从中构建二叉树,返回二叉树的根节点。假设二叉树中没有值重复的节点。

分析

前序遍历为:(根节点)(前序遍历左子树)(前序遍历右子树)
后序遍历为:(后序遍历左子树)(后序遍历右子树)(根节点)
例如,二叉树序列化表示为[1,2,3,4,5,6,7],前序遍历为[1]+[2,4,5]+[3,6,7],后序遍历为[4,5,2]+[6,7,3]+[1]
如果我们知道左子树有多少节点,可以分别对左子树和右子树进行分组,并递归生成树的每个子树

算法

令左子树有L个节点,左子树的根节点为pre[1],同时出现在后序遍历左子树的最后,所以pre[1] = post[L-1],所以左子树节点个树L = post.index(pre[1])+1

(ps:左子树根节点为上面的2,这里的2作为根节点的左子节点,在前序遍历中出现在左子树的第一个,那么在后序遍历中一定是左子树的最后一个,因为在后序遍历中左子树的根符合左、右、根,就是在左子树的最后一个(中的左、右、根其中的根就在最后),所以可以找到后序遍历中的2作为分界点。2,4,5对应4, 5, 2

在递归步骤中,左子树由前序pre[1:L+1]和后序post[0:L]重新分支,右子树由pre[L+1:N]pre[L:N-1]重新分支。(N为最后一个元素的索引)

1
2
3
4
5
6
7
8
9
10
11
def construct(self, pre, post):
if not pre: return None
root = TreeNode(pre[0])
if len(pre) == 1: return root
# L为左子树根节点在后序遍历中的下一个位置,表示左子树节点的个数
L = post.index(pre[1]) + 1
# 左子树对应例子中前序的[2,4,5]和后序的[4,5,2]
root.left = self.construct(pre[1:L+1], post[:L])
# 右子树对应例子中前序的[3,6,7]和后序的[6,7,3]
root.right = self.construct(pre[L+1:], post[L:-1])
return root

总结

无论是前中序还是前后序构造二叉树,关键点都是划分左子树和右子树

前中序(中序中根节点有意义,左边为左子树区间,右边为右子树区间)

通过左子树根节点、左子树在中序的索引区间和右子树根节点、右子树在中序的索引区间来划分左右子树
左子树在中序的索引区间为[l,m-1],右子树在中序的索引区间为[m+1,r](m为根节点在中序的位置)

前后序(后序中根节点在末尾,没有意义,直接找左子树的根节点,在左子树序列的末尾)

通过左子树根节点的位置和左子树节点数量(通过左子树在后序中的位置确定)来划分左右子树
1.左子树区间划分
前序中左子树根节点左子树节点数量对应索引+1为左子树区间pre[1:L+1]([2,4,5]),后序中从左子树根节点为左子树区间post[0:L]([4,5,2]),因为后序遍历就是左、右、根
左子树对应例子中前序的pre[1:L+1]和后序的post[0:L]
2.右子树区间划分
前序中左子树节点数量对应索引+1最后一个元素为右子树区间pre[L+1:]([3,6,7]),后序中从左子树根节点倒数第二个元素(去掉最后一个元素为根节点)为右子树区间post[L:-1]([6,7,3])