二叉树的递归/层序遍历

简介: 本文详解二叉树的两种遍历方式:DFS(递归遍历)和BFS(层序遍历)。DFS通过递归按“左→右”顺序遍历,前/中/后序取决于代码位置;BFS借助队列实现逐层遍历,常用于求最短路径。三种BFS写法逐步进阶,适用于不同场景。DFS适合找所有路径,BFS更优解最短路径问题。

递归遍历(DFS)
二叉树的遍历框架:
// 基本的二叉树节点
class TreeNode {
int val;
TreeNode left, right;
}

// 二叉树的遍历框架
void traverse(TreeNode root) {
if (root == null) {
return;
}
traverse(root.left);
traverse(root.right);
}
这段短小精干的代码为什么能遍历二叉树?又是以什么顺序遍历二叉树的?请看VCR

traverse 函数的遍历顺序就是一直往左子节点走,直到遇到空指针不能再走了,才尝试往右子节点走一步;然后再一直尝试往左子节点走,如此循环;如果左右子树都走完了,则返回上一层父节点。其实看代码也能看出来,先递归调用的 root.left,然后才递归调用的 root.right 嘛。
这个递归遍历节点顺序是固定的,务必记住这个顺序,否则你肯定玩不转二叉树结构。
那么有一些数据结构基础的读者肯定要问了:不对呀,只要上过大学的数据结构课程都知道,二叉树有前/中/后序三种遍历,会得到三种不同顺序的结果。为啥你这里说递归遍历节点的顺序是固定的呢?
这个问题很好,我来解答一下。
Important
递归遍历的顺序,即 traverse 函数访问节点的顺序确实是固定的。正如上面那个视频,root 指针在树上移动的顺序是固定的。
但是你在 traverse 函数中不同位置写代码,效果是可以不一样的。前中后序遍历的结果不同,原因是因为你把代码写在了不同位置,所以产生了不同的效果。
比方说,刚进入一个节点的时候,你还对它的子节点一无所知,而当你要离开一个节点的时候,它的所有子节点你都遍历过了。那么在这两种情况下写的代码,肯定是可以有不同的效果的。
你所谓的前中后序遍历,其实就是在上述二叉树遍历框架的不同位置写代码:
// 二叉树的遍历框架
void traverse(TreeNode root) {
if (root == null) {
return;
}
// 前序位置
traverse(root.left);
// 中序位置
traverse(root.right);
// 后序位置
}
前序位置的代码会在进入节点时执行;中序位置的代码会在左子树遍历完成后,遍历右子树之前执行;后序位置的代码会在左右子树遍历完成后执行:

层序遍历(BFS)
上面的递归遍历是依赖函数堆栈递归遍历二叉树的,如果把递归函数 traverse 看做一个指针,那么这个指针在二叉树上游走的顺序大概是从最左侧开始,一列一列走到最右侧。
二叉树的层序遍历,顾名思义,就是一层一层地遍历二叉树。这个遍历方式需要借助队列来实现,而且根据不同的需求,主要有三种不同的写法,下面一一列举。
写法一
这是最简单的写法,代码如下:
void levelOrderTraverse(TreeNode root) {
if (root == null) {
return;
}
Queue q = new LinkedList<>();
q.offer(root);
while (!q.isEmpty()) {
TreeNode cur = q.poll();
// 访问 cur 节点
System.out.println(cur.val);

    // 把 cur 的左右子节点加入队列
    if (cur.left != null) {
        q.offer(cur.left);
    }
    if (cur.right != null) {
        q.offer(cur.right);
    }
}

}
cur 变量在树上游走的顺序可以得到层序遍历是一层一层,从左到右的遍历二叉树节点
这种写法的优缺点
这种写法最大的优势就是简单。每次把队头元素拿出来,然后把它的左右子节点加入队列,就完事了。但是这种写法的缺点是,无法知道当前节点在第几层。知道节点的层数是个常见的需求,比方说让你收集每一层的节点,或者计算二叉树的最小深度等等。
所以这种写法虽然简单,但用的不多,下面介绍的写法会更常见一些。
写法二
对上面的解法稍加改造,就得出了下面这种写法:
void levelOrderTraverse(TreeNode root) {
if (root == null) {
return;
}
Queue q = new LinkedList<>();
q.offer(root);
// 记录当前遍历到的层数(根节点视为第 1 层)
int depth = 1;

while (!q.isEmpty()) {
    int sz = q.size();
    for (int i = 0; i < sz; i++) {
        TreeNode cur = q.poll();
        // 访问 cur 节点,同时知道它所在的层数
        System.out.println("depth = " + depth + ", val = " + cur.val);

        // 把 cur 的左右子节点加入队列
        if (cur.left != null) {
            q.offer(cur.left);
        }
        if (cur.right != null) {
            q.offer(cur.right);
        }
    }
    depth++;
}

}
注意代码中的内层 for 循环:
int sz = q.size();
for (int i = 0; i < sz; i++) {
...
}
这个变量 i 记录的是节点 cur 是当前层的第几个,大部分算法题中都不会用到这个变量,所以你完全可以改用下面的写法:
int sz = q.size();
while (sz-- > 0) {
...
}
这个属于细节问题,按照自己的喜好来就行。
但是注意队列的长度 sz 一定要在循环开始前保存下来,因为在循环过程中队列的长度是会变化的,不能直接用 q.size() 作为循环条件。
写法三
既然写法二是最常见的,为啥还有个写法三呢?因为要给后面的进阶内容做铺垫。
现在我们只是在探讨二叉树的层序遍历,但是二叉树的层序遍历可以衍生出 多叉树的层序遍历,图的 BFS 遍历,以及经典的 BFS 暴力穷举算法框架,所以这里要拓展延伸一下。
回顾写法二,我们每向下遍历一层,就给 depth 加 1,可以理解为每条树枝的权重是 1,二叉树中每个节点的深度,其实就是从根节点到这个节点的路径权重和,且同一层的所有节点,路径权重和都是相同的。
那么假设,如果每条树枝的权重和可以是任意值,现在让你层序遍历整棵树,打印每个节点的路径权重和,你会怎么做?这样的话,同一层节点的路径权重和就不一定相同了,写法二这样只维护一个 depth 变量就无法满足需求了。
写法三就是为了解决这个问题,在写法一的基础上添加一个 State 类,让每个节点自己负责维护自己的路径权重和,代码如下:
class State {
TreeNode node;
int depth;

State(TreeNode node, int depth) {
    this.node = node;
    this.depth = depth;
}

}

