一.树的简介
1.1 树的定义
树作为一个比较重要的数据结构,由节点和边组成,父节点通过边连接到子节点。二叉树是每个节点最多有两个子节点的树,是最常见的一种树。
1.2 树的应用场景
二叉树本身的应用场景不多,但其衍生的树应用很多,比如jdk1.8的HashMap使用了红黑树,数据库索引使用了B树和B+树
1.3 树的结构
java中的二叉树每个节点都维护一个左节点和一个右节点,图和节点代码如下
class TreeNode{
int val;
TreeNode left;
TreeNode right;
}
多叉树每个节点都维护一个子节点列表,图和节点代码如下
class TreeNode{
int val;
List<TreeNode> childList;
}
1.4 树的遍历
这里以递归实现的先序遍历为例,二叉树需要递归遍历其左右子节点
public void recursionTraverse(TreeNode treeNode){
if(treeNode != null){
System.out.println(treeNode.val);
recursionTraverse(treeNode.left);
recursionTraverse(treeNode.right);
}
}
多叉树需要遍历其所有子节点
public void recursionTraverse(TreeNode treeNode){
if(treeNode != null){
System.out.println(treeNode.val);
for(TreeNode child : treeNode.childList){
recursionTraverse(child);
}
}
}
多叉树的遍历与二叉树的遍历思想相同,后续我们都以二叉树为讲解案例
二.广度优先遍历(BFS)
2.1 定义
树的广度优先遍历就是树的层序遍历,从根节点开始,根节点为第一层,根节点的子节点为第二层,以此类推。遍历完上一层的节点,才可以遍历下一层的节点,直到没有节点可以访问。需要一个队列来保存每一层的节点列表。
2.2 图解及代码
- 循环前的准备,把第一层节点加入到队列中,也就是根节点node-0;
- 第一层循环,把第二层节点加入到队列中,具体操作是从队列中逐个取出节点,打印输出或保存到集合中,然后把其非空子节点加入到队列中,也就是node-1和node-2;
- 第二层循环,把第三层节点加入到队列中,操作和上一步相同,也就是node-4、node-5、node-7;
- 第三层循环,把第四层节点加入到队列中,操作和上一步相同,由于没有子节点,本次无新节点入队
- 第四层循环,队列中已无节点,循环结束,遍历完成
public static void floorTraverse(TreeNode treeNode){
if (treeNode == null) return;
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(treeNode);
while (queue.size() != 0){
int size = queue.size();
while (size > 0){
TreeNode node = queue.poll();
System.out.println(node.val);
if (node.left != null) queue.add(node.left);
if (node.right != null) queue.add(node.right);
size--;
}
}
}
2.3 时间复杂度与空间复杂度
- 由于每个节点都会被访问到一次,时间复杂度为O(n),n为节点数
- 每个节点都需要保存到集合中,统一打印,空间复杂度为O(n),n为节点数
三.深度优先遍历(DFS)
3.1 定义
从根节点开始对每一条可能的分支路径深入到不能再深入为止,回溯节点继续深入其他分支;按照根节点、左子节点、右子节点的访问顺序可以具体分为先序遍历(根左右)、中序遍历(左根右)、后序遍历(左右根);按照实现的方式又可以具体分为递归法、非递归法
3.2 图解及代码
3.2.1 先序遍历
递归法:
- 首先打印根节点信息
- 递归遍历左子树
- 递归遍历右子树
public static void preOrderTraverseRecur(TreeNode treeNode){
if (treeNode != null){
System.out.println(treeNode.val);
preOrderTraverseRecur(treeNode.left);
preOrderTraverseRecur(treeNode.right);
}
}
非递归法:
- 首先打印根节点,然后把根节点加入到栈中
- 一直遍历左子树,并把节点加入到栈中,直到左子树为空时停止
- 弹栈,拿到最近访问的节点,转向其右子树,存在时继续遍历左子树,否则继续弹栈转向其右子树
- 当栈为空时,遍历结束
public static void preOrderTraverse(TreeNode root){
if (root == null) return;
Stack<TreeNode> stack = new Stack<>();
TreeNode treeNode = root;
while (treeNode != null || !stack.isEmpty()){
//一直遍历左节点
while (treeNode != null){
System.out.println(treeNode.val);
stack.push(treeNode);
treeNode = treeNode.left;
}
//左节点为空时,弹栈,转向上一节点的右子树
if (!stack.isEmpty()){
TreeNode pop = stack.pop();
treeNode = pop.right;
}
}
}
3.2.2 中序遍历
递归法:
- 递归遍历左子树
- 打印根节点
- 递归遍历右子树
public static void midOrderTraverseRecur(TreeNode treeNode){
if (treeNode != null){
midOrderTraverseRecur(treeNode.left);
System.out.println(treeNode.val);
midOrderTraverseRecur(treeNode.right);
}
}
非递归法:
- 首先把根节点加入到栈中
- 一直遍历左子树,并把节点加入到栈中,直到左子树为空时停止
- 弹栈,拿到最近访问的节点,打印输出,转向其右子树,存在时继续遍历左子树,否则继续弹栈转向其右子树
- 当栈为空时,遍历结束
public static void midOrderTraverse(TreeNode root){
if (root == null) return;
Stack<TreeNode> stack = new Stack<>();
TreeNode treeNode = root;
while (treeNode != null || !stack.isEmpty()){
//一直遍历左节点
while (treeNode != null){
stack.push(treeNode);
treeNode = treeNode.left;
}
//左节点为空时,弹栈,转向上一节点的右子树
if (!stack.isEmpty()){
TreeNode pop = stack.pop();
System.out.println(pop.val);
treeNode = pop.right;
}
}
}
3.2.3 后序遍历
递归法:
- 递归遍历左子树
- 递归遍历右子树
- 打印根节点
public static void afterOrderTraverseRecur(TreeNode treeNode){
if (treeNode != null){
afterOrderTraverseRecur(treeNode.left);
afterOrderTraverseRecur(treeNode.right);
System.out.println(treeNode.val);
}
}
非递归法:
- 首先把根节点加入到栈中,由于根节点需要在左右子树均遍历完之后才打印,所以两次的入栈和出栈,记录其为第一次访问状态
- 一直遍历左子树,并把节点加入到栈中(状态为第一次访问),直到左子树为空时停止
- 弹栈,拿到最近访问的节点,判断其是否为第一次访问,如果是则改变其状态为非第一次访问状态,重新入栈,转向其右子树,存在时继续遍历左子树,然后继续弹栈转向其右子树;如果不是第一次访问状态则左右子树均已遍历完,直接打印输出即可
- 当栈为空时,遍历结束
public static void afterOrderTraverse(TreeNode root){
if (root == null) return;
Stack<TreeNode> stack = new Stack<>();
TreeNode treeNode = root;
while (treeNode != null || !stack.isEmpty()){
//一直遍历左节点
while (treeNode != null){
treeNode.isFirst = true;
stack.push(treeNode);
treeNode = treeNode.left;
}
//左节点为空时,弹栈,转向上一节点的右子树
if (!stack.isEmpty()){
TreeNode pop = stack.pop();
if (pop.isFirst) {
pop.isFirst = false;
stack.push(pop);
treeNode = pop.right;
}else {
System.out.println(pop.val);
}
}
}
}
3.3 时间复杂度与空间复杂度
- 时间复杂度,递归法和非递归法都之需要对树节点访问一次,所以时间复杂度都为O(n),n为节点数
- 空间复杂度,递归法本质是系统去维护了调用栈,栈的最大空间是树的高度,平均情况是O(logn),最坏情况下退化为O(n)
3.4 递归法与非递归法的优缺点
- 递归法实现简单,代码逻辑清晰;但是当树比较大时,递归层级较深,方法不断压栈可能会导致JVM的虚拟机栈发生溢出 StackOverflowError
- 非递归法实现稍显复杂,需要额外借助栈来辅助调用;但也因此避免了多层的方法调用
四.如何唯一确定一棵树(树的序列化)
4.1 定义
- 序列化:把树转化为文件存储的过程叫做序列化
- 反序列化:把文件内容重建为树的过程叫做反序列化 我们都知道,先序和中序、后序和中序可以唯一确定一棵树,先序和后序不可以(无法确定根节点);但是如果我们在遍历时把空节点也记录下来,先序、中序和后序都可以唯一确定一棵树。
4.2 图解及代码
public static void leftFirstTraverse(TreeNode root, List list){
if (root != null){
list.add(String.valueOf(root.val));
leftFirstTraverse(root.left, list);
leftFirstTraverse(root.right, list);
}else {
list.add("#");
}
}