数据结构:二叉树遍历非递归遍历和线索化

迭代遍历

实现二叉树的遍历一般用的是递归,系统维护递归需要调用一个系统栈来保存状态,在递归回溯的时候恢复状态。这些额外的资源占用和代码调用增大了二叉树遍历的开销,因此如果用户通过自己定义一个用户栈来替代,效率会更高。
一般而言基于递归实现的功能,也可以通过自己定义一个栈结构来实现。但是用户自定义的栈并不一定具有更高的效率,例如在一些操作系统上尾递归会以循环的方式来执行,此时递归的效率会更高。
下面的前序、中序和后序遍历的样例,都基于下图的二叉树进行展示。

先序遍历

基于迭代实现先序遍历时,由于顺序是“中-左-右”,左子树总会比右子树先出栈。栈的特点是先进先出,因此在压栈的时候右子树先入栈,然后再是左子树。然后每次有结点出栈,就将结点的右子树和左子树依次入栈迭代进行即可。
上面的二叉树利用栈实现先序遍历后,得到的遍历序列为:1 2 4 5 6 7 3。

#include <stack>

void iterativePreorderTraversal(BiTree T)
{
    if(T == NULL)
        return;
    
    stack<BiTree> bst;
    bst.push(T);
    BiTree ptr;
    // 迭代直到栈空 
    while(bst.empty() == false)
    {
        // 栈顶出栈 
        ptr = bst.pop();
        cout << ptr->data << " ";
        // 右子树入栈 
        if(ptr->rchild != NULL)
        {
            bst.push(ptr->rchild);
        }
        // 左子树入栈 
        if(ptr->lchild != NULL)
        {
            bst.push(ptr->lchild);
        }
    }
}

中序遍历

基于迭代实现中序遍历时,由于顺序是“左-中-右”,左子树比根结先出栈,然后右子树再入栈。因此流程是:

  1. 根结点先入栈;
  2. 如果栈顶的左子树存在就入栈,栈顶的左子树不存在就出栈并输出栈顶结点;
  3. 如果栈顶结点的右孩子存在,将右孩子入栈;
  4. 迭代直到栈空且栈顶元素的右孩子不存在,结束遍历。

上面的二叉树利用栈实现先序遍历后,得到的遍历序列为:4 2 6 5 7 1 3。

#include <stack>

void iterativeOrderTraversal(BiTree T)
{
    if(T == NULL)
        return;
    
    stack<BiTree> bst;
    BiTree ptr = T;
    // 迭代直到栈空且栈顶的 
    while(!bst.empty() || ptr != NULL)
    {
        // 栈顶做孩子存在,入栈 
        while(ptr != NULL)
        {
            bst.push(ptr);
            ptr = ptr->lchild;
        }
        // 栈不为空时出栈 
        if(!bst.empty())
        {
            ptr = bst.pop();
            cout << ptr->data << " ";
            // 右孩子入栈 
            ptr = ptr->rchlid;
        }
    }
}

后序遍历

二叉树的后序遍历可以借助前序遍历得到,将迭代前序遍历的左右子树的遍历顺序交换,就能获得后序遍历的逆序,进而得到后序遍历序列。上面的二叉树后序遍历序列及其逆序为:

后序遍历序列的逆序:1 3 2 5 7 6 4
后序遍历序列:4 6 7 5 2 3 1

#include <stack>

void iterativePostorderTraversal(BiTree T)
{
    if(T == NULL)
        return;
    
    stack<BiTree> bst;
    stack<BiTree> output;
    bst.push(T);
    BiTree ptr;
    // 迭代直到栈空 
    while(!bst.empty())
    {
        // 栈顶出栈 
        ptr = bst.pop();
        output.push(ptr);
        // 右子树入栈 
        if(ptr->lchild != NULL)
        {
            bst.push(ptr->lchild);
        }
        // 左子树入栈 
        if(ptr->rchild != NULL)
        {
            bst.push(ptr->rchild);
        }
    }
    // 输出序列
    while(!output.empty())
    {
        ptr = output.pop();
        cout << ptr->data << " ";
    } 
}

线索二叉树

描述前驱与后继

回顾一下双向链表,为了能够准确描述某个结点的前驱,我们给结点结构体引入了前驱指针域,通过前驱指针域我们就能够清楚地知道一个结点的前驱,而不需要再次遍历。那么再看一下二叉树,虽然在树结构中,结点间的关系是一对多的关系,但是当我们遍历二叉树时,无论是前序遍历、中序遍历还是后序遍历,我们使用了某些规则使得我们能够按照一定的顺序来描述二叉树的结点。也就是说,例如我们使用中序遍历的时候,我们是可以按照中序遍历的规则明白一个结点的前驱和后继的,但是如果我们需要知晓这一点,就不得不进行一次遍历操作。那么我们能不能像双向链表那样开辟一些指针域来描述前驱与后继的关系呢?

