算法学习13: Morris遍历
Morris遍历的引出
Morris遍历解决的问题
工程上的树结构时包含父节点指针的,由子节点容易找到父节点. 但是算法题目中的树结构通常不包括父节点指针,因此一般的遍历写法需要用栈来存储沿途经过的父节点.
Morris遍历
可以做到不需要栈,只需要常数空间和线性时间遍历完整棵树. 其主要思想是: 利用树的空闲空间(叶节点的子节点指针)来找到树的根节点.(同样思想的结构有线索二叉树
)
线索二叉树
利用二叉链表中的空指针域,存放指向结点在某种遍历次序下的前驱和后继结点的指针(这种附加的指针称为线索
). 将一个普通二叉树变为线索二叉树的操作叫做二叉树的线索化
.
以下面这个二叉树为例,将其线索化
:
该树中,H
,I
,J
,F
,G
为叶子节点,其左右子树均指向空;E
的右子树节点也为空,下面我们将其序列化,序列化的原则为:
- 若某结点的左子树为空,则将该结点的左子树指针指向其中序遍历前驱结点
- 若某结点的右子树为空,则将该结点的右子树指针指向其中序遍历后继结点
线索化后得到的线索二叉树如下(实线箭头为二叉树原有的边,虚线箭头为线索
):
可以看到,二叉树中原有的空指针被利用起来了.
二叉树的线索化
代码可以从Morris遍历
代码修改而成,见后面.
Morris遍历实现
Morris遍历伪代码
Morris遍历的主要思路就是在遍历的过程中动态建立和删除线索二叉树的线索
.其具体步骤如下(记curNode
为当前来到的节点):
- 若当前来到节点
curNode
无左子树(不需要遍历左子树,没有建立和删除线索
的过程).则curNode
直接向右子树移动(curNode = curNode.right
) - 若当前来到节点
curNode
有左子树(需要遍历左子树,要经过建立和删除线索
的过程),则找到curNode
左子树的最右节点(中序遍历最后一个节点),记为mostRight
.- 若
mostRight
的右子树指针right
指向空(说明左子树还没被遍历过,则先建立线索
,再遍历左子树).将mostRight
的右子树指针right
指向curNode
,curNode
向左子树移动(curNode = curNode.left
). - 若
mostRight
的右子树指针right
指向curNode
(说明左子树已经被遍历过,则先删除线索
,再遍历右子树).将mostRight
的右子树指针right
指回空,curNode
向右子树移动(curNode = curNode.right
).
- 若
上面是Morris遍历程序主体的思路,要将其翻译为代码,还要解决一个问题: 即要实现
前序遍历
,中序遍历
,后序遍历
,分别应何时执行输出语句?
与一般的遍历相类似,前序遍历
,中序遍历
,后序遍历
的过程中curNode
指针访问树中节点的顺序是相同的,三种遍历的差别仅在于执行输出语句的时机.
Morris遍历的实现
下面通过将Morris遍历与递归遍历相类比,分别得到Morris前序遍历
,Morris中序遍历
,Morris后序遍历
的具体代码.
考虑下边一段递归遍历二叉树的代码,我们在代码中加了三个断点,不同断点处执行输出语句分别可以实现前序遍历
,中序遍历
,后序遍历
.
// 递归遍历
public static void recursiveTraverse(Node head) {
if(head == null) {
return;
}
// 断点1: 前序遍历
//System.out.println(head.value);
recursiveTraverse(head.left);
// 断点2: 中序遍历
//System.out.println(head.value);
recursiveTraverse(head.right);
// 断点3: 后序遍历
//System.out.println(head.value);
}
在Morris遍历中
,每个节点会被访问两次(若该节点没有左子树,则这两次访问会被合并为一次).
-
Morris遍历中
,第一次访问该节点是在其左子树还没有被遍历前,对应上述递归函数recursiveTraverse
的断点1
处,在该处执行输出即为前序遍历
.
// Morris前序遍历 public static void morrisPre(TreeNode root) { if (root == null) { return; } TreeNode curNode = root; // 指向当前节点 TreeNode mostRight = null; // 指向左子树最右节点 while (curNode != null) { // 不断判断当前节点左子树是否存在 if (curNode.left != null) { // 若当前节点存在左子树,则不断寻找左子树最右节点mostRight(先查看线索情况,即查看左子树是否已被遍历) mostRight = curNode.left; while (mostRight.right != null && mostRight.right != curNode) { mostRight = mostRight.right; } // 找到rightMost,则判断rightMost的right指针是指向空还是指向当前节点,即线索是否建立 if (mostRight.right == null) { // 若mostRight的右子树指针right指向空,则先建立线索,再遍历左子树 mostRight.right = curNode; System.out.print(curNode.val + " "); curNode = curNode.left; } else { // 若mostRight的右子树指针right指向curNode,则先删除线索,再遍历右子树 mostRight.right = null; curNode = curNode.right; } } else { // 若当前节点不存在左子树,则直接遍历右子树,即curNode直接向右子树移动 System.out.print(curNode.val + " "); curNode = curNode.right; } } System.out.println(); }
-
同理,
Morris遍历中
,第二次访问该节点是在其左子树已被遍历完成后,对应上述递归函数recursiveTraverse
的断点2
处,在该处执行输出即为中序遍历
.
// Morris中序遍历 public static void morrisIn(TreeNode root) { if (root == null) { return; } TreeNode curNode = root; // 指向当前节点 TreeNode mostRight = null; // 指向左子树最右节点 while (curNode != null) { // 不断判断当前节点左子树是否存在 if (curNode.left != null) { // 若当前节点存在左子树,则不断寻找左子树最右节点mostRight(先查看线索情况,即查看左子树是否已被遍历) mostRight = curNode.left; while (mostRight.right != null && mostRight.right != curNode) { mostRight = mostRight.right; } // 找到rightMost,则判断rightMost的right指针是指向空还是指向当前节点,即线索是否建立 if (mostRight.right == null) { // 若mostRight的右子树指针right指向空,则先建立线索,再遍历左子树 mostRight.right = curNode; curNode = curNode.left; } else { // 若mostRight的右子树指针right指向curNode,则先删除线索,再遍历右子树 System.out.print(curNode.val + " "); mostRight.right = null; curNode = curNode.right; } } else { // 若当前节点不存在左子树,则直接遍历右子树,即curNode直接向右子树移动 System.out.print(curNode.val + " "); curNode = curNode.right; } } System.out.println(); }
-
要利用
Morris遍历
实现后序遍历
有点麻烦,困难在于Morris遍历
中每个节点只会被访问两次,对应递归遍历recursiveTraverse
的断点1
和断点2
处,因此Morris遍历
中不会存在对应递归遍历recursiveTraverse
的断点3
的时机.在这里我们使用一个特殊的策略: 在第二次访问到
curNode
节点时,倒序输出左子树右边界
(从当前节点的左孩子curNode.left
到左子树最右节点rightMost
的路径).
如上图: 第二次访问到B
时,输出A
;第二次访问到D
时,输出C
;第二次访问到F
时,输出ED
;第二次访问到H
时,输出GFB
;第二次访问到K
时,输出J
;第二次访问到0
时,输出KIH
.那么如何实现倒序输出呢? 自然而然地想到用栈,但是
Morris遍历
要求占用常数空间,因此不能用栈.因此我们使用反转链表的方式输出.(当然,这种方式太变态了).// Morris后序遍历 public static void morrisPos(TreeNode root) { if (root == null) { return; } TreeNode curNode = root; // 指向当前节点 TreeNode mostRight = null; // 指向左子树最右节点 while (curNode != null) { // 不断判断当前节点左子树是否存在 if (curNode.left != null) { // 若当前节点存在左子树,则不断寻找左子树最右节点mostRight(先查看线索情况,即查看左子树是否已被遍历) mostRight = curNode.left; while (mostRight.right != null && mostRight.right != curNode) { mostRight = mostRight.right; } // 找到rightMost,则判断rightMost的right指针是指向空还是指向当前节点,即线索是否建立 if (mostRight.right == null) { // 若mostRight的右子树指针right指向空,则先建立线索,再遍历左子树 mostRight.right = curNode; curNode = curNode.left; } else { // 若mostRight的右子树指针right指向curNode,则先删除线索,再遍历右子树 // 在这里要注意下边两句的顺序: 要先断开线索,再倒序输出从 `当前节点的左孩子` 到 `左子树最右节点rightMost` 的路径 mostRight.right = null; printEdge(curNode.left); curNode = curNode.right; } } else { // 若当前节点不存在左子树,则直接遍历右子树,即curNode直接向右子树移动 curNode = curNode.right; } } printEdge(root); System.out.println(); } // 倒序输出树右边界(从 根节点root 到 树最右节点)的路径 public static void printEdge(TreeNode root) { // 将待输出路径先反转,再输出,再反转回来 TreeNode tail = reverseEdge(root); TreeNode cur = tail; while (cur != null) { System.out.print(cur.val + " "); cur = cur.right; } reverseEdge(tail); } // 反转链表 public static TreeNode reverseEdge(TreeNode from) { TreeNode pre = null; TreeNode next = null; while (from != null) { next = from.right; from.right = pre; pre = from; from = next; } return pre; }
逆序输出时路径时,要注意: 先断开线索,再逆序输出路径.
Morris遍历的时间复杂度
Morris遍历
的时间复杂度是O(N). 这个结论比较意外,因为考虑到第一次访问到某个节点时要找到其左子树最右节点mostRight
,因此其时间复杂度应为O(N*lgN),但这是不对的,因为并不是访问到每一个节点都要寻找其左子树最右节点mostRight
(叶子节点制备访问一次).
下面证明Morris遍历
的时间复杂度为O(N):
我们将树按右边界
划分为有限个部分,每次寻找左子树最右节点mostRight
,要遍历一次右边界.但这个搜索mostRight
的过程只会在访问到树根的上层节点
且这棵树作为上层节点的左子树
的情况下触发,因为每个节点最多被访问两次,所以每个右边界
最多被遍历两次,右边界的总结点数为N,因此Morris遍历
的时间复杂度为O(N).