漫谈二叉树遍历(非递归)
——————这篇文章旨在提出一种简单方便,易于理解时空复杂度低且风格统一的二叉树非递归遍历方法。
从二叉树先序遍历开始
二叉树的先序遍历(非递归)相比中后序是最少花哨、最统一的。一般来说先序遍历的代码如下:
void preorder(TNode* p,FUNC Func) { TNode* stack[maxsize]; int itop = -1; if(p == NULL)return ; stack[++itop] = p; while(itop != -1) { p = stack[itop--]; Func(p); if(p->rchild)stack[++itop] = p->rchild; if(p->lchild)stack[++itop] = p->lchild; } }
代码很短。先序的思想非常简单,从代码中可以看出:先将根节点压入栈中,从栈中取元素,遍历,然后按右左节点的顺序将元素压入栈中(如果存在的话),如此循环,直到栈空。
当然这么说的话是很官方了,通俗点儿来讲,其实就是每进入一个节点就先访问本节点,然后左边有节点的话往走左,左边没有往右走,或者左边走完往右走,直到走完为止。这种思想非常通俗易懂,其代码效率也高,所以被广泛采用也不奇怪。其时间复杂度是每个节点访问一遍,进出栈一遍,应为O(3*n),效率很高了。其空间复杂度由于是每出栈一个节点,则其左右节点皆进栈(如果存在的话),考虑最复杂的情况,即二叉树为完全二叉树或满二叉树,则其空间复杂度为O(deepth),其中deepth为树的深度,也是很不错的表现。
不幸的是这种简洁明了的思想风格在中序遍历中并没有延续下去。中序遍历的思想风格相对于先序遍历变得非常奇怪,从其最经典最被广泛采用的的中序遍历代码中可见一斑:
void midorder(TNode* p,FUNC Func) { TNode* stack[maxsize]; int itop = -1; while(itop != -1||p) { while(p!=NULL) { stack[++itop] = p; p = p->lchild; } p = stack[itop--]; Func(p); p = p->rchild; } }
代码依然不长。但其思想似乎变得奇怪了起来:从根节点开始不断地将p置入栈中,p不断地进入左子树,直到p所指向的树为空为止,然后从栈中取元素赋给p,访问p,然后进入右子树,如此循环,直到栈为空且p无效时跳出,遍历完成。
当然还是那句话,这么说的话是很官方了。通俗点儿来讲,就是我从根节点开始一直往左走(把我走过的点都存到栈里),走到走不动了,就访问本节点,再往右边走,然后再往左走一直到走不动。。。如此循环,走完所有的节点为止(此处是因为节点全部来自于p和p的左子树以及栈中,所以当p无效且栈空的时候,自然就走完所有的节点了)。是不是感觉到和先序遍历完全不同的风格?当然,风格这种之后再分析,先来看它的时空复杂度:从代码中可以看出,每个节点都会进栈一次出栈一次,被访问一次,其时间复杂度大致为O(3*n)。空间复杂度上,算法每到一个节点,都会往左走到头为止,考虑最复杂的情况,则其空间复杂度为树的深度O(deepth)。这也是很不错的表现了。
在分析完复杂度之后,转头来看看思想风格:先序的思想风格简单明了,站在当前的节点上,我不需要知道这是叶节点还是根节点,我只需要不断的访问,然后压右走左就可以。但是这种简单明了来到中序之后荡然无存,中序开始站在一种全局的角度进行观察,我开始需要知道我走在哪个节点上了,我开始需要关注整体的遍历顺序了,而这种中序遍历的遍历顺序,在代码中体现的淋漓尽致:”不断往左走,走到头访问,然后往右走。。。“,与之相对比,显然先序并不需要这些考量,它不需要知道先序的遍历顺序是什么样的,它只需要不断的站在当前节点,我该干啥干啥,就OK了。这种思想风格虽然对于新手来说很难写出来,但是毕竟时空复杂度尚可,所以也还可接受。
但后序遍历扭曲的就不仅仅是思想风格了,与之相比还更高的时空复杂度。而后序也因很多算法较高的难以接受的时空复杂度,而产生了五花八门风格各异的算法,也算是百花齐放了。其中算法参差不齐,有优有劣,具体就不再列举,这里来说一说这篇的正题。
风格统一、时空复杂度低、思想简单明了的二叉树非递归遍历
回到文章开头的地方,我们开篇就说了,先序遍历的思想是最简单明了的。显然,如果能按先序的思想进行中序后序遍历,一切就会变得非常简单。
所以我们这里来尝试一下用先序的思想遍历中序。先序是一种基于当前节点,不需要知道我是不是走到头了,我是不是根节点叶节点,我只需要不断的遍历本身,压右进左即可。可是以中序为例,中序的时候显然不是这样,我们需要先遍历完左子树,然后在遍历本身在向右走。在这种情况下,我们必须要知道在当前节点我们是否已经遍历完成左子树。那么我们现在就是要求遍历完成左子树的结论,但是要求结果,和数学题一样,就必须要明确我们都有哪些条件,要知道我们有哪些条件,就必须要还原利用先序遍历思想遍历中序时候的当前情况,才能从中总结出来条件。
那么我们还原一下:我们从根节点开始,将本节点压入栈中,向左走,进入新节点。。。当我们左子树遍历完成,要回到根节点,此时我们需要拿到栈顶元素(这个元素一定是根节点),遍历,然后向右走。。。有没有发现什么?压栈压的是本身节点,这决定了当你从左节点回到父节点时,一定有栈顶元素的左子树等于当前节点!而这正是我们所要求的那个左子树遍历完成的条件!
由此我们就可以总结出来这种中序遍历的思想了,设置一个标志位left,初值为零,代表没走过左子树的状态,当进入一个新节点时,我们设置left=0,表示当前节点没遍历过左子树,当从左子树回到父节点时,设置left=1,表示当前节点(父节点)遍历过了左子树。
代码如下:
void midorder(TNode* p,FUNC Func) { int left = 0; TNode* stack[maxsize]; int itop = -1; while(p) { if(p->lchild&&!left) { stack[++itop] = p; p = p->lchild; left = 0; } else { Func(p); if(p->rchild) { p = p->rchild; left = 0; } else { left = 1; if(itop != -1)p = stack[itop--]; else { p = NULL; } } } } }
同理,推广到后序遍历上,也很容易。我们可以设置两个标志位,一个left一个right,作用同上。代码如下:
void lastorder(TNode* p,FUNC Func) { TNode* stack[maxsize]; int left = 0,right = 0,itop = -1; while(p) { if(!left && p->lchild ) { stack[++itop] = p; p = p->lchild; left = right = 0; } else if(!right && p->rchild) { stack[++itop] = p; p = p->rchild; left = right = 0; } else { Func(p); if(itop != -1) { if(p == stack[itop]->lchild) { left = 1; right = 0; } else { left = right = 1; } p = stack[itop--]; } else { p = NULL; } } } }
由于三者代码思想都是相同的,所以我这里只挑后序的遍历方法来讲讲就可以:从根节点开始,如果left,right都为0,则说明本节点左右都没走过,那么先走左;如果我们要从左节点回到根节点,那么设置left为1,right为0,表示左节点走过,但右节点没走过;如果我们要从右节点回到根节点(此时判断右节点是否与栈顶元素的右子树相等),那么设置left,right都为1,表示此根节点左右节点都走过,只需要遍历本节点就可以了;如果我们要进入左节点或者右节点,那么就设置left,right都为0,因为进入新节点则其左右都没走过。
这里代码是写的不太好的,因为实在懒得优化了。但其思想是非常好的。时间复杂度按后序来算,则其每个节点要走三次,大致为O(3*n),其实也是O(n),与前中序差不多,很好的表现了;空间复杂度按后序来算,应为树的深度O(deepth),也与前中序差不多的表现了。
这个算法还有一个好的地方在哪里呢?它的可扩展性,它可以不仅仅用于二叉树,也可以用于n叉树的m序遍历,只需要给定m个标志位即可,而其思想与代码的风格仍是大同小异,这里就不再赘述,有兴趣的读者可以自己去实现,也是很有意思的一件事。