别急,我们先来观察一下,如图所示的二叉树使用二叉链表来组织结点,中序遍历的结点顺序为 GDBEACF,但是我们发现并不是所有的结点的指针域都得到了充分的应用。该二叉树有 7 个结点,也就是说有 14 个指针域,可是我们只使用了 6 个指针域来描述逻辑关系。再接着看,当我们需要描述后继关系时,也就是 G->D、D->B、E->A、F->NULL 这四个关系,描述清楚之后就能够吧中序遍历所得的后继关系说明白;描述前驱关系时,需要把 G->NULL、E->B、C->A、F->C 这四个关系说明白。观察一下,如图二叉树有 6 个分支,这些分支分别需要有 1 个指针域来存储信息,总共有 14 个指针域,那也就是还有 8 个指针域是空闲的,然后我们就能发现,这个数字与我们要描述清前驱后继所需要的指针域是一样的,也就是说我们无需对结构体的定义进行操作,只需要将这些空闲的空间充分利用即可。
如图所示,描述后继关系:

描述前驱关系:

对于这类用于描述前驱和后继的指针,我们称之为线索,而将空闲的指针域利用起来的二叉链表,也就是引入线索的二叉链表成为线索链表,描述的二叉树成为线索二叉树。通过对线索的使用,我们把一棵二叉树描述为一个双向链表,我们很清楚双线链表的插入、删除和查找结点的操作都是很方便的,而我们以某种遍历顺序设置线索的过程成称为线索化。线索二叉树的结构即充分利用了二叉树的空指针域,又使得一次遍历就能获取结点的前驱和后继信息,既节省了空间也节省了时间。

线索二叉树结点结构体定义

我们明白了可以利用空闲的指针域来描述前驱后继,但是我们要如何确定这些指针域是用来描述左右子结点还是前驱后继的关系的呢?也就是说,我们不仅需要一些机制来进行判断,更要留下一些标志来为我们后续的访问提供便利。我们的做法是,引入两个 bool 性成员变量 ltag、rtag,当 ltag 的值为 0 时表示指针域指向该结点的左结点,值为 1 时指向该结点的前驱,rtag 的用法同理。

typedef enum {Link,Thread} PointerTag;    //Link 表示指向子结点,Thread 表示指向前驱或后继
typedef struct BiThrNode
{
    ElemType data;    //数据域
    BiThrNode *lchild,*rchild;    //左右孩子的指针域
    PointerTag LTag;    //判断左指针域作用的 flag
    PointerTag RTag;    //判断右指针域作用的 flag
}BiThrNode, *BiThrTree;

线索化

所谓线索化就是将二叉树中没有使用的空闲指针域进行修改,使其能够描述前驱和后继的过程,而前驱和后继的信息我们在遍历的时候比较关心,因此线索化本质上就是在中序遍历的时候添加描述的过程,算法的实现也是基于遍历算法的实现。

BiThrTree pre;    //当前访问结点的前驱指针
void InThreading(BiThrTree ptr)
{
    if(ptr != NULL)
    {
        InThreading(ptr->lchild);    //左子树线索化
        if(!ptr->lchild)    //结点无左子树
        {
            ptr->LTag = Thread;    //修改 flag
            ptr->lchild = pre;    //左指针域指向前驱
        }
        if(!pre->rchild)    //结点的前驱无右子树
        {
            pre->RTag = Thread;    //修改 flag
            pre->rchild = ptr;    //右指针域指向后继
        }
        pre = ptr;    //移动 pre,使其始终指向当前操作结点的前驱
        InThreading(ptr->rchild);    //左子树线索化
    }
}

遍历线索二叉树

由于线索二叉树实现了近似于双向链表的结构,因此我们可以添加一个头结点,使其左指针域指向线索二叉树的根结点,右指针域指向中序遍历访问的最后一个结点。同时我们可以运用一下循环链表的思想,使中序遍历的第一个结点的左指针域和最后一个结点的右指针域指向头结点,就能够实现从任何结点出发都能够完整遍历线索二叉树的功能了。该算法时间复杂度 O(n)。

bool InOederTraverse_Thr(BiThrTree T)    //指针 T 指向头结点
{
    BiThrTree ptr = T->lchild;    //ptr 初始化为根结点
    
    while(ptr != T)    //遇到空树或遍历结束时,ptr 会指向头结点
    {
        while(ptr->LTag == Link)    //结点指向左子树时循环到中序序列的第一个结点
            ptr = ptr->lchild;
        cout << ptr->data;
        while(ptr->RTag == Thread && ptr->rchild != T)    //中序遍历,并找到下一个右子树
        {
            ptr = ptr->rchild;
            cout << ptr->data;
        }
        ptr = ptr->rchild;    //ptr 进入右子树
    }
    return true;
}
posted @ 2022-07-22 14:29  乌漆WhiteMoon  阅读(281)  评论(0编辑  收藏  举报