LeetCode数据结构之树

1.树的基本概念

  • 树是一种非线性数据结构。
  • 树结构的基本单位是节点。
  • 节点之间的链接,称为分支(branch)。
  • 节点与分支形成树状,结构的开端,称为根(root),或根结点。
  • 根节点之外的节点,称为子节点(child)。
  • 没有链接到其他子节点的节点,称为叶节点(leaf)。

如下图是一个典型的树结构:

LeetCode数据结构之树_递归


其他重要概念:

  • 树的高度:节点到叶子节点的最大值就是其高度。
  • 树的深度:高度和深度是相反的,高度是从下往上数,深度是从上往下。因此根节点的深度和叶子节点的高度是 0。
  • 树的层:根开始定义,根为第一层,根的孩子为第二层。
  • 二叉树,三叉树,。。。 N 叉树,由其子节点最多可以有几个决定,最多有 N 个就是 N 叉树。

2.二叉树

2.1 二叉树概念

二叉树是树结构的一种,两个叉就是说每个节点最多只有两个子节点,我们习惯称之为左节点和右节点。(注意这个只是名字而已,并不是实际位置上的左右)

2.2 二叉树分类
  • 完全二叉树
  • 满二叉树
  • 二叉搜索树
  • 平衡二叉树
  • 红黑树
  • … …
2.3 二叉树存储
  • 链表存储
  • 数组存储。非常适合完全二叉树

3.二叉树问题解决

一个口诀
一个中心,两个基本点,三种题型,四个重要概念,七个技巧

3.1 一个中心

树的遍历就是这个中心!!!

  • 树的遍历的本质就是去把树里边儿的每个元素都访问一遍。
  • 树虽然只能从根开始访问,但是我们可以选择在访问完毕回来的时候做处理,还是在访问回来之前做处理,这两种不同的方式就是后序遍历和先序遍历。
  • 树的遍历又可以分为两个基本类型,分别是深度优先遍历和广度优先遍历。这两种遍历方式并不是树特有的,但却伴随树的所有题目。

树的遍历迭代写法——双色标记法
模仿垃圾回收算法之一:三色标记法
即:用白色表示尚未访问;灰色表示尚未完全访问子节点;黑色表示子节点全部访问
双色标记法的核心思想:

  • 使用颜色标记节点的状态,新节点为白色,已访问的节点为灰色。
  • 如果遇到的节点为白色,则将其标记为灰色,然后将其右子节点、自身、左子节点依次入栈。
  • 如果遇到的节点为灰色,则将节点的值输出。

中序遍历的实现:
实际上 WHITE 就表示的是递归中的第一次进入过程,Gray 则表示递归中的从叶子节点返回的过程。 因此这种迭代的写法更接近递归写法的本质。

class Solution:
def inorderTraversal(self, root: TreeNode) -> List[int]:
WHITE, GRAY = 0, 1
res = []
stack = [(WHITE, root)]
while stack:
color, node = stack.pop()
if node is None: continue
if color == WHITE:
stack.append((WHITE, node.right))
stack.append((GRAY, node))
stack.append((WHITE, node.left))
else:
res.append(node.val)
return res

实现前序、后序遍历,也只需要调整左右子节点的入栈顺序即可,其他部分是无需做任何变化。

3.2 两个基本点

深度优先遍历(以下简称 DFS,还可以细分为前中后序遍历)和广度优先遍历(以下简称 BFS,还可以细分为带层的和不带层的),这就是两个基本点。

DFS 适合做一些暴力枚举的题目,DFS 如果借助函数调用栈,则可以轻松地使用递归来实现。

LeetCode数据结构之树_二叉树_02

3.2.1 深度优先遍历(DFS)
  • 深度优先搜索算法(英语:Depth-First-Search,DFS)是一种用于遍历树或图的算法。
  • 沿着树的深度遍历树的节点,尽可能深的搜索树的分支。
  • 当节点 v 的所在边都己被探寻过,搜索将回溯到发现节点 v 的那条边的起始节点。这一过程一直进行到已发现从源节点可达的所有节点为止。
  • 如果还存在未被发现的节点,则选择其中一个作为源节点并重复以上过程,整个进程反复进行直到所有节点都被访问为止,属于盲目搜索。

算法流程

LeetCode数据结构之树_二叉树_03


这里的 stack 可以理解为自己实现的栈,也可以理解为调用栈。如果是调用栈的时候就是递归,如果是自己实现的栈的话就是迭代。

算法模板(二叉树)

function dfs(root) {
if (满足特定条件){
// 返回结果 or 退出搜索空间
}
dfs(root.left)
dfs(root.right)
}

两种常见分类:前序遍历和后序遍历
如果代码主逻辑在左右子树之前执行,那么就是前序遍历。如果代码主逻辑在左右子树之后执行,那么就是后序遍历。
前序遍历:主逻辑在左右子树之前

