03-二叉树——从递归遍历到非递归实现

老程序员回炉补基础(三):二叉树------从递归遍历到非递归实现

树是最让我感到"脑力不够用"的数据结构。递归遍历还好,一旦去掉递归用栈模拟,脑子里就像在走迷宫。但正是这种"烧脑"的过程,让我对递归的本质有了真正深入的理解。


二叉树节点

java 复制代码
public class tNode<T> {
    private T data;
    private tNode<T> left = null;
    private tNode<T> right = null;
    private tNode<T> parent = null;

    public tNode(T data) {
        this.data = data;
    }
    // getter/setter 省略...
}

一、递归遍历(三种经典方式)

递归遍历是二叉树最基础的入门。

java 复制代码
public class mTree<T> {
    private tNode<T> root = null;

    // 前序遍历:根 -> 左 -> 右
    public void prescan(tNode<T> r) {
        if (r != null) {
            System.out.print(r.getData() + ",");
            prescan(r.getLeft());
            prescan(r.getRight());
        }
    }

    // 中序遍历:左 -> 根 -> 右
    public void oscan(tNode<T> r) {
        if (r != null) {
            oscan(r.getLeft());
            System.out.print(r.getData() + ",");
            oscan(r.getRight());
        }
    }

    // 后序遍历:左 -> 右 -> 根
    public void bscan(tNode<T> r) {
        if (r != null) {
            bscan(r.getLeft());
            bscan(r.getRight());
            System.out.print(r.getData() + ",");
        }
    }
}

三种遍历的本质区别

唯一区别就是"访问根节点"的时机

复制代码
       1
      / \
     2   3
    / \
   4   5
遍历方式 访问顺序 结果
前序 根→左→右 1,2,4,5,3
中序 左→根→右 4,2,5,1,3
后序 左→右→根 4,5,2,3,1

二、层序遍历(BFS)

层序遍历用队列实现,一层一层地输出:

java 复制代码
public void lscan(tNode<T> r) throws Exception {
    mQueue<tNode<T>> l = new mQueue<tNode<T>>();
    l.inQueue(new qNode<tNode<T>>(r));
    while (l.getCurrSize() > 0) {
        tNode<T> temp = l.outQueue().getData();
        System.out.print(temp.getData() + ",");
        if (temp.getLeft() != null)
            l.inQueue(new qNode<tNode<T>>(temp.getLeft()));
        if (temp.getRight() != null)
            l.inQueue(new qNode<tNode<T>>(temp.getRight()));
    }
}

这里直接复用了上一篇手写的 mQueue。根入队→出队打印→左右孩子入队→循环。简单直观。


三、非递归遍历(用栈模拟递归)

这是我花时间最多的部分。 递归遍历3分钟就能写完,非递归遍历我写了整整一个下午。

3.1 非递归前序遍历

java 复制代码
public void prescanS(tNode<T> r) {
    System.out.print(r.getData() + ",");
    mStack<tNode<T>> s1 = new mStack<tNode<T>>();

    tNode<T> p = r;
    // 沿左子树一路到底,边走边打印,右孩子入栈
    while (p.getLeft() != null) {
        System.out.print(p.getLeft().getData() + ",");
        if (p.getRight() != null) s1.push(new sNote<tNode<T>>(p.getRight()));
        p = p.getLeft();
    }
    // 弹栈,处理每棵被暂存的右子树
    while (!s1.isNull()) {
        p = s1.pop().getData();
        if (p != null) {
            System.out.print(p.getData() + ",");
            while (p.getLeft() != null) {
                System.out.print(p.getLeft().getData() + ",");
                if (p.getRight() != null) s1.push(new sNote<tNode<T>>(p.getRight()));
                p = p.getLeft();
            }
        }
    }
}

思路:前序遍历是"根→左→右"。沿左子树一路到底,每遇到一个节点就打印(这就是"根"),同时把右孩子压栈保存(等左子树走完了再处理右子树)。

3.2 非递归中序遍历

