数据结构——二叉树
一、树
1.基本概念
树是一种由结点组成的数据结构,在链表中,一个结点连接着另一个结点,树也是由许多的结点构成的,唯一的区别就是一个树结点可以连接多个树结点,一颗树只有一个根结点,根结点作为起源,由它展开一个树状的数据结构。
在树中,每个结点都含有自己的数值,以及与之相连的子结点,连接结点的线叫做相连线(edge)。如下图所示,A是根结点(root),也是B和C的父结点(parent node),也就是说B、C都是A的子结点(child node)。同理,B是D和E的父结点,以此类推。结点用于的子树数称为结点的度(Degree),度为0的结点如:H、I、J、F、G是尾结点(leaf node,叶结点、终端结点),因为它们位于树的最底部,没有任何子结点。
树的度是树内各结点度的最大值
一个树由许许多多的子树(sub-tree)构成,每个节点加上它所有的子节点(包括子节点的子节点们)就是一个子树,如上图,D、H、和I就是能构成sub tree,B、D、E、H、I、和J也是一个子树。如果树中结点的各子树从左向右有序,不可互换,则称该树为有序树,否则为无序树。
树中还有两个重要的名词,一个是节点的高度(height),意味着此节点到尾节点之间相连线的数量(从上向下数),B的高度就是2,因为B到尾节点H之间的edge数量为2。另一个名词就是节点的深度(depth),意味着此节点到根节点的edge数量(从下向上数),D的深度是2,因为D到根节点A之间的edge数量是2。
2.树的分类
-
二叉树(Binary Tree):每个结点最多含有两个子结点,上面图示中的树就是二叉树。
-
完全二叉树(Complete Binary Tree):假设一个二叉树深度(depth)为d(d > 1),除了第d层(最底层)外,其它各层的结点数量均已达到最大值,且第d层所有结点从左向右紧密排列(最底层的节点都集中在该层最左边的若干位置),这样的二叉树就是完全二叉树。
若最底层为第 h 层,则该层包含 1~ 2^h -1 个节点 -
满二叉树(Full Binary Tee):在满二叉树中,每个不是尾结点的结点都有两个子结点。只有度为0的结点和度为2的结点,并且度为0的结点在同一层上。
这棵二叉树为满二叉树,也可以说深度为k,有2^k-1个节点的二叉树。 -
二叉排序树(Binary Search Tree):也称二叉搜索树,二叉查找树。在此树中,每个结点的数值比左子树上的每个结点都大,比所有右子树上的结点都小。是一个有数值的有序树。
若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
它的左、右子树也分别为二叉排序树
-
平衡二叉树(AVL Tree):也称AVL树(Adelson-Velsky and Landis)。任何结点的两颗子树的高度差的绝对值不大于1的二叉树,且左右两个子树都是一棵平衡二叉树
-
B树(B-Tree):B树也称多路查找树,和平衡二叉树一样,只不过它是一种多叉树(一个节点的子节点数量可以超过二)。
-
红黑树(Red—Black Tree):使用另一种平衡算法的自平衡二叉树。
二、二叉树
1.二叉树的存储方式
链式存储或顺序存储
顺序存储的元素在内存是连续分布的,而链式存储则是通过指针把分布在散落在各个地址的节点串联一起。
链式存储方式用指针
顺序存储的方式用数组
如果父节点的数组下标是 i,那么它的左孩子就是 i * 2 + 1,右孩子就是 i * 2 + 2
当前结点下标为 k,若其有父结点,则其父结点的下标为 (k-1)/2
2.二叉树的定义
链式存储的定义:
public class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode() {}
TreeNode(int val) { this.val = val; }
TreeNode(int val, TreeNode left, TreeNode right) {
this.val = val;
this.left = left;
this.right = right;
}
}
3.二叉树的性质
- 在二叉树的第 i 层至多有2i-1个结点(i>=1)
- 深度为k的二叉树至多有2k-1个结点(k>=1)
- 对任何一颗二叉树,如果其终端结点数为n0,度为2的结点数为n2,则n0 = n2 + 1
- 具有n个结点的完全二叉树的深度为[log2n] + 1 ([x]表示不大于x的最大整数)
- 对一颗有n个结点的完全二叉树,对任一结点 i 有:
若i=1,则结点 i 是二叉树的根
若i>1,则其双亲是结点[i / 2]
若2i>n,则结点 i 无左孩子(为叶节点);否则其左孩子是结点2i
若2i+1>n,则结点 i 无右孩子;否则其右孩子是结点2i+1
4.二叉树的遍历
在树中我们无法随时获取任意节点,遍历树总是从根节点开始
二叉树主要有两种遍历方式:
- 深度优先遍历(DFS):先往深走,遇到叶子节点再往回走。
- 广度优先遍历(DFS):一层一层的去遍历。
这两种遍历是图论中最基本的两种遍历方式
从深度优先遍历和广度优先遍历进一步拓展,才有如下遍历方式:
- 深度优先遍历(DFS)
- 前序遍历(递归法,迭代法)
- 中序遍历(递归法,迭代法)
- 后序遍历(递归法,迭代法)
- 广度优先遍历(BFS)
- 层序遍历(迭代法)(也有递归)
前中后序遍历的前中后,其实指的就是中间结点的遍历顺序,对应中间结点的位置
- 前序遍历:中左右
- 中序遍历:左中右
- 后序遍历:左右中
1)二叉树的递归遍历
递归算法的三个要素。每次写递归,都按照这三要素来写
确定递归函数的参数和返回值: 确定哪些参数是递归的过程中需要处理的,那么就在递归函数里加上这个参数, 并且还要明确每次递归的返回值是什么进而确定递归函数的返回类型。
确定终止条件: 写完了递归算法, 运行的时候,经常会遇到栈溢出的错误,就是没写终止条件或者终止条件写的不对,操作系统也是用一个栈的结构来保存每一层递归的信息,如果递归没有终止,操作系统的内存栈必然就会溢出。
确定单层递归的逻辑: 确定每一层递归需要处理的信息。在这里也就会重复调用自己来实现递归的过程。
对于前序遍历:
确定递归函数的参数和返回值:因为要打印出前序遍历节点的数值,所以参数里需要传入ArrayList在放节点的数值,除了这一点就不需要在处理什么数据了也不需要有返回值,所以递归函数返回类型就是void
public void preorder(TreeNode root, List<Integer> result)
确定终止条件:当前遍历的节点是空了,那么本层递归就要要结束了,所以如果当前遍历的这个节点是空,就直接return
if (root == null) { return; }
确定单层递归的逻辑:前序遍历是中左右的循序,所以在单层递归的逻辑,是要先取中节点的数值
result.add(root.val);
preorder(root.left, result);
preorder(root.right, result);
class Solution {
public List<Integer> preorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<>();
preorder(root,result);
return result;
}
public void preorder(TreeNode root, List<Integer> result) {
if (root==null) {
return;
}
result.add(root.val);
preorder(root.left,result);
preorder(root.right,result);
}
}
同理可得:
class Solution {
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<>();
inorder(root,result);
return result;
}
public void inorder(TreeNode root,List<Integer> result) {
if (root == null) {
return;
}
inorder(root.left,result);
result.add(root.val);
inorder(root.right,result);
}
}
class Solution {
public List<Integer> postorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<>();
postorder(root,result);
return result;
}
public void postorder(TreeNode root,List<Integer> result) {
if (root == null) {
return;
}
postorder(root.left,result);
postorder(root.right,result);
result.add(root.val);
}
}
2)二叉树的迭代遍历
用栈使用迭代法也可以是实现二叉树的前后中序遍历
前序遍历:
前序遍历是中左右,每次先处理的是中间节点,那么先将根节点放入栈中,然后将右孩子加入栈,再加入左孩子。这样出栈的时候才是中左右的顺序。
注意空结点不入栈
代码随想录动画:
class Solution {
public List<Integer> preorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<>();
if (root==null) {
return result;
}
Deque<TreeNode> stack = new ArrayDeque<>();
stack.push(root);
while (!stack.isEmpty()) {
TreeNode node = stack.pop();// 中
result.add(node.val);
if (node.right!=null) {
stack.push(node.right);// 右(空节点不入栈)
}
if (node.left!=null) {
stack.push(node.left);// 左(空节点不入栈)
}
}
return result;
}
}
中序遍历:
前序遍历的代码,不能和中序遍历通用
因为前序遍历的顺序是中左右,先访问的元素是中间节点,要处理的元素也是中间节点(处理:将元素放进result数组中、访问:遍历节点)
中序遍历是左中右,先访问的是二叉树顶部的节点,然后一层一层向下访问,直到到达树左面的最底部,再开始处理节点(把节点的数值放进result数组中),这就造成了处理顺序和访问顺序是不一致的。
在使用迭代法写中序遍历,就需要借用指针的遍历来帮助访问节点,栈则用来处理节点上的元素。
代码随想录动画:
class Solution {
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<>();
if (root==null) {
return result;
}
Deque<TreeNode> stack = new ArrayDeque<>();
TreeNode cur = root;
while (cur!=null || !stack.isEmpty()) {
if (cur!=null) {// 访问节点到最底层
stack.push(cur);// 将访问的节点放进栈
cur = cur.left;// 左
} else {
cur = stack.pop();// 从栈里弹出的数据,就是要处理的(放进result数组里的数据)
result.add(cur.val);// 中
cur = cur.right;// 右
}
}
return result;
}
}
后序遍历:
前序遍历是中左右,后续遍历是左右中,那么我们只需要调整一下前序遍历的代码顺序,就变成中右左的遍历顺序,然后在反转result数组,输出的结果顺序就是左右中了
class Solution {
public List<Integer> postorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<>();
if (root==null) {
return result;
}
Deque<TreeNode> stack = new ArrayDeque<>();
stack.push(root);
while (!stack.isEmpty()) {
TreeNode node = stack.pop();// 中
result.add(node.val);
if (node.left!=null) {
stack.push(node.left);// 左
}
if (node.right!=null) {
stack.push(node.right);// 右
}
}
Collections.reverse(result);
return result;
}
}
3)二叉树的统一迭代法
针对三种遍历方式,使用迭代法可以写出统一风格的代码,只需改变操作顺序即可实现三种遍历。
使用栈的话,无法同时解决访问节点(遍历节点)和处理节点(将元素放进结果集)不一致的情况。那我们就将访问的节点放入栈中,把要处理的节点也放入栈中但是要做标记。
如何标记呢,就是要处理的节点放入栈之后,紧接着放入一个空指针作为标记。 这种方法也可以叫做标记法。
前序遍历:
class Solution {
public List<Integer> preorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<>();
Deque<TreeNode> stack = new LinkedList<>();
if (root!=null) {
stack.push(root);
}
while (!stack.isEmpty()) {
TreeNode node = stack.peek();
if (node!=null) {
stack.pop(); // 将该节点弹出,避免重复操作,下面再将右左中节点添加到栈中
if (node.right!=null) stack.push(node.right); // 添加右节点(空节点不入栈)
if (node.left!=null) stack.push(node.left); // 添加左节点(空节点不入栈)
stack.push(node); // 添加中节点
stack.push(null); // 中节点访问过,但是还没有处理,加入空节点做为标记。
} else {// 只有遇到空节点的时候,才将下一个节点放进结果集
stack.pop(); // 将空节点弹出
node = stack.pop(); // 重新取出栈中元素
result.add(node.val); // 加入到结果集
}
}
return result;
}
}
中序遍历:
class Solution {
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<>();
Deque<TreeNode> stack = new LinkedList<>();
if (root!=null) {
stack.push(root);
}
while (!stack.isEmpty()) {
TreeNode node = stack.peek();
if (node!=null) {
stack.pop(); // 将该节点弹出,避免重复操作,下面再将右中左节点添加到栈中
if (node.right!=null) stack.push(node.right); // 右
stack.push(node); // 中
stack.push(null); // 中节点访问过,但是还没有处理,加入空节点做为标记。
if (node.left!=null) stack.push(node.left); // 左
} else {// 只有遇到空节点的时候,才将下一个节点放进结果集
stack.pop(); // 将空节点弹出
node = stack.pop(); // 重新取出栈中元素
result.add(node.val); // 加入到结果集
}
}
return result;
}
}
代码随想录动画:
后序遍历:
class Solution {
public List<Integer> postorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<>();
Deque<TreeNode> stack = new LinkedList<>();
if (root!=null) {
stack.push(root);
}
while (!stack.isEmpty()) {
TreeNode node = stack.peek();
if (node!=null) {
stack.pop(); // 将该节点弹出,避免重复操作,下面再将中右左节点添加到栈中
stack.push(node); // 中
stack.push(null); // 中节点访问过,但是还没有处理,加入空节点做为标记。
if (node.right!=null) stack.push(node.right); // 右
if (node.left!=null) stack.push(node.left); // 左
} else {// 只有遇到空节点的时候,才将下一个节点放进结果集
stack.pop(); // 将空节点弹出
node = stack.pop(); // 重新取出栈中元素
result.add(node.val); // 加入到结果集
}
}
return result;
}
}
4)二叉树的层序遍历
需要借用一个辅助数据结构即队列来实现,队列先进先出,符合一层一层遍历的逻辑,而是用栈先进后出适合模拟深度优先遍历也就是递归的逻辑。
代码随想录动画:
102. 二叉树的层序遍历
BFS——迭代遍历
class Solution {
public List<List<Integer>> resList = new ArrayList<List<Integer>>();
public List<List<Integer>> levelOrder(TreeNode root) {
//BFS--迭代方式--借助队列
if (root == null) return resList;
Queue<TreeNode> que = new LinkedList<TreeNode>();
que.offer(root);
while (!que.isEmpty()) {
List<Integer> itemList = new ArrayList<Integer>();
int len = que.size();
while (len > 0) {
TreeNode tmpNode = que.poll();
itemList.add(tmpNode.val);
if (tmpNode.left != null) que.offer(tmpNode.left);
if (tmpNode.right != null) que.offer(tmpNode.right);
len--;
}
resList.add(itemList);
}
return resList;
}
}
BFS——递归遍历
class Solution {
public List<List<Integer>> resList = new ArrayList<List<Integer>>();
public List<List<Integer>> levelOrder(TreeNode root) {
checkFun01(root,0);
return resList;
}
//DFS--递归方式
public void checkFun01(TreeNode node, Integer deep) {
if (node == null) return;
deep++;
if (resList.size() < deep) {
//当层级增加时,list的Item也增加,利用list的索引值进行层级界定
List<Integer> item = new ArrayList<Integer>();
resList.add(item);
}
resList.get(deep - 1).add(node.val);
checkFun01(node.left, deep);
checkFun01(node.right, deep);
}
}
三、对二叉树的理解与补充
1.对二叉树前中后序遍历的理解
二叉树递归遍历框架:
void traverse(TreeNode root) {
if (root == null) {
return;
}
// 前序位置
traverse(root.left);
// 中序位置
traverse(root.right);
// 后序位置
}
只要是递归形式的遍历,都会有一个前序和后序位置,分别在递归之前和之后。所谓前序位置,就是刚进入一个节点(元素)的时候,后序位置就是即将离开一个节点(元素)的时候。二叉树递归形式多了一个中序位置。
把代码写在不同位置,代码执行的时机也不同。
-
前序位置的代码在刚刚进入一个二叉树节点的时候执行;
-
后序位置的代码在将要离开一个二叉树节点的时候执行;
-
中序位置的代码在一个二叉树节点左子树都遍历完,即将开始遍历右子树的时候执行。
每个节点都有「唯一」属于自己的前中后序位置。前中后序位置是遍历二叉树过程中处理每一个节点的三个特殊时间点,遍历得到的结果是三个顺序不同的 List。
后序位置的特殊之处:
前序位置的代码只能从函数参数中获取父节点传递来的数据,而后序位置的代码不仅可以获取参数数据,还可以获取到子树通过函数返回值传递回来的数据。
所以一旦发现题目和子树有关,那大概率要给函数设置合理的定义和返回值,在后序位置写代码了
2.二叉树问题递归解法的思路
二叉树题目的递归解法可以分两类思路:
第一类是遍历一遍二叉树得出答案,第二类是通过分解问题计算出答案,这两类思路分别对应着 回溯算法 和 动态规划
遇到一道二叉树的题目时的通用思考过程是:
-
是否可以通过遍历一遍二叉树得到答案?
-
如果不能的话,是否可以分解原问题,定义一个递归函数,通过子问题(子树)的答案推导出原问题的答案?
-
如果单独抽出一个二叉树节点,它需要做什么事情?需要在什么时候(前/中/后序位置)做?其他的节点不用你操心,递归函数会帮你在所有节点上执行相同的操作。