一次性说清楚二叉树前中后序遍历的所有方式(编程语言:JavaScript)

166 阅读8分钟

二叉树的前、中、后序遍历

方法一:递归

  1. 前序遍历

    var preorderTraversal = function(root) {
        const res = []
        const preorder = node => {
            if(node){
                res.push(node.val)
                preorder(node.left)
                preorder(node.right)
            }
        }
        preorder(root)
        return res
    };
    
  2. 中序遍历

    var inorderTraversal = function(root) {
        const res = []
        const inorder = node => {
            if(node){
                inorder(node.left)
                res.push(node.val)
                inorder(node.right)
            }
        }
        inorder(root)
        return res
    };
    
  3. 后序遍历

    var postorderTraversal = function(root) {
        const res = []
        const postorder = node => {
            if(node){
                postorder(node.left)
                postorder(node.right)
                res.push(node.val)
            }
        }
        postorder(root)
        return res
    };
    

方法二:迭代

  1. 前序遍历

    var preorderTraversal = function(root) {
        const res = []
        const stack = []
        let p = root
        while(p || stack.length){
            if(p){
                // 在第一次遍历到该节点时,对该节点进行访问
                res.push(p.val)
                stack.push(p)
                p = p.left
            }else{
                p = stack.pop()
                p = p.right
            }
        }
        return res
    };
    
  2. 中序遍历

    var inorderTraversal = function(root) {
        const res = []
        const stack = []
        let p = root
        while(p || stack.length){
            if(p){
                stack.push(p)
                p = p.left
            }else{
                p = stack.pop()
                // 在第二次遍历到该节点时,对该节点进行访问
                res.push(p.val)
                p = p.right
            }
        }
        return res
    };
    
  3. 后序遍历

    var postorderTraversal = function(root) {
        const res = []
        const stack = []
        let p = root
        let r = null // 定义一个指针,指向最近访问过的节点
        while(p || stack.length){
            if(p){
                stack.push(p)
                p = p.left
            }else{
                // 读取栈顶元素
                p = stack[stack.length - 1]
                if(!p.right || p.right === r){
                    // 若右子树不存在,或者已经被访问过,则从栈中弹出节点并访问
                    p = stack.pop()
                    res.push(p.val)
                    // 记录最近访问过的节点
                    r = p
                    // 节点访问完后,重置p指针
                    p = null
                }else{
                    // 否则,转向右
                    p = p.right
                }
            }
        }
        return res
    };
    

    我们知道,后序遍历中节点的访问顺序是『左右根』,而前序遍历是『根左右』。所以我们也可以这样做:将前序遍历修改为『根右左』,然后将结果反转即可。下面是代码实现:

    var postorderTraversal = function(root) {
        const res = []
        const stack = []
        let p = root
        while(p || stack.length){
            if(p){
                res.push(p.val)
                stack.push(p)
                p = p.right
            }else{
                p = stack.pop()
                p = p.left
            }
        }
        return res.reverse()
    };
    

方法三:标记法(参考力扣第 94 题的题解——颜色标记法)

这种方法的优点在于对于前、中、后序遍历,能够写成完全一致的代码。其核心步骤如下:

  1. 根节点入栈
  2. 每次从栈中弹出一个节点,记为 top,判断该节点有没有被标记过:
    • 如果 top 被标记过,则直接访问该节点
    • 如果 top 没有被标记过,则标记该节点,并以遍历顺序的逆序入栈(因为对于栈这种数据结构,是后进先出的)。比如:对于前序遍历,由于其遍历顺序是『根左右』,那么入栈顺序就是『右左根』。中序遍历和后序遍历以此类推

