一棵树是一些节点的集合。这个集合可以是空集;若非空,则一棵树由称做根(root)的节点r以及0个或多个非空子树 T 1 , T 2 , . . . , T k T_1,T_2,...,T_k T1,T2,...,Tk组成.这些子树中每一颗的根都被来自根r的一条有向的边所连接。
树的基础概念:
应用:最流行的用法之一是包括UNIX、VAX/VMS和DOS在内的许多常用操作系统中的目录结构。
遍历策略:先序遍历、后序遍历、中序遍历。
先序:首先访问根节点,然后左侧子树的先序遍历,之后右侧子树的递归先序遍历。根->左子树->右子树
中序:递归对左子树进行一次遍历,访问根节点,最后递归遍历右子树。左子树->根->右子树
后序:递归对左子树和右子树进行后序遍历,然后访问根节点。左子树->右子树->根
二叉树是一棵树,其中每个节点都不能有多余两个的儿子。
二叉树一点行应用是表达式树:表达式树的树叶是操作数,比如常树或变量,而其他节点为操作符。
一般通过中序遍历得到表达式树所对应的式子。
二叉查找树的所有元素每一个节点都被指定一个关键字值。所有关键字都是互异的,一般来说一个节点的左儿子的值会小于其父亲的值,右儿子的值会大于父亲的值。
AVL树是带有平衡条件的二叉查找树。(平衡条件:任何节点的深度均不得过深)。一棵AVL树是其每个节点的左子树和右子树的高度最多差1的二叉查找树。(空树的高度定义为-1)。每一个节点都保持高度信息。
AVL树插入可能使其平衡被改变。平衡发生改变时可以通过简单的修正来改正。让我们把必须重新平衡的节点叫做a。由于任意节点最多有两个儿子,因此高度不平衡时, a点的两棵子树的高度差2,容易看出,这种不平衡可能出现在下面四种情况中:
下图显示单旋转如何调整情形1。旋转前的图在左边,而旋转后的图在右边。节点 k 2 k_2 k2不满足AVL平衡特性,因为它的左子树比右子树深2层(图中间的几条虚线标示树的各层)。该图所描述的情况只是情形1的一种可能情况,在插入之前 k 2 k_2 k2满足AVL特性,但在插入之后这种特性被破坏了。子树 X已经长出一层,这使得它比子树Z深出2层。Y不可能与新X 在同一水平上,因为那样 k 2 k_2 k2在插入以前就已经失去平衡了; Y也不可能与Z在同一层上,因为那样 k 1 k_1 k1就会是在通向根的路径上破坏AVL平衡条件的第一个节点。
为使树恢复平衡,我们把X 上移一层,并把Z下移一层。注意,此时实际上超出了AVL特性的要求。为此,我们重新安排节点以形成一棵等价的树,如图4-31的第二部分所示。抽象地形容就是:把树形象地看成是柔软灵活的,抓住节点 k 1 k_1 k1,闭上你的双眼,使劲摇动它,在重力作用下, k 1 k_1 k1就变成了新的根。二叉查找树的性质告诉我们,在原树中 k 2 > k 1 k_2>k_1 k2>k1,于是在新树中 k 2 k_2 k2变成了 k 1 k_1 k1的右儿子, X和Z仍然分别是 k 1 k_1 k1的左儿子和 k 2 k_2 k2的右儿子。子树Y包含原树中介于 k 2 和 k 1 k_2和k_1 k2和k1之间的那些节点,可以将它放在新树中 k 2 k_2 k2的左儿子的位置上,这样,所有对顺序的要求都得到满足。
这样的操作只需要一部分指针改变,结果我们得到另外一棵二叉查找树,它是一棵AVL树,因为 向上移动了一层, Y停在原来的水平上,而Z下移一层。 k 2 和 k 1 k_2和k_1 k2和k1不仅满足AVL要求,而且它们的子树都恰好处在同一高度上。不仅如此,整个树的新高度恰恰与插入前原树的高度相同,而插入操作却使得子树X长高了。因此,通向根节点的路径的高度不需要进一步的修正,因而也不需要进一步的旋转。图4-32显示在将6插入左边原始的AVL树后节点8便不再平衡。于是,我们在7和8之间做一次单旋转,结果得到右边的树。
上面描述的算法有一个问题:如图4-34所示,对于情形2和3上面的做法无效。问题在于子树Y太深(深的树在父亲内侧),单旋转没有减低它的深度。可以用4-35中的双旋转解决
我们看到,不能再让 k 3 k_3 k3作为根了,而图4-34所示的在 k 3 和 k 1 k_3和k_1 k3和k1之间的旋转又解决不了问题,惟一的选择就是把 k 2 k_2 k2用作新的根。这迫使 k 1 k_1 k1是 k 2 k_2 k2的左儿子,$ k_3$是它的右儿子,从而完全确定了这四棵树的最终位置。容易看出,最后得到的树满足AVL树的特性,与单旋转的情形一样,我们也把树的高度恢复到插入以前的水平,这就保证所有的重新平衡和高度更新是完善的。图4-36指出,对称情形3也可以通过双旋转得以修正。在这两种情形下,其效果与先在a的儿子和孙子之间旋转而后再在a和它的新儿子之间旋转的效果是相同的。
现在我们描述一种相对简单的数据结构,叫做伸展树(splay tree) ,它保证从空树开始任意连续M次对树的操作最多花费O(M log N)时间。虽然这种保证并不排除任意一次操作花费O(N)时间的可能,而且这样的界也不如每次操作最坏情形的界O(log N)那么短,但是实际效果是一样的:不存在坏的输入序列。一般说来,当M次操作的序列总的最坏情形运行时间为O(MF(N))时,我们就说它的摊还(amortized)运行时间为O(F(N))。因此,一棵伸展树每次操作的摊还代价是O(log N),经过一系列的操作之后,有的可能花费时间多一些,有的可能要少一些。
伸展树是基于这样的事实:对于二叉查找树来说,每次操作最坏情形时间O(N)并不坏,只要它相对不常发生就行。任何一次访问,即使花费O(N),仍然可能非常快。二叉查找树的问题在于,虽然一系列访问整体都有可能发生不良操作,但是很罕见。此时,累积的运行时间很重要。具有最坏情形运行时间O(N)但保证对任意M次连续操作最多花费O(M log N)运行时间的查找树数据结构确实可以满意了,因为不存在坏的操作序列。
如果任意特定操作可以有最坏时间界O(N),而我们仍然要求一个o(log N)的摊还时间界,那么很清楚,只要一个节点被访问,它就必须被移动。否则,一旦我们发现一个深层的节点,我们就有可能不断对它进行Find操作。如果这个节点不改变位置,而每次访问又花费O(N),那么M次访问将花费O(M·N)的时间。
伸展树的基本想法是,**当一个节点被访问后,它就要经过一系列AVL树的旋转被放到根上。**注意,如果一个节点很深,那么在其路径上就存在许多的节点也相对较深,通过重新构造可以使对所有这些节点的进一步访问所花费的时间变少。因此,如果节点过深,那么我们还要求重新构造应具有平衡这棵树(到某种程度)的作用。除在理论上给出好的时间界外,这种方法还可能有实际的效用,因为在许多应用中当一个节点被访问时,它就很可能不久再被访问到。研究表明,这种情况的发生比人们预料的要频繁得多。另外,伸展树还不要求保留高度或平衡信息,因此它在某种程度上节省空间并简化代码(特别是当实现例程经过审慎考虑而被写出的时候)。
实施上面描述的重新构造的一种方法是执行单旋转,从下向上进行。这意味着我们将在访问路径上的每一个节点和它们的父节点实施旋转。作为例子,考虑在下面的树中对k1进行一次访问(一次Find)之后所发生的情况。
虽然前面所看到的查找树都是二叉树,但是还有一种常用的查找树不是二叉树。这种树叫做B-树(Btree)。
阶为M的B-树是一棵具有下列结构特性的树
树的根或者是一片树叶,或者其儿子数在2和M之间。
除根外,所有非树叶节点的儿子数在[M/2]和M之间。
所有的树叶都在相同的深度上。
所有的数据都存储在树叶上。在每一个内部节点上皆含有指向该节点各儿了的指针 P 1 , P 2 . . . . . P M P_1,P_2..... P_M P1,P2.....PM和分别代表在子树 P 2 , P 3 , . . . P M P_2, P_3, ...P_M P2,P3,...PM中发现的最小关键字的值 k 1 , k 2 , k M − 1 k_1, k_2,k_{M-1} k1,k2,kM−1。当然,可能有些指针是NULL,而其对应的 k i k_i ki则是未定义的。对于每一个节点,其子树 P 1 P_1 P1中所有的关键字都小于子树 P 2 P_2 P2的关键字,如此等等。树叶包含所有实际数据,这些数据或者是关键字本身,或者是指向含有这些关键字的记录的指针。为使例子简单,我们将假设为前者。B-树有多种定义,这些定义在一些次要的细节上不同于我们定义的结构,不过,我们定义的B-树是一种流行的结构。(另一种流行的结构允许实际数据存储在树叶上,也可以存储在内部节点上,正如我们在二叉查找树中所做的那样。)我们还要求(暂时)在(非根)树叶中关键字的个数也在[M//2]和M之间
13树实际用于数据库系统,在那里树被存储在物理的磁盘上而不是主存中。一般说来,对磁盘的访问要比任何的主存操作慢几个数量级。如果我们使用M阶B树,那么磁盘访问次数是 O ( l o g M N ) O(log_MN) O(logMN)。虽然每次磁盘访问花费O(log M)来确定分支的方向,但是执行该操作的时间一般要比读存储器的区块(block)所花费的时间少得多,因此可以被认为是无足轻重的(只要M选择得合理)。即使在每个节点执行更新要花费O(M)操作时间,这些花费一般还是不大。此时M的值选择为使得一个内部节点仍然能够装入一个磁盘区块的最大值,那么它一般说来是在32