⎨ ⎧ n = n 0 + n 1 + n 2 n 0 = n 2 + 1 n 1 = 1 or 0 得:n 0 = ( n − n 1 ) / 2 n_0=(n-n_1)/2 n 0 = ( n − n 1 ) /2 接下来根据给定的n n n 的奇偶性,选取合适的n 1 n_1 n 1 即可(必须要使得n 0 n_0 n 0 为整数)
♾️完全二叉树 有n 0 n_0 n 0 个叶子结点,则总的结点的个数最多 是多少?
【推导】本题恰好与上一题相反。考虑到上一题中“推导1”的常规思路过于繁琐,且极度依赖于题目中所给的数据,所以这里不再阐述此类方法。 直接利用二叉树度与结点数的关系n 0 = n 2 + 1 n_0=n_2+1 n 0 = n 2 + 1 ,我们直接可以得出:n = 2 n 0 + n 1 − 1 n=2n_0+n_1-1 n = 2 n 0 + n 1 − 1 根据性质6 ,n 1 n_1 n 1 可以取1,也可取0,为了使n n n 最大,直接取1 1 1 即可。故n = 2 n 0 n=2n_0 n = 2 n 0 .
二叉树的存储 顺序存储 二叉树的顺序存储是指用一组地址连续的存储单元依次自上而下、自左到右存储完全二叉树 上的结点元素。即将完全二叉树上编号为i i i 的结点元素存储在一维数组下标是i i i 的位置。(此处假设数组从1开始)
根据二叉树的性质,完全二叉树和满二叉树是最适合采用顺序存储的,并且树中的结点序号可以唯一反映结点间的逻辑关系,能最大可能地节省存储空间 。
而对于一般的二叉树,为了让数组下标能反应逻辑关系,只能实际没有结点的位置通过填充空结点的方式维持逻辑结构,这使得在最坏情况下 ,高度为h h h 并且只有h h h 个结点的树(其实就是上面我们介绍的斜二叉树)利用顺序存储需要2 h − 1 2^h-1 2 h − 1 个存储单元。
求解任意两个结点i , j i,j i , j 的公共祖先结点
链式存储 由于顺序存储的空间利用率较低,所以二叉树我们一般都采用链式存储结构。 对于链式二叉树,可使用双链表 的形式对其进行映射 ,示意图如下:
于是,可以很轻易地建立相应的结构体实现构建:
1 2 3 4 typedef struct BiTNode { Elemtype data; struct BiTNode *lchild,*rchild; }BiTNode,*BiTree;
前面我们已经证明: ♾️一棵有n n n 个结点的二叉树采用二叉链存储结点,空指针个数为n + 1 n+1 n + 1
此处,为了便于后续编写程序的统一性和完备性,这里我们将采用 C++
的class
类模板 创建的方式对二叉链表形成的二叉树进行构建。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 template <class elementType >class BT { public : elementType root_data; int count; bool isNULL; public : BT (); virtual int getHight () = 0 ; virtual int getLeafNode (elementType res[],int index = 0 ) = 0 ; virtual void InOrder () = 0 ; virtual void PreOrder () = 0 ; virtual void PostOrder () = 0 ; virtual void LevelOrder () = 0 ; }; template <class elementType >class BinTree :public BT<elementType>{ public : BinTree<elementType>* left; BinTree<elementType>* right; public : BinTree (); BinTree (int n, elementType args[]); BinTree (elementType Pre[],elementType In[]); ~BinTree (); void InOrder () ;void PreOrder () ;void PostOrder () ;void LevelOrder () ; template <class flowType > friend ostream &operator << (ostream &output,BinTree<flowType>* tree); }; template <class elementType > BT<elementType>::BT (){ this ->isNULL = true ; this ->count = 0 ; } template <class elementType > BinTree<elementType>::BinTree (){ this ->left = NULL ; this ->right = NULL ; }
获取二叉树高度 根据树的高度的定义,当一棵树是空树时,高度为0; 当一棵树有且只有一个节点(其左右孩子都是空)时,该节点正好是一个叶子节点,并且该树的高度为1; 否则,一棵树的高度等于其左右子树的高度再加上该树根节点自身的1个单位的高度。
于是可以总结得出:
H e i g h t ( T r e e ) = { 0 , if T r e e is null 1 , only one Node 1 + max { H e i g h t ( L e f t ) , H e i g h t ( R i g h t ) } , else Height(Tree)= \begin{cases} 0, & \text{if }Tree\text{ is null} \\ 1, & \text{only one Node}\\ 1+\max \{Height(Left),Height(Right)\}, &\text{else} \end{cases} He i g h t ( T ree ) = ⎩ ⎨ ⎧ 0 , 1 , 1 + max { He i g h t ( L e f t ) , He i g h t ( R i g h t )} , if T ree is null only one Node else
显然,可将一棵树高的问题分解为左右两颗子树的子问题。
这种把大问题转化为同样且独立的小问题并求解的算法思想就是分治算法
结合二叉树独特的性质,可以通过递归 的方式实现:
1 2 3 4 5 6 7 8 9 10 template <class elementType >int BinTree<elementType>::getHight (){ if (this ->isNULL) return 0 ; int lhight = this ->left->getHight (); int rhight = this ->right->getHight (); int height = max (lhight,rhight); return 1 +height; }
获取二叉树叶子结点 1 2 3 4 5 6 7 8 9 10 11 12 13 template <class elementType >int BinTree<elementType>::getLeafNode (elementType res[],int index){ if (this ->isNULL) return index; index = this ->left->getLeafNode (res,index); index = this ->right->getLeafNode (res,index); if (this ->left->isNULL && this ->right->isNULL){ res[index] = this ->root_data; return index+1 ; } return index; }
二叉树的遍历 二叉树的遍历是指按照某条搜索路径 访问树中的结点,使得每个结点均被访问且只访问一次。
由于二叉树是一种非线性结构,每个结点都有可能有两颗子树,所以需寻求一种规律,使得二叉树上的结点能排列在一个线性队列中,进而便于遍历。
由二叉树的递归定义可知,遍历一棵二叉树需要决定对其根结点N N N 、左子树L L L 和右子树R R R 的访问顺序。于是我们可以组合得出以下三种遍历算法:
先序遍历(N L R NLR N L R )(PreOrder ) 中序遍历(L N R LNR L NR )(InOrder ) 后序遍历(L R N LRN L RN )(PostOrder ) 以下图中的二叉树为例,我们可以从图中的序号 1
开始,跟着虚线箭头观察一次完成的遍历过程。其中,向下的箭头表示更深一层的递归调用,向上的箭头表示从递归调用中推出返回。
一次完整的遍历,如果我们只按照图中三角形的顺序依次输出结点,那这种遍历就是先序遍历,按照圆形的顺序就是中序遍历,按照方形的顺序就是后续遍历。
对于图中的这棵树,有:
先序遍历:ABDEC
中序遍历:DBEAC
后序遍历:DEBCA
遍历的递归实现 以中序遍历为例 ,通过二叉树的递归定义,可设计中序遍历的流程如下:
若二叉树为空,则什么也不做; 否则:i. 中序遍历左子树;ii. 访问根节点;iii. 中序遍历右子树。 为了代码可读性和方便使用,此处我采用友元函数 对<<
运算符重载,使得iostream
可以直接中序输出自定义的二叉树类。具体代码如下:
1 2 3 4 5 6 7 8 9 10 11 template <class elementType >ostream &operator << (ostream &output,BinTree<elementType>* tree) { if (!tree->isNULL){ output << tree->left << " " ; output << tree->root_data << " " ; output << tree->right; } return output; }
不管采用哪种遍历算法,每个结点都访问且只访问一次,故其时间复杂度为O ( n ) O(n) O ( n ) .
在利用递归算法实现遍历时,递归工作栈的深度也恰好就是树的深度 ,所以最坏情况下,斜二叉树 使得结点有n n n 个的二叉树其高度也是n n n ,故空间复杂度是O ( n ) O(n) O ( n ) .
遍历的非递归实现 通过对访问结点流程的熟悉与把握,事实上我们可以利用栈来实现二叉树的非递归方法遍历。
我们先给出先序遍历和中序遍历的非递归实现。 而后序遍历的非递归实现相比之下较有难度,因为我们需要在 左孩子在右孩子之前访问结束才能访问根结点 的前提下使得左右孩子都被访问。
为了区别于上面的递归实现,这里我们就不再使用友元函数处理了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 template <class elementType > void BinTree<elementType>::InOrder (){ stack<BinTree<elementType> *> S; BinTree<elementType> *tree = this ; while (!tree->isNULL || !S.empty ()){ if (!tree->isNULL){ S.push (tree); tree = tree->left; }else { tree = S.top (); cout << tree->root_data << " " ; S.pop (); tree = tree->right; } } cout << endl; } template <class elementType > void BinTree<elementType>::PreOrder (){ stack<BinTree<elementType> *> S; BinTree<elementType> *tree = this ; while (!tree->isNULL || !S.empty ()){ if (!tree->isNULL){ cout << tree->root_data << " " ; S.push (tree); tree = tree->left; }else { tree = S.top (); S.pop (); tree = tree->right; } } cout << endl; }
后序非递归遍历二叉树是先访问左子树,再访问右子树,最后访问根结点。 由于有着左子树和右子树的入栈出栈过程,所以不能直接分别出什么时候是右子树访问完毕(也就不知道当前的根结点是否应该输出),所以需要增设一个辅助指针 来指示。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 template <class elementType > void BinTree<elementType>::PostOrder (){ stack<BinTree<elementType> *> S; BinTree<elementType> *tree = this ; BinTree<elementType> *visited; while ((tree && !tree->isNULL) || !S.empty ()){ if (tree && !tree->isNULL){ S.push (tree); tree = tree->left; }else { tree = S.top (); if (!tree->right->isNULL && tree->right != visited){ tree = tree->right; }else { S.pop (); cout << tree->root_data << " " ; visited = tree; tree = NULL ; } } } cout << endl; }
层次遍历及其实现 按照完全二叉树编号的顺序进行的遍历就是层次遍历 。 即对二叉树从上到下每一层从左到右依次进行遍历。
可以想到,欲实现层次遍历,应当借助队列 。将二叉树的根结点入队,然后出队,访问出队结点,若它有左子树,则将左子树的根结点入队,若它有右子树,右子树的根结点也入队。然后继续出队,如此反复,直到队列为空。
例如,下图中的树,其层次遍历的结果是 : 5 1 7 2 4 6 3
利用队列的特性,我们有下图所示的入队出队情况:
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 template <class elementType > void BinTree<elementType>::LevelOrder (){ queue<BinTree<elementType>*> Q; BinTree<elementType> *tree = this ; Q.push (tree); while (!Q.empty ()){ tree = Q.front (); Q.pop (); cout << tree->root_data << " " ; if (!tree->left->isNULL) Q.push (tree->left); if (!tree->right->isNULL) Q.push (tree->right); } cout << endl; }
注:若欲实现从下至上,从右往左 的层次遍历,只需在原有的层次遍历基础上,出队后不马上输出结点信息,而是将其入栈 。待所有结点访问完毕之后,从栈中依次出栈并输出结点信息即可。
因为层次遍历与层数直接联系,所以我们也可以通过对层次遍历的算法进行适当修改,就能实现,如:
求某层的结点个数 求二叉树的最大宽度 求二叉树高度的非递归算法 (高度取决于层数) 判断某棵树是否为完全二叉树【思想:左右子树即使为空也入队,若最终队列里的空结点之后还有非空结点,则不是完全二叉树】 遍历序列的相关性质 根据各类遍历得到的序列,我们总结出如下性质:
先序序列+中序序列可以 唯一确定一棵二叉树后序序列+中序序列可以 唯一确定一棵二叉树层序序列+中序序列可以 唯一确定一棵二叉树先序序列+后序序列不可以 唯一确定二叉树【注意】总结就是,有中序序列的必然可以唯一确定一棵二叉树。
下面是一个通过先序序列和中序序列确定的二叉树示例,其中,先序序列:A B C D E F G H I ABCDEFGHI A BC D EFG H I ;中序序列:B C A E D G H F I BCAEDGHFI BC A E D G H F I .
因为先序序列的第一个结点A A A 一定是根,所以我们把A A A 作为整棵树的树根,然后在中序序列中找到A A A ,则A A A 的左边结点的全体构成左子树,右边结点的全体构成右子树,如图(a)所示。进而,我们递归地又把左边和右边看成构建子树的子问题,如此进行下去。最终就能得到一棵确定的树。
在下一节,我们将介绍如何利用 C++ 实现给定先序序列与中序序列的建树。
另外,涉及二叉树遍历序列的性质还有:
♾️若某叶子结点 是二叉树某子树中序序列的最后一个结点 ,则它也是 该子树前序序列的最后一个结点
【推导】设二叉树中序遍历的最后一个结点为p p p ,p p p 一定是从根开始沿右指针走到底的结点。
若p p p 不是叶结点(说明它左子树非空),则前序遍历的最后一个结点在p p p 左侧; 若p p p 是叶结点,则前序与中序遍历的最后一个结点都是它。(得证) ♾️中序遍历 中,结点n n n 在m m m 之前的条件是:n n n 在m m m 左方
♾️后序遍历 中,结点n n n 在m m m 之前的条件是:n n n 是m m m 子孙
♾️先序遍历 中,结点n n n 在m m m 之前的条件是:n n n 是m m m 祖先
♾️采用 后序遍历 可以找到某个结点m m m 到其祖先n n n (甚至到根结点)的路径
【推导】在后序遍历的编程实现中不难发现,我们在回退访问子树的根结点x x x 的时候,已经将其子孙访问完毕了,那么此根结点x x x 必然是从某结点m m m 往上回退的祖先。 所以,只需在后序遍历算法访问根结点时,加入判断:
如果递归访问得到目标结点m m m 在x x x 的子树中,则输出x x x ;否则不予处理。 通过这样的方式即可输出从目标结点m m m 从下往上地输出其祖先结点的目的。该操作也就输出了从m m m 经过x x x 直到根结点的路径,也是唯一路径。
在下一节的 二叉树其他基本操作 中,我们也将利用 C++ 完成此代码的编写
♾️给定一棵满二叉树 (结点值均不同)及其先序序列 ,求它的后序序列
【推导】对于一般的二叉树,已经指出先序序列和后序序列无法唯一确定一棵二叉树。 而满二叉树则不同 。
假设先序序列为A = ( a 1 , a 2 , ⋯ , a n ) A=(a_1,a_2,\cdots,a_n) A = ( a 1 , a 2 , ⋯ , a n ) ,后序序列为B = ( b 1 , b 2 , ⋯ , b n ) B=(b_1,b_2,\cdots,b_n) B = ( b 1 , b 2 , ⋯ , b n ) .
则满二叉树的先序序列的第一个结点就是后序序列的最后一个结点,即a 1 = b n a_1=b_n a 1 = b n 。 不仅如此,满二叉树任一结点的左子树和右子树的结点数都相同,即( a 2 , a 3 , ⋯ , a m ) (a_2,a_3,\cdots,a_m) ( a 2 , a 3 , ⋯ , a m ) 与( a m + 1 , a m + 2 , ⋯ , a n ) (a_{m+1},a_{m+2},\cdots,a_n) ( a m + 1 , a m + 2 , ⋯ , a n ) 个数相等,且分别构成根结点a 1 a_1 a 1 的左子树序列和右子树序列。
同样的,后序序列B B B 也预留有两个位置:( b 1 , b 2 , ⋯ , b m − 1 ) (b_1,b_2,\cdots,b_{m-1}) ( b 1 , b 2 , ⋯ , b m − 1 ) 与( b m , b m + 1 , ⋯ , b n − 1 ) (b_m,b_{m+1},\cdots,b_{n-1}) ( b m , b m + 1 , ⋯ , b n − 1 ) 分别存放左子树和右子树。 也就是说,我们要把先序序列划分出来的两个部分中,左边的部分与后序序列划分的左边对应,要把右边的部分与右边对应。 这个对应过程其实就是求先序序列转后序序列的过程。这个问题是本问题的子问题。
所以我们可以采用递归 的方法实现。
♾️ 非空树的先序序列与后序序列完全相反 ,当且仅当二叉树仅有一个叶结点 (高度等于结点个数 )
【推导】先序序列与后序序列完全相反,即是N L R = N R L NLR=NRL N L R = NR L (后序序列反向),那么树就只有根结点或者根结点只有左子树或右子树。以此类推,其子树也有同样的性质。 进而,树中所有非叶结点的度均是1。 也就是n − n 0 = n 1 n-n_0=n_1 n − n 0 = n 1 . 再结合n 0 = n 2 + 1 , n = n 0 + n 1 + n 2 n_0=n_2+1,\;n=n_0+n_1+n_2 n 0 = n 2 + 1 , n = n 0 + n 1 + n 2 可解得n 0 = 1 n_0=1 n 0 = 1 即二叉树仅有一个叶结点。
二叉树仅有一个叶结点也等价于其形态是高度等于结点个数 ,是一条链树(如斜二叉树)。
♾️ 非空树的先序序列与后序序列完全相同 ,当且仅当二叉树仅有一个根结点
【推导】先序序列与后序序列完全相同,即是N L R = L R N NLR=LRN N L R = L RN ,当且仅当L , R L,R L , R 都为空才能成立。此时,二叉树仅有一个根结点。
♾️ 已知某结点数为n n n 的二叉树的先序序列,求其可以确定多少种树?
【推导1】假设给定的先序序列为A = ( a 1 , a 2 , ⋯ , a n ) A=(a_1,a_2,\cdots,a_n) A = ( a 1 , a 2 , ⋯ , a n ) ,且f ( n ) f(n) f ( n ) 是结点数为n n n 的该先序序列可以导出的二叉树种数。我们有f ( 0 ) = 1 , f ( 1 ) = 1 f(0)=1,f(1)=1 f ( 0 ) = 1 , f ( 1 ) = 1 . 即空树和只有一个结点的情况,树的种类只有一种。
那么首先,显然a 1 a_1 a 1 是树的根结点,而a 1 a_1 a 1 之后 的序列A / a 1 = ( a 2 , a 3 , ⋯ , a n ) A/a_1=(a_2,a_3,\cdots,a_n) A / a 1 = ( a 2 , a 3 , ⋯ , a n ) 一分为二之后,就是a 1 a_1 a 1 对应的左子树 先序序列和右子树 先序序列。
当A / a 1 = ( ( ) , ( a 2 , a 3 , a 4 , ⋯ , a n ) ) A/a_1=((),(a_2,a_3,a_4,\cdots,a_n)) A / a 1 = (( ) , ( a 2 , a 3 , a 4 , ⋯ , a n )) 时,问题划归为两个子问题,种数为f ( 0 ) × f ( n − 1 ) f(0)\times f(n-1) f ( 0 ) × f ( n − 1 ) 。左右两颗子树的种数的乘积。 当A / a 1 = ( ( a 2 ) , ( a 3 , a 4 , ⋯ , a n ) ) A/a_1=((a_2),(a_3,a_4,\cdots,a_n)) A / a 1 = (( a 2 ) , ( a 3 , a 4 , ⋯ , a n )) 时,问题划归为两个子问题,种数为f ( 1 ) × f ( n − 2 ) f(1)\times f(n-2) f ( 1 ) × f ( n − 2 ) 。左右两颗子树的种数的乘积。 当A / a 1 = ( ( a 2 , a 3 ) , ( a 4 , ⋯ , a n ) ) A/a_1=((a_2,a_3),(a_4,\cdots,a_n)) A / a 1 = (( a 2 , a 3 ) , ( a 4 , ⋯ , a n )) 时,问题划归为两个子问题,种数为f ( 2 ) × f ( n − 3 ) f(2)\times f(n-3) f ( 2 ) × f ( n − 3 ) 。左右两颗子树的种数的乘积。 如此下去,取遍所有情况。 则:
f ( n ) = ∑ k = 0 n [ f ( k ) f ( n − k − 1 ) ] f(n)=\sum_{k=0}^n[f(k)f(n-k-1)] f ( n ) = k = 0 ∑ n [ f ( k ) f ( n − k − 1 )]
这就是数论中著名的卡特兰数 的递推定义之一。有:f ( n ) = 1 n + 1 C 2 n n \begin{aligned}f(n)=\frac1{n+1}C_{2n}^{n}\end{aligned} f ( n ) = n + 1 1 C 2 n n .
【推导2】考虑二叉树先序遍历和中序遍历的递归算法。 先序遍历和中序遍历都一样,是不断往下遍历的过程,区别是先序遍历遇到根结点就先输出,而中序遍历遇到后先将其入栈,处理完左子树后再考虑根结点。 也就是说,如果我们给定一串先序序列,相当于也就给定了遍历过程中根结点的流程。而不同的树的构造会使得中序遍历的输出顺序不一样。 将这个过程转换一下,正是一个入栈次序和出栈次序的问题。 一个入栈次序可以产生多少种出栈次序,等价于一个先序序列可以产生多少种中序序列,而一对先序序列+中序序列又唯一确定一棵树。所以,树的种数就是出栈次序的种数。 于是,利用我们在学习栈的时候给出的卡特兰数的结论,本问题的答案就是1 n + 1 C 2 n n \begin{aligned}\frac1{n+1}C_{2n}^{n}\end{aligned} n + 1 1 C 2 n n
其他基本操作 二叉树的复制 提到复制 可能最容易想到的方法就是使用=
进行赋值。比如BinTree<int> *new_tree = old_tree;
然而这并不是完全的复制,不如说这仅仅是又建立一个指针指向了原来的二叉树。也就是说,用new_tree
做的任何操作都会同步影响到old_tree
,这也即是所谓浅拷贝
的一种表征形式。
清楚上述思想之后,我们注意到真正需要实现的是深拷贝
,即构造完完全全与旧树相同的新树对象实例,而不是指针。于是,编程思路也清晰了,只需要约定一个遍历规则,依次进行复制即可。
1 2 3 4 5 6 7 8 9 10 11 12 template <class elementType > BinTree<elementType> *BinTree<elementType>::CopyTree (){ BinTree<elementType>* NewTree = new BinTree <elementType>(); if (this ->isNULL) return NewTree; NewTree->root_data = this ->root_data; NewTree->left = this ->left->CopyTree (); NewTree->right = this ->right->CopyTree (); NewTree->isNULL = false ; return NewTree; }
二叉树的释放 容易想到,只要将根结点的左右子树的指针域置空,那么从用户看来这颗树便只有一个结点,如果再将该结点删除,似乎就完成了二叉树的删除。
而事实上,这是一种“掩耳盗铃 ”的“手法”,二叉树在存储空间里依然存在并一直占用空间(直到程序完全运行结束后,操作系统才主动释放)。那么,如何进行内存释放呢?这与二叉树的复制类似,我们只需要递归地从叶子节点开始逐一释放即可。
在c++
的类中,delete
操作可以为我们释放一个对象实例,这个操作发生前,会运行析构函数。因此可以编写析构函数如下实现整个二叉树的释放:
1 2 3 4 5 6 7 8 9 10 template <class elementType > BinTree<elementType>::~BinTree (){ if (this ->isNULL){ return ; } delete this ->left; delete this ->right; this ->isNULL = true ; }
层次遍历用于建树 下面我们来实现基于层次遍历思想的建树策略。其实就是完全二叉树的链式建立。
给定数组 args[n]
,实现按照完全二叉树的排序方法依次存入二叉树中。首先将根结点入队,然后出队,若该结点为空,则出队,并将第一个元素作为根结点存入;如果当前结点没有左子树,则新建左子树,并将空的子树入队,右子树同理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 template <class elementType > BinTree<elementType>::BinTree (int n, elementType args[]){ int k = 0 ; this ->isNULL = true ; this ->count = 0 ; this ->left = NULL ; this ->right = NULL ; BinTree<elementType> *tree = this ; queue<BinTree<elementType>*> Q; Q.push (tree); while (!Q.empty () && k < n){ tree = Q.front (); if (tree->isNULL){ tree->isNULL = false ; tree->root_data = args[k++]; if (!tree->left){ tree->left = new BinTree <elementType>(); Q.push (tree->left); } if (!tree->right){ tree->right = new BinTree <elementType>(); Q.push (tree->right); } } Q.pop (); } }
中序+先序序列建树 在前面的学习中,我们已经介绍了如何通过中序序列和先序序列构造一棵二叉树。在这里,我们利用编程语言实现之。
首先假定某待建二叉树的中序序列与先序序列分别存放于数组I n [ 1.. n ] , P r e [ 1.. n ] In[1..n],Pre[1..n] I n [ 1.. n ] , P re [ 1.. n ] 中。 算法流程如下:
根据先序序列确定树的根 结点; 根据根结点在中序序列 的位置,划分 出左子树 与右子树 应当包含的结点序列,(该序列同样也是左右子树的中序序列),用下标I n l o w , I n h i g h In_{low},In_{high} I n l o w , I n hi g h 表示划分出的子树结点序列在中序数组中的边界; 在先序序列中也划分 出来P r e l o w , P r e h i g h Pre_{low},Pre_{high} P r e l o w , P r e hi g h ,用于作为递归参数进行左子树和右子树的建树。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 template <class elementType > void BinTree<elementType>::PreInCreate (elementType Pre[],elementType In[], int Inl, int Inh, int Prel, int Preh){ int i; BinTree<elementType> *tree = this ; tree->root_data = Pre[Prel]; tree->isNULL = false ; for (i = Inl; In[i] != tree->root_data; i++); int leftSize = i-Inl; int rightSize = Inh-i; tree->left = new BinTree <elementType>(); if (leftSize){ tree->left->PreInCreate (Pre, In, Inl, Inl+leftSize-1 , Prel+1 , Prel+leftSize); tree->left->isNULL = false ; } tree->right = new BinTree <elementType>(); if (rightSize){ tree->right->PreInCreate (Pre, In, Inh-rightSize+1 , Inh, Preh-rightSize+1 , Preh); tree->right->isNULL = false ; } } template <class elementType > BinTree<elementType>::BinTree (int n, elementType Pre[], elementType In[]){ PreInCreate (Pre, In, 0 , n-1 , 0 , n-1 ); }
结点查找与路径输出 在 遍历序列的相关性质 一节中,我们已经分析了如何实现该算法。
在后序遍历回退访问子树的根结点x x x 的时候,我们已经将其子孙访问完毕了,那么此根结点x x x 必然是从某结点m m m 往上回退的祖先。所以,只需在后序遍历算法访问根结点时,加入判断:
如果递归访问得到目标结点m m m 在x x x 的子树中,则输出x x x ;否则不予处理。 于是,得到该算法的递归 求解思路:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 template <class elementType > BinTree<elementType> *BinTree<elementType>::FindTree (elementType x, int flag){ BinTree<elementType> *tree; if (this ->isNULL) return NULL ; if (this ->root_data == x) return this ; tree = this ->left->FindTree (x,flag); if (tree){ if (flag) cout << this ->root_data << " " ; return tree; } tree = this ->right->FindTree (x,flag); if (tree){ if (flag) cout << this ->root_data << " " ; return tree; } return NULL ; }
当然,我们还可以结合后序遍历的非递归方法 编写程序。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 template <class elementType > BinTree<elementType> *BinTree<elementType>::FindTree2 (elementType x){ stack<BinTree<elementType> *> S; BinTree<elementType> *tree = this ; BinTree<elementType> *visited; while ((tree && !tree->isNULL) || !S.empty ()){ if (tree && !tree->isNULL && tree->root_data != x){ S.push (tree); tree = tree->left; }else if (tree && !tree->isNULL && tree->root_data == x){ cout << tree->root_data << " " ; while (!S.empty ()){ cout << S.top ()->root_data << " " ; S.pop (); } return tree; } else { tree = S.top (); if (!tree->right->isNULL && tree->right != visited){ tree = tree->right; }else { S.pop (); visited = tree; tree = NULL ; } } } return NULL ; }
线索二叉树|Threaded BinaryTree 基本概念 在前面的学习中,我们不止一次的提到如下结论:对于n n n 个结点的二叉树,在二叉链存储结构中有n + 1 n+1 n + 1 个空链域。
自然,我们为了更好地利用存储空间,是很不希望这些空域白白浪费的。 所以,结合实际需求,我们考虑,利用某个结点的空链域存放 在某种遍历次序 下该结点的前驱结点和后继结点的指针。而这些指针我们就称为线索 ,而加上线索的二叉树就称为线索二叉树。
显然,加入线索之后,不仅可以使得空链域得以利用,而且还加快了我们对二叉树结点查找的速度。
规定 :若无左子树,则令其左指针指向其前驱结点,增设标志域 ltag
表明此过程。同理,若无右子树,则令其右指针指向其后继结点,增设标志域 rtag
表明此过程。标志域具体设置如下:
l T a g = { 0 , B i n T r e e . L e f t → 左孩子 1 , B i n T r e e . L e f t → 前驱结点 R T a g = { 0 , B i n T r e e . R i g h t → 右孩子 1 , B i n T r e e . R i g h t → 后继结点 \begin{aligned} lTag=\begin{cases}0,&BinTree.Left\to\text{左孩子}\\ 1,&BinTree.Left\to\text{前驱结点}\end{cases}\\\\ RTag=\begin{cases}0,&BinTree.Right\to\text{右孩子}\\ 1,&BinTree.Right\to\text{后继结点}\end{cases} \end{aligned} lT a g = { 0 , 1 , B in T ree . L e f t → 左孩子 B in T ree . L e f t → 前驱结点 RT a g = { 0 , 1 , B in T ree . R i g h t → 右孩子 B in T ree . R i g h t → 后继结点
接下来,我们需要在原来二叉树类模板(作为基类 )的基础上,建立派生类 ,即线索二叉树类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 template <class elementType >class ThreadBinTree : public BT<elementType>{ protected : ThreadBinTree<elementType>* left; ThreadBinTree<elementType>* right; int ltag, rtag; int id; public : ThreadBinTree (); ThreadBinTree (int n, elementType args[]); ThreadBinTree<elementType>* InThread (ThreadBinTree<elementType> *pre) ; void BeginInThread () ; void DeThread () ; ThreadBinTree<elementType>* Leftmost () ; void InOrder () ; ~ThreadBinTree (); }; template <class elementType > ThreadBinTree<elementType>::ThreadBinTree (){ id = 2 ; }
二叉树是逻辑结构,而线索二叉树 是加上线索之后的链表结构, 所以它是二叉树在计算机中的存储结构,也是物理结构
二叉树的线索化 我们以中序线索二叉树的构建为例,实现二叉树的线索化。
手算时,我们只需写出二叉树按照某种遍历方法进行遍历得到的序列后,根据序列的次序,在图中依次用虚线进行标记即可。
一个示例如下图所示:
线索化的编程实现 我们可以定义指针 pre
始终指向当前读取的子树树根的前驱,然后再递归地依次遍历二叉树,遍历过程中根据前驱不断修改指针。
每次递归结束后应当把 pre
调整,以便下一次递归时不出错。
下面我们给出以中序线索化为例的程序,先序与后序的操作是类似的,只需将递归访问和指针调整的顺序修改即可。
在整个类中,为了区分线索树类型,我们还增设了一个标志位 id
,id
取不同值时表示当前这颗二叉树是哪种类型的线索二叉树。(其含义我们已在上面的线索二叉树类模板声明中给出)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 template <class elementType > ThreadBinTree<elementType>* ThreadBinTree<elementType>::InThread (ThreadBinTree<elementType> *pre){ ThreadBinTree<elementType> *tree = this ; if (tree->isNULL) return pre; pre = tree->left->InThread (pre); if (tree->left->isNULL){ tree->left = pre; tree->ltag = 1 ; } if (!pre->isNULL && pre->right->isNULL){ pre->right = tree; pre->rtag = 1 ; } pre = tree; pre = tree->right->InThread (pre); tree->id = 1 ; return pre; } template <class elementType > void ThreadBinTree<elementType>::BeginInThread (){ ThreadBinTree<elementType> *pre = new ThreadBinTree <elementType>(); if (!this ->isNULL){ pre = this ->InThread (pre); pre->right = new ThreadBinTree <elementType>(); pre->right->isNULL = true ; pre->rtag = 1 ; } pre->id = 1 ; }
去线索化的编程实现 我们还可以实现对线索二叉树的去线索化 。 思路就是从根结点开始遍历二叉树,如果遇到 ltag
或 rtag
为1,说明对其进行了线索化,我们将其对应的左指针或右指针置空,然后把标志更改回 0 即可。
待更
线索树的快速遍历 同样以中序线索二叉树为例,当我们对某棵二叉树进行了中序线索化之后,可以对其进行更加快速的中序遍历。
因为中序序列的第一个元素是整个二叉树最左端最下方的结点 ,因此我们先将其找到。于是通过其 rtag
标志位,依次往下访问其后继即可。 我们下面通过对线索二叉树类继承 的基类纯虚函数 InOrder()
成员函数进行实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 template <class elementType > ThreadBinTree<elementType> *ThreadBinTree<elementType>::Leftmost (){ ThreadBinTree<elementType> *tree = this ; while ((!tree->left->isNULL) && (tree->ltag == 0 )){ tree = tree->left; } return tree; } template <class elementType > void ThreadBinTree<elementType>::InOrder (){ if (this ->id != 1 ) return ; ThreadBinTree<elementType> *tree = this ->Leftmost (); while (!tree->isNULL){ cout << tree->root_data << " " ; if (!tree->right->isNULL && tree->rtag == 0 ){ tree = tree->right->Leftmost (); }else if (tree->rtag == 1 ){ tree = tree->right; } } cout << endl; }
线索树的性质 通过前面我们对二叉树线索化的流程,我们可以总结出二叉线索树的如下性质:
中序 线索树的某结点p p p 有右孩子,则其后继结点是它右子树的最左下结点(不一定是叶结点);中序 线索树的某结点p p p 有左孩子,则其前驱结点是它左子树的最右下结点(不一定是叶结点);并不是每个结点都能通过线索直接找到其前驱和后继! 关于第三点,我们还能展开往下讲解。
在先序 线索树中,查找某结点的后继较为简单 ,而查找其前驱则需要知道其父结点。 在后序 线索树中,查找某结点的前驱较为简单 ,而查找其后继则需要知道其父结点。 后序线索树的遍历仍然需要栈的支持【重要】 树与森林|Forest 树的存储结构 双亲表示法 双亲表示法采用一组连续的空间存储每个结点,同时在每个结点中增设一个伪指针,用于指向某个结点的双亲结点处于数组的哪个位置。规定用 0
表示根结点下标,其伪指针域为 -1
.
双亲表示法的一个示例如下:
该存储结构利用了每个结点(除根结点外)只有唯一双亲的特点,可以很快得到每个结点的双亲,但是求孩子结点却需要遍历整个结构。
注:二叉树属于树,所以树的存储结构都能用来存储二叉树;但是一棵树却未必都能用二叉树的存储结构进行存储
孩子表示法 孩子表示法将每个结点的孩子结点都用单链表 链接以形成一个线性结构。有n n n 个结点就有n n n 个孩子链表,其中叶子结点的孩子链表为空。
该存储结构可以方便寻找子女,而寻找双亲则需要遍历。
孩子兄弟表示法 孩子兄弟表示法又称二叉树表示法 ,即以二叉链表作为树的存储结构。 每个结点包含三部分内容:结点值、指向结点第一个孩子的指针、指向下一个兄弟结点的指针。 如上图(b)所示。
孩子兄弟表示法遵循着 “左孩子、右兄弟”的规则,可以将一棵树转化为二叉树进行存储。 沿着右子树(即指向兄弟结点的指针)找下去即可找到所有兄弟结点。 由于根结点没有兄弟,所以其右子树为空。这表明,树转换为二叉树后,此二叉树没有右子树 。
孩子兄弟表示法比较灵活,易于查找孩子结点,但是查找双亲结点就比较繁琐。
在 二叉树 的专栏学习中,我们给出了这样的性质: 若一棵树的非叶结点数为n n n ,则将其转换为二叉树之后的右指针为空的结点数是n + 1 n+1 n + 1
森林 就是多棵树构成的集合。 如果我们把森林中每棵树的根结点都视为兄弟结点,则森林也可以用类似于树的存储方式进行存储。
将树转换成二叉树的方法我们前面已经指出,但对于其画法还有一些“鸡肋”的捷径用于手画。 在原本的树中作如下处理:
在兄弟结点之间加一条线 ; 对每个结点只保留它与它第一个孩子 (最左边的孩子)的连线,其余线都去掉; 以树根为轴心,将上述处理后得到的图像顺时针旋转 45度 。 对于森林,则先将每棵树转化为二叉树,然后再把根结点视为兄弟,依次作为第一颗树的右子树加入二叉树行列,即可得到森林的二叉树。
二叉树转换为树或森林是唯一的
树和森林的遍历 树的遍历 树的遍历主要分为 先根遍历 和 后根遍历 。此外还有层次遍历,这与二叉树完全一致。
先根遍历 就是在树非空时,先访问根结点,然后再依次遍历其各子树,对其子树的遍历也按照此规则。对一棵树的先根遍历得到的结果相当于其转换得到的二叉树的先序序列 。后根遍历 就是在树非空时,先访问根结点的各子树,然后再访问其根结点,对其子树的遍历也按照此规则。对一棵树的后根遍历得到的结果相当于其转换得到的二叉树的中序序列 。森林的遍历 森林的遍历有 先序遍历 和 中序遍历 。
先序遍历就是按每棵树从左到右依次先根遍历访问树。对应二叉树的先序序列。 中序遍历就是按每棵树从左到右依次后根遍历访问树。对应二叉树的中序序列。 部分教材也将森林的中序遍历称为后根遍历。事实上都是指同一种遍历。
在上面我们给出的对应图示的森林里, 先序序列是:A B C D E F G H I ABCDEFGHI A BC D EFG H I ;中序序列是:B C D A F E H I G BCDAFEHIG BC D A FE H I G
三者的对照关系如下表所示:
树的遍历 森林的遍历 二叉树的遍历 先根遍历 先序遍历 先序遍历 后根遍历 中序遍历 中序遍历
森林的性质 高度为h h h 的满二叉树 对应的森林所含的树的个数是h h h (完全二叉树则不一定成立); 一个森林中有n n n 个非终端结点,则其对应的二叉树中有n + 1 n+1 n + 1 个右指针为空的结点(已在树的性质一节中给出证明); 森林中叶结点的个数等于其对应的二叉树中左指针为空的结点数; 若一个森林含有l l l 条边,n n n 个结点,则其包含的树的棵数为x = n − l x=n-l x = n − l . 证明如下: 假设森林中存在x x x 棵树,则每一棵树的边数和结点数都有l i + 1 = n i l_i+1=n_i l i + 1 = n i ,其中i = 1 , 2 , . . . , x i=1,2,...,x i = 1 , 2 , ... , x 则:
∑ i = 1 x ( l i + 1 ) = l + x = ∑ i = 1 x n i = n \sum_{i=1}^x(l_i+1)=l+x=\sum_{i=1}^xn_i=n i = 1 ∑ x ( l i + 1 ) = l + x = i = 1 ∑ x n i = n
即x = n − l x=n-l x = n − l ,证毕。
其他树|Other Tree 我们尽量简述了数据结构中最常见的树,以及相关的算法与代码。事实上,受限于篇幅,还有很多树 并没有介绍,比如:
平衡二叉树 AVL 树 伸展树 替罪羊树 B树 哈夫曼树 最小生成树 … 可移步到下面的文章中继续深入。