二叉树这章分为6个小节:
- 二叉树的遍历方式
- 二叉树的属性
- 二叉树的修改与构造
- 求二叉搜索树的属性
- 二叉树公共祖先问题
- 二叉搜索树的修改与构造
本章节目录
1)前序遍历:144. 二叉树的前序遍历 - 力扣(LeetCode)
2)中序遍历:94. 二叉树的中序遍历 - 力扣(LeetCode)
3)后续遍历:145. 二叉树的后序遍历 - 力扣(LeetCode)
一、二叉树理论基础
1、二叉树的种类
在解题中,二叉树主要有2种形式:满二叉树和完全二叉树。
- 满二叉树:如果一棵二叉树只有度为0的结点和度为2的结点,并且度为0的结点在同一层上,则这棵二叉树为满二叉树。(即深度为k,有2^k-1个节点的二叉树)
- 完全二叉树:除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。(若最底层为第 h 层(h从1开始),则该层包含 1~ 2^(h-1) 个节点)
- 二叉搜索树:是有数值的树,二叉搜索树是一个有序树。如果根节点的左右子树不为空,那么左子树上所有结点值均应小于其根节点值;右子树上所有结点值均应大于其子根结点的值。(即它的左右子树也分别都是二叉排序树)
- 平衡二叉搜索树:又被称为AVL树,且具有以下性质:它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。
在c++中,map、set、multimap、multiset的底层容器都是平衡二叉搜索树,所以他们增删操作的时间复杂度是logn。而unordered_map、unordered_set,unordered_map、unordered_set底层实现是哈希表。
2、二叉树的存储方式
二叉树可以链式存储,也可以顺序存储。(链式存储用指针, 顺序存储用数组)。
- 下左图就是链式存储,通过指针把分布在各个地址的节点串联一起。即地址在内存中不是连续的。(更好理解,所以一般都是用链式来存储二叉树)
- 下右图是顺序存储,用数组实现,即元素在内存是连续分布的。(遍历的时候,如果父节点的数组下标是i,那么它的左孩子下标就是2*i+1,右孩子结点下标是2*i+2,所以比较麻烦)
3、二叉树的遍历方式
主要是有两种遍历方式,深度优先和广度优先,拓展的话就有下面4种。
- 深度优先遍历:先往深走,遇到叶子结点再往回走。按照中间节点的遍历顺序,分为前中后:
- 前序遍历(递归法or迭代法):中左右
- 中序遍历(递归法or迭代法):左中右
- 后续遍历(递归法or迭代法):左右中
- 广度优先遍历:一层一层去遍历。
- 层次遍历(迭代法)
深度优先遍历一般用栈来通过递归实现,广度优先遍历一般都是用队列来实现的。
4、二叉树的定义
这里用链表来进行定义,二叉树的定义和链表是差不多的,相对于链表 ,二叉树的节点里多了一个指针, 有两个指针,分别指向左右孩子。
一棵树由根节点、左孩子、右孩子构成。根节点是一个结点,是int类型,左右孩子是TreeNode类型,即泛指左右子树。
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; // 左右孩子这里并不是单纯指结点,也分别是一个TreeNode
this.right = right; // 泛指左右子树
}
}
二、二叉树的递归遍历
这里总结了写递归算法的三要素:
- 确定递归函数的参数和返回值:确定哪些参数是递归的过程中需要处理的,并且还要明确每次递归的返回值是什么。
- 确定终止条件:操作系统也是用一个栈的结构来保存每一层递归的信息,如果递归没有终止,操作系统的内存栈必然就会溢出。
- 确定单层递归的逻辑:会重复调用自己来实现递归的过程。
1)前序遍历:144. 二叉树的前序遍历 - 力扣(LeetCode)
比如按照前面的三步骤:
- 首先确定参数和返回值:目标是打印每个结点的值,所以参数是要传入当前结点以及记录存放节点数值的列表。也不需要什么返回值。
- 确定终止条件:当遍历到当前节点为空的时候,本层递归就结束了。
- 确定单层的遍历逻辑:比如前序是中左右的顺序,每层遍历就是先取中间节点数值,再根据指针找到左右结点的数值。
class Solution {
public List<Integer> preorderTraversal(TreeNode root) { //只用传入根节点
List<Integer> result = new ArrayList<Integer>(); // 这个列表存放遍历的结果
preorder(root, result); // 调用方法,传入根节点和结果列表,会返回工作列表
return result;
}
// 下面这是一个递归的方法(前序遍历)
public void preorder(TreeNode root, List<Integer> result) {
if (root == null) {
return;
}
result.add(root.val); // 把根节点的值存入result列表
preorder(root.left, result); // 然后递归调用preorder方法,分别取遍历左右子树
preorder(root.right, result); // 这是把左右子树的根节点当作新的根节点传入函数
}
}
2)中序遍历:94. 二叉树的中序遍历 - 力扣(LeetCode)
class Solution {
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
inorder(root, res);
return res;
}
// 下面这是一个递归的方法(中序遍历)
void inorder(TreeNode root, List<Integer> list) {
if (root == null) {
return;
}
inorder(root.left, list); //先遍历左结点,然后中间的根节点,然后右结点
list.add(root.val);
inorder(root.right, list);
}
}
3)后续遍历:145. 二叉树的后序遍历 - 力扣(LeetCode)
class Solution {
public List<Integer> postorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
postorder(root, res);
return res;
}
// 下面这是一个递归的方法(中序遍历)
void postorder(TreeNode root, List<Integer> list) {
if (root == null) {
return;
}
postorder(root.left, list);
postorder(root.right, list);
list.add(root.val); // 左右中的遍历顺序
}
}
三、二叉树的迭代遍历
同样针对上面的3道力扣题目,这里用迭代法来进行二叉树的前中后序遍历。其实就是用栈实现。
1)前序遍历(迭代法)
前序遍历是中左右,每次先处理的是中间节点,那么先将根节点放入栈中,然后将右孩子加入栈,再加入左孩子。这样出栈才是先左后右(根节点是直接立马出栈的)
先把根节点压入栈。然后开启循环,先弹出栈顶元素,然后这个弹出元素的右、左结点先后压入栈。在下一轮循环中,会先弹出左结点,再把弹出的结点存入result。然后再把这个弹出的结点的右、左结点再压入栈,继续遍历。这样就实现了深度遍历,先把左子树遍历完,再遍历右子树。
// 前序遍历顺序:中-左-右,入栈顺序:中-右-左
class Solution {
public List<Integer> preorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<>(); // result 存放遍历后的结果
if (root == null){
return result;
}
Stack<TreeNode> stack = new Stack<>(); // 初始化一个栈
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;
}
}
2)中序遍历(迭代法)
中序遍历是左中右,所以要遍历到左子树最下面的时候再把结点值存入result,所以比较麻烦些。
所以在迭代的时候,就要借助指针的遍历来帮助访问结点,然后还是用栈来处理节点上的元素。
遍历顺序是先遍历左子树,然后访问根节点,最后遍历右子树。
首先,将根节点入栈,然后不断将左子节点入栈,直到遇到一个空节点。此时,从栈中弹出一个节点,访问它,然后将它的右子节点入栈(如果右结点为空的话,就还会弹出再指向右节点),继续这个过程,直到栈为空。
比如上面这个例子,先把5、4、1压入栈,1的左结点为空了,然后就弹出1,记录1,接着访问1的右节点,因为右节点为空,所以再弹出栈顶元素4,记录4,访问4的右节点2,压入栈,发现2的左结点为空,就弹出2记录2.然后发现2的右节点也为空,就弹出5记录5 ,然后访问5的右节点6,把6压入栈,然后发现6的左结点为空,就把6弹出记录。最后结果就是14256.
// 中序遍历顺序: 左-中-右 入栈顺序: 左-右
class Solution {
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<>();
if (root == null){
return result;
}
Stack<TreeNode> stack = new Stack<>();
TreeNode cur = root; // cur首先是根节点
while (cur != null || !stack.isEmpty()){ // 也是循环到栈为空
if (cur != null){
stack.push(cur); // 把cur压入栈
cur = cur.left; // 然后cur为左结点
}else{ // 左结点的左结点这么遍历下去,直到当前节点为空
cur = stack.pop(); // 就把栈顶元素弹出,
result.add(cur.val);
cur = cur.right; // 然后指向右结点(如果右结点为空,会再执行else弹出,再弹出的就是子树的根节点了)
}
}
return result;
}
}
3)后序遍历(迭代法)
(类似先序遍历)后序遍历是左右中,那么我们只需要调整一下先序遍历的代码顺序,就变成中右左的遍历顺序,然后在反转result数组,输出的结果顺序就是左右中了
// 后序遍历顺序 左-右-中 入栈顺序:中-左-右 出栈顺序:中-右-左, 最后翻转结果
class Solution {
public List<Integer> postorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<>();
if (root == null){
return result;
}
Stack<TreeNode> stack = new Stack<>();
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); // 翻转result,就是左右中
return result;
}
}
四、二叉树的统一迭代法
上面前中后序的迭代法代码风格其实不统一(不能像递归那样用一个套路),这里就统一下。方法就是把访问的节点放入栈中,把要处理的节点也放入栈中(紧接着放一个空指针作为标记)。
1)统一法迭代实现中序遍历
将访问的节点直接加入到栈中,但如果是处理的节点则后面放入一个空节点, 这样只有空节点弹出的时候,才将下一个节点放进结果集。
入栈顺序是右、中、左,然后在中节点入栈的时候接着入栈一个null,这样弹出顺序就是左中右
先压入5。然后执行循环,node=5,不为空,弹出5。然后把 6 5 null 4 依次压入栈。然后下一轮,node=4,不为空,把4弹出,然后把 2 4 null 1压入栈。此时栈中为 6 5 null 2 4 null 1。然后下一轮,node=1,不为空,就弹出1,然后把 1 null压入栈,此时栈中为 6 5 null 2 4 null 1 null。然后下一轮,node=null,就弹出栈顶这个null,标记node=1,然后再弹出、,记录这个弹出的1到result。下一轮,node=null,然后同样的执行else,弹出null再弹出4,记录4.然后再一轮,栈顶node=2,会执行if,把2弹出后,再压入2 null,此时栈中为 6 5 null 2 null。然后下一轮node就为null,弹出null和2,记录2.然后下一轮node还是为null,弹出null和5,记录5.再下一轮node为6,弹出再压入6 null。最后一轮node为null,弹出null和6,记录6.
class Solution {
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> result = new LinkedList<>();
Stack<TreeNode> st = new Stack<>(); // 同样用一个栈实现
if (root != null) st.push(root); // 先把根节点压入栈
while (!st.empty()) { // 循环直到栈为空
TreeNode node = st.peek(); // node为栈顶节点
if (node != null) {
st.pop(); // 将该节点弹出,避免重复操作,下面再将右中左节点添加到栈中
if (node.right!=null) st.push(node.right); // 添加右节点(空节点不入栈)
st.push(node); // 添加中节点
st.push(null); // 中节点访问过,但是还没有处理,加入空节点做为标记。
if (node.left!=null) st.push(node.left); // 添加左节点(空节点不入栈)
} else { // 直到栈顶结点为空
st.pop(); // 将空节点弹出
node = st.peek(); // 重新取出栈中元素
st.pop();
result.add(node.val); // 加入到结果集
}
}
return result;
}
}
2)统一法迭代实现前序遍历
入栈顺序就是右、左、中,然后还是在中节点后加一个null。其他不变。弹出顺序就是中左右
class Solution {
public List<Integer> preorderTraversal(TreeNode root) {
List<Integer> result = new LinkedList<>();
Stack<TreeNode> st = new Stack<>();
if (root != null) st.push(root);
while (!st.empty()) {
TreeNode node = st.peek();
if (node != null) {
st.pop(); // 将该节点弹出,避免重复操作,下面再将右中左节点添加到栈中
if (node.right!=null) st.push(node.right); // 添加右节点(空节点不入栈)
if (node.left!=null) st.push(node.left); // 添加左节点(空节点不入栈)
st.push(node); // 添加中节点
st.push(null); // 中节点访问过,但是还没有处理,加入空节点做为标记。
} else { // 只有遇到空节点的时候,才将下一个节点放进结果集
st.pop(); // 将空节点弹出
node = st.peek(); // 重新取出栈中元素
st.pop();
result.add(node.val); // 加入到结果集
}
}
return result;
}
}
3)统一法迭代实现后续遍历
入栈顺序就是中、右、左,然后还是在中节点后加一个null。其他不变,弹出顺序就是左右中
class Solution {
public List<Integer> postorderTraversal(TreeNode root) {
List<Integer> result = new LinkedList<>();
Stack<TreeNode> st = new Stack<>();
if (root != null) st.push(root);
while (!st.empty()) {
TreeNode node = st.peek();
if (node != null) {
st.pop(); // 将该节点弹出,避免重复操作,下面再将右中左节点添加到栈中
st.push(node); // 添加中节点
st.push(null); // 中节点访问过,但是还没有处理,加入空节点做为标记。
if (node.right!=null) st.push(node.right); // 添加右节点(空节点不入栈)
if (node.left!=null) st.push(node.left); // 添加左节点(空节点不入栈)
} else { // 只有遇到空节点的时候,才将下一个节点放进结果集
st.pop(); // 将空节点弹出
node = st.peek(); // 重新取出栈中元素
st.pop();
result.add(node.val); // 加入到结果集
}
}
return result;
}
}
五、二叉树的层序遍历
层序遍历一个二叉树。就是从左到右一层一层的去遍历二叉树。需要借用一个辅助数据结构即队列来实现,队列先进先出,符合一层一层遍历的逻辑,而用栈先进后出适合模拟深度优先遍历也就是递归的逻辑。
主要看借助队列实现的方法:首先把根节点6放入que。
- 第1轮循环:len=1,把que中的结点6弹出并存入结果集,然后把6的左右结点4、6放入que
- 第2轮循环:len=2,内部循环2次,第一次先弹出4,存入13,第二次弹出7,存入58
- 第3轮循环:lem=4,内部循环4次,分别弹出1358.
- 注意每一轮循环都会新建一个列表,来存储当前层的值。
class Solution {
public List<List<Integer>> resList = new ArrayList<List<Integer>>(); // 一个二维列表
public List<List<Integer>> levelOrder(TreeNode root) {
//checkFun01(root,0); // 可以用递归的方式,也可以用队列来实现
checkFun02(root);
return resList;
}
//BFS--递归方式
public void checkFun01(TreeNode node, Integer deep) {
if (node == null) return;
deep++; //deep记录层数,每次深入一层,深度 deep 增加。
if (resList.size() < deep) { // 第一轮deep为1
//如果resList的大小小于deep,则创建一个新的子列表并添加到 resList 中。
List<Integer> item = new ArrayList<Integer>();
resList.add(item); //将当前节点的值添加到对应深度的子列表中。
}
resList.get(deep - 1).add(node.val);
checkFun01(node.left, deep); // 递归调用,分别对左子节点和右子节点进行遍历。
checkFun01(node.right, deep);
}
//BFS--迭代方式--借助队列
public void checkFun02(TreeNode node) {
if (node == null) return;
Queue<TreeNode> que = new LinkedList<TreeNode>();
que.offer(node); // 先把根节点加入队列
while (!que.isEmpty()) { // 循环直到队列为空
// 在每次循环迭代中,创建一个新的列表 itemList,用于存储当前层的节点值。
List<Integer> itemList = new ArrayList<Integer>();
int len = que.size(); // 获取当前层的节点数量。初始que里面只有一个根节点,len=1
while (len > 0) {
TreeNode tmpNode = que.poll(); //取出根节点,记录到itemList
itemList.add(tmpNode.val);
// 然后把根节点的左右结点分别加入到que中。
if (tmpNode.left != null) que.offer(tmpNode.left);
if (tmpNode.right != null) que.offer(tmpNode.right);
len--; // 第一轮只循环1次(第二轮que中有2个结点,遍历2次,第三轮遍历4次)
}
resList.add(itemList);
}
}
}