数据结构_二叉树的遍历

二叉树的遍历

树的遍历即是:按照某种次序访问树中各个节点,并且每个节点恰好被访问一次。
这里写图片描述
遍历的方式有以下几种:
先序:V | L | R
中序:L | V | R
后序:L | R | V
层次/广度优先:自上而下,先左后右。

先序遍历
递归版本:

/*
struct TreeNode {
    int val;
    struct TreeNode *left;
    struct TreeNode *right;
    TreeNode(int x) :
            val(x), left(NULL), right(NULL) {
    }
};*/
void preorder(TreeNode*pnode,(*visit))
{
    if(pnode)
    {
        visit(pnode->val);
        preorder(pnode->left,visit);
        preorder(pnode->right,visit);
    }
}

迭代实现1:
在递归实现中,对左、右子树的递归遍历都类似于尾递归,我们可以以某种方式直接消除。
思路:二分递归 ——>迭代+单递归——>迭代+栈。

void preorder(TreeNode*pnode)
{
    stack<TreeNode*> s;
    if(pnode)
        s.push(pnode);
    while(s.empty())
    {
        TreeNode*p=s.top();
        s.pop();
        visit(p->val);
        if(p->left)
            s.push(p->left);
        if(p->right)
            s.push(p->right);
    }
}

分析:每步迭代,都有一个节点出栈并被访问,每个节点入/出栈一次,每次迭代只需要O(1)。总的效率为O(n).

迭代分析2:
这里写图片描述

可以分析出:整个遍历过程可以划分为自上而下对左侧分支的访问,及随后自下而上对一系列右子树的遍历,不同右子树的遍历相互独立。
实现如下:

//自上而下访问左孩子
void VisitAlongLeftBranch(TreeNode*root,(*visit),stack<TreeNode*> &s)
{
    while(root)
    {
        visit(root->val);
        if(root->right)
            s.push(root->right);
        root=root->left;
    }
}

void predorder(TreeNode*root,(*visit))
{
    stack<TreeNode*> s;
    while(true)
    {
        VisitAlongLeftBranch(root,visit,s);
        if(s.empty()) break;
        root=s.top();
        s.pop();
    }
}

中序遍历
递归实现:

/*
struct TreeNode {
    int val;
    struct TreeNode *left;
    struct TreeNode *right;
    TreeNode(int x) :
            val(x), left(NULL), right(NULL) {
    }
};*/
void inorder(TreeNode*pnode,(*visit))
{
    if(pnode)
    {
        inorder(pnode->left,visit);
        visit(pnode->val);
        inorder(pnode->right,visit);
    }
}

迭代实现1:
不同于先序遍历,尽管右子树的递归遍历是尾递归,但左子树绝对不是。实际上,实现迭代式中序遍历算法的难点正在于此。由先序遍历的版本2启发,我们找到第一个被访问的节点将其祖先用栈保存,这样,原问题就被分解为依次对若干个右子树遍历的问题。
这里写图片描述
分析:从根出发沿左分支下行,直到最深的那个节点,这个就是最先被访问的节点。

void AlongLeftBranch(TreeNode*root,stack<TreeNode*>&s)
{
//assert: 所有节点都会进栈,或早或迟;但不会重复进栈
    while(root)
    {
        s.push(root);
        root=root->left;
    }
}

inorder(TreeNode*pnode)
{
    stack<TreeNode*> s
    while(true)
    {
        AlongLeftBranch(pnode,s);
        if(s.empty())
            break;
        //assert: L‐subtree(pnode)为空,或已遍历(相当于空)
        pnode=s.top();
        s.pop()
        visit(pnode->val);
        pnode=pnode->right;
    }
}

分析:每个节点出栈时其左子树(若存在)已经完全遍历,而右子树尚未入栈,于是,每当有节点出栈,只需要访问它,然后从右孩子出发…..如下图所示:
这里写图片描述

迭代实现2:
版本2只不过是版本1的等价形式,实现如下:

void inorder(TreeNode*pnode,(*visit))
{
    stack<TreeNode*> s;
    while(true)
    {
        if(pnode)
        {
            s.push(pnode);         //根节点入栈
            pnode=pnode->left;     //深入遍历左子树
        }
        else if(!s.empty())
        {
            pnode=s.top();         //尚未访问的最低祖先节点出栈
            s.pop();
            visit(pnode->val);    
            pnode=pnode->right;   //遍历该祖先的右子树
        }
        else
            break;
    }
}

