二叉树的递归/层序遍历 递归遍历(DFS)

简介: 本文详解二叉树的遍历框架,涵盖递归遍历的固定访问顺序及前、中、后序的本质区别——即代码在递归函数中的位置不同所致。同时深入讲解层序遍历(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);

}

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<TreeNode> 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<TreeNode> 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<State> 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 题「二叉树的最小深度」:

111. 二叉树的最小深度 | 力扣 | 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<TreeNode> 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 算法在寻找最短路径的问题中更常用。


相关文章
|
1天前
|
存储 缓存 算法
学习数据结构和算法的框架思维
本文系统梳理数据结构与算法核心思想:所有数据结构本质为数组或链表的变形,基本操作均为遍历与访问;算法本质是穷举,关键在于“无遗漏”和“无冗余”。掌握框架思维,方能以不变应万变,高效刷题。
学习数据结构和算法的框架思维
|
1天前
|
存储 算法 索引
二叉树基础及常见类型
二叉树是最核心的数据结构之一,不仅是红黑树、堆、字典树等复杂结构的基础,更体现了递归思维的本质。掌握二叉树,等于掌握算法解题的钥匙。从满二叉树到完全二叉树,再到二叉搜索树,各类变体应用广泛。其链式存储与哈希表表示法在算法题中灵活实用,是刷题进阶的必经之路。
|
2月前
|
算法 搜索推荐 JavaScript
基于python智能推荐算法的全屋定制系统
本研究聚焦基于智能推荐算法的全屋定制平台网站设计,旨在解决消费者在个性化定制中面临的选择难题。通过整合Django、Vue、Python与MySQL等技术,构建集家装设计、材料推荐、家具搭配于一体的一站式智能服务平台,提升用户体验与行业数字化水平。
|
2月前
|
测试技术 Python
Python装饰器:为你的代码施展“魔法”
Python装饰器:为你的代码施展“魔法”
255 100
|
16天前
|
人工智能 Java API
Java 正式进入 Agentic AI 时代:Spring AI Alibaba 1.1 发布背后的技术演进
Spring AI Alibaba 1.1 正式发布,提供极简方式构建企业级AI智能体。基于ReactAgent核心,支持多智能体协作、上下文工程与生产级管控,助力开发者快速打造可靠、可扩展的智能应用。
1182 41
|
5月前
|
存储 消息中间件 NoSQL
跟着大厂学架构01:如何利用开源方案,复刻B站那套“永不崩溃”的评论系统?
本文基于B站技术团队分享的《B站评论系统的多级存储架构》,解析其在高并发场景下的设计精髓,并通过开源技术栈(MySQL、Redis、Java)复刻其实现。文章深入讲解了多级存储、数据同步、容灾降级等关键设计,并附有完整代码实现,助你掌握大厂架构设计之道。
195 0
|
1天前
|
存储 API 索引
数据结构的存储方式
数据结构底层存储只有数组和链表两种,其他如栈、队列、树、图等均为其衍生。数组支持随机访问但扩容困难,链表灵活增删但无法随机访问。所有数据结构的操作本质为“增删查改”,遍历方式分为线性迭代与非线性递归。理解二者差异,是掌握各类高级数据结构的基础。(238字)
|
1天前
|
机器学习/深度学习 人工智能 自然语言处理
做了15年认知心理学研究,聊聊我是怎么被文献淹没、又怎么爬出来的
一位认知心理学研究者分享15年科研中如何摆脱文献困扰:从每周耗12小时筛选论文,到借助AI工具将时间减至4小时。通过智能检索、批量分析、跨语言翻译等功能,高效追踪前沿、提升综述质量,并推动团队协作升级。工具助力,让科研回归思考本质。
29 1
|
1天前
|
存储 算法
树结构
树结构通过二叉搜索树(BST)实现二分查找:每个节点左子树值小于根,右子树值大于等于根。查找时从根节点出发,比较目标值与当前节点值,决定向左或右子树递归,每次排除一半数据,时间复杂度为O(log n),实现高效检索。
|
1天前
|
自然语言处理 运维 负载均衡
10 | 索引拆分:大规模检索系统如何使用分布式技术加速检索?
在大规模检索系统中,分布式技术通过拆分倒排索引提升性能。基于文档的水平拆分将数据随机分片,各服务器并行处理,降低单次查询耗时,且易于扩展与维护;而基于关键词的垂直拆分虽减少请求复制,但易引发负载不均与运维复杂。工业界普遍采用文档拆分,兼顾效率与可维护性。