二叉树的N种遍历

213 阅读4分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第6天,点击查看活动详情

二叉树定义

首先,这是一个树状结构,也就是说,从根节点开始,可能有最多两个子节点,形状如下:

image.png

每个节点的子节点,有左右之分。

例如,图中染色的节点,没有左子节点,但是拥有右子节点。

遍历二叉树

一般二叉树只会给一个根节点,我们如果要访问到所有的节点,就必须采用某种遍历手段:

我们给出一个具体的树:

image.png

前序遍历(先序遍历)

先访问根节点,然后依次访问左子节点,右子节点,所以又简称根左右

如果按照这种方法,前面的树遍历顺序如下:

一,二,四,三,五,六

递归实现前序遍历

递归的方式是非常自然的,代码也非常容易阅读。

这里我们将结果放入一个数组中,便于观察和理解:

var Result = [];
function scan(node) {
    if (!node) {return;}
    Result.push(node.value);
    scan(node.left);
    scan(node.right);
}
scan(root);

循环实现前序遍历

如果不让用递归,那么循环也是可以的,无非就是要自己实现一个栈结构,好存储一些中间状态。

  • 初始化的时候,将根节点放入栈中。
  • 然后遍历栈,从栈上取一个节点,将节点的右子节点放入栈中
  • 然后将节点的左子节点放入栈中
  • 此时,栈非空,接着进行上面的操作,循环即可

之所以,先放入节点的右子节点,因为栈是先进后出的,先放右边的,是因为我们先要遍历到左边。

我们看几个图,来理解一下:

image.png

image.png

image.png

image.png

image.png

image.png

根据上面的几个步骤,写出代码如下:

function scan(node) {
    let Result = [];
    let Stack; // 此处Stack是一个数据结构的栈
    Stack.push(node);
    while(!Stack.empty()) { // 栈如果非空
        let _node = Stack.pop();
        Result.push(_node.value);
        if (_node.right) {Stack.push(_node.right);}
        if (_node.left) {Stack.push(_node.left);}
    }
    return Result;
}

上述代码里,我们依赖于一个数据结构栈,这个东西用js很容易实现,就不赘述。

中序遍历

按照前面的说法,中序就是指,先访问左子节点,然后访问根节点,然后访问右子节点,简称左根右

还是看这个图:

image.png

左根右的遍历结果就是:

二,四,一,五,三,六

递归实现中序遍历

递归的思路依然简单:

let Result = [];

function scan(node) {
    if (!node) {return;}
    scan(node.left);
    Result.push(node.value);
    scan(node.right);
}

循环实现中序遍历

思路还是一样,要用一个栈存储中间状态。

观察一下上面的scan函数,每调用一次,都是将一个元素push到函数栈上,我们依循上面函数的轨迹,理清思路:

这个栈的打开过程就是一直在沿着左子节点往下遍历的过程,一旦左子节点为空了,我们就会pop栈顶元素,然后再去处理这个元素的右子节点。

所以我们的循环里,一定有这种逻辑,就是一直不停将左子节点push到栈的过程。 一旦左子节点为空,那么就立即对栈进行pop,然后存入结果数组,我们来举一个例子:

image.png

我们可以用一个表格来记录程序执行状态,表格有三列,分别是:

cur_node,stack,result

初始情况,:

(1,null,null)(1, null, null)

程序的遍历过程应该是这样:

  • (null,(1),null)(null, (1), null)
  • (2,(),(1))(2, (), (1))
  • (3,(2),(1))(3, (2), (1))
  • (null,(2,3),(1))(null, (2,3), (1))
  • (null,(2),(1,3))(null, (2), (1,3))
  • (null,(),(1,3,2))(null, (), (1,3,2))

根据上面的遍历过程,我们写出如下代码:

function scan(node) {
    let res = [];
    let stack;
    let cur_node = root;
    while(true)
    {
        if (cur_node) {
            stack.push(cur_node);
            cur_node = cur_node.left;
        }
        else
        {
            if (stack.empty())
            {
                break;
            }
            let t_node = stack.top();
            stack.pop();
            res.push_back(t_node.val);
            if(t_node.right)
            {
                cur_node = t_node.right;
            }
        }
    }
    return res;
}

后序遍历

将根节点最后访问,先访问左子节点,再访问右子节点,简称左右根

递归实现后序遍历

极其简单,直接放代码:

let Result = [];
function scan(node) {
    if(!node) {return;}
    scan(node.left);
    scan(node.right);
    Result.push(node.value);
}

循环实现后序遍历

我们仔细观察上面的递归算法,还是按照递归的逻辑,来拟合循环算法。

我们发现依然是不停的沿着左子节点往栈上push节点,但是上面的递归算法暗含了一个顺序,当某个节点的左子节点完全处理完毕之后,才会去处理这个节点的右子节点,且在这两个逻辑之间,栈顶元素就是当前节点,我们在循环算法里需要包含这种信息。上面的递归中,这种执行顺序是由代码直接写好的。

但是在循环里,这种执行顺序需要额外的变量来判断,我们这里新建一个额外的变量,用来存储,上一次在栈上pop出来的节点。

直接看如下代码:

function scan(node) {
    let Result = [];
    if (!node) {return;} // 特判
    let Stack;
    Stack.push(node);
    let prev_pop = null; // 上一次pop出来的节点
    while(!Stack.empty()) {
        let t_node = Stack.top();
        if (t_node.left && t_node.left!=prev_pop && t_node.right != prev_pop) {
            Stack.push(t_node.left);
        }else if (t_node.right && t_node.right!=prev_pop) {
            Stack.push(t_node.right);
        }else{
            Result.push(t_node.value);
            Stack.pop();
            prev_pop = t_node;
        }
    }
    return res;
}