来源于2020 王道考研 数据结构,博客内容是对自己笔记的书面整理,根据自身学习需要,我可能会增加必要内容。
树是n(n≥0)个结点的有限集合,是一种逻辑结构。n=0时的树称为空树。下图是一棵非空树:
对任意非空树应满足:
1)有且仅有一个特定的称为根的结点,上图表示的树的根结点为A;
2)当n>1时,其余结点可分为m(m>0)个互不相交的有限集合,其中每一个集合本身又是一棵树,称为根结点的子树;
3)n个结点的树中只有n-1条边,上图表示的树的结点数为6,边数为5。
1)祖先、子孙、双亲、孩子、兄弟:考虑结点K。根A到结点K的唯一路径上的任意结点,称为结点K的祖先。如结点B是结点K的祖先,而结点K是结点B的子孙。路径上最接近结点K的结点E称为K的双亲,而K为结点E的孩子。根A是树中唯一没有双亲的结点。有相同双亲的结点称为兄弟,如结点K和结点L有相同的双亲E,即K和L为兄弟;
2)度:树中一个结点的孩子个数称为该结点的度,树中结点的最大度数称为树的度。如结点B的度为2,结点D的度为3,树的度为3;
3)分支结点、叶子结点:度大于0的结点称为分支结点(又称非终端结点);度为0(没有子女结点)的结点称为叶子结点(又称终端结点)。在分支结点中,每个结点的分支数就是该结点的度;
4)结点的深度、高度和层次:结点的层次从树根开始定义,根结点为第1层,它的子结点为第2层,以此类推。双亲在同一层的结点互为堂兄弟,上图中的结点G与E、F、H、I、J互为堂兄弟。结点的深度是从根结点开始自顶向下逐层累加的。结点的高度是从叶结点开始自底向上逐层累加的。树的高度(或深度)是树中结点的最大层数。上图中树的高度为4;
5)有序树、无序树:树中结点的各子树从左到右是有次序的,不能互换,称该树为有序树,否则称为无序树。假设上图为有序树,若将任一子结点位置互换,则变成一棵不同的树;
6)路径、路径长度:树中两个结点之间的路径是由这两个结点之间所经过的结点序列构成的,而路径长度是路径上所经过的边的个数。注意:由于树中的分支是有向的,即从双亲指向孩子,所以树中的路径是从上向下的,同一双亲的两个孩子之间不存在路径;
7)森林:森林是m(m≥0)棵互不相交的树的集合。森林的概念与树的概念十分相近,因为只要把树的根结点删去就成了森林。反之,只要给m棵独立的树加上一个结点,并把这m棵树作为该结点的子树,则森林就变成了树。
树具有如下最基本的性质:
1)树中的结点数等于所有结点的度数加1;
2)度为m的树中第i层上至多有mi-1个结点,其中i≥1;
3)高度为h的m叉树至多有(mh-1)/(m-1)个结点;
4)具有n个结点的m叉树的最小高度为⌈logm(n·(m-1)+1)⌉。
二叉树是n(n≥0)个结点的有限集合,是一种逻辑结构。对任意二叉树应满足:
1)n=0时,二叉树为空;
2)n>0时,由根结点和两个互不相交的被称为根的左子树和右子树组成。左子树和右子树也分别是一棵二叉树。
二叉树的5种基本形态如下:
三个结点的二叉树只有以下5种:
二叉树VS度为2有序树:
1)二叉树可以为空,而度为2的有序树至少有三个结点;
2)二叉树的孩子结点始终有左右之分,而度为2有序树的孩子结点次序是相对的,若只有一个孩子结点则不区分左右。
1、满二叉树:一棵高度为h,且含有2h-1个结点的二叉树为满二叉树。对于编号为i的结点,若存在,其双亲的编号为⌊i/2⌋,左孩子为2i,右孩子为2i+1。如下为一棵满二叉树:
2、完全二叉树:设一个高度为h、有n个结点的二叉树,当且仅当其每个结点都与高度为h的满二叉树中编号1~n的结点——对应时,称为完全二叉树。性质:1)若i≤⌊n/2⌋,则结点i为分支结点,否则为叶子结点;2)叶子结点只可能在层次最大的两层上出现。对于最大层次的叶子结点,都依次排在最左边的位置上;3)度为1的结点若存在,则只可能有一个,且是编号最大的分支结点,并且它的孩子结点一定是左结点。如下为一棵完全二叉树:
3、二叉排序树:二叉排序树是一棵二叉树,若树非空则具有如下性质:对任意结点,若存在左子树或右子树,则其左子树上所有结点的关键字均小于该结点,右子树上所有结点的关键字均大于该结点。如下为一棵二叉排序树:
4、平衡二叉树:平衡二叉树上任意结点的左子树和右子树的深度只差不超过1。如下图左面为一棵平衡二叉树,右面不是:
二叉树的性质如下:
1)非空二叉树上的叶子结点数等于度为2的结点数加1,即n0=n2+1。证明:设度为0、1和2的结点个数分别为n0、n1和n2,结点总数n=n0+n1+n2。再看二叉树中的分支数,除根结点外,其余结点都有一个分支,设B为分支总数,则n=B+1。由于这些分支是由度为1或2的结点发出的,所以又有B=n1+2n2。于是得n0+n1+n2=n1+2n2+1,则n0=n2+1。拓展到任意一棵树,若结点数量为n,则边的数量为n-1。
2)非空二叉树上第k层上至多有2k-1个结点(k≥1)。
3)高度为h的二叉树至多有2k-1个结点(h≥1)。
4)对完全二叉树按从上到下、从左到右的顺序依次编号1、2…n,则有以下关系:① 当i>1时,结点i的双亲的编号为⌊i/2⌋,即当i为偶数时,其双亲的编号为i/2,它是双亲的左孩子:当i为奇数时,其双亲的编号为(i-1)/2,它是双亲的右孩子;② 当2i≤n时,结点i的左孩子编号为2i,否则无左孩子;③ 当2i+1≤n时,结点i的右孩子编号为2i+1,否则无右孩子;④ 结点i所在层次(深度)为⌊log2i⌋+1。
5)具有n个(n>0)结点的完全二叉树的高度为⌈log2(n+1)⌉或⌊log2n⌋+1。
二叉树的顺序存储:使用一组连续的存储单元依次自上而下、自左至右存储完全二叉树上的结点元素。如下:
在完全二叉树中依次编号,对于结点i,若存在左孩子,则左孩子编号为2i;若存在右孩子,则右孩子编号为2i+1。
对于普通的二叉树,添加不存在的空结点,在数组中用0表示,如下:
顺序存储的缺点:最坏情况下会非常浪费存储空间,比较适合完全二叉树,如下图中的树用顺序存储的话会浪费很多内存:
二叉树的链式存储:用链表来存放一棵二叉树,二叉树中每个结点用链表的一个链结点来存储。每个结点存储:数据、左孩子指针、右孩子指针,如下:
定义二叉树的结点的C++代码如下:
typedef struct BiTNode{
ElemType data;
struct BiTNode *lchild,*rchild;
}BiTNode,*BiTree;
下图左面为一棵二叉树的逻辑结构,右面是对应的链式存储示意图:
上图中绿色部分为NULL,即空链域。对于含有n个结点的二叉链表中,有n+1个空链域,n+1=2n-(n-1)。
二叉树的遍历是指按某条搜索路径访问树中的每个结点,树的每个结点均被访问一次,而且只访问一次。
二叉树的遍历方式有四种,即先序遍历、中序遍历、后序遍历、层次遍历。
先序遍历的递归步骤:若二叉树非空:
1)访问根结点;
2)先序遍历左子树;
3)先序遍历右子树;
先序遍历序列为124536。
二叉树先序遍历的C++递归代码如下:
void PreOrder(BiTree T){
if(T!=NULL){
visit(T); //这里可以是打印结点T的值或者对结点T的其他操作
PreOrder(T->lchild);
PreOrder(T->rchild);
}
}
时间复杂度为O(n),其中n为二叉树的结点个数。
先序遍历的非递归算法需要借助栈,二叉树先序遍历的C++非递归代码如下:
void PreOrder2(BiTree T){
InitStack(S); //初始化一个栈
BiTNode *p=T; //工作指针
while(p||!isEmpty(S)){ //循环直到栈空或者工作指针指向NULL为止
if(p){
visit(p); //这里可以是打印结点p的值或者对结点p的其他操作
Push(S,p); //先访问后入栈
p=p->lchild; //将左侧结点都进栈
}
else{
Pop(S,p); //栈顶元素出栈
p=p->rchild; //扫描右孩子结点的所有左侧结点并一一进栈
}
}
}
中序遍历的递归步骤:若二叉树非空:
1)中序遍历左子树;
2)访问根结点;
3)中序遍历右子树;
中序遍历序列为425163。
二叉树中序遍历的C++递归代码如下:
void InOrder(BiTree T){
if(T!=NULL){
InOrder(T->lchild);
visit(T); //这里可以是打印结点T的值或者对结点T的其他操作
InOrder(T->rchild);
}
}
时间复杂度为O(n),其中n为二叉树的结点个数。
中序遍历的非递归算法需要借助栈,算法思想:
1)初始时依次扫描根结点的所有左侧结点并将它们一一进栈;
2)出栈一个结点,访问它;
3)扫描该结点的右孩子结点并将其进栈;
4)依次扫描右孩子结点的所有左侧结点并一一进栈,到第2步;
5)反复该过程直到栈空为止。
二叉树中序遍历的C++非递归代码如下:
void InOrder2(BiTree T){
InitStack(S); //初始化一个栈
BiTNode *p=T; //工作指针
while(p||!isEmpty(S)){ //循环直到栈空或者工作指针指向NULL为止
if(p){
Push(S,p);
p=p->lchild; //将左侧结点都进栈
}
else{
Pop(S,p); //栈顶元素出栈
visit(p); //这里可以是打印结点p的值或者对结点p的其他操作
p=p->rchild; //扫描右孩子结点的所有左侧结点并一一进栈
}
}
}
后序遍历的递归步骤:若二叉树非空:
1)后序遍历左子树;
2)后序遍历右子树;
3)访问根结点;
后序遍历序列为452631。
二叉树后序遍历的C++递归代码如下:
void PostOrder(BiTree T){
if(T!=NULL){
PostOrder(T->lchild);
PostOrder(T->rchild);
visit(T); //这里可以是打印结点T的值或者对结点T的其他操作
}
}
时间复杂度为O(n),其中n为二叉树的结点个数。
后序遍历的非递归算法需要借助栈,二叉树后序遍历的C++非递归代码如下:
void PostOrder2(BiTree T){
InitStack(S);
BiTNode *p=T;
BiTNode *r=NULL;
while(p||!isEmpty(S)){
if(p){
Push(S,p);
p=p->lchild;
}
else{
GetTop(S,p);
if(p->rchild&&p->rchild!=r){
p=p->rchild;
Push(S,p);
p=p->lchild;
}
else{
Pop(S,p);
visit(p);
r=p;
p=NULL;
}
}
}
}
层次遍历是从上到下、从左到右依次访问各个结点,如下:
对上图进行层次遍历得到的序列为ABCDEFGHI。
层次遍历需要借助队列。算法思想:
1)开始时将根入队并访问根结点,然后出队;
2)若有左子树,则将左子树的根入队;
3)若有右子树,则将右子树的根入队;
4)然后出队,访问该结点,到第2步;
5)反复该过程直到队列空为止。
二叉树层次遍历的C++代码如下:
void LevelOrder(BiTree T){
InitQueue(Q); //初始化一个队列
BiTNode *p; //工作指针
Enqueue(Q,T); //将根结点入队
while(!isEmpty(Q)){ //循环直到队列空为止
DeQueue(Q,p); //队头结点出队
visit(p); //这里可以是打印结点p的值或者对结点p的其他操作
if(p->lchild!=NULL) //若有左子树,则将左子树的根入队
Enqueue(Q,p->lchild);
if(p->rchild!=NULL) //若有右子树,则将右子树的根入队
Enqueue(Q,p->rchild);
}
}
1、先序遍历序列和中序遍历序列可以唯一确定一棵二叉树:在先序遍历序列中,第一个结点一定是二叉树的根结点;而在中序遍历中,根结点必然将中序序列分割成两个子序列,前一个子序列是根结点的左子树的中序序列,后一个子序列是根结点的右子树的中序序列。根据这两个子序列,在先序序列中找到对应的左子序列和右子序列。在先序序列中,左子序列的第一个结点是左子树的根结点,右子序列的第一个结点是右子树的根结点。如此递归地进行下去,便能唯一地确定这棵二叉树。例如,由先序遍历序列为124536,与中序遍历序列为425163确定的二叉树为:
2、后序遍历序列和先序遍历序列不可以唯一确定一棵二叉树,也就是说这样得到的二叉树可能不止一棵。
3、后序遍历序列和中序遍历序列可以唯一确定一棵二叉树:在后序遍历序列中,最后一个结点一定是二叉树的根结点;而在中序遍历中,根结点必然将中序序列分割成两个子序列,前一个子序列是根结点的左子树的中序序列,后一个子序列是根结点的右子树的中序序列。根据这两个子序列,在后序序列中找到对应的左子序列和右子序列。在后序序列中,左子序列的最后一个结点是左子树的根结点,右子序列的最后一个结点是右子树的根结点。如此递归地进行下去,便能唯一地确定这棵二叉树。例如,由后序遍历序列为452631,与中序遍历序列为425163确定的二叉树为:
4、层次遍历序列和中序遍历序列可以唯一确定一棵二叉树:在层次遍历序列中,第一个结点一定是二叉树的根结点;而在中序遍历中,根结点必然将中序序列分割成两个子序列,前一个子序列是根结点的左子树的中序序列,后一个子序列是根结点的右子树的中序序列。根据这两个子序列,在层次序列中找到对应的左子序列和右子序列。在层次序列中,左子序列的第一个结点是左子树的根结点,右子序列的第一个结点是右子树的根结点。如此递归地进行下去,便能唯一地确定这棵二叉树。例如,由层次遍历序列为123456,与中序遍历序列为425163确定的二叉树为:
将普通的链式存储的二叉树转变为线索二叉树的方法——对于每个结点作下面的改变:
(1)若该结点无左子树,则将左指针指向其前驱结点;
(2)若该结点无右子树,则将右指针指向其后继结点。
这样,便把普通的链式存储的二叉树转变为了线索二叉树。根据遍历序列的不同,分为三种线索二叉树,举个例子:下图为一棵普通的二叉树:
先序遍历序列为124536,因此先序线索二叉树为:
后序遍历序列为452631,因此后序线索二叉树为:
中序遍历序列为425163,因此中序线索二叉树为:
线索二叉树结点结构如下:
其中ltag与rtag是标志域:
这种结点结构构成的二叉链表作为二叉树的存储结构,称为线索链表,线索链表结点的C++代码如下:
typedef struct ThreadNode{
ElemType data;
struct ThreadNode *lchild,*rchild;
int ltag,rtag;
}ThreadNode,*ThreadTree;
下面只讨论中序线索二叉树。对于中序线索二叉树的每个结点:
(1)关于前驱结点:
(2)关于后驱结点:
可对照下图去验证上述结论:
中序线索二叉树线索化的C++代码如下:
void InThread(ThreadTree &p,ThreadTree &pre){
if(p!=NULL){
InThread(p->lchild,pre);
if(p->lchild==NULL){
p->lchild=pre;
p->ltag=1;
}
if(pre!=NULL&&pre->rchild==NULL){
pre->rchild=p;
pre->rtag=1;
}
pre=p;
InThread(p->rchild,pre);
}
}
void CreateInThread(ThreadTree T){
ThreadTree pre=NULL;
if(T!=NULL){
InThread(T,pre);
pre->rchild=NULL;
pre->rtag=1;
}
}
为了方便,可以在二叉树的线索链表上添加一个头结点,令其lchild域的指针指向二叉树的根结点,其rchild域的指针指向中序遍历时访问的最后一个结点;令二叉树中序序列中的第一个结点的lchild域指针和最后一个结点的rchild域指针均指向头结点。这好比为二叉树建立了一个双向线索链表,方便从前往后或从后往前对线索二叉树进行遍历。下图是一个示例:
遍历中序线索二叉树的C++代码如下:
ThreadNode *Firstnode(ThreadNode *p){
while(p->ltag==0)
p=p->lchild;
return p;
}
ThreadNode *Nextnode(ThreadNode *p){
if(p->rtag==0)
return Firstnode(p->rchild);
else
return p->rchild;
}
void Inorder(ThreadNode *T){
for(ThreadNode *p=Firstnode(T);p!=NULL;p=Nextnode(p))
visit(p);
}
双亲表示法采用一组连续的存储空间来存储每个结点,同时在每个结点中增设一个伪指针,指示双亲结点在数组中的位置。根结点的下标为0,其伪指针域为-1。
C++代码如下:
#define MAX_TREE_SIZE 100 //树可以容纳的最多结点数
typedef struct{ //定义结点
ElemType data; //数据域
int parent; //伪指针,指示双亲的位置
}PTNode;
typedef struct{ //定义树
PTNode nodes[MAX_TREE_SIZE]; //使用静态数组
int n; //结点数
}PTree;
举个栗子,下面是一棵普通的树:
下图是使用双亲表示法表示这棵树的逻辑示意图:
孩子表示法将每个结点的孩子结点都用单链表连接起来形成一个线性结构,n个结点具有n个孩子链表。
C++代码如下:
#define MAX_TREE_SIZE 100
typedef struct{ //定义孩子结点
int child; //链表中每个结点存储的不是数据本身,而是数据在数组中存储的位置下标
struct CNode *next; //下一个孩子结点
}CNode;
typedef struct{ //定义树结点
ElemType data; //数据域
struct CNode *child; //孩子链表的头指针
}PNode;
typedef struct{ //定义树
PNode nodes[MAX_TREE_SIZE]; //使用静态数组
int n; //结点总数
}CTree;
举个栗子,下面是一棵普通的树:
下图是使用孩子表示法表示这棵树的逻辑示意图:
孩子兄弟表示法以二叉链表作为树的存储结构,又称二叉树表示法,左孩子右兄弟。孩子兄弟表示法的结点结构如下:
C++代码如下:
typedef struct CSNode{
ElemType data;
struct CSNode *firstchild,*nextsibling;
}CSNode,*CSTree;
举个栗子,下面是一棵普通的树:
下图是使用孩子兄弟表示法表示这棵树的逻辑示意图:
表示法 | 优点 | 缺点 |
---|---|---|
双亲表示法 | 寻找结点的双亲结点效率高 | 寻找结点的孩子结点效率低 |
孩子表示法 | 寻找结点的孩子结点效率高 | 寻找结点的双亲结点效率低 |
孩子兄弟表示法 | 寻找结点的孩子结点效率高,并且方便实现树转换为二叉树 | 寻找结点的双亲结点效率低 |
由于二叉树和树都可以用二叉链表作为存储结构,因此以二叉链表作为媒介可以导出树与二叉树的一个对应关系,即给定一棵树,可以找到唯一的一棵二叉树与之对应。从物理结构上看,它们的二叉链表是相同的,只是解释不同而已。
树转换为二叉树的规则:对于树中的每个结点,有两个指针域,左指针指向该结点的第一个孩子,右指针指向该结点的下一个兄弟。这个规则又称“左孩子右兄弟”。
由于任意一棵树的根结点都没有兄弟,所以任意一棵树对应的二叉树都没有右子树。
树转换成二叉树的画法:
① 在兄弟结点之间加一连线;
② 对每个结点,只保留它与第一个孩子的连线,而与其他孩子的连线全部抹掉;
③ 以树根为轴心,顺时针旋转45°。
举个栗子,下面是一棵普通的树:
按照前面提到的画法,将上面的树转换为二叉树,分为三步:
① 在兄弟结点之间加一连线,如下:
② 对每个结点,只保留它与第一个孩子的连线,而与其他孩子的连线全部抹掉,如下:
③ 以树根为轴心,顺时针旋转45°,如下:
二叉树转换为树是树转换为二叉树的逆过程:
(1)加线。对于所有结点,若该结点的左孩子结点存在,则将这个左孩子的右孩子结点、右孩子的右孩子结点、右孩子的右孩子的右孩子结点…都作为该结点的孩子。将该结点与这些右孩子结点用线连接起来;
(2)去线。删除原二叉树中所有结点与其右孩子结点的连线;
(3)层次调整。
举个栗子,下面是一棵普通的二叉树:
按照前面提到的画法,将上面的二叉树转换为树,分为三步:
将森林转换为二叉树的规则与树类似。先将森林中的每棵树转换为二叉树,由于任何一棵和树对应的二叉树的右子树必空,若把森林中第二棵树根视为第一棵树根的右兄弟,即将第二棵树对应的二叉树当作第一棵二叉树根的右子树,将第三棵树对应的二叉树当作第二棵二叉树根的右子树……以此类推,就可以将森林转换为二叉树。
森林转换成二叉树的画法:
① 将森林中的每棵树转换成相应的二叉树;
② 将每棵二叉树的根依次作为上一棵二叉树的右子树。
举个栗子,下面是由三棵树组成的森林:
按照前面提到的画法,将上面的森林转换为二叉树,分为两步:
① 将森林中的每棵树转换成相应的二叉树,如下:
② 将每棵二叉树的根依次作为上一棵二叉树的右子树,如下:
二叉树转换为森林的规则:
(1)若二叉树非空,则二叉树的根及其左子树为第一棵树的二叉树形式,故将根的右链断开。二叉树根的右子树又可视为一个由除第一棵树外的森林转换后的二叉树,应用同样的方法,直到最后只剩一棵没有右子树的二叉树为止;
(2)最后再将每棵二叉树依次转换成树,就得到了原森林。
举个栗子,下面是一棵普通的二叉树:
按照前面提到的画法,将上面的二叉树转换为森林,分为两步:
① 断链,如下:
② 将每棵二叉树依次转换成树,如下:
二叉树转换为树或森林是唯一的。
只要把树的根结点删去就成了森林。反之,只要给m棵独立的树加上一个结点,并把这m棵树作为该结点的子树,则森林就变成了树。
树的遍历是指按照某种方式访问树中的每个结点,且每个结点仅访问一次。
若树非空,则先访问根结点,再按从左到右的顺序遍历根结点的每棵子树。举个栗子,对于下面的树:
该树的先根遍历序列为:RADEBCFGHK。该树对应的二叉树为:
将该树转换为二叉树,二叉树的先序遍历序列也为:RADEBCFGHK。
这是一般性结论:树的先根遍历序列与该树对应二叉树的先序遍历序列相同。
若树非空,则先按从左到右的顺序遍历根结点的每棵子树,再访问根结点。举个栗子,对于下面的树:
该树的后根遍历序列为:DEABGHKFCR。该树对应的二叉树为:
将该树转换为二叉树,二叉树的中序遍历序列也为:DEABGHKFCR。
这是一般性结论:树的后根遍历序列与该树对应二叉树的中序遍历序列相同。
层次遍历是从上到下、从左到右依次访问树中的各个结点。举个栗子,对于下面的树:
对上图进行层次遍历得到的序列为RABCDEFGHK。
若森林非空,则:
(1)访问森林中第一棵树的根结点;
(2)先序遍历第一棵树的子树森林;
(3)先序遍历除去第一棵树之后剩余的树构成的子树森林。
举个栗子,对于下面的森林:
该森林的先序遍历序列为:ADEBCFGHK。该森林对应的二叉树为:
将该森林转换为二叉树,二叉树的先序遍历序列也为:ADEBCFGHK。
这是一般性结论:森林的先序遍历序列与该森林对应二叉树的先序遍历序列相同。
若森林非空,则:
(1)中序遍历第一棵树的根结点的子树森林;
(2)访问第一棵树的根结点;
“(3)中序遍历除去第一棵树之后剩余的树构成的子树森林。
举个栗子,对于下面的森林:
该森林的中序遍历序列为:DEABGHKFC。该森林对应的二叉树为:
将该森林转换为二叉树,二叉树的中序遍历序列也为:DEABGHKFC。
这是一般性结论:森林的中序遍历序列与该森林对应二叉树的中序遍历序列相同。
树 | 森林 | 二叉树 |
---|---|---|
先根遍历 | 先序遍历 | 先序遍历 |
后根遍历 | 中序遍历 | 中序遍历 |
并查集是一种简单的集合表示,由多个子集合构成。并查集支持以下3种操作:
(1)Union(S,Root1,Root2):把集合S中的子集合Root2并入子集合Root1。要求Root1和Root2互不相交,否则不执行合并。为了得到两个子集合的并,只需将其中一个子集合根结点的双亲指针指向另一个集合的根结点;
(2)Find(S,x):查找集合S中单元素x所在的子集合,并返回该子集合的名字;
(3)Initial(S):将集合S中的每个元素都初始化为只有一个单元素的子集合。
通常用树(森林)的双亲表示作为并查集的存储结构,每个子集合以一棵树表示。所有表示子集合的树,构成表示全集合的森林,存放在双亲表示数组内。用数组元素的下标代表对应结点的元素名;用数组元素的值代表该结点的双亲所在位置的索引;用根结点的下标代表所在子集合的子集合名;根结点对应的数组元素的值为负数,绝对值为该根结点所在子树中的结点个数。
举个栗子,如下是一个并查集,由三个子集合构成:
将每个子集合以一棵树表示如下:
表示不同子集合的这三棵树,构成了表示全集合的森林。
设有一个全集合S={0,1,2,3,4,5,6,7,8,9},初始化时每个元素自成一个单元素子集合:S0={0},S1={1},S2={2},S3={3},S4={4},S5={5},S6={6},S7={7},S8={8},S9={9}。每个子集合的双亲结点为为-1,如下:
经过一段时间的某种计算,这些子集合合并为3个更大的子集合S1={0,6,7,8},S2={1,4,9),S3={2,3,5},此时并查集的树形表示和存储结构如下:
在采用树的双亲指针数组表示作为并查集的存储表示时,集合元素的编号从0到size-1。其中size是并查集中元素的总个数。下面是并查集主要基本操作的实现:
#define SIZE 100 //并查集可容纳的最多元素个数
int UFSets[SIZE]; //使用数组存储并查集的元素
void Initial(int S[]){ //对并查集S进行初始化
for(int i=0;i<size;i++) //size为并查集S的元素个数
S[i]=-1;
}
int Find(int S[],int x){ //在并查集S中查找并返回包含元素x的树的根
while(S[x]>=0)
x=S[x];
return x;
}
void Union(int S[],int Root1,int Root2){ //求两个不相交子集合的并集
//要求Root1与Root2是不同的,且Root1、Root2表示子集合的名字
s[Root2]=Root1; //将根Root2连接到另一根Root1下面
}
例如执行操作Union(S1,S2)后,此时并查集的树形表示如下:
此时并查集的存储结构如下:
二叉排序树(Binary Sort Tree,BST),也称二叉查找树。二叉排序树或者为空树,或者为非空树,当为非空树时,对于任意一个结点都有:
1)若该结点的左子树非空,则左子树上所有结点关键字值均小于根结点的关键字;
2)若该结点的右子树非空,则右子树上所有结点关键字值均大于根结点的关键字;
3)左、右子树本身也分别是一棵二叉排序树。
举个栗子,下图为一棵二叉排序树:
对于上图的二叉排序树,其中序遍历序列为:1、2、3、4、5、7、8、10、16。解释如下。
中序遍历的顺序是:① 左子树;② 根;③ 右子树。而二叉排序树中有:左子树结点值<根结点值<右子树结点值。因此,二叉排序树的中序遍历序列总是一个递增有序序列。
当二叉树非空时,查找根结点,若相等则查找成功;若不等,则:
(1)当小于根结点值时,查找左子树;
(2)当大于根结点值时,查找右子树;
(3)当查找到叶结点仍没查找到相应的值,则查找失败。
C++代码如下:
BSTNode *BST_Search(BiTree T,ElemType key){
while(T!=NULL&&key!=T->data){
if(key<T->data)
T=T->lchild;
else
T=T->rchild;
}
return T;
}
上述代码的时间复杂度为O(h),其中h为二叉排序树的高度。
分为两种情况:
(1)若二叉排序树为空,则直接插入结点;
(2)若二叉排序树非空,当值小于根结点时,插入左子树;当值大于根结点时,插入右子树;当值等于根结点时不进行插入。
可见二叉排序树中所有结点的关键字值没有重复。
C++代码如下:
int BST_Insert(BiTree &T,ElemType k){
if(T=NULL){
T=(BiTree)malloc(sizeof(BSTNode));
T->data=k;
T->lchild=T->rchild=NULL;
return 1;
}
else if(k==T->data)
return 0;
else if(k<T->data)
return BST_Insert(T->lchild,k);
else
return BST_Insert(T->rchild,k);
}
构造二叉排序树的步骤是:将第一个元素作为根结点,然后不断读入下一个元素并建立结点:
(1)当值小于根结点时,插入左子树;
(2)当值大于根结点时,插入右子树;
(3)当值等于根结点时不进行插入。
总之,就是从一棵空树出发,依次输入元素,将它们插入二叉排序树中的合适位置。
C++代码如下:
void Create_BST(BiTree &T,ElemType str[]){
for(T=NULL,int i=0;i<length(str);i++)
BST_Insert(T,str[i]);
}
举个栗子,设查找的关键字序列为{45,24,53,45,12,24),则生成二叉排序树的过程如下所示:
现在有两个数组:str1[4]={2,1,4,3},str2[4]={1,2,3,4},对应的二叉排序树分别为:
尽管两个集合中的元素个数与值都相等,但是构造的二叉排序树是不一样的。
不能直接将某个结点删除,需要考虑实际情况:
(1)若删除的是叶子结点,则可以直接删除;
(2)若被删除的结点z只有一棵子树,则让z的子树代替z结点,从而成为z的父结点的子树;
(3)若被删除结点z有两棵子树,则让z的中序序列直接后继代替z,并删去直接后继结点。
举个栗子,如对于下面的树:
要删除结点4,而结点4有两棵子树,两棵子树的根结点分别为2和5,树的中序序列为1、2、3、4、5、7、8、10、16,即结点4的直接后继结点为5,则让结点5代替4:
然后删除原来的结点5,最后得到的二叉排序树为:
考虑这个问题:在二叉排序树中先删除某个结点,然后再插入该结点,得到的二叉排序树是否与原来相同?
答案是——不一定。举个栗子,如对于下面的树:
删除结点7后的树为:
再插入结点7后的树为:
这时候得到的二叉排序树与原来相同。但是对于树:
删除结点5后的树为:
再插入结点5后的树为:
这时候得到的二叉排序树与原来就不相同了。
平均查找长度(Average Search Length,ASL)取决于树的高度。
举个栗子,现在有两个数组:str1[4]={2,1,4,3},str2[4]={1,2,3,4},对应的二叉排序树分别为:
上面的两棵二叉排序树的ASL分别为:ASL1=(1+2*2+3)/4=2,ASL2=(1+2+3+4)/4=2.5。
在二叉排序树中,若所有结点的左、右子树的高度之差的绝对值不超过1,则这样的二叉排序树称为平衡二叉树,它的平均查找长度为O(log2n)。上图的左面那棵树就是平衡二叉树。
在最坏情况下,即构造二叉排序树的输入序列是有序的,则会形成一个倾斜的单支树,此时二叉排序树的性能显著变坏,树的高度也增加为元素个数n,即h=n。若二叉排序树是一个只有右(左)孩子的单支树(类似于有序的单链表),则其平均查找长度为O(n)。上图的右面那棵树就是单支树。
在二叉排序树中,若所有结点的左、右子树的高度之差的绝对值不超过1,则这样的二叉排序树称为平衡二叉树。平衡二叉树也叫AVL树。在AVL树中,任意结点的平衡因子的绝对值不超过1,其中结点的平衡因子=该结点的左子树高度-该结点的右子树高度,则平衡二叉树结点的平衡因子的值只可能是-1、0或1。
举个栗子,看下面的树,其中结点中的值为该结点的平衡因子大小:
根据定义,上述描述的树是一棵平衡二叉树。再看下面的树:
根据定义,上述描述的树并不是一棵平衡二叉树。
因此,平衡二叉树可定义为或者是一棵空树,或者是具有下列性质的二叉树:它的左子树和右子树都是平衡二叉树,且左子树和右子树的高度差的绝对值不超过1。
这是一个一般性结论:高度为h的最小平衡二叉树的结点数Nh,其中Nh=Nh-1+Nh-2+1,而N0=0,N1=1,h≥2。如下,Ti为高度为i的最小平衡二叉树:
总之,高度为h的最小平衡二叉树Th的根结点的左子树为Th-1,右子树为Th-2,其中h≥2,T0为空树,T1如上图最左面的树。Th如下:
在平衡二叉树上进行查找的过程与二叉排序树的相同。因此,在查找过程中,与给定值进行比较的关键字个数不超过树的深度。含有n个结点的平衡二叉树的平均查找长度为O(log2n)。
判断条件为:若左子树和右子树均为平衡二叉树且左子树与右子树高度差的绝对值小于等于1,则平衡。
利用递归的后序遍历过程,对于树中每一个结点:
1)判断该结点的左子树是否为一棵平衡二叉树;
2)判断该结点的右子树是否为一棵平衡二叉树;
3)判断以该结点为根的二叉树是否为平衡二叉树。
C++代码如下:
void Judge_AVL(BiTree bt,int &balance,int &h){ //balance=1表示bt是平衡二叉树,为0表示不是平衡二叉树;h为树bt的高度
int bl=br=hl=hr=0; //bl,br表示该结点左右子树的平衡情况;hl,hr是该结点左右子树的高度
if(bt==NULL){
h=0;
balance=1; //空树是平衡二叉树
}
else if(bt->lchild==NULL&&bt->rchild==NULL){
h=1;
balance=1; //只有单个根结点是平衡二叉树
}
else{
Judge_AVL(bt->lchild,bl,hl);
Judge_AVL(bt->rchild,br,hr);
if(hl>hr)
h=hl+1;
else
h=hr+1;
if(abs(hl-hr)≤1&&bl==1&&br==1) //若左子树和右子树均为平衡二叉树且左子树与右子树高度差的绝对值小于等于1,则平衡
balance=1;
else
balance=0;
}
}
二叉排序树保证平衡的基本思想如下:每当在二叉排序树中插入一个结点时,首先检查其插入路径上的结点是否因为此次操作而导致了不平衡。若导致了不平衡,则先找到插入路径上离插入结点最近的平衡因子的绝对值大于1的结点A,再对以A为根的子树,在保持二叉排序树特性的前提下,调整各结点的位置关系,使之重新达到平衡。
总之就是先插入再调整。
注意:每次调整的对象都是最小不平衡子树,即以插入路径上离插入结点最近的平衡因子的绝对值大于1的结点作为根的子树。举个栗子,下图是插入27、16、75、38之后的二叉排序树:
其中结点下面的数字代表结点的平衡因子,插入51后的树为:
上图中的虚线框内为最小不平衡子树。
平衡二叉树的插入过程的前半部分与二叉排序树相同,但在新结点插入后,若造成查找路径上的某个结点不再平衡,则需要做出相应的调整。可将调整的规律归纳为4种情况:LL平衡旋转、RR平衡旋转、LR平衡旋转、RL平衡旋转。
LL平衡旋转(右单旋转):
(1)原因在结点A的左孩子的左子树上插入了新结点;
(2)调整方法——右旋操作:将A的左孩子B向右上旋转代替A成为根结点,将A结点向右下旋转成为B的右子树的根结点,而B的原右子树则作为A结点的左子树。
RR平衡旋转(左单旋转):
(1)原因在结点A的右孩子的右子树上插入了新结点;
(2)调整方法——左旋操作:将A的右孩子B向左上旋转代替A成为根结点,将A结点向左下旋转成为B的左子树的根结点,而B的原左子树则作为A结点的右子树。
LR平衡旋转(先左后右双旋转):
(1)原因在结点A的左孩子的右子树上插入了新结点;
(2)调整方法——先左旋后右旋操作:先将A结点的左孩子B的右子树的根结点C向左上旋转提升到B结点的位置,然后再把该C结点向右上旋转提升到A结点的位置。
注意:LR旋转时,新结点究竟是插入C的左子树还是插入C的右子树不影响旋转过程,而上图中以插入C的左子树中为例。
RL平衡旋转(先右后左双旋转):
(1)原因在结点A的右孩子的左子树上插入了新结点;
(2)调整方法——先右旋后左旋操作:先将A结点的右孩子B的左子树的根结点C向右上旋转提升到B结点的位置,然后再把该C结点向左上旋转提升到A结点的位置。
注意:RL旋转时,新结点究竟是插入C的左子树还是插入C的右子树不影响旋转过程,而上图中以插入C的左子树中为例。
假设关键字序列为{15,3,7,10,9,8},通过该序列生成平衡二叉树的过程:
(1)图(d)插入7后导致不平衡,最小不平衡子树的根为15,插入位置为其左孩子的右子树,故执行LR旋转,先左后右双旋转,调整后的结果如图(e)所示;
(2)图(g)插入9后导致不平衡,最小不平衡子树的根为15,插入位置为其左孩子的左子树,故执行LL旋转,右单旋转,调整后的结果如图(h)所示;
(3)图(i)插入8后导致不平衡,最小不平衡子树的根为7,插入位置为其右孩子的左子树,故执行RL旋转,先右后左双旋转,调整后的结果如图(i)所示。
该序列生成的平衡二叉树为图(j)。
在树的许多应用中,树中的结点常常被赋予一个表示某种意义的数值,称为该结点的权。从树的根到任意结点的路径中经过的边数称为路径长度。路径长度与该结点上权值的乘积,称为该结点的带权路径长度。树中所有叶结点的带权路径长度之和称为该树的带权路径长度(Weighted Path Length of Tree,WPL),记为:
上式中,wi是第i个叶结点所带的权值,li是该叶结点到根结点的路径长度。下面是2个示例:
在含有n个带权叶结点的二叉树中,其中带权路径长度(WPL)最小的二叉树称为哈夫曼树,也称最优二叉树。
给定n个权值分别为w1,w2,…,wn的结点,构造哈夫曼树的算法描述如下:
1)将n个结点作为n棵仅含有一个根结点的二叉树,构成森林F;
2)生成一个新结点,并从森林F中找出根结点权值最小的两棵树作为新结点的左右子树,且令新结点的权值为两棵子树的根结点的权值之和;
3)从森林F中删除选择的这两棵树,并将新生成的树加入到F中;
4)重复2,3步骤,直到F中只有一棵树为止,此时即为哈夫曼树。
哈夫曼树的性质:
1)每个初始结点都会成为叶结点,双支结点都为新生成的结点;
2)权值越大离根结点越近,权值越小离根结点越远;
3)哈夫曼树中没有度为1的结点;
4)n个叶子结点的哈夫曼树的结点总数为2n-1,其中度为2的结点数为n-1,这n-1个结点都是新结点。
在数据通信中,若对每个字符用相等长度的二进制位表示,称这种编码方式为固定长度编码。若允许对不同字符用不等长的二进制位表示,则这种编码方式称为可变长度编码。可变长度编码比固定长度编码要好得多,其特点是对频率高的字符赋以短编码,而对频率较低的字符则赋以较长一些的编码,从而可以使字符的平均编码长度减短,起到压缩数据的效果。哈夫曼编码是一种被广泛应用而且非常有效的数据压缩编码。
若编码中没有一个编码是其他编码的前缀,则称这样的编码为前缀编码。举个栗子:设计字符A、B、C对应的编码0、101、100就是前缀编码。对前缀编码的解码很简单,因为没有一个编码是其他编码的前缀。所以识别出第一个编码,将它翻译为原码,再对余下的编码文件重复同样的解码操作即可。例如,码串00101100可被唯一地翻译为0,0,101和100。
由哈夫曼树得到哈夫曼编码是很自然的过程。首先,将每个出现的字符当作一个独立的结点,其权值为它出现的次数,构造出对应的哈夫曼树。显然,所有字符结点都出现在叶结点中。可将字符的编码解释为从根至该字符的路径上边标记的序列,其中边标记为0表示“转向左孩子”,标记为1表示“转向右孩子”。下图所示为一个由哈夫曼树构造哈夫曼编码的示例,矩形方块表示字符及其出现的次数:
上图的这棵哈夫曼树的WPL=1×45+3×(13+12+16)+4×(5+9)=224。此处的WPL可视为最终编码得到二进制编码的长度,共224位。若采用3位固定长度编码,则得到的二进制编码长度为3×(45+12+13+5+9+16)=300位,因此哈夫曼编码共压缩了25%的数据。利用哈夫曼树可以设计出总长度最短的二进制前缀编码。
注意:0和1究竟是表示左子树还是右子树没有明确规定。左、右孩子结点的顺序是任意的,所以构造出的哈夫曼树并不唯一,但各哈夫曼树的带权路径长度WPL相同且均为最优的。此外,如有若干权值相同的结点,则构造出的哈夫曼树更可能不同,但WPL依然相同且都是最优的。
END