“遍历三兄弟”的迭代实现

77 阅读5分钟

如何用迭代实现递归去遍历二叉树->以上的思路都来自于掘金小测的前端算法和数据结面试:底层逻辑解读与大厂真题训练

先序遍历

题目描述:给定一个二叉树,返回它的前序(先序)遍历序列。

示例: 输入: [1,null,2,3]
返回:
/**
*1 
   \ 2
   / 3
**/

回到题目上来。我们接着栈往下说,题目中的出参是一个数组,大家仔细看这个数组,它像不像是一个栈的出栈序列?实际上,做这道题的一个根本思路,就是通过合理地安排入栈和出栈的时机、使栈的出栈序列符合二叉树的前序遍历规则。   

前序遍历的规则是,先遍历根结点、然后遍历左孩子、最后遍历右孩子——这正是我们所期望的出栈序列。按道理,入栈序列和出栈序列相反,我们似乎应该按照 右->左->根 这样的顺序将结点入栈。不过需要注意的是,我们遍历的起点就是根结点,难道我们要假装没看到这个根结点、一鼓作气找到最右侧结点之后才开始进行入栈操作吗?答案当然是否定的,我们的出入栈顺序应该是这样的:  

  1. 将根结点入栈 

  2. 取出栈顶结点,将结点值 push 进结果数组 

  3. 若栈顶结点有右孩子,则将右孩子入栈

  4. 若栈顶结点有左孩子,则将左孩子入栈

这整个过程,本质上是将当前子树的根结点入栈、出栈,随后再将其对应左右子树入栈、出栈的过程。

重复 2、3、4 步骤,直至栈空,我们就能得到一个先序遍历序列。

编码实现

 const root = {
            val: "A",
            left: {
                val: "B",
                left: {
                    val: "D"
                },
                right: {
                    val: "E"
                }
            },
            right: {
                val: "C",
                right: {
                    val: "F"
                }
            }
        };
 const preorderTraversal = function (root) {
            const statck = [], res = [];
            statck.push(root)
            while (statck.length) {
                const n = statck.pop();

                res.push(n.val) // 把当前的值推到结果数组中

                // 如果右孩子有节点,就把他推到当前栈中
                if (n.right) statck.push(n.right);
                // 如果左孩子有节点,就把它推到当前栈中
                if (n.left) statck.push(n.left);
            }
            return res;
        }
        let result = preorderTraversal(root)

        console.log("先序结果数组返回", result) // ["A", "B", "D", "E", "C", "F"]

异曲同工的后序遍历迭代实现

后序遍历的出栈序列,按照规则应该是 左 -> 右 -> 根 。这个顺序相对于先序遍历,最明显的变化就是根结点的位置从第一个变成了倒数第一个。   
如何做到这一点呢?与其从 stack 这个栈结构上入手,不如从 res 结果数组上入手:我们可以直接把 pop 出来的当前结点 unshift 进 res 的头部,改造后的代码会变成这样:

while(stack.length) {
  // 将栈顶结点记为当前结点
  const cur = stack.pop() 
  // 当前结点就是当前子树的根结点,把这个结点放在结果数组的头部
  res.unshift(cur.val)
  // 若当前子树根结点有右孩子,则将右孩子入栈
  if(cur.right) {
    stack.push(cur.right)
  }
  // 若当前子树根结点有左孩子,则将左孩子入栈
  if(cur.left) {
    stack.push(cur.left)
  }
}

大家可以尝试在大脑里预判一下这个代码的执行顺序:由于我们填充 res 结果数组的顺序是从后往前填充(每次增加一个头部元素),因此先出栈的结点反而会位于 res 数组相对靠后的位置。出栈的顺序是 当前结点 -> 当前结点的左孩子 -> 当前结点的右孩子 ,其对应的 res 序列顺序就是 右 -> 左 -> 根 。这样一来, 根结点就成功地被我们转移到了遍历序列的最末尾。
现在唯一让人看不顺眼的只剩下这个右孩子和左孩子的顺序了,这两个孩子结点进入结果数组的顺序与其被 pop 出栈的顺序是一致的,而出栈顺序又完全由入栈顺序决定,因此只需要相应地调整两个结点的入栈顺序就好了:

// 若当前子树根结点有左孩子,则将左孩子入栈
if(cur.left) {
  stack.push(cur.left)
}  
// 若当前子树根结点有右孩子,则将右孩子入栈
if(cur.right) {
  stack.push(cur.right)
}

编码实现

  const postorderTraversal = (root) => {
            const statck = [], res = [];
            statck.push(root);
            while (statck.length) {
                const n = statck.pop();
                res.unshift(n.val)
                if (n.left) statck.push(n.left)
                if (n.right) statck.push(n.right)
            }
            return res;
        }
        let result = postorderTraversal(root)
        console.log(result, '后续结果数组返回') // ['D', 'E', 'B', 'F', 'C', 'A'] '后续结果数组返回'

中序遍历

经过上面的讲解,大家会发现先序遍历和后序遍历的编码实现其实是非常相似的,它们遵循的都是同一套基本框架。那么我们能否通过对这个基本框架进行微调、从而同样轻松地实现中序遍历呢?   
答案是不能,为啥不能?因为先序遍历和后序遍历之所以可以用同一套代码框架来实现,本质上是因为两者的出栈、入栈逻辑差别不大——都是先处理根结点,然后处理孩子结点。而中序遍历中,根结点不再出现在遍历序列的边界、而是出现在遍历序列的中间。这就意味着无论如何我们不能再将根结点作为第一个被 pop 出来的元素来处理了——出栈的时机被改变了,这意味着入栈的逻辑也需要调整。这一次我们不能再通过对 res 动手脚来解决问题,而是需要和 stack 面对面 battle。     

中序遍历的序列规则是 左 -> 中 -> 右 ,这意味着我们必须首先定位到最左的叶子结点。在这个定位的过程中,必然会途径目标结点的父结点、爷爷结点和各种辈分的祖宗结点:

编程实现

const inorderTraversal = (root) => {
            const stack = [], res = [];
            let cur = root;
            while (cur || stack.length) {
                //  // 这个 while 的作用是把寻找最左叶子结点的过程中,途径的所有结点都记录下来
                while (cur) {
                    // 将途径的结点入栈
                    stack.push(cur)
                    cur = cur.left;
                }
                // 从栈中弹出一个头结点
                cur = stack.pop(); // 这个作用是为了找到父节点,然后方便找到right节点
                res.push(cur.val); // 把当前的结果值保留到结果数组中
                cur = cur.right; // 开始右节点的遍历
            }
            return res; // 返回结果数组
        }