注意:为了节省空间,我这里不创建额外的空间来保存一个节点是否被标记过,而是使用这种方式:对于不标记的结点,是将该节点(包括节点的值和左右指针)压栈;而对于需要标记的节点,只将节点的值压栈。下面是代码实现:

  1. 前序遍历

    var preorderTraversal = function(root) {
        const res = []
        const stack = []
        // 根节点入栈
        root && stack.push(root)
        // 栈不为空时循环
        while(stack.length){
            // 弹出栈顶元素,判断其是否被标记过(一个节点或是一个原始值)
            const top = stack.pop()
            if(top instanceof TreeNode){
                // 未被标记过,将右子节点、左子节点和自身的值入栈
                top.right && stack.push(top.right)
                top.left && stack.push(top.left)
                stack.push(top.val)
            }else{
                // 被标记过,加入结果集
                res.push(top)
            }
        }
        return res
    };
    
  2. 中序遍历

    var inorderTraversal = function(root) {
        const res = []
        const stack = []
        // 根节点入栈
        root && stack.push(root)
        // 栈不为空时循环
        while(stack.length){
            // 弹出栈顶元素,判断其是否被标记过(一个节点或是一个原始值)
            const top = stack.pop()
            if(top instanceof TreeNode){
                // 未被标记过,将右子节点、自身的值和左子节点入栈
                top.right && stack.push(top.right)
                stack.push(top.val)
                top.left && stack.push(top.left)
            }else{
                // 被标记过,加入结果集
                res.push(top)
            }
        }
        return res
    };
    
  3. 后序遍历

    var postorderTraversal = function(root) {
        const res = []
        const stack = []
        // 根节点入栈
        root && stack.push(root)
        // 栈不为空时循环
        while(stack.length){
            // 弹出栈顶元素,判断其是否被标记过(一个节点或是一个原始值)
            const top = stack.pop()
            if(top instanceof TreeNode){
                // 未被标记过,将自身的值、右子节点和左子节点入栈
                stack.push(top.val)
                top.right && stack.push(top.right)
                top.left && stack.push(top.left)
            }else{
                // 被标记过,加入结果集
                res.push(top)
            }
        }
        return res
    };
    

方法四:Morris 遍历

Morris 遍历算法是一种优秀的遍历算法,能将空间复杂度降到 O(1)。其整体步骤如下(假设当前遍历到的节点为 x):

  1. 如果 x 无左子树,则遍历 x 的右子树,即x = x.right
  2. 如果 x 有左子树,则找到 x 在中序遍历下的前驱节点,即左子树的最右节点,记为 pre。根据 pre 的右孩子是否为空,进行如下操作:
    • 如果 pre 的右孩子为空,则将其右孩子指向 x,然后遍历 x 的左子树,即x = x.left
    • 如果 pre 的右孩子不为空,则此时其右孩子肯定指向 x,说明我们已经遍历完 x 的左子树。我们需要将 pre 的右孩子置空,然后继续遍历 x 的右子树,即x = x.right

下面是 Morris 遍历的代码实现:

var traversal = function(root) {
    let p = root // 指向当前节点
    let pre = null // 指向当前节点在中序遍历下的前驱节点,即左子树的最右节点
    while(p){
        if(!p.left){
            // 当前节点没有左子树,继续遍历右子树
            p = p.right
        }else{
            // 找到当前节点在中序遍历下的前驱节点
            pre = p.left
            while(pre.right && pre.right !== p){
                pre = pre.right
            }
            if(!pre.right){
                // 第一次到该节点,当前节点的左子树还未被遍历,让前驱节点的右指针指向当前节点,继续遍历左子树
                pre.right = p
                p = p.left
            }else{
                // 第二次到该节点,当前节点的左子树已经遍历完了,断开连接,继续遍历右子树
                pre.right = null
                p = p.right
            }
        }
    }
};

