在第二轮刷题一开始,我们是整理了关于数组的五道 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<