06 二叉树

 

首先来解决二叉树的实现,输入和输出问题。

实现二叉树最常用的是二叉链表表示方法。

实现链表节点的类是BSTNode :

public class BSTNode {

  protected Comparable el;

  protected BSTNode left, right;

  ……

}

为什么是BST(二叉查找树)?因为考虑到将来可能会用到。而且一般二叉树和BST的节点没有什么区别,除了BST要求每个元素可以比较外。所以上面要求el是实现了Comparable接口的对象。我们演示二叉树时都使用String来表示信息(输出方便),而且它又实现了Comparable接口。表示二叉树的类就是BST:

public class BST {

  protected BSTNode root = null;

……

}

只要有一个根节点就好了。

接着要解决输入二叉树的问题了。因为二叉树可以由先序和中序决定,而且我觉得这可能是最快的输入方式了,所以实现这种输入方法。此外,实现它的方法使用了递归,这是在二叉树(树)中最常用的思想,因为二叉树的定义就是递归的。

我们约定先序遍历和中序遍历序列用两个字符串来表示,元素用“,”隔开。然后,BuildTreeByPreInOrder方法会把两个字符串分成两个字符串数组,调用递归方法BuildTree来构造这棵二叉树。

怎么由先序和中序序列得到二叉树呢?先序的第一个元素是二叉树的根节点,在中序序列中找到这个根节点,它把中序序列分成两部分,左边的是它的左子树,右边的是它的右子树(由中序遍历的定义)。并且如果它的左子树有X个子节点,那么在它的先序序列中根节点后面的X个元素都是它的左子树上的元素(根据先序遍历的定义)。

比如有先序序列:A,B,D,C,E,H,F,G,I和中序序列D,B,A,E,H,C,G,F,I。A是二叉树的根节点,D,B是左子树上的节点,其它的是右子树上的节点。去掉根节点A,序列被分成两部分:B,D 与 C,E,H,F,G,I 以及 D,B 与 E,H,C,G,F,I。而且B,D和D,B是左子树的先序和中序序列;C,E,H,F,G,I和E,H,C,G,F,I是右子树的先序和中序序列。为什么是?因为先序序列是先序访问根节点+先序访问左子树+先序访问右子树得到的序列,现在又知道左子树有两个节点,所以左子树的先序序列是B,D。这样我们就可以递归的构造这棵树:根据根节点A构造出树根,递归的构造左子树,递归的构造右子树,然后把根指向左右子树。

  private BSTNode BuildTree(String[] pre,String[] in,

           int head1,int tail1,int head2,int tail2) throws Exception{

    if(head1>tail1||head2>tail2)

      return null;

    BSTNode root=new BSTNode(pre[head1]);

    int pos;

    for(pos=head2;pos<=tail2;pos++){

      if(in[pos].equals(pre[head1])){

        break;

      }

    }

    if(pos>tail2){

      StringBuffer sb=new StringBuffer();

      for(int i=head2;i<=tail2;i++)

        sb.append(in[i]);

      throw new Exception("先序序列中的节点("+"pre["+head1+"]="+ pre[head1]+")没有在中序序列("+sb+")中出现。");

    }

    int leftNodes=pos-head2;

root.left=BuildTree(pre,in,head1+1,head1+leftNodes,

head2,head2+leftNodes-1);

    root.right=BuildTree(pre,in,head1+leftNodes+1,tail1,pos+1,tail2);

    return root;

  }

实现起来也很容易。参数的意义是:pre是先序序列String数组,in是中序序列String数组。head1和tail1表示当前二叉树的先序序列的开始和结束下标。head2和tail2表示当前二叉树的中序序列的开始和结束下标。递归出口是head1>tail1||head2>tail2,表示当前二叉树没有先序或中序遍历,即它是空树。此外为了防止先序序列中的节点没有在中序序列出现(比如先序是a,b,c,中序是a,c,d),还会抛出一个异常。但我懒得再定义一个类了,而是直接抛出一个Exception类的对象。注意:因为是递归方法,调用BuildTree的方法可能是它自己,但它自己并不会处理这个异常,但因为它不处理,系统会自动把它层层上抛,最后交给BuildTreeByPreInOrder。所以不会有什么问题。

