一文通吃二叉树遍历的恩怨情仇(递归、非递归和Morris遍历)
这里的讨论以节点的方式存储的二叉树
二叉树结构
int val;
Node left;
Node right;
Node(int data) {
this.val = data;
}
一、递归
递归是我们最常用的遍历方法,也比较容易理解,我这里直接上先、中和后序的模板代码。
/**
* 递归,先序遍历
*
* @param head
*/
public void preOrderRecur(Node head) {
if (head == null) {
return;
}
System.out.println(head.val + " ");
preOrderRecur(head.left);
preOrderRecur(head.right);
}
/**
* 递归,中序遍历
*
* @param head
*/
public void inOrderRecur(Node head) {
if (head == null) {
return;
}
inOrderRecur(head.left);
System.out.println(head.val + " ");
inOrderRecur(head.right);
}
/**
* 递归,后序遍历
*
* @param head
*/
public void posOrderRecur(Node head) {
if (head == null) {
return;
}
posOrderRecur(head.left);
posOrderRecur(head.right);
System.out.println(head.val + " ");
}
二、非递归
能用递归实现的方法自然也可以用非递归的方法实现。这是因为递归方法无非就是利用函数栈来保存信息,如果用自己申请的数据结构来替代函数栈,也可以实现相同的功能。
非递归的方法同样也是很容易理解,特别是非递归后序遍历下面采取最容易理解的遍历方式——采用两个栈完遍历功能,其实也是把前序遍历倒过来而已,没有什么新奇的东西。
如果条件允许,自己可以着手从时间复杂度和空间复杂度下手。
/**
* 非递归:先序遍历
*
* @param head
*/
public void preOrderUnRecur(Node head) {
System.out.println("非递归:先序遍历:");
if (head != null) {
Stack<Node> stack = new Stack<Node>();
stack.add(head);
while (!stack.isEmpty()) {
head = stack.pop();
System.out.println(head.val + " ");
if (head.right != null) {
stack.push(head.right);
}
if (head.left != null) {
stack.push(head.left);
}
}
}
System.out.println();
}
/**
* 非递归:中序遍历
*
* @param head
*/
public void inOrderUnRecur(Node head) {
System.out.println("非递归:中序遍历:");
if (head != null) {
Stack<Node> stack = new Stack<Node>();
while (!stack.isEmpty() || head != null) {
while (!stack.isEmpty() || head != null) {
if (head != null) {
stack.push(head);
head = head.left;
} else {
head = stack.pop();
}
System.out.println(head.val + " ");
head = head.right;
}
}
}
System.out.println();
}
/**
* 非递归:后序遍历,前序遍历倒过来,用两个栈
*
* @param head
*/
public void posOrderUnRecur(Node head) {
System.out.println("非递归:后序遍历:");
if (head != null) {
Stack<Node> stack1 = new Stack<Node>();
Stack<Node> stack2 = new Stack<Node>();
stack1.push(head);
while (!stack1.isEmpty()) {
head = stack1.pop();
stack2.push(head);
if (head.left != null) {
stack1.push(head.left);
}
if (head.right != null) {
stack1.push(head.right);
}
}
while (!stack2.isEmpty()) {
System.out.println(stack2.pop().val + " ");
}
}
System.out.println();
}
三、Morris巧妙的遍历
从头结点head开始完成二叉树的先序遍历、中序遍历和后序遍历。如果二叉树结点数为n,则要求时间复杂度为O(N),额外空间复杂度为O(1)
回顾:我们最容易理解的先、中、后遍历是递归方法,再者是利用栈记录结点的地址,来完成回到父节点。递归方法实际是利用了函数栈;栈则是利用了额外的空间。
首先来看普通的递归和非递归的方法,其实都利用了栈结构,在处理完二叉树某个节点后可以回到上层去。为什么从下层回到上层会如此之难?因为二叉树的结构使然,每个节点都有指向孩子节点的指针,所以从上层到下层容易,但是没有指向父节点的指针,所以从下层到上层需要用栈结构辅助完成。
Morris遍历的实质就是避免用栈结构, 而是让下层到上层有指针, 具体是通过让底层节点指向null的空闲指针指回上层的某个节点, 从而完成下层到上层的移动。我们知道, 二叉树上的很多节点都有大量的空闲指针, 比如, 某些节点没有右孩子节点, 那么这个节点的right指针就指向null, 我们称为空闲状态, Morris遍历正是利用了这些空闲指针。
假设当前节点为cur, 初始时cur就是整棵树的头节点, 根据以下标准让cur移动:
1.如果cur为null, 则过程停止, 否则继续下面的过程。
2.如果cur没有左子树, 则让cur向右移动, 即令cur=cur.right。
3.如果cur有左子树, 则找到cur左子树上最右的节点, 记为mostRight。
-
如果most Right的right指针指向null, 则令mostRight.right=cur,也就是让mostRight 的right指针指向当前节点, 然后让cur向左移动, 即令cur=cur.left。
-
如果mostRight的right指针指向cur, 则令mostRight.right=null, 也就是让mostRight 的right指针指向null, 然后让cur向右移动, 即令cur=cur.right。
下面上图1简单地解析一波。
图1
- 初始时cur来到4节点,cur此时有左子树,所以根据刚才的描述,找到cur的左子树的最右节点(即3节点),发现节点3的右指针是指向空的,那么让其指向cur,树枝调整成如下图2,然后cur向左移动来到节点2。
图2
2.cur来到节点2,cur此时有左子树,找到cur的左子树最右节点(即节点1),发现节点
1的右指针是指向空的,那么让其指向cur,被调整成如下图3,然后cur向左移动来到节点1。
图3
3.cur来到节点1,cur此时没有左子树,根标准令cur向右指针方向移动,所以cur回
到了节点2。
4.cur来到节点2,cur此时有左子树,找cur的左子树最右节点,即节点1,发现节点
1的右指针是指向cur的,根据标准让其指向null,树被调整回如图2所示的样子,然后根据
标准,cur向右指针方向移动,所以cur来到了节点3。
5.cur来到节点3,cur此时没有左子树,根据标准令cur向右指针方向移动,所以cur回
到了节点4。
6.cur来到节点4,cur此时有左子树,找到cur的左子树最右节点,即节点3,发现节点
3的右指针是指向cur的,那么让其指向nu,树被调整回如图1所示的样子,然后根据标准
cur向右移动来到节点6。
7.cur来到节点6,cur此时有左子树,找到cur的左子树最右节点,即节点5,发现节点
5的右指针是指向null的,那么让其指向cur,被调整成如图4所示的样子,然后根据标准,
cur向左移动来到节点5。
图4
8.cur来到节点5,cur此时没有左子树,根据标准令cur向右指针方向移动,所以cur回
到了节点6。
9.cur来到节点6,cur此时有左子树,找到cu的左子树最右节点,即节点5,发现节点
5的右指针是指向cur的,那么让其指向nul,树被调整回如图1所示的样子,然后根据标准,
cur向右移动来到节点7。
10.cur来到节点7,cur此时没有左子树,根据标准令cur向右指针方向移动,cur来到null 的位置。
11.cur为空,过程停止。
以上所有步骤都严格按照我们之前说的cur移动标准,cur依次到达的节点为:4、2、1、2、
3、4、6、5、6、7,我们将这个序列叫Morris序。
可以看出对于有左子树的节点都可以到达两次(4、2、6),没有左子树的节点都只会到达一次。对于任何一个只能到达一次的节点X,接下来cur要么跑到X的右子树上,要么就返回上级。而对于任何一个能够到达两次的节点Y,在第一次达到Y之后
cur都会先去Y的左子树转一圈,然后会第二次来到Y,接下来cur要么跑到Y的右子树上,要么就返回上级。同时,对于任何一个能够到达两次的节点Y,是如何知道此时的cur是第一次来到Y还是第二次来到Y呢?如果Y的左子树上的最右节点的指针( mostRight.right))是指向null的,那么此时cur就是第一次到达Y;如果 mostRight.right是指向Y的,那么此时cur就是第二
次到达Y。这就是 Morrisi遍历和 Morris序的实质。
下面给出Morris的模板代码。
/**
* morris遍历模板
* @param head
*/
public void morris(Node head) {
if (head == null) {
return;
}
Node cur = head;
Node mostRight = null;
while (cur != null) {
mostRight = cur.left;
// 如果当前cur有左子树
if (mostRight != null) {
// 找到cur左子树最右的节点
while (mostRight.right != null && mostRight.right != cur) {
mostRight = mostRight.right;
}
// 从上面的while里出来后,mostRight就是cur左子树上最右的节点
if (mostRight.right == null) { // 如果mostRight.right指向null
mostRight.right = cur; //让其指向cur
cur = cur.left; //cur向左移动
continue; //回到最外层的while,继续判断cur的情况
} else { //如果mostRight.right是指向cur的
mostRight.right = null; //让其指向null
}
}
// cur如果没有左子树,cur向右移动
// 或者cur左子树上最右节点的右指针是指向cur的,cur向右移动
cur = cur.right;
}
}
Morris的先序和中序遍历只要在遍历的特定位置打印即可。
直接附上代码。
Morris前序遍历
/**
* morris的先序遍历
*
* @param head
*/
public void morrisPre(Node head) {
if (head == null) {
return;
}
Node cur = head;
Node mostRight = null;
while (cur != null) {
mostRight = cur.left;
// 如果当前cur有左子树
if (mostRight != null) {
// 找到cur左子树最右的节点
while (mostRight.right != null && mostRight.right != cur) {
mostRight = mostRight.right;
}
// 从上面的while里出来后,mostRight就是cur左子树上最右的节点
if (mostRight.right == null) { // 如果mostRight.right指向null
mostRight.right = cur; //让其指向cur
System.out.println(cur.val + ""); //**打印行为**
cur = cur.left; //cur向左移动
continue; //回到最外层的while,继续判断cur的情况
} else { //如果mostRight.right是指向cur的
mostRight.right = null; //让其指向null
}
} else {
System.out.println(cur.val + ""); //**打印行为**
}
// cur如果没有左子树,cur向右移动
// 或者cur左子树上最右节点的右指针是指向cur的,cur向右移动
cur = cur.right;
}
System.out.println();
}
Morris中序遍历
/**
* morris的中序遍历
*
* @param head
*/
public void morrisIn(Node head) {
if (head == null) {
return;
}
Node cur = head;
Node mostRight = null;
while (cur != null) {
mostRight = cur.left;
// 如果当前cur有左子树
if (mostRight != null) {
// 找到cur左子树最右的节点
while (mostRight.right != null && mostRight.right != cur) {
mostRight = mostRight.right;
}
// 从上面的while里出来后,mostRight就是cur左子树上最右的节点
if (mostRight.right == null) { // 如果mostRight.right指向null
mostRight.right = cur; //让其指向cur
cur = cur.left; //cur向左移动
continue; //回到最外层的while,继续判断cur的情况
} else { //如果mostRight.right是指向cur的
mostRight.right = null; //让其指向null
}
}
// cur如果没有左子树,cur向右移动
// 或者cur左子树上最右节点的右指针是指向cur的,cur向右移动
System.out.println(cur.val + ""); //打印行为
cur = cur.right;
}
System.out.println();
}
至于Morris的后序遍历相对复杂一点。
根据 Morris遍历,加工出后序遍历。
1.对于cur只能到达一次的节点(无左子树的节点),直接跳过,没有打印行为。
2.对于cur可以到达两次的任何一个节点(有左子树的节点)X,cur第一次到达X时没有打印行为;当第二次到达X时,逆序打印X左子树的右边界。
3.cur遍历完成后,逆序打印整棵树的右边界。
以图1来举例说明后序遍历的打印过程,棵二叉树的 Morris序为:4、2、1、2、3、4、
6、5、6、7。
当第二次达到2时,逆序打印节点2左子树的右边界:1
当第二次达到4时,逆序打印节点4左子树的右边界:3、2
当第二次达到6时,逆序打印节点6左子树的右边界:5
cur遍历完成后,逆序打印整棵树的右边界:7、6、4
可以看到这个顺序就是后序遍历的顺序。但是我们应该如何实现逆序打印一棵树的右边界?因为整个过程的额外空间复杂度要求是O(1),所以逆序打印一棵树右边界的过程中,是不
能申请额外的数据结构的。为了更好地说明整个过程,下面举一个右边界比较长的例子,如图5。
假设cur第二次到达了A,并且要逆序打印节点A左子树的右边界,首先将E.R指向nul,
然后将右边界逆序调整成如图6所示的样,整个过程类似单链表的逆序操作。
附上Morris后序遍历的代码。
/**
* morris的后序遍历
*
* @param head
*/
public void morrisPos(Node head) {
if (head == null) {
return;
}
Node cur = head;
Node mostRight = null;
while (cur != null) {
mostRight = cur.left;
// 如果当前cur有左子树
if (mostRight != null) {
// 找到cur左子树最右的节点
while (mostRight.right != null && mostRight.right != cur) {
mostRight = mostRight.right;
}
// 从上面的while里出来后,mostRight就是cur左子树上最右的节点
if (mostRight.right == null) { // 如果mostRight.right指向null
mostRight.right = cur; //让其指向cur
cur = cur.left; //cur向左移动
continue; //回到最外层的while,继续判断cur的情况
} else { //如果mostRight.right是指向cur的
mostRight.right = null; //让其指向null
printEdge(cur.left);
}
}
// cur如果没有左子树,cur向右移动
// 或者cur左子树上最右节点的右指针是指向cur的,cur向右移动
cur = cur.right;
}
printEdge(head);
System.out.println();
}
/**
* 打印树枝
*
* @param head
*/
public static void printEdge(Node head) {
Node tail = reverseEdge(head);
Node cur = tail;
while (cur != null) {
System.out.println(cur.val + " ");
cur = cur.right;
}
reverseEdge(tail);
}
/**
* 反转树枝:实际就是反转树的树枝(链表)
*
* @param from
* @return
*/
public static Node reverseEdge(Node from) {
Node pre = null;
Node next = null;
while (from != null) {
next = from.right;
from.right = pre;
pre = from;
from = next;
}
return pre;
}
四、总结
二叉树的递归和非递归遍历还是比较容易的,相对于Morris遍历就是把节点的null指向利用上而已,不过在Morris的后序遍历还是复杂的,需要仔细研究。
Morris遍历已经是进阶的二叉树遍历了,可以实现时间复杂度为O(N),额外空间复杂度为O(1),(PS:如果对时间复杂度和空间复杂度不清楚可以看看其他大神的博客哦,滑稽 ,,,)
画图软件:drawio
参考:《程序员代码面试指南》第二版———左程云