数据结构——二叉(查找)树

来源:互联网 发布:nginx js 文件被截断 编辑:程序博客网 时间:2024/05/22 03:51

上一篇博客中,我们提到了树结构,而这一次,我们要提出的就是“特殊”的树结构——二叉树。所谓二叉树,就是树中的每个节点都不能有超过两个的孩子(自然而然地,每个节点最多也就只能向下分两个“叉路”了,这也是取名为二叉树的原因吧微笑,当然,类似的也有三叉四叉树等等,但我们本次只讨论二叉树)。

 


但是,为什么我们讨论二叉树,却不讨论三叉四叉五叉树呢?原因就是我们可以利用二叉树来方便的生成一种新的结构——二叉查找树!(但一般不会有人用五叉树这样的东西来做查找树吐舌头

但在我们详细讨论二叉查找树(我们真正要讨论的东西)之前,我们还是应该先对二叉树有所了解(讨论F1之前,总得先认识汽车吧吐舌头)。

 

 

首先,二叉树是什么样的树我们在第一段已经说过了,那么接下来,我们要讲的就是二叉树的“特殊”实现方法,以及一种仅适用于二叉树的新遍历顺序(讲完这两个我们就讲查找树,不拖沓大笑

 

 

讲解树结构时,我们利用“兄弟链表”来保存一个节点的所有孩子(因为我们不知道节点会有几个孩子),但是在二叉树中,我们已经知道一个节点最多只能有两个孩子,所以在定义节点结构时,我们不需要再使用链表来存储孩子们的数据,只需要为两个孩子分别留一个指针就好了(即使并非所有节点都一定有两个孩子,但通过链表来存储第二个孩子的位置意味着每次你找节点的第二个孩子都得“路过”一次链表的头结点,这很可能带来巨大的时间开销)

 

所以,二叉树的节点定义例程为:

struct  Node{

      Data data;

      struct Node * lChild;

      struct Node * rChild;

};

 

(o)哦,差点忘了,一般来说,我们是以“左”孩子和“右”孩子来称呼孩子们的(因为画成图的话,两个孩子恰好一左一右惊讶

 

 

讲完了二叉树的“特殊”实现方法(其实原理还是一样的,该是你的孩子你还是得存着呢微笑),接下来要讲的就是二叉树独有的遍历方法了!

对于树结构,我们有先序遍历和后序遍历,而对于二叉树,我们不仅有这两种遍历方法,我们还有独有的——中序遍历!

 

根据先序遍历和后序遍历的理解,中序遍历的意思显然是:“根”节点的处理应该位于中间

对于普通树结构来说,中序遍历显然是不可能的,因为若处理“根节点”的时间位于“中间”,那么如果一个节点有奇数个孩子比如3,那它应该在哪个孩子的后面处理呢?而且树当中各节点的孩子数很可能大不相同。

 

但是,对于二叉树来说,中序遍历是可以的,因为节点最多就2个孩子!只要先处理左孩子, 再处理节点,再处理右孩子就可以了!

 

中序遍历的例程很简单,和先序遍历与后序遍历是如此地相似!(相似点在哪呢?请自行回顾吐舌头):

 

void  InorderTravelTree  T

{

       ifT!=NULL

       {

              inorderTravelT->lChild;

              /* do something for T*/;

              inorderTravelT->rChild;

       }

}

 

那么,为了秉承讲解知识尽量说出用处的原则,接下来我们要讲一个其实非必学的内容来展示中序遍历的一个用途

 

首先,我们要说一个二叉树的例子——表达式树

 

形如上图的树即表达式树,其叶子节点均为操作数,非叶子节点则是操作符。一颗表达式树其实就是存储了一个表达式

 

为什么要提表达式树呢?因为你如果对表达式树进行后序遍历,就会得出该表达式的后缀表达式!上图中为abc*+de*f+g*+

而你对它进行先序遍历,则会得出前缀表达式!(当然,不太常用……)

上图中为++a*bc*+*defg

接下来……如果你对它进行中序遍历,就会得到我们最熟悉的中缀表达式!

上图中为(a+b* c)+((d*e+f)*g)

(是不是有点惊奇?为什么事物之间会有这样的联系?其实很多事物都是有关联的,数学、物理学、工程学、计算机科学,说是各自的领域,其实都互有关联……随便侃侃~

 

那么,表达式树该如何构建呢?这个就不做讨论了,只能提示:将后缀表达式从前到后逐一压入栈,入栈元素为数字则直接入栈,为操作符则弹出栈内两个元素,形成新的树后再次压入栈。例如ab+,先入a,b,然后遇到+,弹出a,b,形成根为+,左孩子a,右孩子b的树后,压入,结束。

 


 

好了,二叉树的实现及它特有的遍历顺序讲完了,接下来该“入正题”了,什么是二叉查找树?

 

树如其名,二叉查找树就是为了方便“查找”操作而存在的树。但我们为什么要使用二叉树来作为查找用的数据结构呢?

 

为了解决这个问题,我们先假设我们没有学过二叉树吧,那么如果现在有一个已经排好序的表(或者说数据),而且我们对这个表的常用操作就是:查找。那么,你会如何存储这个顺序表呢?


数组?当然可以,可是如果我们虽然常用查找,但也有不少的机会要执行“插入”和“删除”呢?


链表?嗯,不错,解决了插入和删除的麻烦。那么继续假设吧!假设数据就是奇数表1,3,5,7,9……,然后我想找999呢(看999是否在表内或者说999序号对应元素的其他数据)?

问题出来了!你想找999,就得从链表的头结点开始,一个一个和999进行比较,注意哦,从头开始,一个一个的!

 

那么,这个麻烦该如何解决呢?或许我们可以借鉴一下查字典的方法


假设你现在要查kill这个单词,如果按照上面的“链表思想”(没有贬低链表的意思……准确来说可以形容为“表思想”),那么你得先用kill的开头K与字典中A开头的块比较,然后与BCDEFGHIJ比较,直到你遇上K块,然后再找kill(这当中你如果继续用“链表思想”,你又得先从ka开头找起……)


但是事实上我们采用的是另外的办法!我们会把字典对半翻开,到达M块,然后发现KM前面,于是继续向前对半翻开,到达例如E块,然后发现KE后面,接着继续对半翻开(EM之间),到达I块,KI后面,于是继续对半翻开,到了,K块!这样的话,你要经过的块为MEI,只有3个!比起ABCDEFGHIJ要快多了!!

(上面的这个思想,就是有名的——对分查找!)

 

那么,有了使用对分查找的思想后,我们就该为之配备相应的数据结构来实现想法了!(数据结构与算法总是紧密结合的)

 

不卖关子,二叉树就是可以实现对分查找的数据结构!

但是要想用二叉树来完成查找工作,我们也还有一个工作要做就是:令二叉树中的每个节点,大于其左孩子,小于其右孩子!

这样一来,根节点就可以起到类似于字典中的M块的作用!

 

回到之前的顺序奇数表上,假设该奇数表就是从1999的,那么我们只要令根节点为499,左右孩子分别为1499的一半249499999的一半749,以此类推下去形成一颗二叉树,就可以使我们日后查找该表快的飞起!(对于要查找的数x,先与499比较,然后根据与499的大小关系判断继续与499的左孩子还是右孩子比较,以此类推的话,每次都直接“划去”了当前的一半数据,也就是二分查找的体现!)

 

当然,我们实际遇到的数据不会既排好顺序,又给定范围且不再变化……


所以实际上我们构建二叉查找树都是先MakeEmpty形成一个空二叉树,然后把拿来的数据逐一Insert来形成所给数据的二叉查找树(Insert时会决定新插入的数据插入到哪边的哪儿去,但也只是根据大小关系来判断新节点该往哪儿去!这可能导致什么呢?这可能导致树是严重不平衡的,如下图)


 

严重不平衡的二叉查找树会使查找操作的平均花费比平衡的二叉查找树慢不少,但是呢,此处给出一个信息:二叉树的深度平均值为logN,而二叉树中的任意节点的期望深度值为logD(对于上述奇数树,最慢也就是logN,肯定能查出想查的数是否在其内,而对于深度为D的节点,只需logD时间,就可以找出)

 

 

MakeEmpty然后Insert形成的二叉查找树的确可能严重不平衡,但我们之后也会有相应的对策——平衡二叉查找树,当然,那不是本次讨论的内容,所以我们先把二叉查找树的平衡问题放一边吧!

 

 

抛开二叉树的平衡问题后,接下来我们要讲一讲二叉查找树的各种可能操作,这也是本次博客要讲的最后内容~

 


首先是如何MakeEmpty一颗二叉查找树

好好学过malloc的同学肯定记得:malloc申请到的内存空间在不用的时候一定要记得free释放掉!否则它将一直占着位置不放!

所以,对于一颗二叉查找树,我们不能简单的使根节点T=NULL来完成MakeEmpty(这样做将导致树依然占着内存空间,但你却找不到它!)

所以,我们必须对所有节点进行free操作!

 

既然是要对所有节点做free,那么我们当然要遍历整棵树,于是问题就成了:该选择哪个遍历方式来遍历整棵树呢?

先序遍历是不行的,因为你先把“根”free了,就丢失了“孩子们的位置”!

中序遍历也是不行的,因为你先把左子树free了(嗯,至少左孩子释放了),但接下来free“根”会使得你丢失“右孩子的位置”

所以MakeEmpty选择的遍历方式必然是后序遍历!

 

void  MakeEmptyTree T

{

       ifT!=NULL

       {

              MakeEmptyT->lChild;

              MakeEmptyT->rChild;

              freeT;

       }

}

 

按照之前所说的生成二叉查找树的过程,接下来我们应该要讲的是Insert操作。Insert操作可能会有点“麻烦”,麻烦在哪儿呢?

1.考虑插入的树(或子树)是空树的情况

2.考虑插入的元素是已有元素的情况

第一个情况很好解决,只要在Insert例程中多一个ifT==NULL)的情况就好(同时也可以解决已经“插到叶子位置”了的情况),第二个情况则有两个办法来解决:

1.什么也不做

2.执行一些“更新”操作(比如节点的结构中有一个int元素表示“频率”,那么你这时应使该“频率”+1

不考虑麻烦情况的话,Insert的基本原理是简单的:比较插入元素与当前元素的大小,若小于当前元素,则继续向其左子树内Insert,大于则向其右子树内Insert

 

此处给的例程是对于第二种情况采取什么也不做的方法

 

Tree  InsertTree  T, Element  X

{

       ifT==NULL

       {

              T=mallocsizeofstruct Node));

              ifT==NULL)     //多一层判断,万一malloc失败呢?

                     Error();  

              else{

                     T->Element=X;

                     T->lChild=T->rChild=NULL;

              }

       }

       else{

              ifX<T->Element

                     T->lChild=InsertT->lChild,X;

              else ifX>T->Element

                     T->rChild=InsertT->rChild,X;

       }

       return  T;

}

 

Insert例程返回值设为Treestruct  Node *)的原因是:若插入元素小于当前节点,且当前节点左孩子为NULL,那么你理应使当前节点的左孩子不再是NULL而是指向新分配的节点,而这个操作就是通过T->lChild=InsertT->lChild,X)来完成的

 

 

