数据结构和算法(八)——你真的懂二叉树遍历吗

1,215 阅读5分钟

二叉树遍历

如果你之前学过二叉树,你一定清楚二叉树的遍历方式有四种,分别为前序遍历、中序遍历、后序遍历、层序遍历。而二叉树遍历除层序遍历位通常使用递归的方式来实现。

递归代码如下:

//前序遍历
public void preorderTraversal(TreeNode head) {
        if(head == null)return;
        System.out.println(head.val);//前序遍历放在前面
        preorderTraversal(head.left);
        preorderTraversal(head.right);
}
//中序遍历
public void preorderTraversal(TreeNode head) {
        if(head == null)return;
        preorderTraversal(head.left);
        System.out.println(head.val);//中序遍历放在中间
        preorderTraversal(head.right);
}
//后序遍历
public void preorderTraversal(TreeNode head) {
        if(head == null)return;
        preorderTraversal(head.left);
        preorderTraversal(head.right);
        System.out.println(head.val);//后序遍历放在后面
}

使用递归代码遍历二叉树非常简单,根据前序、中序、后序遍历分别将输出放在前面、中间、后面就行了。

如果面试要让你实现二叉树的遍历,一定会问你迭代的方式如何实现。就算不是为了面试,如果二叉树的深度非常深,就有可能造成栈溢出异常,这个时候就需要考虑迭代的方式。那么迭代的方式如何实现呢,这里以前序遍历为例,如图求它的前序遍历结果:

使用递归法很简单,但是迭代应该怎么做呢?我们知道方法的调用本质是使用了栈,那我们能不能直接使用栈来模拟遍历二叉树的过程呢。代码如下:

public List<Integer> preorderTraversal(TreeNode head) {
    //保存二叉树遍历结果
    List<Integer> result = new ArrayList<>();
    Stack<TreeNode> stack = new Stack<>();
    stack.push(head);
    while (!stack.empty()){
        TreeNode next = stack.pop();
        result.add(next.val);
        if (next.right != null)stack.push(next.right);
        if (next.left != null)stack.push(next.left);
    }
    return result;
}

前序遍历的顺序是: 根节点 ---> 左子树 ---> 右子树 ,在上述代码中我们用栈结构来模拟遍历过程:

  1. 判断栈中是否还有节点,如果没有节点则直接返回,否则进入第二步
  2. 将栈首的节点出栈
  3. 判断其右子树、左子树是否为null,不为null则加入栈中,由于栈是先进后出的数据结构,所以先判断右子树,再判断左子树。之后继续第一步。

同理使用栈实现二叉树的中序遍历的代码如下:

public List<Integer> inorderTraversal(TreeNode root) {
    List<Integer> result = new ArrayList<>();
    if(root == null)return result;
    Stack<TreeNode> stack = new Stack<>();
    do{
        while(root != null){
           stack.push(root);
           root = root.left;
        }
        if(!stack.isEmpty()){
            TreeNode node = stack.pop();
            result.add(node.val);
            root = node.right;
        }
    }while(!stack.isEmpty() || root != null);
    return result;
}

中序遍历的顺序是: 左子树 ---> 根节点 ---> 右子树,算法流程如下:

  1. 先遍历到root结点的最左侧结点,也就是当root为空时,栈顶的元素为其子树的根结点,这里是确保栈顶元素为最左侧的节点
  2. 弹出栈顶元素,并获取当前节点的右子树。当前节点的右子树可能为 null ,所以在 while 循环,有两个判断条件。继续第一步。

由于后序遍历的结果是前序遍历的逆序,所以后序遍历的迭代的代码有严格的和不严格的,代码如下:

  • 不严格的逆序遍历
//不严格的后序遍历,只是遍历结果和后序遍历结果相同
//但是如果需要按照后序遍历的顺序对树节点进行访问(或操作),这种方式就不行了
public List<Integer> postorderTraversal(TreeNode root) {
    List<Integer> result = new LinkedList<>();
    if(root == null)return result;
    Stack<TreeNode> stack = new Stack<>();
    stack.push(root);
    while(!stack.isEmpty()){
        TreeNode node = stack.pop();
        result.add(0,node.val);
        if(node.left != null)stack.push(node.left);
        if(node.right != null)stack.push(node.right);
    }
    return result;
}
  • 严格的后序遍历

代码来源leetcode的后序遍历

