基本概念
树是n(n大≥0)个结点的有限集合,n=0时称为空树,这是一种特殊情况。在任意一棵非空树中应满足:
- 有且仅有一个特定的称为根的结点;
- 当n>1时,其余结点可分为m(m>0)个互不相交的有限集合
,其中每个集合本身又是一棵树,并且称为根节点的子树。
非空树的特性:
- 有且仅有一个根节点;
- 没有后继的结点称为“叶子结点”;
- 有后继的结点称为“分支节点”;
- 除了根节点外,任何一个结点都有且仅有一个前驱;
- 每个结点可以有0个或多个后继;
结点和树的属性
- 结点间的路径:只能从上往下数;
- 路径长度:两个结点经过了几条边;
- 结点的层次(深度):从上往下数;
- 结点的高度:从下往上数;
- 数的高度(深度):总共多少层
- 结点的度:有几个孩子(分支);
- 树的度:各个结点的度的最大值;
有序树和无序树
- 有序树:逻辑上看,树中结点的各子树从左至右是有次序的,不能互换;
- 无序树:逻辑上看,树中结点的各子树从左至右是无次序的,可以互换;
选择哪种树,具体看用树存什么,是否需要用结点的左右位置反映某些逻辑关系;
树和森林
森林是m(m≥0)棵互不相交的树的集合
常考性质
一、结点数=总度数+1
结点的度意思是结点有几个孩子,相加之后可以求出所有子节点个数,然后需要把根节点也计算上,所以+1;
二、区分度为m的树和m叉树
度为m的树 | m叉树 |
---|---|
树的度:各结点的度的最大值 | m叉树:每个结点最多只能有m个孩子的树 |
任意结点的度≤m(最多m个孩子) | 任意结点的度≤m(最多m个孩子) |
至少有一个结点的度=m(要保证有一个结点的孩子数量为m) | 允许所有结点的度都<m |
一定是非空树,至少有m+1个结点 | 可以是空树 |
三、度为m的树的第i层至多有
四、高度为h的m叉树至多有
其实就是等比数列求和,首项为1;
五、高度为h的m叉树至少有h个结点;高度为h、度为m的树至少有h+m-1个结点
求至少,那么按照最少的来算就行;m叉树就让每一层的度都为1;规定树的度为m,那就只让尾结点的数量为m;
六、具有n个结点的m叉树的最小高度为
求m叉树的最小高度,那么就让树的每一层的度都为m。已知第h层最多有
二叉树
二叉树是n(n大≥0)个结点的有限集合:
- n=0时为空二叉树;
- n>0时,由一个根结点和两个互不相交的被称为根的左子树和右子树组成。左子树和右子树分别是一颗二叉树
特点: + 每个结点至多只有两棵子树; + 二叉树是有序树,左右子树不能颠倒; + 要注意区分度为2的有序树,度为2的有序树,至少需要有一个结点有2个孩子;
二叉树的五种状态:空树、只有左子树、只有右子树、只有根节点、左右子树都有;
几种特殊的二叉树
- 满二叉树
一颗高度为h,且含有
- 只有最后一层有叶子节点;
- 不存在度为1的结点;
- 按层序从1开始编号,结点i的左孩子为2i,右孩子为2i+1;结点的父节点为
- 完全二叉树
当且仅当其每个结点都与高度为h的满二叉树中编号为1~n的结点一一对应,称为完全二叉树。
满二叉树一定是完全二叉树,完全二叉树不一定是满二叉树。
- 只有最后两层可能有叶子结点;
- 最多只有一个度为1的结点;
- 按层序从1开始编号,结点i的左孩子为2i,右孩子为2i+1;结点的父节点为
; - i≤
为分支结点,i> 为叶子节点; - 如果一个结点只有一个孩子,那么一定是左孩子;
- 二叉排序树
一颗二叉树或者是空二叉树,或者是具有如下性质的二叉树:
- 左子树上的所有节点的关键字均小于根结点的关键字;
- 右子树上所有结点的关键字均大于根节点的关键字
- 左右子树分别又是一棵二叉排序树;
二叉排序树可以用于元素的排序、搜索
- 平衡二叉树
树上任意结点的左子树和右子树的深度之差不超过1;
二叉树常考性质
- 一、设非空二叉树中度为0、1、2的结点个数分别为
、 、 ,则
推导:假设树中的结点总数为n,则可得:
- n =
+ + - 已知数的结点数=总度数+1,则n=
+2 +1
两式联立可得
- 二、二叉树第i层至多有
个结点(i≥1)
- 三、高度为h的二叉树至多有
个结点(此时是满二叉树)
完全二叉树常考性质
- 一、具有n个结点的完全二叉树的高度为h=
或
高为h的满二叉树共有
高为h-1的满二叉树共有
高为h的完全二叉树至少
- 二、对于完全二叉树,可以由结点数n推出度为0、1、2的结点个数
、 、
突破点:完全二叉树最多只有一个度为1的结点,即
又由
- 若完全二叉树有2k(偶数)个结点,则必有
; - 若完全二叉树有2k-1(奇数)个结点,则必有
;
二叉树的存储结构
顺序存储
1 |
|
可以让数组的第一个位置空缺,保证数组下标和结点编号位置一致
基本操作:
- i的左孩子——2i;
- i的右孩子——2i+1;
- i的父节点——
或
若完全二叉树共有n个结点,则:
- 判断i是否有左孩子——2i≤n?
- 判断i是否有右孩子——2i+1≤n?
- 判断i是否是叶子/分支结点?——i>
n/2
重要:二叉树的顺序存储中,一定要把二叉树的结点编号与完全二叉树对应起来,这样才能计算逻辑关系。所以顺序存储很浪费空间,最坏的情况,高度为h且每个结点都只有一个右孩子,那么也依旧需要
链式存储
二叉链表。
1 | typedef struct BiTNode{ |
对于采用链式存储的二叉树,如果有n个结点,那么就会有2n个指针。其中除根节点外,每个结点都有指针指向自己,即n-1个指针指向具体结点,那么就会剩下n+1个结点指向空链域。可以利用这些空链域构造线索二叉树。
上面的链式存储找到指定结点的左右孩子的操作非常简单,但是如果想寻找父节点,那么就需要从头遍历。如果实际操作中需要频繁的寻找父节点,那么可以用三叉链表:
1 | typedef struct BiTNode{ |
但考试中基本不涉及这种场景,需要针对二叉链表进行遍历。
二叉树的遍历
遍历就是按照某种次序把所有结点都访问一遍。对于树形结构来说,总体上有两种遍历顺序,一种是层序遍历:基于树的层次特性确定的次序规则;另一种是先:基于树的递归特性确定的次序规则。
先中后序遍历
- 先序遍历:根左右(NLR)
- 中序遍历:左根右(LNR)
- 后序遍历:左右根(LRN)
上图的先序遍历:ABDECFG;中序遍历:DBEAFCG;后序遍历:DEBFCGA。
1 | typedef struct BiTNode{ |
层序遍历
算法思想:
- 初始化一个辅助队列;
- 根节点入队
- 若队列非空,则队头结点出队,访问该节点,并将其左、右孩子插入队尾
- 重复3直到队列为空
1 | typedef struct BiTNode{ |
由遍历序列构造二叉树
不论遍历序列是前序、中序、后序还是层序,一个遍历序列都可以对应多种二叉树形态。如果只给出一颗二叉树的前/中/后/层序遍历中的一种,不能唯一确定一颗二叉树。
但只要给出中序遍历,那么任意结合前序、后序、层序遍历的一种,即可唯一确定一颗二叉树。
前序+中序:
后序+中序:
层序+中序:
核心就是找到树的根节点,并根据中序序列划分左右子树,再找到左右子树根节点。
必须要结合中序遍历序列,否则无法确定。
线索二叉树
二叉树的遍历并不能向线性表一样从任意结点开始访问,如果要找某个结点的前驱或者后继,必须要从根节点开始。
由前面的结论可得,n个结点的二叉树,有n+1个空链域,可以用来记录前驱、后继的信息。我们根据中序遍历的顺序来指定前驱和后继。这样我们就可以方便的找到其前驱和后继,方便遍历操作。
线索二叉树中,只有线索指向的结点才能称为前驱(后继)
1 | //线索二叉树结点 |
先序线索二叉树和后序线索二叉树和中序一样,只是前驱和后序不同而已。
二叉树的线索化
对二叉树进行线索化的过程:直接进行遍历,遍历的过程中进行线索化。用一个指针pre记录当前访问结点的前驱结点
要注意最后一个结点的rchile、rtag的处理
1 | //中序线索化 |
1 | //先序线索化 |
1 | //后序线索化 |
线索二叉树找前驱、后继
中序线索二叉树中找到指定结点*p的中序后继next:
- 如果p->rtag == 1 ,则next=p->rchild;
- 如果p->rtag==0,则p必有右孩子,next=p右子树中最左下结点;
1 | //找到以P为根的子树中,第一个被中序遍历的结点 |
在中序线索二叉树中找到指定结点*p的中序前驱pre
- 若p->ltag == 1,则pre = p->lchild
- 若p->ltag == 0,则p必有左孩子。pre=p的左子树中最右下的结点
1 | //找到以P为根的子树中,最后一个被中序遍历的结点 |
在先序线索二叉树中找到指定结点*p的先序后继next:
- 若p->rtag == 1,则next = p->rchild;
- 若p->rtag == 0,p必有右孩子
- 若p有左孩子,则先序后继为左孩子
- 若p没有作海周四,则先序后继为右孩子
在先序线索二叉树中找到指定节点*p的先序前驱pre
- 若p->ltag == 1,则next = p->lchild;
- 若p->ltah == 0,则只能用土办法从头开始先序遍历了。先序遍历中左右子树的结点只可能是根的后继,不可能是根的前驱
在后序线索二叉树中找到指定结点*p的后序前驱pre:
- 若p->ltag == 1,则pre=p->lchild;
- 若p->ltag == 0,则p必有左孩子
- 若p有右孩子,则后序前驱为右孩子;
- 若p没有右孩子,则后序前驱为左孩子
在后序线索二叉树中找到指定结点*p的后序后继next
- 若p->rtag == 1,则next = p->rchild;
- 若p->rtag == 0,则只能用土办法从头开始后序遍历,后序遍历中,左右子树中的结点只可能是根的前驱,不可能是后继
树的存储结构
二叉树的顺序存储,可以按照完全二叉树中的结点顺序,将各个结点存储到数组的对应位置。数组下标反映结点之间的逻辑关系。但对于普通的树,一个分支结点可以有多棵子树,没有完全树的概念。只依靠数组下标,无法反映结点之间的逻辑关系。
双亲表示法
顺序存储。
思路:用数组顺序存储各个结点。每个结点中保存数据元素、指向双亲结点的“指针”(数组下标)。非根节点的双亲指针=父节点在数组中的下标、根节点的双亲指针为-1
1 |
|
森林是m棵互不相交的数的集合。双亲表示法也可以存储森林。
- 优点:找父节点很方便,直接读取parant;
- 缺点:找孩子不方便,只能从头到尾遍历整个数组;
适用于找父亲多,找孩子少的场景,比如“并查集”。
孩子表示法
既使用顺序存储,也使用链式存储。
思路:用数组顺序存储各个结点。每个结点中保存数据元素和孩子链表的头指针。用一个链表记录一个结点的所有孩子的编号,结点的指针域指向第一个孩子编号。
1 |
|
孩子表示法也可以存储森林。但需要记录多个根的位置。
- 优点:找孩子很方便;
- 缺点:找双亲不方便,只能遍历每个链表;
使用于找孩子多,找父亲少的场景,比如服务流程树。
孩子兄弟表示法
链式存储方式。
孩子兄弟表示法与二叉树类似,采用二叉链表实现。每个结点内保存数据元素和两个指针,但两个指针的含义和二叉树不同。
1 | typedef struct CSNode{ |
孩子兄弟表示法也可以存储森林。每棵树的树根都看成平级的关系,当作兄弟结点处理。
由于存储视角上看形态和二叉树雷系,所以是重要考点:树、森林和二叉树的转换。
树、森林转二叉树
树转二叉树
- 先在二叉树中,画出一个根结点;
- 按照树的“层序”依次处理每个结点:如果当前处理的结点在树中有孩子,就把所有孩子结点“用右指针串成糖葫芦”,并在二叉树中把第一个孩子挂在当前结点的左指针下方。
森林转二叉树
森林中各个树的根结点均视为平级的兄弟关系:
- 先把所有树的根结点画出来,在二叉树中用右指针串成糖葫芦
- 按照森林的“层序”依次处理每个结点:如果当前处理的结点在树中有孩子,就把所有孩子结点“用右指针串成糖葫芦”,并在二叉树中把第一个孩子挂在当前结点的左指针下方。
二叉树转树、森林
二叉树转树:
- 先画出树的根节点;
- 从树的根节点开始,按照树的“层序”恢复每个结点的孩子:在二叉树中,如果当前处理的结点有左孩子,就把左孩子和“一整串右指针糖葫芦”拆下来,按顺序挂在当前结点的下方;
二叉树转森林:
- 先把二叉树的根节点和“一整串右指针糖葫芦”拆下来,作为多棵树的根节点;
- 按照森林的“层序”恢复每个结点的孩子:在二叉树中,如果当前处理的结点有左孩子,就把左孩子和“一整串右指针糖葫芦”拆下来,按顺序挂在当前结点的下方;
树和森林的遍历
树的遍历分为三种:
- 先根遍历:若树非空,先访问根节点,再依次对每棵子树进行先根遍历(根左右);树的先根遍历序列与这棵树相应的二叉树的先序序列相同;
- 后根遍历:若树非空,先依次对每棵子树进行后根遍历,最后再访问根节点(左右根)。树的后根遍历序列与这棵树对应的二叉树的中序序列相同
- 层次遍历(用辅助队列实现):
- 若树非空,则根节点入队;、
- 若队列非空,队头元素出队并访问,同时将该元素的孩子依次入队
- 重复2直到队列为空
先根遍历和后根遍历是深度优先,层次遍历是广度优先
森林的遍历分为两种:
- 先序遍历(效果等同于依次对各个树进行先根遍历,或者直接把森林转换成二叉树,看二叉树的先序遍历序列):
- 访问森林中第一棵树的根节点;
- 先序遍历第一棵树中根节点的子树森林
- 先序遍历除第一棵树之后剩余的树构成的森林。
- 中序遍历(效果上等同于依次对各个树进行后根遍历,或者看对应二叉树的中序遍历序列):
- 中序遍历森林中第一棵树的根节点的字数森林
- 访问森林中第一棵树的根节点
- 中序遍历除去第一棵树之后剩余的树
哈夫曼树
- 结点的权:有某种现实含义的数值,比如表示结点的重要性等。
- 结点的带权路径长度:从树的根到该节点的路径长度(经过的边数)与该结点上权值的乘积。
- 树的带权路径长度:树中所有叶节点的带权路径长度之和。
在含有n个带权叶结点的二叉树中,其中带权路径长度最小的二叉树称为哈夫曼树,也称最优二叉树
哈夫曼树的构造
给定n个权值分别为
- 每个初始结点最终都成为叶结点,且权值越小的结点到根结点的路径长度越大;
- n个结点总共合并n-1次,每次合并都会导致增加一个结点,最终的结点总数为2n-1;
- 哈夫曼树中不存在度为1的结点;
- 哈夫曼树并不唯一,但每个哈夫曼树的带权路径长度必然相同且为最优。
哈夫曼编码
哈夫曼编码常用于数据压缩。
考虑以下场景,假设需要通过编码传递A、B、C、D的选择题答案,首先想到的是固定长度编码:每个字符用相等长度的二进制位表示。比如用ASCII编码,每个字符用8bit来表示A(01000001)、B(01000010)、C(01000011)、D(01000100).可见这种方式效率极低,如果有100道题目,总共要花费800bit。
首先想到的优化方式是,只有4个选项,那么每个字符可以只用长度为2的二进制表示:A(00)、B(01)、C(10)、D(11).这也是固定长度编码,100道题的二进制长度只有200bit.
假设已经知道了100题中有80个C,10个A,8个B,2个D,那么可以根据这个数字来构造哈夫曼树.
构造出来后,可以用0表示C,10表示A,111表示B,110表示D。这就是可变长度编码——允许使用不同字符用不等长的二进制位表示。这100道题最终只需要130bit。
哈夫曼编码是前缀编码:没有一个编码是另一个编码的前缀。前缀编码解码时无歧义。
哈夫曼编码:字符集中的每个字符作为一个叶子节点,各个字符出现的频度作为结点的权值,构造哈夫曼树。哈夫曼树不唯一,所以哈夫曼编码不唯一。