function dfs(root) {
if (满足特定条件){
// 返回结果 or 退出搜索空间
}
// 主要逻辑
dfs(root.left)
dfs(root.right)
}

后序遍历:主逻辑在左右子树之后

function dfs(root) {
if (满足特定条件){
// 返回结果 or 退出搜索空间
}
dfs(root.left)
dfs(root.right)
// 主要逻辑
}

递归遍历的学习技巧:画图 + 手动代入

//前序遍历一棵这样的树:
1
/ \
2 3
/ \
4 5

LeetCode数据结构之树_递归_04

3.2.2 广度优先遍历(BFS)
  • BFS 采用横向搜索的方式,在数据结构上通常采用队列结构。注意,DFS 我们借助的是栈来完成,而这里借助的是队列。
  • BFS 比较适合找最短距离/路径和某一个距离的目标。

算法流程

  • 首先将根节点放入队列中。
  • 从队列中取出第一个节点,并检验它是否为目标。如果找到目标,则结束搜索并回传结果。否则将它所有尚未检验过的直接子节点加入队列中。
  • 若队列为空,表示整张图都检查过了——亦即图中没有欲搜索的目标。结束搜索并回传“找不到目标”。
  • 重复步骤 2。

算法模板

const visited = {}
function bfs() {
let q = new Queue()
q.push(初始状态)
while(q.length) {
let i = q.pop()
if (visited[i]) continue
if (i 是我们要找的目标) return 结果
for (i的可抵达状态j) {
if (j 合法) {
q.push(j)
}
}
}
return 没找到
}

BFS 的两种基本方式,即带层和不带层

# **标记层**
class Solution:
def bfs(k):
# 使用双端队列,而不是数组。因为数组从头部删除元素的时间复杂度为 N,双端队列的底层实现其实是链表。
queue = collections.deque([root])
# 记录层数
steps = 0
# 需要返回的节点
ans = []
# 队列不空,生命不止!
while queue:
size = len(queue)
# 遍历当前层的所有节点
for _ in range(size):
node = queue.popleft()
if (step == k) ans.append(node)
if node.right:
queue.append(node.right)
if node.left:
queue.append(node.left)
# 遍历完当前层所有的节点后 steps + 1
steps += 1
return ans
# 不标记层
class Solution:
def bfs(k):
# 使用双端队列,而不是数组。因为数组从头部删除元素的时间复杂度为 N,双端队列的底层实现其实是链表。
queue = collections.deque([root])
# 队列不空,生命不止!
while queue:
node = queue.popleft()
# 由于没有记录 steps,因此我们肯定是不需要根据层的信息去判断的。否则就用带层的模板了。
if (node 是我们要找到的) return node
if node.right:
queue.append(node.right)
if node.left:
queue.append(node.left)
return -1
3.3 三种题型

搜索类,构建类和修改类

3.3.1 搜索类

三个核心点,即开始点,结束点 和 目标
DFS 搜索
DFS 搜索类的基本方法就是从入口开始做 dfs,然后在 dfs 内部判断是否是结束点,这个结束点通常是叶子节点或空节点。如果目标是一个基本值(比如数字)直接返回或者使用一个全局变量记录即可,如果是一个数组,则可以通过扩展参数的技巧来完成。

# 其中 path 是树的路径, 如果需要就带上,不需要就不带
def dfs(root, path):
# 空节点
if not root: return
# 叶子节点
if not root.left and not root.right: return
path.append(root)
# 逻辑可以写这里,此时是前序遍历
dfs(root.left)
dfs(root.right)
# 需要弹出,不然会错误计算。
# 比如对于如下树:
"""
5
/ \
4 8
/ / \
11 13 4
/ \ / \
7 2 5 1
"""
# 如果不 pop,那么 5 -> 4 -> 11 -> 2 这条路径会变成 5 -> 4 -> 11 -> 7 -> 2,其 7 被错误地添加到了 path

path.pop()
# 逻辑也可以写这里,此时是后序遍历

return 你想返回的数据

BFS 搜索
大多数是求距离,套用模板即可。

3.3.2 构建类

普通二叉树的构建和二叉搜索树的构建。

普通二叉树的构建

普通二叉树的构建又分为三种:

  • 给你两种 DFS 的遍历的结果数组,让你构建出原始的树结构。比如根据先序遍历和后序遍历的数组,构造原始二叉树。
  • 还有一种是给你描述一种场景,让你构造一个符合条件的二叉树。这种题和上面的没啥区别,做法一样。
  • 动态构建二叉树的,对于这种,直接 BFS 就好了。