java 复制代码
public void oscanS(tNode<T> r) {
    mStack<tNode<T>> s1 = new mStack<tNode<T>>();
    s1.push(new sNote<tNode<T>>(r));
    tNode<T> p = r, q = r;
    // 先沿左子树全部压栈
    while (p.getLeft() != null) {
        s1.push(new sNote<tNode<T>>(p.getLeft()));
        p = p.getLeft();
    }
    while (!s1.isNull()) {
        p = s1.pop().getData();
        if (p != null) {
            System.out.print(p.getData() + ","); // 弹出时才打印
            q = p.getRight();
            if (q != null && q.getLeft() != null) {
                s1.push(new sNote<tNode<T>>(p.getRight()));
                while (q != null && q.getLeft() != null) {
                    s1.push(new sNote<tNode<T>>(q.getLeft()));
                    q = q.getLeft();
                }
            } else {
                if (p.getRight() != null)
                    s1.push(new sNote<tNode<T>>(p.getRight()));
            }
        }
    }
}

思路:中序遍历是"左→根→右"。先把左子树全部压栈,然后弹一个打印一个,每弹出一个节点就处理它的右子树(同样先把右子树的左链路全部压栈)。

3.3 非递归后序遍历

java 复制代码
public void bscanS(tNode<T> r) {
    mStack<tNode<T>> s1 = new mStack<tNode<T>>();
    s1.push(new sNote<tNode<T>>(r));
    tNode<T> p = r, q = r;
    while (p.getLeft() != null) {
        s1.push(new sNote<tNode<T>>(p.getLeft()));
        p = p.getLeft();
    }
    while (!s1.isNull()) {
        p = s1.pop().getData();
        // 有右孩子且未处理 → 需要先处理右子树
        if (p != null && p.getRight() != null) {
            // 压入一个只含数据、没有子节点的"标记节点"
            s1.push(new sNote<tNode<T>>(new tNode<T>(p.getData())));
            q = p.getRight();
            if (q != null && q.getLeft() != null) {
                s1.push(new sNote<tNode<T>>(p.getRight()));
                while (q != null && q.getLeft() != null) {
                    s1.push(new sNote<tNode<T>>(q.getLeft()));
                    q = q.getLeft();
                }
            } else {
                if (p.getRight() != null)
                    s1.push(new sNote<tNode<T>>(p.getRight()));
            }
        } else {
            // 没有右孩子或是标记节点 → 打印
            System.out.print(p.getData() + ",");
        }
    }
}

思路 :后序遍历是"左→右→根",是最难的非递归遍历。核心难点是:弹出栈顶节点时,不知道它的右子树是否已经处理过了

我的解决方案是引入一个标记节点:当弹出节点有右孩子时,不直接打印,而是把一个"只含数据、不含子节点"的副本压回栈中,然后先处理右子树。下次弹到这个标记节点时,它没有右孩子,直接打印。

三种非递归遍历对比

遍历 何时打印 栈的作用 难度
前序 入栈前就打印 保存右子树 简单
中序 弹栈时打印 保存左链路 中等
后序 弹栈且右子树处理完才打印 保存待回溯节点 困难

四、由前序+中序还原二叉树

这是树的另一个经典问题:给定前序遍历和中序遍历序列,还原出原始二叉树。

java 复制代码
public void createByPreAndMid(tNode<T> r, String pre, String mid) {
    if (pre.length() > 0) {
        int c = pre.indexOf(",");
        String sR = "";
        if (c >= 0) {
            sR = pre.substring(0, c);
        } else {
            sR = pre;
        }
        r.setData((T) sR);

        // 在中序序列中找到根的位置,划分左右子树
        c = mid.indexOf("," + sR + ",");
        String smL = "", smR = "", spL = "", spR = "", a = "";
        if (c < 0) {
            c = mid.indexOf(sR + ",");
            smL = "";
            if (c < 0) {
                smR = "";
            } else {
                smR = mid.substring(c + 1 + sR.length(), mid.length());
            }
        } else {
            smL = mid.substring(0, c);
            smR = mid.substring(c + 2 + sR.length(), mid.length());
        }
        // ... 根据 smL 的长度从 pre 中截取对应的左子树前序序列
        // 递归构建左右子树
        if (spL != null && spL.length() > 0) {
            tNode<T> t = new tNode<T>(null);
            r.setLeft(t);
            createByPreAndMid(t, spL, smL);
        }
        if (spR != null && spR.length() > 0) {
            tNode<T> t = new tNode<T>(null);
            r.setRight(t);
            createByPreAndMid(t, spR, smR);
        }
    }
}