“麻烦”的Insert操作讲完,接下来讲一个容易一点的操作——Find,这也是查找树的重头戏,毕竟是查找树嘛!

Find就不做过多介绍了,下面是例程,也很容易懂的

 

Position  FindTree T, Element X

{

       ifX<T->Element

              return FindT->lChild,X;

       ifX>T->Element

              return FindT->rChild,X;

       return T;

}

 

不对T==NULL的情况做特殊操作是因为当T==NULL时,我们就选择返回NULL

 

最后要讲的操作,是与Insert相对立的DeleteDelete操作的难度可以说比Insert要高不少

为什么这么说呢?让我们假设一下被删除节点的可能“性质”:

1.是叶子节点

2.有一个孩子

3.有两个孩子

第一种情况很好解决,将节点free掉就好了(当然,要记得使其父亲指向其的指针变为NULL,这将通过Delete函数的返回值来完成)

 

第二种情况还好,我们通过一个Temp变量保存当前节点(即要删除的节点)位置,然后令当前节点指向其孩子,再freeTemp所指向的节点OK!如下图(删4,使3成为2的右孩子)


第三种情况就不太好了,但我们还是得解决!简单的解决办法就是:令当前节点的元素变为其右子树中最小的元素,然后删除掉右子树中的最小元素!(如果右子树中最小节点也有两个孩子,则继续执行该办法即递归,相当于把任务下放!)使当前节点保存的元素为原右子树最小元素可以使得二叉查找树的性质不变!(右子树中的最小元素必然小于右孩子,而右子树的最小元素只需沿着右孩子的左路一直走到底即可!)如下图(删除2


 

Tree  DeleteTree T,Element X

{

       //树中没有该节点

       ifT==NULL

              Error();

       //要找的节点小于当前节点

       ifX<T->Elment

       {

              T->lChild=DeleteT->lChild,X;

              return T;

       }

       //要找的节点大于当前节点

       ifX>T->Element

       {

              T->rChild=DeleteT->rChild,X;

              return T;

       }

       //当前节点即所找节点,且当前节点有两个孩子的情况

       ifT->lChild!=NULL && T->rChild!=NULL

       {

              Position  temp=FindMinT->rChild;

              T->Element=temp->Element;

              T->rChild=DeleteT->rChild,temp->Element;

              return T;

       }

       //当前节点即所找节点,当前节点有一个或没有孩子的情况

       else

       {

              Position  temp=T;

              ifT->lChild!=NULL

              {

                     T=T->lChild;

              }

              else{

                     T=T->rChild;

              }

              freeTemp;

              return  T;

       }

}

 

之前我们说过,Insert操作可能会因为输入数据的顺序问题而使得树变得倾斜,然而简单的Delete带来的这种影响会更大,因为每次Delete都是使右子树变得更小(换成左子树的最大元素也只是改为每次使左子树更小罢了,并不能解决平衡问题)。但我们本次不打算解决这个问题。

还有一种删除的方法值得一提——懒惰删除。正如其名,每次删除我们都很“懒”,并没有真正的free掉节点,而是使对该结点做个“删除标记”(例如之前Insert时提到的频率变量,我们可以使其变为-1来表示被删除)。

 

 

详细的算法分析我们就不讨论了,此处只给出一个“结论”:上述FindInsertDelete操作的平均运行时间为OlogN

 

 

很明显,我们下一次将要讨论的数据结构将会是一直提到却一直不说的——平衡二叉树,在那儿我们将看到一些额外的操作来使树保持平衡,而讲解那些操作将依赖绘图(对我来说有点不情愿……)

0 0
原创粉丝点击