二叉搜索树的构建

  • 普通二叉树无法根据一种序列重构的原因是只知道根节点,无法区分左右子树。
  • 如果是二叉搜索树,那么就有可能根据一种遍历序列构造出来。 原因就在于二叉搜索树的根节点的值大于所有的左子树的值,且小于所有的右子树的值。
  • 因此我们可以根据这一特性去确定左右子树的位置,经过这样的转换就和上面的普通二叉树没有啥区别了。
3.3.3 修改类

修改类的题目有两种基本类型。

  • 一种是题目让你增加,删除节点,或者是修改节点的值或者指向。
  • 另外一种是为了方便计算,自己加了一个指针。
3.4 四个重要概念
3.4.1 二叉搜索树(二叉查找树)

二叉搜索树具有下列性质的二叉树:

  • 若左子树不空,则左子树上所有节点的值均小于它的根节点的值;
  • 若右子树不空,则右子树上所有节点的值均大于它的根节点的值;
  • 左、右子树也分别为二叉排序树;
  • 没有键值相等的节点。

对于一个二叉查找树,常规操作有插入,查找,删除,找父节点,求最大值,求最小值

二叉搜索树的中序遍历的结果是一个有序数组。

3.4.2 完全二叉树

一棵深度为 k 的有 n 个结点的二叉树,对树中的结点按从上至下、从左到右的顺序进行编号,如果编号为 i(1≤i≤n)的结点与满二叉树中编号为 i 的结点在二叉树中的位置相同,则这棵二叉树称为完全二叉树。

如下就是一颗完全二叉树:

LeetCode数据结构之树_二叉树_05

我们可以给完全二叉树编号,这样父子之间就可以通过编号轻松求出。比如我给所有节点从左到右从上到下依次从 1 开始编号。那么已知一个节点的编号是
i,那么其左子节点就是 2 * i,右子节点就是 2 * i + 1,父节点就是 (i + 1) / 2。

3.4.3 路径

LeetCode数据结构之树_递归_06


上图可以看出:

  • 路径可以由一个节点做成,可以由两个节点组成,也可以由三个节点组成等等,但是必须连续。
  • 路径必须是”直来直去“的,不能有分叉。 比如上图的路径的左下角是 3,当然也可以是 2,但是 2 比较小。但是不可以 2 和 3 同时选。

由题目说是 ”从任意节点出发…“ 可以知道大概率是要么全局记录最大值,要么双递归。

  • 如果使用双递归,那么复杂度就是 LeetCode数据结构之树_二叉树_07,实际上,子树的路径和计算出来了,可以推导出父节点的最大路径和,因此如果使用双递归会有重复计算。一个可行的方式是记忆化递归。
  • 如果使用全局记录最大值,只需要在递归的时候 return 当前的一条边(上面提了不能拐),并在函数内部计算以当前节点出发的最大路径和,并更新全局最大值即可。 这里的核心其实是 return 较大的一条边,因为较小的边不可能是答案。
# 这里使用第二种方法的代码:
class Solution:
ans = float('-inf')
def maxPathSum(self, root: TreeNode) -> int:
def dfs(node):
if not node: return 0
l = dfs(node.left)
r = dfs(node.right)
# 选择当前的节点,并选择左右两边,当然左右两边也可以不选。必要时更新全局最大值
self.ans = max(self.ans, max(l,0) + max(r, 0) + node.val)
# 只返回一边,因此我们挑大的返回。当然左右两边也可以不选
return max(l, r, 0) + node.val
dfs(root)
return self.ans
3.4.4 距离

和路径类似,距离也是一个相似且频繁出现的一个考点,并且二者都是搜索类题目的考点。原因就在于最短路径就是距离,而树的最短路径就是边的数目。

3.5 七个技巧
3.5.1 dfs(root)

(一开始 current 就是 root)
相当于把 root 当成是 current 指针来用了。最开始 current 指针指向 root,然后不断修改指向树的其它节点。这样就概念就简化了,只有一个当前指针的概念。如果使用 node,就是当前指针 + root 指针两个概念了。

3.5.2 单/双递归
  • 树的题目大多数都可以用递归轻松地解决。如果一个递归不行,那么来两个。(至今没见过三递归或更多递归)
  • 那就是如果题目有类似,任意节点开始 xxxx 或者所有 xxx这样的说法,就可以考虑使用双递归。但是如果递归中有重复计算,则可以使用双递归 + 记忆化 或者直接单递归

双递归的基本套路就是一个主递归函数和一个内部递归函数。主递归函数负责计算以某一个节点开始的 xxxx,内部递归函数负责计算 xxxx,这样就实现了以所有节点开始的 xxxx。

一个典型的加法双递归是这样的:

def dfs_inner(root):
# 这里写你的逻辑,就是前序遍历
dfs_inner(root.left)
dfs_inner(root.right)
# 或者在这里写你的逻辑,那就是后序遍历
def dfs_main(root):
return dfs_inner(root) + dfs_main(root.left) + dfs_main(root.right)
3.5.3 前后遍历