可以发现:对于没有左子树的节点,只会到该节点一次。而对于有左子树的节点,会到该节点两次:第一次到该节点时,需要找到左子树的最右节点,然后将其右指针指向当前节点,继续遍历左子树;第二次到该节点时,说明当前节点的左子树已经遍历完了,应该将当前节点的前驱节点的右指针指向 null,转而遍历其右子树。对于这样一颗二叉树:[1, 2, 3, 4, 5, 6, null],其 Morris 序列为1 2 4 2 5 1 3 6 3。其中节点4 5 6只会到该节点一次,而节点1 2 3会到该节点两次。下面我们讨论如何使用 Morris 算法对二叉树进行前、中、后序遍历:

  1. 前序遍历:对于二叉树[1, 2, 3, 4, 5, 6, null],其前序遍历序列为1 2 4 5 3 6,可以发现,这在 Morris 序列中,对每个节点的访问时间点为:对于只会到一次的节点,遍历到该节点时就访问;对于会到两次的节点,在第一次到该节点时访问
  2. 中序遍历:对于二叉树[1, 2, 3, 4, 5, 6, null],其中序遍历序列为4 2 5 1 6 3,可以发现,这在 Morris 序列中,对每个节点的访问时间点为:对于只会到一次的节点,遍历到该节点时就访问;对于会到两次的节点,在第二次到该节点时访问
  3. 后序遍历:对于二叉树[1, 2, 3, 4, 5, 6, null],其后序遍历序列为4 5 2 6 3 1,可以发现,这在 Morris 序列中,无法直接找到这样的序列。我们可以这样做:对于会到两次的节点,在第二次到该节点时,逆序访问其左子树的右边缘。遍历结束后,再逆序访问整棵树的右边缘。在这个例子中,我们定义一个结果集 res = [],第二次到节点 2 时,逆序访问其左子树的右边缘,即4,此时res = [4];第二次到节点 1 时,逆序访问其左子树的右边缘,即5 2,此时res = [4 5 2];第二次到节点 3 时,逆序访问其左子树的右边缘,即6,此时res = [4 5 2 6]。最后逆序访问整棵树的右边缘,即3 1,此时结果集就是最终的后序遍历序列4 5 2 6 3 1

具体代码实现如下:

  1. 前序遍历

    var preorderTraversal = function(root) {
        const res = []
        let p = root
        let pre = null
        while(p){
            if(!p.left){
                // 只会到一次的节点,遍历到该节点时就访问
                res.push(p.val)
                p = p.right
            }else{
                pre = p.left
                while(pre.right && pre.right !== p){
                    pre = pre.right
                }
                if(!pre.right){
                    // 会到两次的节点,在第一次到该节点时访问
                    res.push(p.val)
                    pre.right = p
                    p = p.left
                }else{
                    pre.right = null
                    p = p.right
                }
            }
        }
        return res
    };
    
  2. 中序遍历

    var inorderTraversal = function(root) {
        const res = []
        let p = root
        let pre = null
        while(p){
            if(!p.left){
                // 只会到一次的节点,遍历到该节点时就访问
                res.push(p.val)
                p = p.right
            }else{
                pre = p.left
                while(pre.right && pre.right !== p){
                    pre = pre.right
                }
                if(!pre.right){
                    pre.right = p
                    p = p.left
                }else{
                    // 会到两次的节点,在第二次到该节点时访问
                    res.push(p.val)
                    pre.right = null
                    p = p.right
                }
            }
        }
        return res
    };
    
  3. 后序遍历

    let res;
    // 该函数传入一棵二叉树的根节点,将该树的右边缘(可以看成是一个单链表)进行反转
    var reverse = function(root){
        let cur = root, pre = null
        while(cur){
            const right = cur.right
            cur.right = pre
            pre = cur
            cur = right
        }
        return pre
    }
    // 该函数传入一颗二叉树的根节点,逆序访问其右边缘
    var visitRight = function(root){
        const tail = reverse(root)
        let p = tail
        while(p){
            res.push(p.val)
            p = p.right
        }
        // 为了不破坏原本树的结构,反转右边缘后还需要进行恢复
        reverse(tail)
    }
    var postorderTraversal = function(root) {
        res = []
        let p = root
        let pre = null
        while(p){
            if(!p.left){
                p = p.right
            }else{
                pre = p.left
                while(pre.right && pre.right !== p){
                    pre = pre.right
                }
                if(!pre.right){
                    pre.right = p
                    p = p.left
                }else{
                    pre.right = null
                    // 对于会到两次的节点,第二次到该节点时,逆序访问其左子树的右边缘
                    visitRight(p.left)
                    p = p.right
                }
            }
        }
        // 遍历完成,逆序访问整棵树的右边缘
        visitRight(root)
        return res
    };