效率分析:迭代版本与递归版本时间复杂度都为O(n),但迭代版本常数系数要远远小于递归版本的常数系数。

迭代实现3:
上面的两种迭代方式都需要使用辅助栈,尽管对算法的渐进时间复杂度没有实质的影响,但是所需要的辅助空间的规模线性正比于二叉树的高度,在最坏的情况下与节点总数相当。为此,我们实现第三个迭代版本,只需要使用O(1)的辅助空间。假设我们定义的树中有指向父节点的指针。
首先,我们要实现一个函数找出中序遍历中的下一个节点,在二叉树下一个节点中我们已经实现,为方便说明,在此还是列出该函数,如下:

struct TreeLinkNode {
    int val;
    struct TreeLinkNode *left;
    struct TreeLinkNode *right;
    struct TreeLinkNode *next;
    TreeLinkNode(int x) :val(x), left(NULL), right(NULL), next(NULL) {

    }
};
*/

TreeLinkNode *nextnode(TreeLinkNode *pnode)
{
    TreeLinkNode *pnext=NULL;
    if(!pnode)
        return pnext;
    if(pnode->right)
    {
        TreeLinkNode *pleft=pnode->right;
        while(pleft->left)
            pleft=pleft->left;
        pnext=pleft;
    }
    //(1、若为节点A的左子树则下一个节点就为A,2、若为节点A的右子树则 沿着当前节点向上找直到找到一个节点B它是节点C的左子节点 则C就为下一个节点)
    else if(pnode->next)
    {
        TreeLinkNode *pcurrent=pnode;
        TreeLinkNode *pparent=pnode->next;
        while(pparent&&pcurrent==pparent->right)
        {
            pcurrent=pparent;
            pparent=pcurrent->next;
        }
        pnext=pparent;
    }
    return pnext;
}

借助于上面的函数我们可以实现我们的版本3,如下:

void indrder(TreeLinkNode*pnode,(*visit))
{
    bool backtrack=false;//回溯标志,前一步是否从右子树回溯——省去栈
    while(true)
    {
        if(!backtrack&&pnode&&pnode->left)
        {
            pnode=pnode->left;//有左子树且不是刚刚回溯,就深入遍历左子树
        }
        else//否则,无左子树或者刚刚回溯
        {
            visit(pnode->val);
            if(pnode&&pnode->right)//右子树非空
            {
                pnod=pnode->right;  //遍历右子树
                backtrack=false;    //关闭回溯标志
            }
            else
            {
                if(!pnode=nextnode(pnode))  //中序遍历中没有下一个节点时
                    break;
                backtrack=true; 
            }
        }
    }
}

说明:这里相当于将原辅助栈替换成一个标志位backtrack。每当抵达一个节点,借助该标记位即可判断此前是否刚做过一次自下而上的回溯。若不是,则按照中序遍历的侧率优先遍历左子树。反之,若刚刚发生过回溯,则意味着当前节点的左子树已经遍历完成(或者等效的左子树为空),然后深入右子树继续遍历。
虽然属于就地算法,但是要反复的调用nextnode(),因此时间效率有所倒退,渐进的时间复杂度依然保持O(n)。

迭代实现4:
版本4是在版本3上的改进,无须借助标记位和辅助栈。

void indrder(TreeLinkNode*pnode,(*visit))
{
    while(true)
    {
        if(pnode&&pnode->left)
        {
            pnode=pnode->left;//有左子树,就深入遍历左子树
        }
        else//否则
        {
            visit(pnode->val);
            while(!pnode||!pnode->right)//在没有右分支处
            {
                if(!pnode=nextnode(pnode))  //中序遍历中没有下一个节点时 直接退出
                    return;
                else
                    visit(pnode->val);
            }
            pnode=pnode->right;      //直到有右子树,转向非空的右子树。
        }
    }
}

后续遍历
递归实现:

/*
struct TreeNode {
    int val;
    struct TreeNode *left;
    struct TreeNode *right;
    TreeNode(int x) :
            val(x), left(NULL), right(NULL) {
    }
};*/
void postorder(TreeNode*pnode,(*visit))
{
    if(pnode)
    {
        postorder(pnode->left,visit);
        postorder(pnode->right,visit);
        visit(pnode->data);
    }
}

