二叉树理论基础
在解题过程中,二叉树主要有两种主要的形式:满二叉树和完全二叉树。
满二叉树
如果一棵二叉树只有度为0的结点和度为2的结点,并且度为0的结点在同一层上,则这棵二叉树为满二叉树。
深度为k的满二叉树,共有2^k-1个结点。
完全二叉树
在完全二叉树中,除了最底层结点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的结点都集中在该层最左边的若干位置。若最底层为第h层(h从1开始),则该层包含1~2^(h-1)个结点
二叉搜索树
二叉搜索树有数值,是一个有序树。
- 若它的左子树不为空,则左子树上所有结点的值均小于它的根结点的值;
- 若它的右子树不为空,则右子树上所有节点的值均大于它的根节点的值;
- 它的左、右子树也分别为二叉排序树。
平衡二叉搜索树
平衡二叉树又被称为AVL(Adelson-Velsky and Landis)树,且具有以下性质:它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。
C++中map、set、multimap,multiset的底层实现都是平衡二叉搜索树,所以map、set的增删操作时间时间复杂度是logn,注意我这里没有说unordered_map、unordered_set,unordered_map、unordered_set底层实现是哈希表。
所以大家使用自己熟悉的编程语言写算法,一定要知道常用的容器底层都是如何实现的,最基本的就是map、set等等,否则自己写的代码,自己对其性能分析都分析不清楚!
在Java中,HashSet(Map)的底层实现是哈希桶,TreeSet(Map)的底层实现是二叉搜索树(红黑树)。
二叉树的存储方式
可以链式存储(用指针),也可以顺序存储(用数组)。
用数组来存储二叉树如何遍历的呢?
如果父结点的数组下标是i,那么它的左孩子就是i*2+1,右孩子就是i*2+2。
但用链式表示的二叉树,更有利于我们的理解,所以一般都用链式存储二叉树,但用数字依然可以表示二叉树。
二叉树的遍历方式
1.深度优先遍历:先往深走,遇到叶子结点再往回走。
- 前序遍历(递归法,迭代法):中左右
- 中序遍历(递归法,迭代法):左中右
- 后序遍历(递归法,迭代法):左右中
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;
}
}
递归遍历
递归算法三要素:
- 确定递归函数的参数和返回值:确定哪些参数是递归过程中需要处理的,就在递归函数里加上这个参数,并且还要明确每次递归的返回值是什么进而确定递归函数的返回类型。
- 确定终止条件:写完了递归算法,运行的时候,经常会遇到栈溢出的错误,就是没写终止条件或终止条件写的不对,操作系统也是用一个栈的结构来保存每一层递归的信息,如果递归没有终止,操作系统的内存必然会溢出。
- 确定单层递归的逻辑:确定每一层递归需要处理的信息。在这里也就会重复调用自己来实现递归的过程。
LeetCode 144. 二叉树的前序遍历
class Solution {
public List<Integer> preorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<Integer>();
preorder(root, result);
return result;
}
public void preorder(TreeNode cur, List<Integer> result) {
if(cur == null) return;
result.add(cur.val); // 中
preorder(cur.left, result); // 左
preorder(cur.right, result); // 右
}
}
LeetCode 145. 二叉树的后序遍历
class Solution {
public List<Integer> postorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<Integer>();
postorder(root, result);
return result;
}
public void postorder(TreeNode cur, List<Integer> result) {
if(cur == null) return;
postorder(cur.left, result);
postorder(cur.right, result);
result.add(cur.val);
}
}
class Solution {
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<Integer>();
traversal(root, result);
return result;
}
public void traversal(TreeNode cur, List<Integer> result) {
if(cur == null) return;
traversal(cur.left, result);
result.add(cur.val);
traversal(cur.right, result);
}
}
迭代遍历
为什么可以用迭代法(非递归的方式)来实现二叉树的前后中序遍历呢?
递归的实现就是:每一次递归调用都会把函数的局部变量、参数值和返回地址等压入调用栈中,然后递归返回的时候,从栈顶弹出上一次递归的各项参数,所以这就是递归为什么可以返回上一层位置的原因。
此时大家应该知道我们用栈也可以是实现二叉树的前后中序遍历了。
前序遍历
前序遍历顺序是中左右,那么先将根结点放入栈中,然后将右孩子加入栈,再加入左孩子。
为什么要先加入 右孩子,再加入左孩子呢? 因为这样出栈的时候才是中左右的顺序。
注意空结点不入栈!!代码如下:
class Solution {
public List<Integer> preorderTraversal(TreeNode root) {
Stack<TreeNode> stack = new Stack<>();
List<Integer> result = new ArrayList<Integer>();
if(root == null) return result;
stack.push(root);
while(!stack.isEmpty()){
TreeNode top = stack.pop(); //中
result.add(top.val);
if(top.right != null) stack.push(top.right); // 右
if(top.left != null) stack.push(top.left); // 左
}
return result;
}
}
中序遍历
分析一下为什么刚刚写的前序遍历的代码,不能和中序遍历通用呢,因为前序遍历的顺序是中左右,先访问的元素是中间节点,要处理的元素也是中间节点,所以刚刚才能写出相对简洁的代码,因为要访问的元素和要处理的元素顺序是一致的,都是中间节点。
那么再看看中序遍历,中序遍历是左中右,先访问的是二叉树顶部的节点,然后一层一层向下访问,直到到达树左面的最底部,再开始处理节点(也就是在把节点的数值放进result数组中),这就造成了处理顺序和访问顺序是不一致的。
那么在使用迭代法写中序遍历,就需要借用指针的遍历来帮助访问节点,栈则用来处理节点上的元素。
代码如下:
class Solution {
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<Integer>();
if(root == null) return result;
Stack<TreeNode> stack = new Stack<>();
TreeNode cur = root;
while(cur != null || !stack.isEmpty()) {
if(cur == null) {
// 指针访问到底部
TreeNode node = stack.pop();
result.add(node.val); // 中
cur = node.right; // 右
} else {
stack.push(cur);
cur = cur.left; // 左
}
}
return result;
}
}
例子:
root = [1,null,2,3],当stack弹出栈顶元素1,此时cur=2,但stack已经空了,二叉树却还没有遍历结束,所以while的条件与前序遍历相比还需加上cur != null。
后序遍历
前序遍历是中左右,后序遍历是左右中,只需调整一下前序遍历的代码顺序,就变成中右左的便利顺序,然后在翻转result数组(使用Collections.reverse()函数),输出的结果就是左右中啦。代码如下:
class Solution {
public List<Integer> postorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<Integer>();
if(root == null) return result;
Stack<TreeNode> stack = new Stack<>();
stack.push(root);
while(!stack.isEmpty()) {
TreeNode top = stack.pop();
result.add(top.val);
if(top.left != null) stack.push(top.left);
if(top.right != null) stack.push(top.right);
}
Collections.reverse(result);
return result;
}
}
统一迭代
以中序遍历为例,前面提到说使用栈的话,无法同时解决访问结点(遍历结点)和处理结点(将元素放入结果集)不一致的情况。
那我们就将访问的结点放入栈中,把要处理的结点也放入栈中但是要做标记。
如何做标记呢,就是要处理的结点放入栈之后,紧接着放入一个空指针作为标记,这种方法也可以叫做标记法。
为什么遇到空指针就要处理呢?其实空指针标记的是中间节点和叶子结点,如果当前结点的左孩子为空,则将null弹出后,将该结点加入结果集,此时栈顶结点为右孩子,继续遍历;若当前结点的左孩子不为空,则栈顶元素为左孩子,继续遍历。入栈顺序为右中左,则出栈顺序为左中右。
代码如下:
class Solution {
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<Integer>();
if(root == null) return result;
Stack<TreeNode> stack = new Stack<>();
TreeNode cur = root;
stack.push(root);
while(!stack.isEmpty()) {
TreeNode top = stack.pop();
if(top == null) {
top = stack.pop(); // 取到真正的结点
result.add(top.val);
} else {
if(top.right != null) stack.push(top.right); // 右
stack.push(top);
stack.push(null); // 中 标记为访问过的结点
if(top.left != null) stack.push(top.left); // 左
}
}
return result;
}
}
按照这种统一写法,则前序遍历的代码为:
lass Solution {
public List<Integer> preorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<Integer>();
if(root == null) return result;
Stack<TreeNode> stack = new Stack<>();
TreeNode cur = root;
stack.push(root);
while(!stack.isEmpty()) {
TreeNode top = stack.pop();
if(top == null) {
top = stack.pop(); // 取到真正的结点
result.add(top.val);
} else {
if(top.right != null) stack.push(top.right); // 右
if(top.left != null) stack.push(top.left); // 左
stack.push(top);
stack.push(null); // 中 标记为访问过的结点
}
}
return result;
}
}
后序遍历的代码为:
class Solution {
public List<Integer> postorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<Integer>();
if(root == null) return result;
Stack<TreeNode> stack = new Stack<>();
TreeNode cur = root;
stack.push(root);
while(!stack.isEmpty()) {
TreeNode top = stack.pop();
if(top == null) {
top = stack.pop(); // 取到真正的结点
result.add(top.val);
} else {
stack.push(top);
stack.push(null); // 中 标记为访问过的结点
if(top.right != null) stack.push(top.right); // 右
if(top.left != null) stack.push(top.left); // 左
}
}
return result;
}
}
统一风格的迭代法写出来了,但并不好理解,可以根据自己的喜好,对于前中后序遍历,选择一种自己容易理解的递归和迭代法。