一、树的定义和基本术语
1.定义:
树是由一个根节点和若干个互不相交的子树构成(子树没有结点,则成空树)
2.结点之间关系描述:
关键词:遍历后有前驱、后继
祖先结点:从自身往上走到头,凡是经过的结点都是自身的祖先结点。
子孙结点同理。
父节点。
子节点。
兄弟结点:拥有同一父结点的同一层结点。
堂兄弟结点:父节点是兄弟结点,且现在处于同一层的结点。
结点之间的路径:有向的路径【只能从上往下】、路径长度【经过几条边】
结点的深度【从上往下数】、结点的高度【从下往上数】
树的高度、深度【总共多少层】
结点的度:有几个孩子(分支)
数的度:各个结点度的最大值
有序树和无序树:具体看存什么,看需不需要存各个结点间的逻辑关系。
森林:m个(m>=0)互不相交的树族组成森林。【可以为空森林】
3.常考点
(1)结点数等于总度数+1。【总度数把孩子都算进去了,唯独没算根节点】
(2)度为m的树、m叉树的区别
ps:第一条为相同点,二三条为不同点。
度规定了最少。几叉树规定了最多。
(3)度为m的树【改为m叉树也可】,第i层最多有m^(i-1)个结点。
如:度为3的树,第一层为3^0、第二层为3^1...
(4)高度为h的m叉树【改为度为m也可】,一共最多有个结点
(5)
(6)
ps:有一个向上取整的符号。
二、二叉树的定义和基本术语
1.定义
二叉树是n(n>=0)个结点的有限集合
【n=0,为空二叉树;否则由一个根节点和两个互不相交的被称为根的左子树和右子树组成。】
注意左右子树不能颠倒【二叉树为有序树】
2.几类特殊的二叉树
(1)形状特殊的
【完全二叉树是缺失部分的满二叉树】【从后往前删】
(2)功能特殊的【重要!】
a.二叉排序树:是一棵二叉树或者空二叉树。以根节点为基准,左小右大。
b.平衡二叉树:树上任一结点的左子树和右子树的深度之差不超过1.(左右深度差不多)
利用平衡二叉树,可以有更高的搜索效率。
三、二叉树的性质
(1)
(2)
(3)
层数:内层加1要取上,取下外层再加1
四、二叉树的存储
1.顺序存储(应用较少,容易浪费空间)
#define MaxSize 100
struct TreeNode {
int value;
bool isEmpty;
};
TreeNode t[MaxSize];
int main()
{
for (int i = 0; i < MaxSize; i++)
t[i].isEmpty = true;//means empty;
return 0;
}
为了对应,很容易浪费空间。
最坏情况:比如高度为h且只有h个结点的单支树(所有结点都只有右孩子,深度达到了,但是密度太低了,这样会占用2^h-1个存储单元,但是实际利用上的只有一半左右)
结论:二叉树的顺序存储结构只适合存完全二叉树。
2.链式存储
struct ElemType
{
int value;
};
typedef struct BiTNode {
ElemType data;
struct BiTNode* lchild, * rchild;
}BiTNode,*BiTree;
BiTree root = NULL;
/*1.插入新结点*/
{
root = new BiTNode;//声明根节点空间
root->data = { 1 };
root->lchild = NULL;
root->rchild = NULL;
}
/*2.插入新结点*/
{
BiTNode* p = new BiTNode;//声明结点
p->data = { 2 };
p->lchild = NULL;
p->rchild = NULL;
root->lchild = p;//To be the left child.
}
五、遍历二叉树
1.前中后序遍历(递归)
void PreOrder(BiTree T)//传入树或者成结点
{
if (T != NULL)//该结点不为空才进,为空则什么也不做
{
visit(T);//前序遍历:第一次走就写下
PreOrder(T->lchild);//访问左孩子【需要递归,注意每个结点都需要走三次】
PreOrder(T->rchild);
}
}//前缀表达式
void MidOrder(BiTree T)//传入树或者成结点
{
if (T != NULL)//该结点不为空才进,为空则什么也不做
{
MidOrder(T->lchild);
visit(T);//中序遍历:第二次走就写下
MidOrder(T->rchild);
}
}//中缀表达式
递归算法的空间复杂度【使用栈的层数】为O(h+1),加一是因为最后一层还要去NULL。
舍去常数级时,空间复杂度为O(h)
前序遍历:第一次经过时就写下。【前缀表达式】
中序遍历:第二次经过时写下。【中缀表达式(需要加界限符)】
后序遍历:第三次经过才写下。【后缀表达式】
2.二叉树的层次遍历(利用辅助队列)
链式队列定义本来是ElemType data,这里换成了BiTNode* data,把根节点信息放进队列。
判断队列,不空则开始循环【如果是空树,就不用开始喽!】
一开始只有根节点,后面根节点出队【赋值给p】,判断是否需要其他结点进来
3.由遍历序列构造二叉树
如给你:
前序为BAC
中序为ABC
让你画出原来的二叉树
六、线索二叉树
1.意义
由图需要转化为线性链表,线索二叉树就是为了加快查找结点前驱和后继的速度。
当用二叉链表作为二叉树的存储结构时, 可以很方便地找到某个结点的左右孩子;但在一般情况下,无法直接找到该结点在某种遍历序列中的前驱和后继结点。知道了“前驱”和“后继”信息,就可以把二叉树看作一个链表结构,从而可以像遍历链表那样来遍历二叉树,进而提高效率。【你在相邻层当然好找了,如果不在相邻层而是跳跃了呢?】
解决办法:
a.从根节点出发遍历寻找——浪费时间
b.增设前驱、后继指针域——增加存储负担(包含浪费,有的结点前后驱和子节点的地址相同)
c.利用二叉链表中的空指针域建立线索二叉树
2.定义
对二叉树按某种遍历次序【比如CBEGDFA】使其变为线索二叉树【一般是对叶子指针增加线索】的过程称为线索化。
步骤:
1.根据遍历次序,在二叉树中编号12345...
2.按数字编号从前往后,看每个结点是否有左右孩子【前驱后继与左右孩子是矛盾的】
->没有孩子就看遍历次序表中是否有前驱后继,有前驱/后继就建立“线索”!
->前驱/后继也没有的话,指向NULL
eg:
3.存储结构(采取后一种)
我们心目中的代码就是要将中序线索二叉树用二叉链表来实现。
4.线索化的代码实现/建立线索二叉树
已知某种遍历次序,建立线索二叉树。【利用快慢指针】
以中序遍历为例子:
#include <iostream> using namespace std; typedef struct ThreadNode { int data; ThreadNode* lchild, * rchild; int ltag, rtag = 0; }ThreadNode,*ThreadTree; ThreadNode* pre = NULL; void CreateInThread(ThreadTree T)//逻辑上创建线索二叉树的过程 { pre = NULL; if (T != NULL) { InThread(T);//线索化整棵树 if (pre->rchild ==NULL)//这里也可以不用判断,直接处理最后一个结点的rtag //因为中序遍历:第一个结点的左孩子和最后一个结点的右孩子一定为空 pre->rtag = 1; } } void InThread(ThreadTree T) //中序遍历的递归式 { if (T != NULL) { InThread(T->lchild); visit(T); InThread(T->rchild); } } void visit(ThreadNode* q) //左子树靠q补齐,右子树靠pre指针来补 { if (q->lchild == NULL) { q->lchild = pre; q->ltag = 1;//q=0 means child; } if (pre != NULL && pre->rchild == NULL) { pre->rchild = q; pre->rtag = 1; } pre = q;//不论是否被线索化,pre都会跟着q来到最后访问的结点 //由于pre是全局变量,所以q被自然而然地带入当前结点的 //lchild或者rchild,pre仍然指向之前的结点(前驱) }
先序线索化的转圈问题:
访问顺序为:【根、左、右】,根结点的处理在左孩子之前。所以在处理根节点时,可能顺带把左孩子已经线索化变成前驱结点了(此时ltag==1),所以在处理“左孩子”的时候,PreThread(T->lchild)会重新指回前驱结点(上一层的根),然后循环往复,一直在这里打转。所以添加条件:tag==0才是真正访问当前结点的左孩子。
解决办法:利用tag值判断是否已经被线索化
void PreThread(ThreadTree T) //中序遍历的递归式 { if (T != NULL) { visit(T); if(T->ltag==0)//lchild不是前驱线索 PreThread(T->lchild); PreThread(T->rchild); } }
因为在中序遍历的顺序为左孩子、根结点、右孩子,后序遍历的顺序为左孩子、右孩子、根结点。在遍历到跟结点时它的左孩子肯定是已经被遍历过了,不存在上述“转圈圈”的问题,所以可以正常遍历。 【中序、后序遍历是自下而上的,但是先序线索化会导致“上-下-上”的问题】
pre是上,q是下。
5.根据线索二叉树找前驱/后继
(1)寻找中序后继
【next一定是p的右子树中最左下结点】
线索化后:tag=0则表示连接的一定是孩子,tag=1表示连接的是前驱或者后继。
typedef struct ThreadNode { int data; ThreadNode* lchild, * rchild; int ltag, rtag = 0; }ThreadNode, * ThreadTree; //循环找最左下角结点(有可能是自己) ThreadNode* FirstNode(ThreadNode* p) { while (p->ltag == 0) p = p->lchild; return p; } //在中序线索二叉树中找到结点p的后继结点【1.已经被线索化 2.未被线索化】 ThreadNode* NextNode(ThreadNode* p) { if (p->rtag == 0)//一定有右孩子,后继结点取右孩子的最左下角结点 return FirstNode(p->rchild); else return p->rchild;//已经被线索化,中序后继结点一定是右孩子 } //给一个树,利用线索实现的非递归算法,空间复杂度O(1) //1.初始化,找打头的结点; 2.判断p的存在性【避免空树/达到最后一个结点无法访问的情况】 //3.一些操作(如visit); 4.让p指向NextNode,继续判断存在性 //【如果p是最后一个结点,已经被线索化,rtag=1,rchild=NULL; // 进入NextNode函数,直接返回p->rchild,即p返回空,停止循环】 void InOrder(ThreadNode* T) { for (ThreadNode* p = FirstNode(T); p != NULL; p = NextNode(p)) visit(p); }
(2)寻找中序前驱
(3)找前序后继/后序前驱
前序后继:根左右 --> 根【根左右】右
后序前驱:左右根-->左【左右根】根
(4)找前序前驱/后序后继时,比较困难,需要用土办法从头开始遍历找到父结点。
七、树的存储结构【不一定是二叉树哦】
1.(存)双亲表示法(顺序存储)
其实只有一个parent,所以在结点内用一个parent变量储存父结点的地址即可
删除时,让尾巴结点来填充该位置,实现连续空间都是有效数据。
二叉树是特殊的一类树,所以这里,二叉树也可以采用双亲表示法。
2.孩子表示法(顺序+链式存储)
3.孩子兄弟表示法(链式存储)
(1)意义:可以实现树和二叉树的相互转化,然后就可以用二叉树的方法对任意树进行遍历。
【主要是将任意数转化成二叉树】
(2)语法:左孩子,右兄弟
树转化为二叉树:先一个孩子,再写出他的全部兄弟;再写一个孩子,继续写他的全部兄弟。
最后验证各自的兄弟是否能对上号。【森林转化为二叉树也是类似,把同一层的看成兄弟就行】
八、树和森林的遍历
1.树的遍历和二叉树遍历无异;
注意:树的层次遍历也称广度优先遍历【字上而下,一层一层地遍历嘛】
树的后根遍历也称深度优先遍历【先一条路走到头,才能记录值/visit结点】
2.森林的先序遍历容易,中序遍历等同于二叉树的后根遍历!
如果不会推,就先转化而二叉树,再使用中序遍历。
九、哈夫曼树
1.预备知识:带权路径长度
2.哈夫曼树的定义:
即最优二叉树,在含有给定的n个带权叶结点的二叉树中,WPL 最小的二叉树。
3.哈夫曼树的构造:
直观感觉:让权值越小的越靠下层,这样乘以路径才最小,所以做最远的叶子结点。
又由于是二叉树,所以让两个权值最小的作为兄弟结点,形成小树之后【注意此时权值要相加】再找个权值小的当兄弟结点...以此类推。
4.小结
十、并查集
1.定义:
操作【并Union、查Find】+逻辑结构【元素之间为集合关系】
2.存储结构:
最好采用双亲表示法,因为有查根的操作嘛,而且合并也是对根进行操作。
这里x表示数组下标。Root表示根的数组下标,如果两个数组下标相同,表明是同一棵 树,不用比较直接return;不同的话让树1的根作为树2的根。
3.Union优化
(1)方法:Union过程很容易,但是查找难度随着树的高度变大而递增。可以让小树合并到大树优化。所以实现优化的核心思想:尽可能让树变矮
(2)优化过程:让顶根不再为-1,而是赋予特别的含义,而是采用【结点总数的相反数】作为数组元素表示树的大小,比如-5,表示含有一共有5个结点的子树根节点。
【我感觉这个小树合入大树办法不妥,即便是结点少的树,高度也可能很大啊?】
该方法构造的树可以使得树高不超过
(3)提升:Union优化后,Find操作最坏时间不超过O(logn)
4.Find优化(类似KMP算法,压缩路径)
(1)方法:利用Find操作找到根节点后,再用while循环将查找路径上所有结点都挂到根节点下。【自下而上后,再自上而下】
(2)代码实现
int Find(int S[], int x)
{//x为初始结点的数组下标
int root = x;//root是要找到真正的根,这里先初始化为当前结点
while (S[root] >= 0)//只要“根”的数据元素值非负,就不是真正的根
root = S[root];//让数组元素的值,也就是上一级根的数组下标成为“根”
while (x != root)
{
int t = S[x];//先记录初始结点上一层根的数组下标【一会再来收拾你!】
S[x] = root;//直接将当前结点挂到最上层根那儿
x = t;//让刚才的t成为下一个被遍历的结点【等不及了吧小宝贝...】
}
return root;//返回真正的根
}
(3)提升:
每次Find 操作,先找根,再“压缩路径”,可使树的高度不超过O(a(n))。 a(n)是一个增长很缓慢的函数,对于常见的n值,通常,因此优化后并查集的Find、Union操作时间开销都很低。
5.小结
解释第一个:Union一共n个独立元素,一共需要重复n-1次。但是首先需要找到每个元素的根就需要O(n)的数量级,重复n-1次,所以数量级一共是O(n*(n-1))=O(n^2)