和链表一样, 要掌握树的前后序,也只需要记住一句话就好了。那就是如果是前序遍历,那么你可以想象上面的节点都处理好了,怎么处理的不用管。相应地如果是后序遍历,那么你可以想象下面的树都处理好了,怎么处理的不用管。

对于树来说,其实更形象地说应该是自顶向下或者自底向上。自顶向下和自底向上在算法上是不同的,不同的写法有时候对应不同的书写难度。

  • 自顶向下就是在每个递归层级,首先访问节点来计算一些值,并在递归调用函数时将这些值传递到子节点,一般是通过参数传到子树中。
  • 自底向上是另一种常见的递归方法,首先对所有子节点递归地调用函数,然后根据返回值和根节点本身的值得到答案。
  • 大多数树的题使用后序遍历比较简单,并且大多需要依赖左右子树的返回值。
  • 不多的问题需要前序遍历,而前序遍历通常要结合参数扩展技巧。
  • 如果你能使用参数和节点本身的值来决定什么应该是传递给它子节点的参数,那就用前序遍历。
  • 如果对于树中的任意一个节点,如果你知道它子节点的答案,你能计算出当前节点的答案,那就用后序遍历。
  • 如果遇到二叉搜索树则考虑中序遍历
3.5.4 虚拟节点

链表的虚拟指针的技巧:

  • 其中一种情况是链表的头会被修改。这个时候通常需要一个虚拟指针来做新的头指针,这样就不需要考虑第一个指针的问题了(因为此时第一个指针变成了我们的虚拟指针,而虚拟指针是不用参与题目运算的)。树也是一样,当你需要对树的头节点(在树中我们称之为根节点)进行修改的时候,
    就可以考虑使用虚拟指针的技巧了。
  • 另外一种是题目需要返回树中间的某个节点(不是返回根节点)。实际上也可借助虚拟节点。由于我上面提到的指针的操作,实际上,你可以新建一个虚拟头,然后让虚拟头在恰当的时候(刚好指向需要返回的节点)断开连接,这样我们就可以返回虚拟头的
    next 就 ok 了。
3.5.5 边界

搜索类的题目,树的边界其实比较简单:

1.空节点

2.叶子节点

LeetCode数据结构之树_子节点_08


相比于搜索类, 构建就比较麻烦:

1.参数扩展的边界

2.虚拟节点

3.5.6 参数扩展大法

dfs 携带更多的有用信息。典型的有以下三种情况:
1.携带父亲或者爷爷的信息。

def dfs(root, parent):
if not root: return
dfs(root.left, root)
dfs(root.right, root)

2.携带路径信息,可以是路径和或者具体的路径数组等。
路径和:

def dfs(root, path_sum):
if not root:
# 这里可以拿到根到叶子的路径和
return path_sum
dfs(root.left, path_sum + root.val)
dfs(root.right, path_sum + root.val)

路径:

def dfs(root, path):
if not root:
# 这里可以拿到根到叶子的路径
return path
path.append(root.val)
dfs(root.left, path)
dfs(root.right, path)
# 撤销
path.pop()
3.5.7 返回元组/列表

通常,我们的 dfs 函数的返回值是一个单值。而有时候为了方便计算,我们会返回一个数组或者元组。

这个技巧和参数扩展有异曲同工之妙,只不过一个作用于函数参数,一个作用于函数返回值。

返回元组

题目:求具有所有最深节点的最小子树

# 即除了返回深度,也要把节点给返回。
class Solution:
def subtreeWithAllDeepest(self, root: TreeNode) -> TreeNode:
def dfs(node, d):
if not node: return (node, d)
l, l_d = dfs(node.left, d + 1)
r, r_d = dfs(node.right, d + 1)
if l_d == r_d: return (node, l_d)
if l_d > r_d: return (l, l_d)
return (r, r_d)
return dfs(root, -1)[0]

返回数组(列表)

两个叶子节点的最短路径(距离)可以用其最近的公共祖先来辅助计算。
即两个叶子节点的最短路径 = 其中一个叶子节点到最近公共祖先的距离 + 另外一个叶子节点到最近公共祖先的距离。
dfs 返回数组比较少见。即使题目要求返回数组,我们也通常是声明一个数组,在 dfs 过程不断 push,最终返回这个数组。而不会选择返回一个数组。绝大多数情况下,返回数组是用于计算笛卡尔积。因此你需要用到笛卡尔积的时候,考虑使用返回数组的方式。

一般来说,如果需要使用笛卡尔积的情况还是比较容易看出的。另外一个不太准确的技巧是,如果题目有”所有可能“,”所有情况“,可以考虑使用此技巧。