void levelOrderTraverse(TreeNode root) {
if (root == null) {
return;
}
Queue q = new LinkedList<>();
// 根节点的路径权重和是 1
q.offer(new State(root, 1));

while (!q.isEmpty()) {
    State cur = q.poll();
    // 访问 cur 节点,同时知道它的路径权重和
    System.out.println("depth = " + cur.depth + ", val = " + cur.node.val);

    // 把 cur 的左右子节点加入队列
    if (cur.node.left != null) {
        q.offer(new State(cur.node.left, cur.depth + 1));
    }
    if (cur.node.right != null) {
        q.offer(new State(cur.node.right, cur.depth + 1));
    }
}

}
这样每个节点都有了自己的 depth 变量,是最灵活的,可以满足所有 BFS 算法的需求。但是由于要额外定义一个 State 类比较麻烦,所以非必要的话,用写法二就够了。
为什么 BFS 常用来寻找最短路径
来看力扣第 111 题「二叉树的最小深度」:

  1. 二叉树的最小深度 | 力扣 | LeetCode |
    给定一个二叉树,找出其最小深度。
    最小深度是从根节点到最近叶子节点的最短路径上的节点数量。
    说明:叶子节点是指没有子节点的节点。
    示例 1:

输入:root = [3,9,20,null,null,15,7]
输出:2
示例 2:
输入:root = [2,null,3,null,4,null,5,null,6]
输出:5
提示:
● 树中节点数的范围在 [0, 105] 内
● -1000 <= Node.val <= 1000
二叉树的最小深度即「根节点到最近的叶子节点的距离」,所以这道题本质上就是让你求最短距离。
DFS 递归遍历和 BFS 层序遍历都可以解决这道题,先看 DFS 递归遍历的解法:
class Solution {
// 记录最小深度(根节点到最近的叶子节点的距离)
private int minDepth = Integer.MAX_VALUE;
// 记录当前遍历到的节点深度
private int currentDepth = 0;

public int minDepth(TreeNode root) {
    if (root == null) {
        return 0;
    }
    // 从根节点开始 DFS 遍历
    traverse(root);
    return minDepth;
}

private void traverse(TreeNode root) {
    if (root == null) {
        return;
    }

    // 前序位置进入节点时增加当前深度
    currentDepth++;

    // 如果当前节点是叶子节点,更新最小深度
    if (root.left == null && root.right == null) {
        minDepth = Math.min(minDepth, currentDepth);
    }

    traverse(root.left);
    traverse(root.right);

    // 后序位置离开节点时减少当前深度
    currentDepth--;
}

}
每当遍历到一条树枝的叶子节点,就会更新最小深度,当遍历完整棵树后,就能算出整棵树的最小深度。你能不能在不遍历完整棵树的情况下,提前结束算法?不可以,因为你必须确切的知道每条树枝的深度(根节点到叶子节点的距离),才能找到最小的那个。
下面来看 BFS 层序遍历的解法。按照 BFS 从上到下逐层遍历二叉树的特点,当遍历到第一个叶子节点时,就能得到最小深度:
class Solution {
public int minDepth(TreeNode root) {
if (root == null) return 0;
Queue q = new LinkedList<>();
q.offer(root);
// root 本身就是一层,depth 初始化为 1
int depth = 1;

    while (!q.isEmpty()) {
        int sz = q.size();
        // 遍历当前层的节点
        for (int i = 0; i < sz; i++) {
            TreeNode cur = q.poll();
            // 判断是否到达叶子结点
            if (cur.left == null && cur.right == null)
                return depth;
            // 将下一层节点加入队列
            if (cur.left != null)
                q.offer(cur.left);
            if (cur.right != null)
                q.offer(cur.right);
        }
        // 这里增加步数
        depth++;
    }
    return depth;
}

}
由于 BFS 逐层遍历的逻辑,第一次遇到目标节点时,所经过的路径就是最短路径,算法可能并不需要遍历完所有节点就能提前结束。DFS 遍历当然也可以用来寻找最短路径,但必须遍历完所有节点才能得到最短路径。从时间复杂度的角度来看,两种算法在最坏情况下都会遍历所有节点,时间复杂度都是 O(N)O(N),但在一般情况下,显然 BFS 算法的实际效率会更高。所以在寻找最短路径的问题中,BFS 算法是首选。
为什么 DFS 常用来寻找所有路径
在寻找所有路径的问题中,你会发现 DFS 算法用的比较多,BFS 算法似乎用的不多。
理论上两种遍历算法都是可以的,只不过 BFS 算法寻找所有路径的代码比较复杂,DFS 算法代码比较简洁。你想啊,就以二叉树为例,如果要用 BFS 算法来寻找所有路径(根节点到每个叶子节点都是一条路径),队列里面就不能只放节点了,而需要使用第三种写法,新建一个 State 类,把当前节点以及到达当前节点的路径都存进去,这样才能正确维护每个节点的路径,最终计算出所有路径。
而使用 DFS 算法就简单了,它本就是一条树枝一条树枝从左往右遍历的,每条树枝就是一条路径,所以 DFS 算法天然适合寻找所有路径。最后结合代码和可视化面板讲解一下,先看 DFS 算法的可视化面板,你可以多次点击 if (root === null) 这部分代码,观察 DFS 算法遍历所有节点并收集根节点到叶子节点的所有路径:
综上,DFS 算法在寻找所有路径的问题中更常用,而 BFS 算法在寻找最短路径的问题中更常用。

