题目描述
给定一颗二叉树的头节点 head,完成二叉树的先序、中序和后序遍历。如果二叉树的节点数为 N,要求时间复杂度为 O(N),额外空间复杂度为 O(1)。
题目解析
能不能用二叉树的前序、中序、后序遍历实现
不管是递归版本,还是非递归版本,实质上它都用到了栈,无法做到额外空间复杂度为O(1)。
比如当我们二叉树的高度有100层,那么递归时,系统就会一直压栈,最坏情况下,一直要压入100次遍历的递归函数,因为此处的空间复杂度是跟这颗二叉树的高度相关的。
为什么遍历二叉树需要压栈呢?
举个例子,如下二叉树:
- 当我们一直往下遍历时,走到值为4的节点,打印输出4之后,该怎么回到2节点呢?
- 每个节点都只有指向左右孩子的引用,并没有指向父节点的引用。
- 所以在遍历4的时候,会提前将2节点的所有信息,放入栈里面,只有在4节点遍历完成之后,才会弹出栈里的信息(2节点)。然后继续遍历其他的节点
这就是为什么遍历二叉树需要压栈的原因。
那么有没有方法,能够不压栈,就能从4节点直接返回到2节点呢?
- 我们可以利用4节点的右孩子引用
- 因为4节点是叶子节点,没有左右孩子节点的。
- 我们将4节点的右孩子引用,执行2节点,这就能从4节点直接返回到2节点了
上图橙色的线条,是Morris方法的作用:
- 改变某些节点的右孩子引用
- 然后在具体的遍历过程中,将橙色线条擦除即可。这样就恢复成了原二叉树
这就是Morris的思想。
那么问题来了,我们该如何画这些橙色的线条呢?
前提:我们准备两个引用变量—curr和mostRight
- curr表示当前遍历的节点
- mostRight表示curr节点的左子树中,往右边遍历,第一个没有右孩子的节点
举个例子,上图中:
- curr是节点1
- mostRight:节点1左子树中,往右下角遍历,第一个没有没有右孩子的节点,为节点5
遍历原则:(curr初始化为根节点)
- 如果curr没有左孩子,curr就向右移动(
curr = curr->right
) - 如果cur有左孩子,找到cur左子树上最右的节点,记为mostright
- 如果mostRight的right指针指向空,让其指向curr,cur向左移动(
cur=cur->left
) - 如果mostright的right指针指向cur,让其指向空,cur向右移动(
cur=cur->right
)
- 如果mostRight的right指针指向空,让其指向curr,cur向左移动(
看个例子:
-
curr 一开始来到节点4,节点4有左子树,找左子树的最右边节点,找到3,因为3.right = null,所以让3的right指向cur,也就是4,然后cur向左移动,来到2;
-
cur来到节点2,节点2有左子树,找左子树的最右边节点,找到节点1,因为1.right = null,所以让1的right指向cur,也就是2,然后cur向左移动,来到1;
-
curr 来到节点1,节点1没有左孩子,根据morris原则的第一条,curr向右移动,但是此时节点1的right已经指向了节点2,所以curr会回到节点2。
-
curr 回到节点2,节点2有左孩子,找左子树的最右边节点,找到节点1。因为1.right = cur,所以让1.right = null,cur向右移动,来到3;
-
cur来到节点3,节点3没有左子树,所以向右移动,来到节点4;
-
cur来到节点4,节点4有左子树,找左子树的最右边节点,找到节点3,因为3.right = cur,所以让3.right = null,cur向右移动,来到节点6;
-
cur来到节点6,节点6有左子树,找左子树的最右边节点,找到节点5,因为5.right = null,所以让5.right = cur,也就是6,cur向左移动,来到移动5;
-
cur来到节点5,节点5没有左子树,所以向右移动,来到节点6;
-
cur来到节点6,节点6有左子树,找左子树的最右边节点,找到节点5,因为5.right = cur,所以让5.right = null,cur向右移动,来到节点7;
-
cur来到节点7,节点7没有左子树,所以向右移动,来到null;
-
因为cur为null,所以停止移动。
从以上遍历过程可以看出,cur依次到达的节点分别是:4、2、1、2、3、4、6、5、6、7,我们将这个序列叫 Morris 序。从到达的这些节点中可以看出,有子节点的节点4,2,6分别来到了两次,第一次来到是在左子树的最右节点指向空的时候,第二次来到是在左子树最右边节点指向自己的时候,这也是 Morris 遍历和 Morris 序的实质。
morris遍历的实质: 建立一种机制,对于没有左子树的节点只到达一次,对于有左子树的节点会到达两次
struct TreeNode {
int val;
TreeNode *left;
TreeNode *right;
TreeNode() : val(0), left(nullptr), right(nullptr) {}
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
};
void morris(TreeNode * head){
if(head == nullptr){
return;
}
TreeNode *curr = head;
TreeNode *mostRight = nullptr;
while (curr != nullptr){
mostRight = curr->left;
if(mostRight != nullptr){ //有左子树
//查找最靠右的节点
while (mostRight->right != nullptr && mostRight->right != curr){
mostRight = mostRight->right;
}
if(mostRight->right == nullptr){ //第一次来到这个最右节点
mostRight->right = curr;
curr = curr->left;
continue;
}else{ //第二次来到这个最右节点
mostRight->right = nullptr;
}
}
curr = curr->right; //没有左子树时&&mostRight.right = cur时
}
}
void morris(TreeNode * head){
if(head == nullptr){
return;
}
TreeNode *curr = head;
TreeNode *mostRight = nullptr;
while (curr != nullptr){
mostRight = curr->left;
if(mostRight == nullptr){
curr = curr->right;
}else{
while (mostRight->right != nullptr && mostRight->right != curr){
mostRight = mostRight->right;
}
if(mostRight->right == nullptr){
mostRight->right = curr;
curr = curr->left;
}else{
mostRight->right = nullptr;
curr = curr->right;
}
};
}
}
注意:当第一次遍历到mostRight时,让mostRight的右孩子指向curr之后,curr再往左子树走,然后应该再写一次continue,让代码继续往curr的左子树遍历
问题:什么时候是第一次遍历到mostRight,什么时候是第二次遍历到mostRight?
- 第一次是mostRight的右孩子是null时,此时我们需要让它的右孩子指向curr,curr = curr.left
- 第二次是mostRight的右孩子是null时,此时我们需要让它的右孩子指向null,curr = curr.right
关于前序、中序、后序遍历,都是在Morris的基础之上,添加printf即可。
前序遍历&&中序遍历
遍历完成后对 cur 进过的节点序列稍作处理就很容易得到该二叉树的先序、中序序列:
morris遍历:有左子树就会回到curr两次,没有只会来一次
我们都知道前序遍历的顺序是:根左右。而Morris改前序遍历,只需要改两个地方:
- 如果curr节点没有左孩子,那么就打印当前curr的值(只会经过一次)
- 如果是第一次遍历到mostRight节点,那么就打印当前curr的值(第一次遍历到mostRight时就打印—这时它还没有处理它的左子树)
void morris(TreeNode * head){
if(head == nullptr){
return;
}
TreeNode *curr = head;
TreeNode *mostRight = nullptr;
while (curr != nullptr){
mostRight = curr->left;
if(mostRight == nullptr){
printf("%d\t", curr->val);
curr = curr->right;
}else{
while (mostRight->right != nullptr && mostRight->right != curr){
mostRight = mostRight->right;
}
if(mostRight->right == nullptr){
printf("%d\t", curr->val);
mostRight->right = curr;
curr = curr->left;
}else{
mostRight->right = nullptr;
curr = curr->right;
}
};
}
}
而使用morris遍历打印中序序列时(左根右):
- 如果cur没有左孩子,直接打印输出cur的值(只会经过一次)
- 如果mostRight是第二次遍历到,那就打印输出cur的值(第二次来到该结点时)
void morris(TreeNode * head){
if(head == nullptr){
return;
}
TreeNode *curr = head;
TreeNode *mostRight = nullptr;
while (curr != nullptr){
mostRight = curr->left;
if(mostRight == nullptr){
printf("%d\t", curr->val);
curr = curr->right;
}else{
while (mostRight->right != nullptr && mostRight->right != curr){
mostRight = mostRight->right;
}
if(mostRight->right == nullptr){
mostRight->right = curr;
curr = curr->left;
}else{
printf("%d\t", curr->val);
mostRight->right = nullptr;
curr = curr->right;
}
};
}
}
后序遍历
Morris改后序遍历,稍微有一点点难,因为Morris遍历出来的左右结果,都满足一个规律:当前节点没有左子树时,那么只会遍历一次这个节点;当前节点有左子树时,那么mostRight节点会返回到curr节点,也就是说有左孩子的节点,会遍历两次。
后序遍历是每个节点,遍历到第三次时,才会打印输出,但是限制Morris遍历最多只会遍历到两次,怎么办?(相比递归版本的遍历,每个节点最多会经过3次,而Morris遍历最多经过2次。)
我们先来看一张图
不难发现,红色的数值就是后序遍历的结果。对比下左边的二叉树,红色线条的指向,从左到右,从下到上的打印顺序,竟然跟后序遍历的结果是一样的。
那我们就以这个为切入点,当遍历cur的时候,我们就可以打印cur左子树的最靠右的那一排的节点。比如cur等于1节点的时候,我们就可以打印2、5节点。
要满足从下到上的打印顺序,最容易想到的就是搞一个栈,压栈进行,然后再依次弹出栈并打印。这样的话,空间复杂度就不是O(1)了。 大家是否还记得逆序单链表?我们将那一排的节点,进行逆序,然后打印输出之后,再逆序回来即可。
morris实现后序遍历:如果在每次遇到第二次经过的节点时,将该节点的左子树的右边界上的节点,从下到上打印,最后再将整棵树的右边界从下到上打印,就得到了后序遍历
// 打印节点的右边界
void printRightEdge(TreeNode * root){
if(root == nullptr){
return;
}
// reverse the right edge
TreeNode *prev = nullptr, *curr = root;
while (curr != nullptr){
TreeNode *next = curr->right;
curr->right = prev;
prev = curr;
curr = next;
}
//print
curr = prev;
while (curr != nullptr){
printf("%d\n", curr->val);
curr = curr->right;
}
// recover
curr = prev, prev = nullptr;
while (curr != nullptr){
TreeNode *next = curr->right;
curr->right = prev;
prev = curr;
curr = next;
}
}
void morris(TreeNode * head){
if(head == nullptr){
return;
}
TreeNode *curr = head;
TreeNode *mostRight = nullptr;
while (curr != nullptr){
mostRight = curr->left;
if(mostRight == nullptr){
curr = curr->right;
}else{
while (mostRight->right != nullptr && mostRight->right != curr){
mostRight = mostRight->right;
}
if(mostRight->right == nullptr){
mostRight->right = curr;
curr = curr->left;
}else{
mostRight->right = nullptr;
// 在这打印左子树的右边界
printRightEdge(curr->left);
curr = curr->right;
}
};
}
// 在这打印整颗树的右边界
printRightEdge(head);
}
morris遍历结点的顺序不是先序、中序、后序,而是按照自己的一套标准来决定接下来要遍历哪个结点
morris遍历的独特之处就是充分利用了叶子结点的无效引用(引用指向的是空,但该引用变量仍然占内存),
从而实现了O(N)的时间复杂度和O(1)的空间复杂度