class Solution {
    public List<Integer> postorderTraversal(TreeNode root) {
        if (root == null) return Collections.emptyList();
        List<Integer> res = new ArrayList<>();  //保存结果
        Stack<TreeNode> call = new Stack<>();   //调用栈
        call.push(root);    //先将根结点入栈
        while (!call.isEmpty()) {
            TreeNode t = call.pop();   
            if (t != null) {   
                call.push(t); 
                call.push(null);//这里用null作为标志来判断是否遍历过
                if (t.right != null) call.push(t.right);  
                if (t.left != null) call.push(t.left); 
            } else {  
                res.add(call.pop().val);   
            }
        }
        return res;
    }
}

我们可以看出二叉树两种遍历的方式的特点:

  • 递归法:实现简单,但是耗时一般比迭代法高,如果二叉树深度深容易发生栈溢出异常
  • 迭代法:耗时一般比递归法短,不会出现栈溢出异常,但是实现复杂,尤其是中序遍历、后序遍历

那有没有一种更好的遍历的方法,不会出现栈溢出异常,而且实现起来比较简单,最好是像递归那样,实现方法有规律,容易记忆。

二叉树的前序遍历非常简单,代码非常像递归实现。那为什么中序遍历和后序遍历和后序遍历就这么复杂呢。二叉树的前序遍历顺序是根节点 ---> 左子树 ---> 右子树,它遍历根节点只需要保存根节点的数据即可,而中序遍历的顺序是左子树 ---> 根节点 ---> 右子树,由于先根节点--->左子树,然后回退到根节点,会重复遍历到根节点,它需要确保根节点不会被重复使用,不能就会造成死循环了。那我们能不能使用一个集合来保存节点的已遍历的节点,每次遍历时判断一下该节点是否遍历过。代码如下:

//中序遍历代码
public List<Integer> inorderTraversal(TreeNode root) {
  ArrayList<Integer> res = new ArrayList<>();
    if (root == null) {
        return outputs;
    }
    Stack<TreeNode> stack = new Stack<>();
    HashSet<TreeNode> visited = new HashSet<>();//记录该节点是否被访问过
    stack.push(root);
    while (!stack.isEmpty()) {
        TreeNode node = stack.pop();
        if (visited.contains(node)) {
            res.add(node.val);
        } else {
            if (node.right != null)stack.push(node.right);
            stack.push(node);
            if (node.left != null)stack.push(node.left);
            visited.add(node); 
        } 
    }
    return res;
}

怎么样代码是不是好理解多了,这种方式最厉害的是将前序、中序、后序遍历的代码实现进行了统一。代码如下:

//前序遍历的迭代方式
public List<Integer> preorderTraversal(TreeNode root) {
        ...//上面的代码和中序遍历相同,这里就不展示了
        } else {
            if (node.right != null)stack.push(node.right);
            if (node.left != null)stack.push(node.left);
            stack.push(node);//栈保存节点的位置放在后面,与递归的方式相反
            visited.add(node); 
        } 
    }
    return res;
}

//后序遍历的迭代方式,这里是严格的方式
public List<Integer> postorderTraversal(TreeNode root) {
        ...//上面的代码和中序遍历相同,这里就不展示了
        } else {
            stack.push(node); //栈保存节点的位置放在前面,与递归的方式相反
            if (node.right != null)stack.push(node.right);
            if (node.left != null)stack.push(node.left);
            visited.add(node);
        } 
    }
    return res;
}

如果你不想用HashMap来保存状态的更厉害的方法,可以看看完全模仿递归,不变一行.

二叉树的遍历总算结束了

我们发现二叉树的遍历无论是递归法还是迭代法,其空间复杂度都为O(n).那能不能将空间复杂度降到O(1)呢?这就需要讲到线索二叉树莫里斯(Morris)遍历了

如图(图片来源深入学习二叉树(二) 线索二叉树):

线索二叉树:若结点的左子树为空,则该结点的左孩子指针指向其前驱结点;若结点的右子树为空,则该结点的右孩子指针指向其后继结点。

莫里斯(Morris)遍历可以看二叉树的莫里斯(Morris)遍历(写的时候才发现有这个东西😓,之后熟悉了再补上)

二叉树的遍历基础

前序遍历

前序遍历首先访问根节点,然后遍历左子树,最后遍历右子树。

如图所示:

遍历结果为: FBADCEGIH

中序遍历

中序遍历是先遍历左子树,然后访问根节点,然后遍历右子树。

如图所示:

遍历结果为:ABCDEFGHI

后序遍历

后序遍历是先遍历左子树,然后遍历右子树,最后访问树的根节点。

如图所示:

后序遍历的结果为: ACEDBHIGF

层序遍历

层序遍历就是逐层遍历树结构。

如图所示:

层序遍历结果为:FBGADICEH

参考