前言
以前在学习数据结构的时候曾经写过一些关于二叉树遍历的博客。最近在刷题的时候,对二叉树的遍历又有了一些新的解法和思路,这里做一总结,希望可以便人便己。
一、解法一:递归解法
关于二叉树的遍历,最简单而又最直接的方式就是利用其本身的特点—即二叉树的左右子树同样是一棵二叉树。这是一个递归定义,所以非常适合采用递归的解法。思路很简单,这里就不赘述了,直接上代码。
void PreOrderDfs(TreeNode * root){
if(root != nullptr){
visit(root);
PreOrderDfs(root->left);
PreOrderDfs(root->right);
}
}
void InOrderDfs(TreeNode* root){
if(root != nullptr){
InOrderDfs(root->left);
visit(root);
InOrderDfs(root->right);
}
}
void PostOrderDfs(TreeNode* root){
if(root != nullptr){
PostOrderDfs(root->left);
PostOrderDfs(root->right);
visit(root);
}
}
总结:随着对专业的不断了解,我现在越发的感觉虽然调优很重要,但是控制软件的复杂度同样很重要(尤其是在目前硬件的性能已经极大提高的情况下)。一个优但是复杂的算法和一个还不错(注意是普通不是劣)但是很简单的算法,情况允许的范围内我可能更偏向于后者。
递归对于二叉树来说算是很简单清晰了, 虽然效率上可能比后面介绍的非递归要低些(现在的编译器已经比较聪明了,对于递归来说,它可能会采取一些优化的编译算法,从编译的角度帮我们把递归优化成非递归) 如果实际项目允许的话,可以尝试使用它。
二、解法二:迭代法
2.1 先序和中序
这是属于二叉树遍历的非递归算法,基本的思路就是使用栈来保存中间的结点(以防后面还能用得到)。
下面解法的具体逻辑是(先序和中序为例,后序遍历要稍微复杂一点点):
(1)、从根结点出发,一直迭代访问其左子树(并不断压栈)
(2)、直到某个左子树结点为空,然后弹出栈顶元素,继续遍历其右子树
(3)、返回(1)…
基本步骤如上面两幅图所示(只画了入栈、出栈的一部分)。
而这里访问先序和中序遍历的区别就在于,具体visit的时机,对于先序遍历来说,其入栈的时候就需要visit 根元素(注意,这里的根元素指的不是整棵树的根,而是当前“所遍历的树”的根,理解这点很重要),而对于中序遍历来说,其出栈的时候需要才需要visit 对应树的根元素。 这个其实也挺好理解,因为先序遍历是NLR, 中序遍历是LNR, 后者在根N 所有的左子树都访问完才可以访问N(此时N正处于出栈状态)。
先序遍历和中序遍历具体的代码如下所示:
void PreOrderIter(){
stack<TreeNode*> nodeSta;
TreeNode* tmpRoot = root;
while(tmpRoot != nullptr || nodeSta.empty() == false){
if(tmpRoot != nullptr){
visit(tmpRoot);
nodeSta.push(tmpRoot); //push from the stack
tmpRoot = tmpRoot->left;
}else{
tmpRoot = nodeSta.top(); //get the top of the stack
nodeSta.pop();
tmpRoot = tmpRoot->right;
}
}
}
void InOrderIter(){
stack<TreeNode*> nodeSta;
TreeNode* tmpRoot = root;
while(tmpRoot!=nullptr || nodeSta.empty() == false){
if(tmpRoot != nullptr){
nodeSta.push(tmpRoot);
tmpRoot = tmpRoot->left;
}else{
tmpRoot = nodeSta.top();
visit(tmpRoot);
nodeSta.pop();
tmpRoot = tmpRoot->right;
}
}
}
2.2 后序
关于后序其入栈的顺序和上述的类似,区别在于其出栈顺序。 对于后序遍历来说,不再是遇到nullptr就弹出一个栈顶元素,而是其左右子树都访问完毕之后,返回的时候弹出“栈顶”根元素。 而且,这里面有些饶人的点在于,如何判断它是从左子树返回、还是从右子树返回,只有从右子树返回才需要弹出栈顶“根元素”(因为是LRN)。
判断是从左子树返回还是从右子树返回可以由不同的方式,在我看来比较简单的方式是标志法(给从左子树还是右子树返回打上不同的标志,这个和下面介绍的颜色标记法本质上是相同的)。不过这种方式需要改变二叉树结点的结构(添加一个标志位),在空间上需要一点损失。 具体的解法, 可以参照我以前写的博客非递归遍历后序二叉树。
这里介绍另外一种做法(比上面的方式要稍微复杂些),这种做法简单来说就是使用一个指针记录刚刚访问的结点,如果是左孩子结点,则继续访问根结点的右子树,如果是右节点,则访问根元素(并弹出栈)。代码示例如下:
void PostOrderIter(){
stack<TreeNode*> nodeSta;
TreeNode* tmpRoot = root;
TreeNode* lastVisit = nullptr; //record last visit node
while(tmpRoot != nullptr || nodeSta.empty() == false){
if(tmpRoot != nullptr){
nodeSta.push(tmpRoot);
tmpRoot = tmpRoot->left; //visit the left
}else {
tmpRoot = nodeSta.top(); //get the top of stack
if(tmpRoot->right!=nullptr && tmpRoot->right != lastVisit){ //right child not yet visit
tmpRoot = tmpRoot->right;
}else{
visit(tmpRoot);
nodeSta.pop(); //pop from the stack
lastVisit = tmpRoot;
tmpRoot = nullptr; //reset to null
}
}
}
}
总结:对于二叉树遍历的非递归算法本质上大都是自己利用栈,来实现递归的逻辑。从某种程度上来说算是人工充当了“编译器”。
三、解法三:颜色标记法
这第三种介绍的方法,以统一的代码形式进行上述三种遍历,其基本逻辑也是利用的栈来实现的非递归遍历。与前述不同的是, ,它借鉴了层次遍历和颜色标记这两种idea。
对于后者颜色标记很好理解,就是把不同状态的结点打上不同的颜色,还未访问的标记成黑色,刚访问完的标记成灰色,需要弹出的标记成白色。
对于借鉴前者层次遍历,这儿要稍微好好说道说道。 我们知道,层次遍历需要使用一个队列记录以后需要访问的元素。先入队的先访问,后入队的后访问,这种数据结构和层次遍历很好的契合在了一起,即先访问结点的孩子的访问时机,也是先于后访问结点孩子的。如下图所示,结点3的孩子4,一定是早于5结点的孩子6的。
那么问题来了,先序(NLR)、中序(LNR)和后序(LRN)遍历有没有这类似的特点呢? 也可以让我们用某种数据结构记录以后访问的结点。
有的,不过与层次遍历有些不同。因为NLR、LNR、LRN这三种遍历顺序都是递归的。什么叫做遍历顺序是递归的呢? 比如说对于NLR来说,根的左孩子一定要比根的右孩子要先访问,根子树左孩子的右孩子,一定要比根子树右孩子的右孩子要先访问… 也就是说,根的左子树要比根的右子树要先访问。
不理解? 看看下图。
对于根结点为1的结点,其左子树的任何一个结点都要比右子树的任何一个结点先访问。
当我们以结点3为根结点的时候,其左子树的任何一个结点要比右子树的任何一个结点要先访问。
…
以上就叫做递归的遍历。正是因为这个递归的遍历,我们无法使用队列的数据结构来记录其以后将要访问的结点,但是“栈”这个数据结构刚好可以契合这种递归的特点。
既然(这里的“既然” 虽然说得如此义正言辞,但是说实话,其本质我还没有深刻的理解。 ̄□ ̄||)要使用“栈”这种数据结构来实现遍历,所以入栈的时候要做一些改变,因为栈是“后进先出”的形式,所以在入栈的时候需要一遍历顺序相反的形式入栈,即
(1)、对于先序遍历(NLR),那么入栈的时候就需要RLN。
(2)、对于中序遍历(LNR),那么入栈的时候就需要RNL。
(3)、对于后序遍历(LRN),那么入栈的时候就需要NRL。
这样的话,栈中任意时刻保存的都是与目的遍历顺序相反的结点(出栈之后就是相同的)。当然有些还未访问的结点,需要把其理解成以此结点为根的树在栈中对应的位置。
这里还需要注意两点:
(1)、每次栈顶元素都需要出栈,然后还需要根据其与左右子树的关系重新入栈,只有第二次访问遇到这个元素的时候,才可以出栈输出。
(2)、由于(1)的需求,这里使用颜色标记法来标识每个结点的不同状态,入栈时为黑色、第一次访问后为灰色,第二次访问后为白色,成为白色的结点可以出栈输出。
说实话,要深刻理解这种方式确实有些困难。下面来份示意图。
代码部分如下:
//traversal with Color mark
void PostOrderCol(){
stack<TreeNode*> nodeSta
TreeNode* tmpRoot = nullptr;
nodeSta.push(root);
while(nodeSta.empty() == false){
tmpRoot = nodeSta.top();
nodeSta.pop();
if(tmpRoot != nullptr){
switch(tmpRoot->colTag){
case BLACK:
tmpRoot->colTag = GRAY;
break;
case GRAY:
tmpRoot->colTag = WHITE;
break;
}
if(tmpRoot->colTag == WHITE){
visit(tmpRoot);
}else{ //NRL
nodeSta.push(tmpRoot);
nodeSta.push(tmpRoot->right);
nodeSta.push(tmpRoot->left);
}
}
}
}
void InOrderCol(){
stack<TreeNode*> nodeSta;
TreeNode* tmpRoot = nullptr;
nodeSta.push(root);
while(nodeSta.empty() == false){
tmpRoot = nodeSta.top();
nodeSta.pop();
if(tmpRoot != nullptr){
switch(tmpRoot->colTag){
case BLACK:
tmpRoot->colTag = GRAY;
break;
case GRAY:
tmpRoot->colTag = WHITE;
break;
}
if(tmpRoot->colTag == WHITE){
visit(tmpRoot);
}else{ //RNL
nodeSta.push(tmpRoot->right);
nodeSta.push(tmpRoot);
nodeSta.push(tmpRoot->left);
}
}
}
}
void PreOrderCol(){
stack<TreeNode*> nodeSta;
TreeNode* tmpRoot = nullptr;
nodeSta.push(root);
while(nodeSta.empty() == false){
tmpRoot = nodeSta.top();
nodeSta.pop();
if(tmpRoot != nullptr){
switch(tmpRoot->colTag){
case BLACK:
tmpRoot->colTag = GRAY;
break;
case GRAY:
tmpRoot->colTag = WHITE;
break;
}
if(tmpRoot->colTag == WHITE){
visit(tmpRoot);
}else{ //RLN
nodeSta.push(tmpRoot->right);
nodeSta.push(tmpRoot->left);
nodeSta.push(tmpRoot);
}
}
}
}
总结:
颜色标记法也是非递归遍历,但是从某种程度上统一了三种非递归遍历的代码形式,虽然一开始不是很容易理解,但是便于记忆,是我比较推荐的方式。
四、总结
在我看来,学习二叉树(或者说更一般的树结构),一定要建立起树的递归定义的概念。也就是说树的子树也是棵树。上文中在讨论二叉树时说到的根,并不一定是指整棵树的根,也有可能泛指任意一棵子树的根。 具体是哪一种,要看我们当前关注的重点是哪一棵树(确切的说是哪一棵子树)。
理解了树的递归定义之后,对于LNR这样的遍历,就会有更深刻的理解,L是一棵子树,是一棵只针对于当前N(根结点)的左子树。