核心原理

复制代码
前序:1,2,4,8,16,17,9,18,19,5,10,20,11,3,6,12,13,7,14,15
中序:16,8,17,4,18,9,19,2,20,10,5,11,1,12,6,13,3,14,7,15
  1. 前序的第一个元素一定是根1
  2. 在中序中找到根的位置,左边是左子树的中序,右边是右子树的中序
  3. 根据左子树的节点数量,从前序序列中截取对应长度,得到左子树的前序
  4. 递归处理左右子树

实现中的坑

这个实现是所有代码中最复杂的部分之一(mTree.java:69-154),字符串的切割处理很容易出错。因为我的输入格式是逗号分隔的字符串,而不是字符数组,所以边界处理(第一个元素、最后一个元素)需要特别小心。

说实话,这段代码写得不够优雅。如果重新来过,我会用字符数组或者 List 作为输入,而不是用字符串截取。但这也正是学习过程的真实写照------先求正确,再求优雅


五、计算树的高度

java 复制代码
public int getLevel(tNode<T> r) {
    int left = 0, right = 0;
    if (r == null) {
        return 0;
    } else {
        left = getLevel(r.getLeft()) + 1;
        right = getLevel(r.getRight()) + 1;
    }
    return left > right ? left : right;
}

简洁的递归:树的高度 = max(左子树高度, 右子树高度) + 1。


架构师视角:为什么树这么重要?

应用场景 背后的树结构
MySQL 索引 B+ 树
Redis 有序集合 跳表(类似平衡树)
Java HashMap(JDK 8+) 红黑树(链表转树)
Java ConcurrentHashMap 红黑树
文件系统目录 多叉树
XML/HTML DOM DOM 树
编译器 AST 抽象语法树

不理解二叉树遍历,你就无法理解 B+ 树的范围查询为什么高效;不理解树的递归结构,你就无法理解 AST 是怎么被解析器构建和遍历的。


学习感悟

二叉树是我觉得最"值"的章节。非递归遍历逼迫我彻底理解了递归的本质------递归就是系统帮你维护了一个栈。当你自己用栈来模拟递归时,你会发现:

  • 前序遍历为什么简单?因为"先处理自己"这件事很直觉 |
    | ZK 集群选举 | ZAB 协议(树形) |

理解了二叉树的遍历,才能理解索引为什么用 B+ 树而不是哈希表;理解了递归,才能理解分布式系统的调用链路树。


下一篇预告《老程序员回炉补基础(四):图------BFS、DFS 与拓扑排序》

相关推荐
nj01281 小时前
Spring 循环依赖详解:三级缓存、早期引用、AOP 代理与懒加载
java·spring·缓存
Brilliantwxx1 小时前
【C++】 vector(代码实现+坑点讲解)
开发语言·c++·笔记·算法
野生技术架构师1 小时前
2026年最全Java面试题及答案汇总(建议收藏,面试前看这篇就够了)
java·开发语言·面试
一只叫煤球的猫2 小时前
ThreadForge 源码解读一:ThreadScope 如何把并发任务放进清晰边界?
java·面试·开源
洛_尘3 小时前
Python 5:使用库
java·前端·python
程序员小假3 小时前
HTTP3 性能更好,为什么内网微服务依然多用 HTTP2?HTTP2 内网优势是什么?
java·后端
Mr数据杨3 小时前
【Codex】用教案主体模块沉淀标准化教学设计内容
java·开发语言·django·codex·项目开发
NorburyL3 小时前
DPO笔记
深度学习·算法
苍煜3 小时前
RocketMQ系列第三篇:Java原生基础使用实操,手把手写生产者消费者Demo
java·rocketmq·java-rocketmq