BuildTreeByPreInOrder(){

    ……

    try{

      root = BuildTree(pre, in,0,pre.length-1,0,in.length-1);

    }catch(Exception e){

      System.out.println(e.getMessage());

    }

    ……

 

接着要实现二叉树的输出(显示)问题。一般的数据结构的书都没有实现(我见过的书都没有)。因为二叉树不易用文本来表示;用图形用户界面又太麻烦了,因为有的读者可能不熟悉,而且和数据结构又没有什么关系。

我的想法是用文本表示二叉树。节点的信息当然比较好表示,直接用文本就可以了,比较麻烦的是表示二叉树结构的“边”怎么表示?幸好键盘上还有/和/。

比如:

             A  

            / / 

           B   C

就看起来“比较”不错。思路就是用/和/来代替边。元素多一点会是什么样子呢?

先序序列A,B,D,J,C,E,H,F,G,I和中序序列D,B,J,A,E,H,C,G,F,I对应的二叉树是:

                 A            

                / /           

               /   /          

              /     /         

             B       /        

            / /       /       

           D   J       C      

                      / /     

                     /   /    

                    /     /   

                   E       F  

                    /     / / 

                     H   G   I

虽然有点占空间,但也勉强可以看出它的结构了。

怎么实现呢?我们先来看那个简单的。

             A  

            / / 

           B   C

A在中间,A的左下是/,右下是/,/的右下是B,/的右下是C。

再看那个大图,为什么A和C之间用5个(而不是6个或4个)/来连接呢?极端一点,如果只用一个/连接,那么C的左子树就和J可能重合在一起了。因为我们不希望左子树和右子树重叠,而是希望它们都分布在根的两边,所以/或/的数量应该取决与左右子树的宽度,比如上面的图中,右子树比较宽,所以A到C的/就多。

接着具体的来分析到底需要多少个/和/来连接父子两个节点。

假设每个节点3个字符(3个字符来表示一些信息应该够用了),不够3个的话就用空格来补上。比如“a”只有一个,则左右各补一个变成“ a ”;比如“bc”,则在中间补上一个(补在中间比较对称)变成“b c”。一个叶子节点的宽度应该是3,高度是1。

由于我们的目的是左右子树不重叠到一起,因此我们要区分一棵树的左边宽度和右边宽度。比如上面的大图C的左边宽度是5,右边宽度是7。但由于C是A的右孩子,只要有5个/就可以使得C这棵子树都在A的右边了。同理,由于B的右边宽度是3,所以A和B之间有3个/。碰巧B的左边宽度也是3,但即使不是3,比如是1(把D删掉),也要3个/来连接A和B。

因此,要求一棵树的左边宽度,应该先求它的左子树的宽度。要求一棵树的右边宽度,则应该求它的右子树的宽度。这也是个递归的过程。除了宽度,也要知道高度(这里的高度不是根到叶子的最长路径的长度而是显示时占的高度)。它的高度是1+max(左子树的高度+/的个数,右子树的高度+/的个数)。

具体实现就不罗嗦了,请参考TreePrinter类(可以看到里面的方法大都是递归方法)。

 

下面我们来看看怎么遍历一棵二叉树。

递归的实现很容易,我们看非递归的方法。

要实现遍历,一种办法是要借助堆栈。为什么要用堆栈呢?以中序为例,我们访问了一个节点,接着应该访问它的后继节点。怎么找它的后继呢?如果我们知道当前节点的父节点,那么这件事情就比较轻松了。如果当前节点有右孩子,那么它的中序后继就是它的右孩子为根节点的二叉树的“最左”的那个节点。否则,即它没有右孩子,那么就要求助于它的父节点了。如果它是它父节点的左孩子,那么后继就是它的父节点;否则说明当前节点是它父亲节点为根的二叉树在中序中最后被访问的节点。说得有点绕口,我们看下图。

                   A   

                    /   

                    B

                   /

                  C

B是A的右孩子,且B没有右孩子。要求B的后继,我们可以“想象”把A的右子树删掉,即A没有右孩子。那么B的后继就是删掉A的右子树后这棵树中A的后继。

比如没有删除A的右子树之前,整棵二叉树的中序序列为:…A的左子树,A,…B,X…(因为B是A这棵树最后被访问的节点)。删除A的右子树后的序列为:…A的左子树,A,X…

 所以B的后继就是删除A的右子树后A的后继。现在把问题转化成了于原来相同的问题:求A在删除后得到的树中的后继。这时A显然没有了右子树,则要看A和它父节点的关系,如果它是父亲的左孩子,则A的后继是A的父亲;否则,A又是它父亲为根节点的二叉树中最后被访问的节点,要求A的后继,可以把A的父节点的右子树“删除”,然后求A的父节点在新树中的后继。当然,实际上我们并不删除右子树,只是为了说明找后继的方法:找这个节点的父节点,直到它是它父节点的左孩子,或者说找这个节点的第一个“左祖先”。

上面这段分析说明,如果有了父节点信息,就能找到后继,从而实现遍历了。然而我们的数据结构中并没有父节点的信息(当然可以定义有左右孩子和父亲的结构),所以要用一个数据结构来保存当前节点的父节点,在上面的分析中我们不但可能要父节点,而且还可能要它的所有祖先,因此用堆栈保存从根到当前节点就比较容易想到了。

先来看先序。

先序遍历第一个访问的就是根节点。如果刚刚访问过的节点有左孩子,那么它的后继就是左孩子,否则如果有右孩子,那么后继是右孩子。如果没有孩子(叶子),则要求助于它的父亲节点了。类似前面的分析如下:

如果它是它父亲节点的右孩子,则它是以它父亲节点为根的二叉树最后被访问的节点,它的后继就是删除它父亲的左右子树后它父亲节点的后继。如果它是它父亲的左孩子,则要看它的父亲有没有右孩子。如果有右孩子,则右孩子是它的后继,否则它是最后访问的节点,删除它父亲的左孩子后父亲的后继就是它的后继。因此,向上找它的父亲节点,直到它是它父亲节点的左孩子并且它父亲有右孩子。就可以得到它的先序后继。

实现它的方法如下:

  public void PreOrderByFindNext(){

    if(this.root==null) return;

    java.util.Stack stack=new java.util.Stack();

    BSTNode curNode=this.root;

    //先序第一个访问的就是根节点。

    this.visit(curNode);

    curNode=FindPreNext(curNode,stack);

    while(curNode!=null){

      this.visit(curNode);

      curNode=FindPreNext(curNode,stack);

    }

  }

先序第一个访问的是根节点,然后不断找它的后继。找后继的方法为:

  private BSTNode FindPreNext(BSTNode curNode,java.util.Stack stack){

    if(curNode.left!=null){

      stack.push(curNode);

      return curNode.left;

    }

    if(curNode.right!=null){

      stack.push(curNode);

      return curNode.right;

    }

    BSTNode parNode;

    while(!stack.empty()){

      parNode=(BSTNode) stack.peek();

      if(parNode.left==curNode&&parNode.right!=null){

        return parNode.right;

      }

      else{

        stack.pop();

        curNode=parNode;

      }

    }

    return null;

  }

代码虽然有点长,但比较容易理解。如果当前节点有左孩子,则后继就是左孩子,并且要把当前节点压入堆栈,以便使得它的左孩子知道它的父亲是谁。同理,如果没有左孩子而有右孩子,在后继是右孩子,也要把当前节点入栈。如果左右孩子都没有,则一直向上,直到它是它父亲的左孩子并且它父亲有右孩子或者它没有父亲(它是根,这时栈为空)。

寻找一个节点的后继在前面已经分析过了,我们来看怎么使得堆栈中保存当前节点“前辈们”(即从根到当前节点的路径上的所有节点)的。

刚开始是根节点,它的“前辈们”当然是空了。如果已知当前节点的“前辈们”了,那么当前节点的后继的“前辈们”就是当前节点的“前辈们”加上当前节点。所以在找后继的方法FindPreNext中如果当前节点有孩子,那么当前节点的孩子的“前辈们”就要加上当前节点,所以出现stack.push(curNode);这条语句。如果当前节点是叶子,则要一直“向上”直到当前节点是它父亲的左孩子并且它父亲有右孩子。如果当前节点是它父亲的右孩子或它的父亲没有右孩子,则“当前”节点要变成它的父亲(假设删除它的孩子使它变成叶子,从而把求当前节点的后继变成求它父亲在新树中的后继),所以stack要变成它父亲节点的“前辈们”,所以要把当前节点的父亲从stack中弹出,所以else后跟了stack.pop();这条语句。如果找到了当前节点的父亲使得它是它父亲的左孩子且它父亲有右孩子,则“当前”节点变成了它父亲的右孩子,并且“新当前”节点(右孩子)的“前辈们”正是“老当前”节点的前辈们(因为他们是兄弟节点)。

有点奇怪?和平时在书上看到的算法不同?我们再来看看一般书上的实现方法。

下面是《数据结构与算法》中的方法(原书用的c++):

  public void PreOrderWithoutRecursion(){

    java.util.Stack stack=new java.util.Stack();

    BSTNode curNode=this.root;

    while(curNode!=null||!stack.isEmpty()){

      if(curNode!=null){

        this.visit(curNode);

        stack.push(curNode);

        curNode=curNode.left;

      }

      else{

        curNode=(BSTNode) stack.pop();

        curNode=curNode.right;

      }

    }

  }

书上的解释是:遇到一个节点,就访问它,然后把它压入堆栈,然后下降去遍历它的左子树。遍历完左子树后,从栈顶弹出这个节点,然后遍历它的右子树。

看过后不能说不懂,但总有的说不清楚的感觉。它的过程就是不断向左访问节点,并且把根到当前节点的路径上的所有节点都保存到堆栈中。当不能再往左走时,说明当前节点是空节点,从堆栈中弹出它的父亲节点,接着访问它的右子树。比较让人头疼的是while有两个条件,其实curNode!=null就是判断能否向左走的,而!stack. isEmpty()判断当前节点是否有父亲节点。原书代码和我的代码两个条件顺序是不同的,我觉得向左走可能成立的比较多,可能有利于短路效应的发生。

顺便补充一下,java.util.Stack在判断栈是否空时提供了两个方法,isEmpty(),empty()。我在写代码时并没有注意,因为用的是Jbuilder的CodeInsight(输入.方法就自己弹出来了)。后来才发现有两个,empty()是Stack自己实现的方法,isEmpty()是从java.util.Vector中继承来的方法。我们知道Vector中的方法都是同步的,isEmpty()也是个同步的方法。在我们的应用中使用empty()就可以了,没有必要同步。

上面的方法保存了从根到当前节点路径上的所有节点。当我们发现,保存它就是为了下一步遍历它的右子树,那么我们也可以直接保存它的右子树而不是保存它。

修改一下就得到PreOrderWithoutRecursion2:

    while(curNode!=null||!stack.isEmpty()){

      if(curNode!=null){

        this.visit(curNode);

        if(curNode.right!=null)

          stack.push(curNode.right);

        curNode=curNode.left;

      }

      else{

        curNode=(BSTNode) stack.pop();

      }

    }

注意,保存右子树其实就是保存后继,当左子树完了之后,堆栈中就是它的后继。也就是说堆栈一直保存着当前节点的后继。如果一个节点没有右子树,那么它的后继就是它父亲的右子树。

再观查一下PreOrderWithoutRecursion(),我们发现在判断条件时有多余的地方。

    while(curNode!=null||!stack.isEmpty()){

      if(curNode!=null){}

      else{}

    }

    比如curNode!=null那么在while中要判断一次,在if中又要判断一次。

    在上面的分析中我们知道,用的比较多的时第一个条件curNode!=null,只有向左不能走时,才去栈中找它的父亲节点的右孩子。所以把两个条件分开可能会好点。

PreOrderWithoutRecursion4是这样处理的:

    while(curNode!=null){

      while(curNode!=null){

        this.visit(curNode);

        if(curNode.right!=null)

          stack.push(curNode.right);

        curNode=curNode.left;

      }

      if(!stack.empty())

        curNode=(BSTNode) stack.pop();

    }

有点奇怪?两个while(curNode!=null)?第二个是用来向左走的,如果走不了时,就要从栈中弹出它的后继,如果没有,则curNode==null,第一个while(curNode!=null)这时(且仅这时)才派上用场,表示遍历结束。我们发现第一个while只是当栈空(当前节点没有后继)时才有用,那么下面的写法可能看起来舒服一点。

    while(true){

      while(curNode!=null){

        this.visit(curNode);

        if(curNode.right!=null)

          stack.push(curNode.right);

        curNode=curNode.left;

      }

      if(!stack.empty())

        curNode=(BSTNode) stack.pop();

      else

        break;

    }

一个while(true),当栈空时,直接break(return 更快)就完了。

还有一个比较有趣的实现方法,看起来非常简单(实际上并不最快)。

  public void PreOrderWithoutRecursion3(){

    if(this.root==null) return;

    java.util.Stack stack=new java.util.Stack();

    BSTNode curNode=this.root;

    stack.push(curNode);

    while(!stack.empty()){

      curNode=(BSTNode) stack.pop();

      this.visit(curNode);

      if(curNode.right!=null)

        stack.push(curNode.right);

      if(curNode.left!=null)

        stack.push(curNode.left);

    }

  }

怎么解释呢?我们注意堆栈的变化。所有待访问的节点都在堆栈中。刚开始时堆栈中只有根节点。访问过后,待访问的节点是它的左右子节点(如果有的话),当左子节点要先访问,所以先要压入右子节点。加上访问了当前节点,那么当前节点的左右子节点应该在堆栈中其他节点之前访问,所以要把它的左右子节点压入。注意:堆栈中的一个节点代表的不是这个节点没有被访问,而是代表这个节点为根的子树都没有被访问。

                 A            

                / /           

               /   /          

              /     /         

             B       /        

            / /       /       

           D   J       C      

                      / /     

                     /   /    

                    /     /   

                   E       F  

                    /     / / 

                     H   G   I

以上图的树为例。我们看栈的变化。A->BC->DJC->JC->C->EF->HF->GI->I->NULL。

刚开始栈中是A,表示以A为根节点的子树(其实就是整棵树)还没有被访问,所以先访问根节点A。接着待访问的是以B为根的子树和以C为根的子树。访问B,待访问的是以D为根的子树,以J为根的子树和以C为根的子树,……。

为什么说它形式上简单当运行起来反而不简单呢?从算法可以看到,树中每个节点都要入一次栈和出一次栈。而我们来看PreOrderWithoutRecursion2()会怎么样?

它遍历上面的树时堆栈为:C->JC->C->NULL->F->HF->F->NULL->I->NULL。共10此进出栈,而如果时看似简单的方法则要20次进出栈。

 

中序的遍历和后序的遍历的实现是:

InOrderWithouRecursion() ,《数据结构与算法》中的算法。

InOrderWithouRecursion2(),《数据与算法(java语言版)》中的算法,用原书的话来讲“几乎是无法理解的,并且如果不通过解释,很难了解这个方法的目的”(我是没有看懂)。

InOrderWithouRecursion3(),对InOrderWithouRecursion()的一点改进,把两个判断条件分开。

PostOrderWithoutRecursion(),《数据结构与算法》中的算法。为了遍历还特别增加了一个类StackElement。算法我是没有看明白。

原书的解释是:“后序周游二叉树比按中序周游和前序周游都更复杂:遇到一个结点,把它推入栈中,去周游它的左子树。周游遍它的左子树后,还不能马上访问处于栈顶的该结点,而是要再按照它的右链接结构指示的地址去周游该结点的右子树。周游右子树后才能从栈顶托出该结点并访问之。因此,需要给栈中的每个元素加上一个特征位,以便当从栈顶托出一个结点时能够区别是从栈顶元素左边回来还是从右边回来的。”

PostOrderWithoutRecursion2(),《数据与算法(java语言版)》中的算法,我也没有看懂。

PostOrderWithoutRecursion3(),写过后自己都有点不懂的算法。大体思路是:向左一直走,如果不能了,再向右走。其实就是找到后序的第一个节点。

有兴趣的朋友可以看看具体实现。

此外,类似于PreOrderByFindNext,我还实现了InOrderByFindNext和PostOrderByFindNext。其中InOrderByFindNext已经分析过了,PostOrderByFindNext没有分析,当源代码的注释比较多(相对其他的方法)。大家可以自己分析一下,再多罗嗦也没有意思了。个人觉得PreOrderByFindNext等方法比较好理解,当然你也可能觉得其他的方法好。反正能理解的就好。

 

用栈来实现二叉树的遍历需要一定的空间,我们能不能尽量节省一些空间呢?从前面的分析我们知道,要实现遍历,关键是要保存从根到当前节点的路径上的所有节点(或者能够知道每个节点的父节点)。但我们定义的数据结构是二叉链表,没有父亲节点的指针。还有,任意一棵二叉树的空指针个数=节点总数(n)+1(证明很简单,把每个空指针指向一个空节点,于是空指针数=空节点数,这样就构成了一棵满二叉树。根据满二叉树的性质:空子树个数=节点数+1)。我们能不能利用这n+1个空间来保存父亲节点的信息呢?初一看,有n个节点,n+1个空间,那么把每个空间都保存一个节点的父亲不就可以了吗?但是很遗憾,这n+1个空间并不是均匀的分配给每个节点(使得每个节点有一个),有的节点可能有两个孩子,那么它就没有空间存放父指针了;而叶子节点却有两个空间;有的节点有一个孩子,但不知道哪个是孩子,哪个指向父亲节点。

那么我们该怎么办呢?注意:我们的目标是在一些空指针位置保存一些信息,使得我们能够找到每一个节点的父亲节点,如果能一次找到当然最好,如果不行,费些周折(但也不能太麻烦,比如从根开始往下找某个节点的父亲节点是可行的,但太费时间了)能找到也行。

我们先来考虑最简单的叶子节点。叶子节点有两个空指针,假设我们把右指针利用一下,利用它保存一些信息。保存什么呢?首先想到的当然是它的父亲节点,这样要找叶子节点的父亲就一步到位了。但很快就遇到麻烦了。如下图的一棵二叉树。

                 A  

                / / 

               /   C

              /     

             B      

            / /     

           D   E 

 如果C,D,E的右指针都指向了它们的父亲A,B,B。那么B的父亲怎么求呢?

 也就是说,非叶子节点B的父亲怎么求?假设我们利用右指针来保存父亲节点的信息,那么如果它没有右孩子,那么右指针就保存了父亲节点相关的信息(可能是指向父亲,也可能不指向父亲的祖先);如果它有右孩子呢?那么我们就一直向右走,总可以走到没有右孩子的节点,那么这个节点(没有右孩子的节点E)的右指针就应该保存了B的父亲的信息。即E中要保存B和E的共同的祖先(而且层次要尽量高的共同祖先,这样求他们的父亲就相对容易一些了)。

 从上面的分析得出,没有右孩子的节点要保存所有这样节点(这些节点能一直向右走到这个节点)的层次尽量高的共同祖先。

 上面的话可能有些绕口,我们来看一个例子。

                 A  

                / / 

             /   C

              /             

             B              

            / /             

           D   /            

                /           

                 E          

                / /         

               F   G         

                    /     

                     H    

                    /     

                   I 

 比如H的右指针应该保存B,E,G和H的最近的共同祖先A。为什么呢?因为我们可能要求H的父亲,所以要保存H的祖先。另外B能够向右走到H,所以也有保存B的祖先,此外还有保存E,G的祖先。但我们发现不用求B,E,G,H的共同祖先,而只要求B,H的共同祖先就可以了。因为E,G能够向右走到H,但B在向右走向H的过程中经过了E,G,所以B已经是E,G的祖先了,那么求B,E,G,H的祖先等价于求B,H的祖先。因此,我们发现:从H一种向左向上走(直到不能再走),再向右走(如果即不能向左,也不能向右,则说明到了根,我们用null表示这种特殊情况)就是我们要保存的信息。或者说:H中保存的是H的第一个“右祖先”。

 也许你要问:说了半天,我怎么从没在别的书上看到这些?

 其实这就是中序穿线树(Threaded Tree)的定义!觉得不像?

 穿线树:如果一个节点的右孩子为空,则它的右指针指向它的中序后继。(有的还定义了如果左孩子为空的情况,但这对遍历并没有帮助,浪费时间和空间而已)。

 一个右孩子为空的节点的中序后继恰好就是我们上面定义的它的第一个“右祖先”。

 证明:假设这个节点为X,如果它的父亲是P。如果X是P的左孩子,那么X的中序后继就是P,而P也正是X的第一个“右祖先”。否则如果X是P的右孩子,那么X的中序后继等价于删除P的左右子树后(P变成叶子)P的中序后继。这样把P看出X求新X的后继就是一直向左的过程。(证明不是很严谨,我一时也找不到好的方法了,不过它的正确性是比较明显的。)

 当然为了区别是右孩子还是中序后继还需要一个额外的布尔型变量。

 ThreadedTreeNode是定义穿线树节点的类:

public class ThreadedTreeNode {

  protected Comparable el;

  protected ThreadedTreeNode left, right;

  protected boolean successor;

  ……

  比一般的二叉树多了一个protected boolean successor;而已。这样如果successor==false 则说明这个节点有(非空的)右孩子;否则右孩子是线索(可以为空,中序遍历的最后一个节点是唯一为空的情况)。

 给二叉树穿线有递归和非递归(但借助栈)两种方法。非递归的比较容易理解,其实就是一个中序遍历的过程,只不过把Visit改成穿线就可以了,为了穿线,还应该保存刚才访问过的节点。

 具体实现请参考InThreadNonRecursion()方法。

 递归的方法为:给一棵树穿线并返回这棵树最后被访问的节点,可以先给它的左子树穿线并返回左子树最后被访问的节点。如果左子树最后被访问的节点不空(空的情况只会发生一次)且它没有右孩子,则它的successor=true,它的right=这棵树的根。然后在给右子树穿线并返回右子树最后一个被访问的节点。最后返回这棵树最后被访问的节点——如果右子树最后被访问的节点不空(即右子树不空),整棵树最后被访问的节点就是右子树最后被访问的节点;否则如果右子树最后被访问的节点为空(即右子树为空),则整棵树中序最后被访问的应该是根节点。

 注意:我们的问题变成了穿线一棵树并返回这棵树中序最后被访问的节点,而不是仅仅穿线一棵树。

 具体实现请参考InThread2()方法。

 怎么是InThread2()而不是InThread()?因为我前面实现时用的是传递引用参数而不是用返回值,结果出现错误,调试了老半天。为了是大家不犯和我同样的错误,我把它留了下来。错误的原因是:如果在C语言中我们可能有这样的代码:

 void swap(int * pa, int * pb){//交换指针。

     int * tmp=pa; pa=pb; pb=tmp;

 }

 ……

 main(){

    int a=4,b=3;

    int *pa= &a, *pb= &b;

    swap(pa,pb);

 }

 这没有任何问题。但如果想在java中实现类似的函数就有问题了。

 void swap(Object a, Object b){

     Object tmp=a; a=b; b=tmp;

 }

 main(){

     Integer c=new Integer(4);

     Integer d=new Integer(3);

     swap(c,d);

     System.out.println(“c=”+c);

     System.out.println(“d=”+d);

 }

 执行后会怎么样呢?并不是我们预期的3和4,而是4和3。为什么?因为在swap函数中,参数a和b只是c和d的别名而已。用下面的简图来说明。()内的是时间内存中的数据,c,d是他们的引用。调用swap是,系统会在堆栈中产生a和b,他们分别引用4和3。

  c------>(4)<------a

  d------>(3)<------b 

执行后呢?

  c------>(4)<------b

  d------>(3)<------a

a和b虽然引用(指向)了3和4,但c和d还是没有变。当swap结束后a,b生命周期结束。

总结一下:如果我们想更改引用所引用(指向)的对象,那么给函数传递参数是和指针没有什么区别(当然引用不能想指针那样进行各种运算)。但如果想更改引用本身,那么给函数传递参数是不可能实现的。

中序遍历中序穿线树比较容易,首先找到中序遍历的第一个节点,也就是最左边的节点。访问它,然后要找它的后继,如果它没有右孩子(successor==true)则后继就是它的右指针,否则它的右子树最左的节点就是它的中序后继。具体实现请参考InOrderBySuccessor()。

 再来看先序遍历:

  public void PreOrderBySuccessor(){

    ThreadedTreeNode curNode=this.root;

    while(curNode!=null){

      this.visit(curNode);

      curNode=FindPreOrderNext(curNode);

    }

  }

 

  关键是找后继的方法FindPreOrderNext()。

  private ThreadedTreeNode FindPreOrderNext(ThreadedTreeNode curNode){

    if(curNode.left!=null) return curNode.left;

    if(curNode.successor==false) return curNode.right;

    ThreadedTreeNode ancestor=curNode.right;

    while(ancestor!=null){

      if(ancestor.successor==false){

        return ancestor.right;

      }

      else{

        ancestor=ancestor.right;

      }

    }

    return null;

  }

   如果节点有孩子,那么后继就是它的孩子。如果它是叶子,回忆一下前面怎么找先序后继的:“向上找它的父亲节点,直到它是它父亲节点的左孩子并且它父亲有右孩子。就可以得到它的先序后继。”(不记得了就看看前面的内容,呵呵,我自己也不记得了,所以把结论拷贝一下)。“向上找它的父亲节点,直到它是它父亲节点的左孩子”不就是找它的第一个“右祖先”吗?正是右线索中的内容(可见在先序遍历时,保存右祖先比保存父亲好,因为保存父亲的话得一直向左走,保存右祖先就一步到位了)。

 

  后序遍历最复杂。假设刚访问的节点是curNode,现在要求它的后序后继。则我们可以先找到它的第一个右祖先。怎么找第一个右祖先呢?如果它没有右孩子,那么右祖先就是它的右指针,否则,应该把它向右走到头,然后那个节点的右指针就是它的第一个右祖先。

它的实现如下:

  private ThreadedTreeNode FindAncestor(ThreadedTreeNode curNode){

    while(curNode.successor==false)

      curNode=curNode.right;

    return curNode.right;

  }

找到了curNode的右祖先ancestor,那么又有两种情况。curNode是ancestor的左孩子;curNode是ancestor左孩子的右子孙。

我们先看第二种情况。

             A

      / /

           B   H    

            /     

             C    

              /   

               D  

比如curNode是D,ancestor是A的情况。那么D的后序后继就是C(从A的左孩子一种向右走)。但我们发现如果只求出D的后继C的话,那么下次就要求C的后继,又得从A先向左,然后一直向右走。比如上图的树D有右孩子E,E有右孩子F,那么求F的后继时会从A->B->C->D->E,求出F的后继是E。然后求E的后继,又要走A->B->C->D。这显然不好。我们应该一次就把F-E-D-C-B遍历完了,不过这要借助栈才能完成。然后,我们就返回H。但如果A没有右孩子呢?有要重复前面的过程。

             K

            / /

           J   L

            /

             A

      /

           B       

            /     

             C    

              /   

               D  

比如求D的后继,我们应该访问D-C-B,然后A没有右孩子,重复上面的过程。访问A-J,返回L。

具体的实现比较罗嗦,请参考PostOrderBySuccessor()和FindPostOrderNext()方法。

 

使用穿线树遍历虽然不用额外的栈空间,但要存储布尔值successor以便区别是右孩子还是右线索。另外当二叉树改变时,线索也要变化,实现起来比较费事。

那么还有没有更好的方法呢?

下面说是Morris算法。

《数据结构与算法(java语言版)》中是这样解释的:如果一个节点没有左孩子,那么应该访问这个节点,然后进入它的右子树。如果它有左孩子,那么它应该在它左子树最右的节点被访问只会才被访问。因此找到它的左子树的最右的节点,把这个最右的节点的右孩子设为当前节点。然后进入当前节点的左子树,当前节点的左子树这时设为空。

具体实现如下:

  public void MorrisInOrder1(){

    BSTNode curNode=this.root;

    BSTNode tmp;

    while(curNode!=null){

      if(curNode.left==null){

        this.visit(curNode);

        curNode=curNode.right;

      }

      else{

        tmp=curNode.left;

        while(tmp.right!=null)

          tmp=tmp.right;

        tmp.right=curNode;

        tmp=curNode.left;

        curNode.left=null;

        curNode=tmp;

      }

    }

  }

 

  但这个算法有很大的缺点――遍历后所有节点的左子树都没有了。因此在进入左子树的时候,不能把当前节点的左指针设成空。这样就会形成一个环。

  比如下图的树,当前节点是A,现在应该进入A的左子树B,但为了能够访问完B后能回到A,所以要在B的最后一个被访问的节点(即最右的节点C)右指针指向A。当访问完C后接着就访问A,但我们怎么知道A是C的右孩子还是C的后继呢?如果从A先向左再一直向右能走到C,则A是C的后继,否则A是C的右孩子。如果A是C的后继,那么下一个访问的应该是A,而且C指向A的指针也不会再使用了,所以要置成空。

               A  

              / / 

             /   D

            /     

           B      

            /     

             C    

下面是完整的代码。

  public void MorrisInOrder(){

    BSTNode curNode=this.root;

    BSTNode tmp;

    while(curNode!=null){

      if(curNode.left==null){

        this.visit(curNode);

        curNode=curNode.right;

      }

      else{

        tmp=curNode.left;

        while(tmp.right!=null&&tmp.right!=curNode)

          tmp=tmp.right;

        if(tmp.right==null){

          tmp.right=curNode;

          curNode=curNode.left;

        }

        else{

          this.visit(curNode);

          tmp.right=null;

          curNode=curNode.right;

        }

      }

    }

  }

 

  不是很明白?让我们从另一个角度来看Morrris算法。

  Morris算法的实质和中序穿线树是一样的!

  我们看看Morris算法都干了些什么?“当前节点如有左子树,那么先向左然后一直向右找到最右的节点,把最右节点的右指针指向当前节点。”这不正是给最右节点穿线吗?!(穿线树不记得了赶快回头去看看)

  与穿线树不同的是Morris算法实在遍历的过程中穿线而不是先穿好了线索再遍历。这样的好处是当二叉树经常被修改时不用维护线索了。

  但是由于没有(不)使用一个布尔变量指示右指针到底是线索还是右孩子,所以Morris算法节省了一些空间,但要判断是线索还是孩子就要先向左然后一直向右走,这可能要费不少时间。

  知道了Morris算法的实质,我们来把它改写成我们熟悉的形式。

    public void MorrisInOrder2(){

    BSTNode curNode=root;

    BSTNode tmp;

    while(curNode.left!=null){

      tmp=curNode.left;

      while(tmp.right!=null)

        tmp=tmp.right;

      tmp.right=curNode;

      curNode=curNode.left;

    }

    while(curNode!=null){

      this.visit(curNode);

      curNode=FindMorrisInOrderNext(curNode);

    }

  }

 

   第一个while循环找到中序遍历的第一个节点。下面的循环不断找中序后继。

    while(curNode!=null){

      this.visit(curNode);

      curNode=FindMorrisInOrderNext(curNode);

    }

   关键是找中序后继的FindMorrisInOrderNext()方法。在这个方法中又用到了

IsThread()方法。因为在穿线树中有一个布尔变量指示时后继还是右孩子,这里只能用这个方法判断。判断的代码有点多,但思路很简单:如果curNode.right能够先向左然后一直向右走到curNode,则curNode.right就是curNode的后继,否则是孩子。

   有了IsThread()方法后,FindMorrisInOrderNext()就和在穿线树中找中序后继非常类似了。

  private BSTNode FindMorrisInOrderNext(BSTNode curNode){

    BSTNode tmp;

    if (IsThread(curNode)) {

      tmp=curNode.right;

      curNode.right=null;//恢复。

      return tmp;

    }

    else {

      curNode = curNode.right;

      while (curNode.left != null) {

      //建立线索。

        tmp = curNode.left;

        while (tmp.right != null)

          tmp = tmp.right;

        tmp.right = curNode;

      //建立线索完毕。

        curNode = curNode.left;

      }

      return curNode;

    }

  }

 

  把代码翻译成文字就是:如果curNode的右指针是线索,则后继就是curNode.right。否则curNode.right子树的最左节点就是curNode的中序后继。

  但是还有点区别:如果是线索,那么用过之后就要恢复过来curNode.right=null;

  如果是孩子,那么要在找最左孩子的过程中建立起线索。

      另外,我还实现了MorrisPreOrder()和MorrisPreOrder2(),思路和中序的差不多,有兴趣话可以看看。用Morris算法实现后续遍历也是可以的,但会比较复杂,由于时间关系,我就没有编写代码了,有兴趣您可以自己试试。

      从上面的分析可以看出,Morris算法节省了空间,但可能要付出时间的代价。那么能不能多费些空间,提高速度呢?其实可以把Morris算法看作在遍历的过程中建立线索的穿线树,那么就可以用ThreadedTreeNode代替BSTNode。遍历前不用穿线,遍历的过程中穿线,这样判断是线索还是孩子就很简单了,就不用IsThread()方法了。我没有实现,您可以自己试试。

 
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值