迭代实现:
由于左、右子树的递归遍历,都不是尾递归。因此,我们很难消除递归。我们可以找到第一个被访问的节点将其祖先及其右兄弟用栈保存,这样原问题就被分解为依次对若干颗右子树的遍历问题。
在这里用下图解释说明:
这里写图片描述
首先我们要明白首先访问谁,有以下几点说明:
1、从根出发下行 尽可能沿左分支 实不得已,才沿右分支。
2、最后一个节点 必是叶子,而且 是从左侧可见的最高叶子(该叶子节点首先接受访问)
整个后续遍历也可以分解成几个片段,每一个片段,分别起始于通路上的一个节点,包括三步:访问当前节点,遍历以右兄弟为根的子树,以及向上回溯转入父节点,并且转入下一片段。
实现如下:

//在以s栈顶节点为根的子树中,找到从左侧可见的最高叶子节点
void gotoHLVFL(stack<TreeNode*> &s)
{
    while(TreeNode* pnode=s.top())
    {
        if(pnode->left)       //尽可能向左
        {
            if(pnode->right)        //如果有右孩子,优先入栈
                s.push(pnode->right);
            s.push(pnode->left);    //再转入左孩子
        }
        else//没有左孩子时,迫不得已
            s.push(pnode->right);
    }
    s.pop();//返回之前,弹出栈顶的空节点
}

void postorder(TreeNode*pnode,(*visit))
{
    stack<TreeNode*> s;
    if(pnode)
        s.push(pnode);  //根节点首先入栈
    while(!s.empty())
    {
        if(s.top()!=pnode->parent)//栈顶不是当前节点之父(则必为其右兄),此时要在遍历右兄为根的子树
            gotoHLVFL(s);
        pnode=s.top();
        s.pop();
        visit(pnode->val);
    }
}

该算法都首先定位对应的最高左侧可见叶节点,并且在此过程中,利用辅助栈逆序保存沿途所经过的各个节点。
在自顶向下查找最高左侧可见叶节点时,始终都是尽可能向左,只有左子树为空的时候才向右。前一种情况下,要让右孩子和左孩子先后入栈,在转向左孩子。后一情况下,只要让右孩子入栈。因此,在主函数的每一步迭代中,如果当前节点的右兄弟存在,则该兄弟必然位于辅助栈栈顶。按照后续遍历次序,此时应转入以右兄弟为根的子树。
为了方便理解,下面举一个实例,演变过程如下:
这里写图片描述
这里写图片描述

层次遍历
在所谓层次遍历或广度优先遍历中,确定节点访问次序的原则可以概括为“先上后下,先左后右”。并且在打印二叉树一文中,已经介绍了层次遍历的应用,这里简要说明下。

/*
struct TreeNode {
    int val;
    struct TreeNode *left;
    struct TreeNode *right;
    TreeNode(int x) :
            val(x), left(NULL), right(NULL) {
    }
};*/
void levelorder(TreeNode*pnode)
{
    deque<TreeNode*> s;
    if(pnode)
        s.push_back(pnode);
    while(!s.empty())
    {
        pnode=s.front();
        s.pop_front();
        visit(pnode->val);
        if(pnode->left)
            s.push_back(pnode->left);
        if(pnode->right)
            s.push_back(pnode->right);
    }
}

初始化时先令树根入队,随后进入循环。在每一步迭代中,首先取出队列的首节点,然后其左右孩子顺序入队。一旦发现队列为空遍历即完成。

满二叉树和完全二叉树

  • 完全二叉树
    完全二叉树的拓扑结构如下:
    这里写图片描述

完全二叉树叶节点只能出现在最底部的两层,且最底层节点均处于此底层节点的左侧。因此,高度为h的完全二叉树,规模介于2^h至2^(h+1)-1之间,反之,规模为n的完全二叉树,高度h=O(logn)。并且叶节点虽然不至于少于内部节点,但是至多多出一个。

  • 满二叉树
    满二叉树的拓扑结构如下:
    这里写图片描述
    所有的叶节点同处于最底层。每一层的节点数达到饱和,因此高度为h的满二叉树由2^(h+1)-1个节点组成。叶节点恰好比内部节点多出一个。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值