相关文章
|
存储 数据采集 缓存
海量数据去重的Hash、bitmap、BloomFilter、分布式一致性hash
海量数据去重的Hash、bitmap、BloomFilter、分布式一致性hash
487 1
|
10月前
|
设计模式 Java 关系型数据库
【设计模式】【创建型模式】抽象工厂模式(Abstract Factory)
一、入门 什么是抽象工厂模式? 抽象工厂模式是一种创建型设计模式,它提供了一个接口,用于创建相关或依赖对象的家族,而不需要指定具体的类。 简单来说,抽象工厂模式是工厂方法模式的升级版,它能够创建一组相
346 14
|
缓存 负载均衡 算法
(四)网络编程之请求分发篇:负载均衡静态调度算法、平滑轮询加权、一致性哈希、最小活跃数算法实践!
先如今所有的技术栈中,只要一谈关于高可用、高并发处理相关的实现,必然会牵扯到集群这个话题,也就是部署多台服务器共同对外提供服务,从而做到提升系统吞吐量,优化系统的整体性能以及稳定性等目的。
574 2
|
Java 数据库连接 数据库
【潜意识Java】深度分析黑马项目《苍穹外卖》在Java学习中的重要性
《苍穹外卖》项目对Java学习至关重要。它涵盖了用户管理、商品查询、订单处理等模块,涉及Spring Boot、MyBatis、Redis等技术栈。
1760 4
|
运维 安全 数据安全/隐私保护
云时代下的企业运维:挑战与解决策略
随着云计算技术的日益成熟和普及,企业IT运维面临着前所未有的变革。传统的运维模式已难以满足现代业务对速度、灵活性和成本效益的要求。本文将深入探讨在云环境下企业运维面临的主要挑战,包括资源管理的复杂性增加、安全威胁的多样化以及合规性的严格要求。同时,文章也将提供一系列针对性的解决策略,旨在帮助企业构建一个高效、安全且具有弹性的运维体系。
|
Kubernetes 应用服务中间件 调度
k8s-高级调度-污点容忍、亲和性调度
k8s-高级调度-污点容忍、亲和性调度
339 1
|
安全 Java 数据库连接
首次面试经历(忘指导)当我在简历上写了苍穹外卖,瑞吉外卖时……
首次面试经历(忘指导)当我在简历上写了苍穹外卖,瑞吉外卖时……
2817 5
|
存储 Cloud Native 中间件
云原生概要介绍-云原生架构模式分析
云原生概要介绍-云原生架构模式分析
云原生概要介绍-云原生架构模式分析

热门文章

最新文章