首页 【进阶算法】Morris 中序遍历
文章
取消

【进阶算法】Morris 中序遍历

Morris 中序遍历

例题:剑指 Offer II 054. 所有大于等于节点的值之和

本例题是反序中序遍历(即下面引文中的“左”<->“右”要反着看),其官解是这样解释 Morris 遍历的:

Morris 遍历的核心思想是利用树的大量空闲指针,实现空间开销的极限缩减。其反序中序遍历规则总结如下:

  • 如果当前节点的右子节点为空,处理当前节点,并遍历当前节点的左子节点;
  • 如果当前节点的右子节点不为空,找到当前节点右子树的最左节点(该节点为当前节点中序遍历的前驱节点);
    • 如果最左节点的左指针为空,将最左节点的左指针指向当前节点,遍历当前节点的右子节点;
    • 如果最左节点的左指针不为空,将最左节点的左指针重新置为空(恢复树的原状),处理当前节点,并将当前节点置为其左节点;
  • 重复步骤 1 和步骤 2,直到遍历结束。

我的疑问在这句:“如果最左节点的左指针不为空,将最左节点的左指针重新置为空(恢复树的原状)”,既然已经是「最左结点」了,怎么可能左指针不为空呢?最左结点的左指针难道不是必然为空吗?否则怎么会成为最左呢?

这个油管视频搞懂了:最左结点还有左指针的情况,是在上一步“如果最左节点的左指针为空,将最左节点的左指针指向当前节点”的时候将结点与其后继结点连接在一起时做的特殊处理(i.e. 下图虚线的箭头指针),正是这个特殊处理使得 Morris 遍历无需依靠栈就能从左子树回到根结点。

Morris 遍历图解 上图的虚线箭头就是临时连接的右指针,8 -> 10 代表 10 是 8 的后继结点(在中序遍历序列中 10 正是 8 的下一位)

思想

中序遍历的顺序为:左 -> 根 -> 右。在脑海中想象从树的根节点开始移动遍历指针会发现,我们可以通过 root = root.left 以及 root = root.right 方便地从根结点转移到左子树或者右子树,但没办法通过结点上的某个指针回到结点的父结点(因为没有 root.parent 这样指向父结点的指针)。

于是,由于这个特性(有办法直接从父到子,但没有直接方法从子到父),中序遍历代码实现的核心在于:当遍历完左子树之后,如何回到其根节点?

递归的返回方法简单粗暴,一边由父下降到子,一边在内存栈中存父结点(碰到一个存一个)。等到子结点处理完后,按序弹出栈内的结点再处理就好;

迭代的方法也是依靠栈。先把根节点全部存在栈(代码写的栈)中,待其左子树全部遍历完毕后,再出栈处理。

以上两种方法都是利用了栈的特性间接从左子树回到根结点。

Morris 遍历的独特与核心实现就在回到根结点的方法上。它没有使用额外的栈,而是利用树本身的空闲子结点指针记录后继结点,为遍历指针临时连接回头路:

  • 利用左子树最右结点空闲的右指针(最右必定 root.right == null,且最右结点必定是根结点的前驱结点),将当前结点(左子树最右结点)与其后继结点(根结点)连接起来,方便之后指针从左子树回到根结点
  • 再次遍历到这个最右结点时,再把这个连接断掉,恢复树的原状

代码及注释

测试地址:94. 二叉树的中序遍历

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
var inorderTraversal = function(root) {
    let curr = root;
    const ans = [];
    while (curr){
        // is leftmost node?
        if (!curr.left){ // yes
            // 左子树为空
            ans.push(curr.val); // 则处理根结点
            curr = curr.right; // 根结点处理完毕后转到右子树
        }else{ // no
            // succ 为 curr 的左子树的最右结点,即 curr 的前驱结点;
            // 反之 succ 为 curr 的后继结点
            let succ = getSuccessor(curr);
            // 众所周知,二叉树的遍历过程中,每一个结点会被经过 3 次
            // if 为第一次经过结点 curr 时(正在沿路下降到左子树)的处理
            // else 为第二次经过结点 curr 时(左子树处理完毕回到根结点)的处理
            // (迭代的方法无法捕捉到第三次,不过不重要,这里不需要捕捉第三次经过结点)
            if (!succ.right){ // succ.right···>curr 这条线还没连上
                // 在根结点下到左子树结点过程中,一路连接沿路各根结点与其前驱结点
                succ.right = curr; // link the way back
                curr = curr.left; // continue going down the left child-tree
            }else{ 
                // succ.right···>curr 已连上,且已利用这条线从左子树回到根结点 curr
                succ.right = null; // break the link
                ans.push(curr.val); // curr 的左子树已遍历完,该处理 curr(根结点)了
                curr = curr.right; // 上一句处理完根结点,本句转移到右子树
            }
        }
    }
    return ans;
};
// 为什么需要连接前驱结点?因为中序遍历是先从根结点下降到左子树,
// 处理完左子树全部结点后需要返回根结点,连接前驱结点就是连通这条原路返回的路径
// 结点 node 的前驱结点就是中序遍历序列中,恰好排在 node 前一位
// 即结点 node 的左子树中的最右结点
// getSuccessor(node) return node's left tree's rightmost node
var getSuccessor = function(node){
    let succ = node.left;
    // 为什么需要 succ.right !== node?
    // 因为此函数会在连接好前驱结点后【再次】遍历到该结点
    // 此时由于前一次遍历,succ 的 right 临时连接到了 node,在树中形成一个临时的环
    // 如果不加上这个条件,将会陷入死循环
    while (succ.right && succ.right !== node) succ = succ.right;
    // 临时连接这里不着急断掉,主函数负责切断连接,此函数只负责返回前驱结点
    return succ;
}

Morris 代码最容易出错的点在 getSuccessor(node) 中的 while 循环条件:务必记得 succ.right !== node 这个循环条件,关键中的关键,否则会导致死循环。

本文由作者按照 CC BY 4.0 进行授权

GitHub Pages + jekyll 搭建博客

【设计模式】详解策略模式