在第二轮刷题一开始,我们是整理了关于数组的五道 LeetCode 题,按照很多算法训练的顺序,一般紧跟数组的是链表、树,那么我们第二篇就集中来整理下树的题目。之前提到,这是我们第二轮刷题,在先前刷到二叉树题目时,曾经整理过两篇关于树的基础知识点和四种遍历方法:
二叉树专题一:基础知识点,前中后序遍历
二叉树专题二:层级遍历及随机题目
如果只是刷一遍题目,可能了解如上的四种遍历代码模版便足够,但这样缺点很明显:只知道这样写代码,别人一问却不知其背后的道理——换言之,只是会解题而已。今天再次回顾整理树的题目,我们要着眼于解决树问题的理念与思路,吃透其背后的解法。
我们之前了解过树的概念:树是一种抽象数据类型或是实现这种抽象数据类型的数据结构,用来模拟具有树状结构性质的数据集合。它是由 n(n>0) 个有限结点组成一个具有层次关系的集合。
当树中每个节点最多只有两个分支、或子节点时,便是我们常说的二叉树。
当树是一棵空树;或其作为二叉树,其左子树上所有结点值均小于根结点值,其右子树上所有结点值均大于根结点值,且其左子树和右子树也符合如上规律时,我们称该树为二叉搜索树。
通常关于二叉树题目的解答区域内,也会有如下关于树结点的定义,一个 TreeNode 类,在其实例化时会定义父结点值 val,左子结点 left 和右子结点 right:
# Definition for a binary tree node.
class TreeNode:
def __init__(self, x):
self.val = x
self.left = None
self.right = None
定义中子结点均为 None,但真到题目中子结点也会是 TreeNode 的实例。此外,题目中多用根结点 root 来代表一棵树作为函数的输入,因为通过该根结点我们可以向下展开得到树中所有结点;或者用一个遍历各结点的列表来作为输入,我们把它当成其树中各结点组成的列表即可。
了解完如上关于树的基本概念后,我们也要明确树的“意义”。有数组、链表这些数据结构,为什么还要引入树这种结构来存储数据呢?这是因为数组、链表只能一维储存数据,即当前数据往下走,没有分支可选;但我们生活中,很多数据的下一个状态是个多项选择,比如我们漫漫人生路,每次面临抉择,是可以做多项选择的,这种面临选择的时刻我们即处于一个父结点,面对的选项为各种子结点,不过可惜的是树中可以回溯到上一结点,但人生却是单项的。
通过之前第一轮刷题时,也很明显感受到,二叉树的题解中出现递归的频率非常高,这是因为树的结构就是由一个父结点不断重复产生其子结点,与递归不断重复执行相同过程是一致的。所以结合着树的题目来练习递归解法,不仅可以加深对递归理解,也能够对树的结构和特定有更清晰的理解。
接下来我们重新回顾之前的题目,先尝试递归实现,再分析之前发现的套路模版,进一步加深对各题目的印象,这样之后遇到题目就不再畏惧其引申与变形了。
这次我们还是实时记录回顾各题目的思路,5-10 分钟考虑,没头绪就看题解,等消化了再来重新做,直到掌握。
题目一
第 144 题:二叉树的前序遍历
难度:中等
给定一个二叉树,返回它的前序遍历。
示例:
输入: [1,null,2,3]
1
\
2
/
3
输出: [1,2,3]
自行尝试
首先回顾下前序遍历,这里前指根结点和子结点之间的位置关系,根-左-右,根结点位于子结点前面,所以为前序遍历:
如图,从根结点开始,最初根-左-右为 [8,3,10],但紧接着 3 作为左子树的根结点要同样进行前序遍历,这里就相当于对该结点调用前序遍历形成递归,同理对 10 这个右子结点也要进行前序遍历来填充我们的遍历列表。这样分析下来便可以写出递归实现代码:
# class TreeNode:
# def __init__(self, x):
# self.val = x
# self.left = None
# self.right = None
class Solution:
def preorderTraversal(self, root: TreeNode) -> List[int]:
# 定义前序遍历的函数
def helper(node):
# 终止条件,空结点返回空列表
if not node:
return []
# 返回 根-左-右,根为根结点处值所在列表,左和右则递归执行 helper 函数,将三个结果列表加起来即最终结果
return [node.val] + helper(node.left) + helper(node.right)
# 从根结点开始执行该函数,将结果返回
return helper(root)
当然,如果不想额外定义这函数,也可以直接在 preorderTraversal 这方法基础上进行递归:
# 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 preorderTraversal(self, root: TreeNode) -> List[int]:
if not root:
return []
return [root.val] + self.preorderTraversal(root.left) + self.preorderTraversal(root.right)
还记得我们之前看到的颜色标记法模版吗?在二叉树专题一里,我们用其完成了前中后序遍历,此时有了递归实现遍历的经验之后,便可以发现其实现原理是自己建一个栈(可以理解为列表,但特点是后进的先出),来模拟递归过程来实现对各结点的全记录。
比如简单些只有 [8,3,10] 三个结点,我们建立的栈只要按照相反的顺序收录 右-左-根 结点,再通过栈输出,即可得到前序遍历结果。那如何区分哪些结点还要进行前序遍历、那些结点直接输出值呢?这就是颜色标记的作用,初次收录该结点时,该结点是以子结点形式出现的、还未进行前序遍历,当再次遇到该结点时、其已为父结点要输出值了。颜色标记即通过数字 1 和 0 来标记其出现的情况,代码实现:
# Definition for a binary tree node.
# class TreeNode:
# def __init__(self, x):
# self.val = x
# self.left = None
# self.right = None
class Solution:
def preorderTraversal(self, root: TreeNode) -> List[int]:
# 以 (状态,结点) 的元组形式记录每个结点,最初栈中只有根结点
stack = [(1,root)]
result = []
# 若栈非空,则循环
while stack:
# 通过列表的 pop() 来实现栈的后进先出
s,node = stack.pop()
# 空结点跳过
if node is None:
continue
# 若标记为 1,要对其进行遍历,按前序的相反顺序记录结点
if s==1:
# 前序的倒序记录,子结点标记为 1,已经标过 1 的父结点状态置为 0
stack.append((1,node.right))
stack.append((1,node.left))
stack.append((0,node))
# 若标记为 0,将结点值记录到结果列表中
else:
result.append(node.val)
# 返回结果列表
return result
注意,这道题目要求以列表形式输出结果,如果还有其它操作,完全可以在 result.append(node.val) 这一步中去处理、实现,这样就不必等到所有结果全部拿到才去做处理。目前遇到不少题目都是问到做些判断,如何加速,便可在这里去实现。
学习题解
我们再来翻翻国外大神点赞最高的题解:
def preorderTraversal(self, root):
ret = []
stack = [root]
while stack:
node = stack.pop()
if node:
ret.append(node.val)
stack.append(node.right)
stack.append(node.left)
return ret
# 来源:https://leetcode.com/problems/binary-tree-preorder-traversal/discuss/45273/Very-simple-iterative-Python-solution
哈哈哈,看过这代码,就会发现我们之前的颜色标记法代码的繁琐之处,在 s 为 1 时我们最后把 (0,node) 塞到了 stack 中,然而下一个循环就会通过 stack.pop() 将其取出,再进入 s 为 0 把 node.val 加入到结果列表中。那么,我们就完全如同如上题解代码中这般,去掉这个添加、再取出过程,直接将其添加到结果列表中!
这样一来,我们的代码可以更简洁了!但是,这里可以精简是因为栈中对父结点的处理恰好连在一起可以省略,若是中序、后序遍历还要看情况!
题目二
第 94 题:二叉树的中序遍历
难度:中等
给定一个二叉树,返回它的中序 遍历。
示例:
输入: [1,null,2,3]
1
\
2
/
3
输出: [1,3,2]
自行尝试
前序遍历是 根-左-右,到了中序遍历,即根结点要位于子结点中间,变成了 左-根-右:
有了之前的经验,我们直接上手代码,先是递归实现:
# Definition for a binary tree node.
# class TreeNode:
# def __init__(self, x):
# self.val = x
# self.left = None
# self.right = None
class Solution:
def inorderTraversal(self, root: TreeNode) -> List[int]:
if not root:
return []
return self.inorderTraversal(root.left) + [root.val] + self.inorderTraversal(root.right)
只是调整了下根结点和子结点递归调用的位置顺序。那么接下来我们来先看下标记法能否应用刚习得的精简方法:
# Definition for a binary tree node.
# class TreeNode:
# def __init__(self, x):
# self.val = x
# self.left = None
# self.right = None
class Solution:
def inorderTraversal(self, root: TreeNode) -> List[int]:
# 状态标记法实现
stack = [(1,root)]
result = []
while stack:
s,node = stack.pop()
if not node:
continue
if s:
stack.append((1,node.right))
stack.append((0,node))
stack.append((1,node.left))
else:
result.append(node.val)
return result
注意,当 s 标记为 1 时,最后栈中加入的是 (1,node.left),那么接下来 pop() 出的也是该结点,无法与 s 为 0 状态相连,所以就没法按照之前看到的精简代码来实现。
学习题解
我们再去看看这次国外大神是否还有惊喜:
# recursively 递归
class Solution:
def inorderTraversal1(self, root):
res = []
self.helper(root, res)
return res
def helper(self, root, res):
if root:
self.helper(root.left, res)
res.append(root.val)
self.helper(root.right, res)
我们之前的递归形式是 “左子结点递归调用 + [node.val] + 右子结点递归调用”,看着简洁,但如果想再其间做些额外处理可能难以拆分,上面这份递归代码就将该过程拆分,但需要额外将 res 这个列表参数进行传递。
# iteratively
def inorderTraversal(self, root):
res, stack = [], []
while True:
# 深度遍历最左一侧所有结点
while root:
stack.append(root)
root = root.left
# 若栈为空,直接返回空列表
if not stack:
return res
# 逐个返回栈中的左结点
node = stack.pop()
# 结果中添加左结点
res.append(node.val)
# 若有右结点,赋值过来再次代入循环遍历该结点以下的左结点
root = node.right
# 来源:https://leetcode.com/problems/binary-tree-inorder-traversal/discuss/31381/Python-recursive-and-iterative-solutions.
这代码看着像是深度优先搜索,先取最左侧所有左子结点,取到尽头,取出结点存入结果,若有右子结点,则进入到右子结点遍历其所有最左侧左子结点;若无右子结点,则通过 stack.pop() 回溯到当前的根结点,再去找其右子结点,从而实现按 左-根-右 的中序遍历记录各结点值。
这解法看看就好,感觉理解不到位的话还挺难写的。。
题目三
第 145 题:二叉树的后序遍历
难度:困难
给定一个二叉树,返回它的 后序 遍历。
示例:
输入: [1,null,2,3]
1
\
2
/
3
输出: [3,2,1]
自行尝试
老样子,后序即根结点位于子结点之后,按 左-右-根 的顺序遍历:
这篇文章中我已经刻意练习三遍了,但没写文章时每次重温题目都会手敲一遍,这样才牢记于心,递归实现:
# Definition for a binary tree node.
# class TreeNode:
# def __init__(self, x):
# self.val = x
# self.left = None
# self.right = None
class Solution:
def postorderTraversal(self, root: TreeNode) -> List[int]:
if not root:
return []
return self.postorderTraversal(root.left)+self.postorderTraversal(root.right)+[root.val]
状态标记法:
# Definition for a binary tree node.
# class TreeNode:
# def __init__(self, x):
# self.val = x
# self.left = None
# self.right = None
class Solution:
def postorderTraversal(self, root: TreeNode) -> List[int]:
result = []
stack = [(1,root)]
while stack:
s,node = stack.pop()
if not node: continue
if s:
stack.append((0,node))
stack.append((1,node.right))
stack.append((1,node.left))
else:
result.append(node.val)
return result
可以看到 s 状态为 1 时栈中最后加入的是 (1,node.left),还是无法与之后的 s 状态 0 相连,没法套用前序遍历时的精简处理。
学习题解
继续观摩国外大神点赞最高的解法,同一个大神带来的两个解法:
class Solution:
# @param {TreeNode} root
# @return {integer[]}
def postorderTraversal(self, root):
traversal, stack = [], [(root, False)]
while stack:
node, visited = stack.pop()
if node:
if visited:
# add to result if visited
traversal.append(node.val)
else:
# post-order
stack.append((node, True))
stack.append((node.right, False))
stack.append((node.left, False))
return traversal
嗯,与我们的状态标记法是一致的,用 True 和 False 来标记状态,但是第二个解法,给跪了!
class Solution:
# @param {TreeNode} root
# @return {integer[]}
def postorderTraversal(self, root):
traversal, stack = [], [root]
while stack:
node = stack.pop()
if node:
# pre-order, right first
traversal.append(node.val)
stack.append(node.left)
stack.append(node.right)
# reverse result
return traversal[::-1]
# 来源:https://leetcode.com/problems/binary-tree-postorder-traversal/discuss/45785/Share-my-two-Python-iterative-solutions-post-order-and-modified-preorder-then-reverse
是不是似曾相识,采用前序遍历然后取倒序?那么问题来了,我们上面三张图示中,同一棵二叉树,前序遍历拿到的结果是 [8,3,1,6,4,7,10,14,13],后续遍历拿到的结果
[1,4,7,6,3,13,14,10,8],怎么就相反了?
注意看,这里 stack 是先添加 node.left 再添加 node.right;而最初前序遍历中是按 根-左-右 的倒序、即先添加 node.right 再添加 node.left 的!我们可以将上面这方法理解为颜色标记法中在 s 为 1状态时最后 stack.append((0,node)) 然后接下来又立即在 s 状态 0 时将其取出,这样栈中添加结点顺序为 左-右-根,那么倒序记录到结果列表中成了 根-右-左,最后再取倒序又变成了 左-右-根!
或者不借助状态标记,在这段代码中,stack 栈中加子结点的顺序是先左后右,那么相应地加到结果列表中的顺序是 根-右-左,最后倒序得到 左-右-根,这样考虑更清晰。这么写够清晰简洁,但就怕熟悉度不够的话与前序遍历搞混、以及最后忘记倒序等。
题目四
第 589 题:N叉树的前序遍历
难度:简单
给定一个 N 叉树,返回其节点值的前序遍历。
例如,给定一个 3叉树 :
返回其前序遍历: [1,3,5,6,2,4]。
自行尝试
LeetCode 答题区域注释代码中,有对 N 叉树类的定义:
"""
# Definition for a Node.
class Node:
def __init__(self, val=None, children=None):
self.val = val
self.children = children
"""
显然,这里 children 是子结点列表,可以通过自测代码打印出来。这么看来 N 叉树只不过将二叉树左右两子结点 left、right 双变量换成了一个不定个数的列表,其处理过程是一致的。
首先递归,沿用熟悉的结果列表与递归调用相加的形式:
class Solution:
def preorder(self, root: 'Node') -> List[int]:
if not root:
return []
# 前序遍历,根在前
result = [root.val]
# 遍历子结点,递归调用并与resul整合到一起
for itm in root.children:
result+=self.preorder(itm)
# 返回结果列表
return result
通过状态标记以及栈的迭代实现:
class Solution:
def preorder(self, root: 'Node') -> List[int]:
stack = [(1,root)]
result=[]
while stack:
s,node = stack.pop()
if not node:
continue
if s:
# 前序为根-子结点,倒序为 子结点列表倒序-根
for itm in node.children[::-1]:
stack.append((1,itm))
stack.append((0,node))
else:
result.append(node.val)
return result
结合着二叉树前序遍历时那一版精简代码,我们可以去掉标记,代码简化为如下:
class Solution:
def preorder(self, root: 'Node') -> List[int]:
stack = [root]
result = []
while stack:
node = stack.pop()
if node:
result.append(node.val)
for itm in node.children[::-1]:
stack.append(itm)
return result
学习题解
继续翻看国外大神点赞最高的题解:
class Solution:
def preorder(self, root: 'Node') -> List[int]:
ret, q = [], root and [root]
while q:
node = q.pop()
ret.append(node.val)
q += [child for child in node.children[::-1] if child]
return ret
基本与我们精简后代码一致,不同点在于其向栈中添加时加了判断,这样就源头是避免 None 入栈,也不用后续检查,这一点可以借鉴。推子结点入栈时,采用列表解析先生成新的列表,再直接列表拼接,值得学习。
题目五
第 590 题:N叉树的后序遍历
难度:简单
给定一个 N 叉树,返回其节点值的后序遍历。
例如,给定一个 3叉树 :
返回其后序遍历: [5,6,3,2,4,1].
自行尝试
后续遍历,即 子结点-根 的顺序,注意子结点位于 children 列表中,其余处理方式也同二叉树的后序遍历。
首先是递归法:
class Solution:
def postorder(self, root: 'Node') -> List[int]:
if not root:
return []
result = []
for itm in root.children:
result += self.postorder(itm)
result += [root.val]
return result
通过状态标记、栈实现的迭代:
class Solution:
def postorder(self, root: 'Node') -> List[int]:
result = []
stack = [(1,root)]
while stack:
s,node = stack.pop()
if not node:
continue
if s:
stack.append((0,node))
for item in node.children[::-1]:
stack.append((1,item))
else:
result.append(node.val)
return result
学习题解
仍然是一位大神带来的递归、迭代双解法:
def postorder(self, root):
"""
:type root: Node
:rtype: List[int]
"""
res = []
if root == None: return res
def recursion(root, res):
for child in root.children:
recursion(child, res)
res.append(root.val)
recursion(root, res)
return res
同之前看到的类似,自定义的递归函数会传递 res 这个结果列表,避免我们代码中相加来拼接。
def postorder(self, root):
res = []
if root == None: return res
stack = [root]
while stack:
curr = stack.pop()
res.append(curr.val)
stack.extend(curr.children)
return res[::-1]
# 来源:https://leetcode.com/problems/n-ary-tree-postorder-traversal/discuss/155806/Python-iterative-and-recursive-solution-with-explanation
类似于二叉树的后序遍历,之前是按 推出根后按 左-右 顺序添加到栈,再按 根-右-左 的顺序记录到结果列表中,最终通过翻转得出 左-右-根 得到二叉树的后序遍历;类似地,在推出根后按正常顺序将子结点添加到栈,之后通过推出按 根-倒序子结点列表 顺序加入结果列表,最终翻转一下得到 子结点正序-根 的 N 叉树后续遍历结果。
问题六
第 429 题:N叉树的层序遍历
给定一个 N 叉树,返回其节点值的层序遍历。 (即从左到右,逐层遍历)。
例如,给定一个 3叉树 :
返回其层序遍历:
[
[1],
[3,2,4],
[5,6]
]
自行尝试
对于层序遍历,之前接触二叉树层序遍历时整理了一套广度优先搜索的解法:维护一个记录层结点的列表,最初第一层只有根结点,首先取该列表中结点值组成列表加入到结果中,然后遍历层中结点,依次将其子结点记录以生成下一层的列表,以此来实现迭代。
"""
# Definition for a Node.
class Node:
def __init__(self, val=None, children=None):
self.val = val
self.children = children
"""
class Solution:
def levelOrder(self, root: 'Node') -> List[List[int]]:
# 维护一个层列表,最初只有根结点
level = [root]
# 最终结果列表
result = []
# 若空树,直接返回空列表
if not root:
return []
# 通过层列表来迭代循环
while level:
# 下一层列表
new_level = []
# 取该层结点值组成列表
level_result = [node.val for node in level if node]
# 将该层结点值列表加入结果
result.append(level_result)
# 遍历层中结点
for node in level:
# 若存在子结点列表,合并到下一层列表中
if node.children:
new_level+= node.children
# 下一层列表赋值给 level 代入下一循环
level = new_level
# 返回最终结果
return result
不同的层序遍历题目可能要求输出的格式有区别,比如这里就是每层的值都要放在一个列表中,这就要在遍历每一层结点时做相应处理。上面这解法是我目前较熟悉的,再去看看大神的解法。
学习题解
class Solution(object):
def levelOrder(self, root):
q, ret = [root], []
while any(q):
ret.append([node.val for node in q])
q = [child for node in q for child in node.children if child]
return ret
# 来源:https://leetcode.com/problems/n-ary-tree-level-order-traversal/discuss/148877/Python-5-lines-BFS-solution
注意代码中的 while 循环用到了 any() 函数,这是 Python 的内置函数,其功能如下:
any() 函数用于判断给定的可迭代参数 iterable 是否全部为 False,则返回 False,如果有一个为 True,则返回 True。元素除了是 0、空、FALSE 外都算 TRUE。
any(iterable)
Return True if any element of the iterable is true. If the iterable is empty, return False. Equivalent to:
def any(iterable):
for element in iterable:
if element:
return True
return False
用在这里最直接的表现就是不用在单独对空树情况进行处理,因为空树时 root 为 None,层列表 [root] 结果为 [None] 其 bool 为 True , any([None]) 则为 False 不会进入迭代过程。同时,这份代码中用两个列表解析式完成我们之前的操作,可以借鉴。
最后,再来看个递归解法:
def levelOrder(self, root: 'Node') -> List[List[int]]:
def traverse_node(node, level):
if len(result) == level:
result.append([])
result[level].append(node.val)
for child in node.children:
traverse_node(child, level + 1)
result = []
if root is not None:
traverse_node(root, 0)
return result
#作者:LeetCode
#链接:https://leetcode-cn.com/problems/n-ary-tree-level-order-traversal/solution/ncha-shu-de-ceng-xu-bian-li-by-leetcode/
自行构造了可以递归的函数,因为我们对每层的操作是重复性的:取当前层结点值加到列表再添加到结果中,取当前层结点的子结点来构造下一层。将其构造出独立函数,再通过层数来控制,实现递归过程。
树专题小结
首先说下时间复杂度,因为都是对树的遍历,基本遍历到树的结点就完成操作,时间复杂度为 O(n),n 对应树的 n 个结点;输出结果时也都是 n 个结点的数组或列表,空间复杂度也为 O(n)。
本篇我们从树的结构与其递归特性出发,再次回顾了树的前序、中序、后序遍历,从递归和迭代两个角度来解决关于二叉树、N 叉树的五道相关题目;最后也通过一道 N 叉树的层序遍历再次练习了广度优先搜索的用法。如果能熟练掌握以上代码解法,那么基于树遍历引申出的题目应该是可以比较轻松解决的。
通过一系列树的练习,我们掌握了相关遍历题目的解法,同时也应该借鉴诸多大神题解里结合具体题目规律对栈的应用,可能由 Python 学上来的朋友对栈没概念,大致理解也就是列表来实现后进先出的功能,但不得不说很多题目会考察栈这一数据结构的应用,要多熟悉并逐步掌握。