二叉树、赫夫曼树、二叉排序树、二叉平衡树
文章参考:
一. 概述
树是一种典型的数据结构。树的最大特点在于:它是递归定义的,即:一个树的子节点可以构成n个子树,且这些子树之间并不相交。
树有多种形式,最典型的就是二叉树。二叉树的每一个节点最多有两个子节点:左节点、右节点。因为这种递归的定义,二叉树中每一个节点都是一个二叉树的根。这种结构让二叉树变得更加有规律可循,方便我们借助它进行排序、查找操作,且递归的定义让我们可以更加方便的进行递归编程。这些优点让二叉树的使用变得相当广泛。
下面的内容都是针对二叉树进行的。
二. 二叉树的遍历
构造二叉树:首先构造二叉树结构如下:
-
图:
-
代码如下:
typedef char Data; // 节点的数据 typedef struct Node { // 节点类型 Data data; struct Node* l_child = nullptr; struct Node* r_child = nullptr; } Node; // 创建一个和上图一致的二叉树 Node* init(){ Node* root = new Node(); root->data = 'A'; Node* nb = new Node(); Node* nc = new Node(); Node* nd = new Node(); Node* ne = new Node(); Node* nf = new Node(); Node* ng = new Node(); nb->data = 'B'; nc->data = 'C'; nd->data = 'D'; ne->data = 'E'; nf->data = 'F'; ng->data = 'G'; root->l_child = nb; root->r_child = nc; nb->l_child = nd; nb->r_child = ne; nc->l_child = nf; ne->l_child = ng; return root; }; // 后序遍历,销毁二叉树,释放所有节点 void destory(Node* t){ if(t != nullptr){ destory(t->l_child); destory(t->r_child); delete t; } }
依照顺序不同,二叉树的遍历可以分为下面四种:
1. 前序遍历
顺序:根节点-->左节点-->右节点
代码:使用递归,通过代码栈,隐式进行入栈、出栈操作,从而实现对二叉树的遍历。
void preOrder(Node* t){
if(t != nullptr){
getValue(t);
preOrder(t->l_child);
preOrder(t->r_child);
}
}
遍历结果:
ABDEGCF
2. 中序遍历
顺序:左节点-->根节点-->右节点
代码:
void midOrder(Node* t){
if(t != nullptr){
midOrder(t->l_child);
getValue(t);
midOrder(t->r_child);
}
}
遍历结果:
DBGEAFC
3. 后序遍历
顺序:左节点-->右节点-->根节点
代码:
void postOrder(Node* t){
if(t != nullptr){
postOrder(t->l_child);
postOrder(t->r_child);
getValue(t);
}
}
遍历结果:
DGEBFCA
4. 层序遍历
顺序:顾名思义,就是从上到下、从左到右,一层一层的进行遍历。
实现思路:这里可以借助队列。步骤如下:
- 最开始将头节点加入队列。
- 从队列弹出一个节点(此时是头节点),获取该节点的值。
- 将弹出节点的左、右子节点加入队列(前提是子节点不为空)。
- 重复步骤2、3,直到队列为空。
可以发现,我们实际上借助队列,将树在逻辑上拉平了,且顺序正是层序遍历所需的。
代码:
void sequenceTraversal(std::queue<Node*>& queue){
if(queue.empty()) return;
Node* node = queue.front();
getValue(node);
queue.pop();
if(node->l_child != nullptr) queue.push(node->l_child);
if(node->r_child != nullptr) queue.push(node->r_child);
sequenceTraversal(queue);
}
std::queue<Node*> queue;
queue.push(tree);
std::cout << "sequenceTraversal: ";
sequenceTraversal(queue);
std::cout << "\n";
destory(tree);
遍历结果:
ABCDEFG
三. 赫夫曼编码
1. 赫夫曼树
定义:在所有含 n 个叶子结点、并且叶子结点带相同权值的二叉树中,其带权路径长度WPL
最小的那棵二叉树称为赫夫曼树,也叫最优二叉树。
如何构造赫夫曼树:现有n个节点,节点的权值为:\({w1,...wi,...,wj,...,wn}\)
- 取出权值最小的两个节点\(wi、wj\),将它们作为左右子节点构造出一棵树。左右子节点的顺序不固定,一般左边<右边。树的根节点记作\(R1\)。且\(R1=wi+wj\)
- 将\(wi、wj\)从节点权值集合中删除,将新的根节点\(R1\)加入集合,得到新的集合\(R1,w1,...wn\)
- 对新的集合重复1、2步,直到集合中只剩下一个节点。此时赫夫曼树构建完毕
上述构造思路实际上是按照贪心算法
得到的。
图解:
2. 赫夫曼编码
概述:赫夫曼编码是一种基于赫夫曼树的信息压缩编码,它可以在保证不产生二义性
的情况下,将报文的字符压缩成不定长编码
(出现越频繁的字符编码越短),从而实现信息压缩的目的。
前缀码:所谓前缀码,就是字符的编码表中,不存在某个字符是另一个字符的前缀。赫夫曼码就是一种前缀码,而前缀码可以避免二义性。下面看一下例子:
-
字符 编码 P 000 Q 11 R 01 S 001 T 10 这种编码就是前缀码,编码是不会产生二义性。
-
字符 编码 P 0 Q 1 R 01 S 10 T 11 这种会产生二义性,例如:
01
,无法确定是R
还是PQ
,不属于前缀码。
赫夫曼编码:赫夫曼编码根据字符出现的次数,为字符节点赋予权重,随后构建赫夫曼树,树上的所有节点就是需要编码的字符。此时将赫夫曼树中所有向左的树枝记作0,向右的树枝记作1,每个字符的编码就是从根节点到该字符所在的叶子节点上的路径的数字串。
图解:
四. 二叉排序树(BST)
1. 概述
定义:二叉排序树(又名二叉搜索树)是一棵具有特殊性质的二叉树。对于二叉排序树中的所有节点而言:
- 左子树上的所有结点的值均小于该节点的值。
- 右子树上的所有节点的值均大于该节点的值。
也就是说:左子树<节点<右子树
例子:
用途:显然,二叉排序树可以大大方便我们进行查找。我们只需要从根节点开始,如果要查找的值>根节点的值,那么只要查找右子树;反之查找左子树。这样查找最好一次就能查到,最坏需要查找的次数和树的深度一致,即一直查到最深的一个叶子节点。
特点:
- 如果使用中序遍历遍历二叉排序树,我们可以得到一个递增的序列。
2. 插入节点
步骤如下:注意插入节点后的树依旧要是二叉排序树
- 从根节点开始比较。
- 如果当前节点为空,直接插入。
- 如果当前节点的值>插入节点的值,将插入节点与左子节点比比较,重复2、3、4。
- 如果当前节点的值<插入节点的值,将插入节点与右子节点比比较,重复2、3、4。
经过测试可以发现:所有新插入的节点都是叶子节点
。
代码:
void insert(Node** T, Data data){
if(*T == nullptr){
*T = new Node();
(*T)->data = data;
return;
}
if( (*T)->data == data ){
std::cout << "having equal data";
return;
} else if(data < (*T)->data){
return insert(&((*T)->l_child), data);
} else {
return insert(&((*T)->r_child), data);
}
}
int main(void){
// 创建二叉搜索树
Node* root = nullptr;
insert(&root, 19);
insert(&root, 13);
insert(&root, 50);
insert(&root, 11);
insert(&root, 26);
insert(&root, 66);
insert(&root, 9);
insert(&root, 12);
insert(&root, 21);
insert(&root, 30);
insert(&root, 60);
insert(&root, 70);
// 中序遍历
std::queue<Node*> queue;
queue.push(root);
sequenceTraversal(queue);
std::cout << "\n";
destory(root);
return 0;
}
输出:
19 13 50 11 26 66 9 12 21 30 60 70
层序遍历结果正确,创建二叉搜索树成功。
3. 搜索节点
步骤如下:
- 从根节点开始。
- 如果当前节点为空,返回,说明没有该节点。
- 如果当前节点的值==搜索的节点,返回结果。
- 如果当前节点的值>搜索的节点,将当前节点的左节点视作新的根节点,重复2、3、4、5步。
- 如果当前节点的值<搜索的节点,将当前节点的右节点视作新的根节点,重复2、3、4、5步。
简单来说,因为二叉排序树的每个节点都和子树有如下关系:左树<节点<右子树。因此通过与当前节点的比较即可确定接下来比较的对象是左子树还是右子树。这样我们的搜索时间得以降低。
代码:
Node* select(Node* T, Data data){
if(T == nullptr) {
std::cout << "no such element\n";
} else {
if(T->data == data) return T;
else if(T->data < data) return select(T->r_child, data);
else return select(T->l_child, data);
}
return nullptr;
};
4. 删除节点
思路:删除节点p,存在以下三种情况:
-
节点p没有子节点:直接删除即可。
-
节点p有且仅有左子节点或右子节点:此时直接删除该节点,然后用子节点替换该节点。
-
节点p有两个子节点:此时为了保证删除节点p后其它元素之间的相对位置不变,可以借助
中序遍历
进行调整。这里又可以分好几种做法,一般是中序遍历得到递增链表后,找到节点p的直接前驱(或直接后继)s,用s来代替节点p,随后要删除节点s。对于节点s而言,因为直接前驱/直接后继的特殊性,我们只会遇到前两种情况,因此很容易删除。思考一:为什么通过第三种情况可以用直接前驱/直接后继来解决?
答:如果我们使用直接前驱/直接后继s来代替节点p,显然,替换后:左子树<节点s<右子树必定成立,不会影响二叉排序树的性质。
思考二:如何找到节点p的直接前驱/后继。
答:
- 直接前驱:因为节点p有左子树,因此只需要找到左子树中最大的一个节点即可。按照树的走向,应当是从p出发,先取左子节点\(P_L\),随后以\(P_L\)为起点一路取右子节点直到叶子节点,该叶子节点就是该节点的直接前驱。因此可以知道:节点p的直接前驱没有右子树。
- 直接后继:因为节点p有右子树,因此只需要找到右子树中最小的一个节点即可。按照树的走向,应当是从p出发,先取右子节点\(P_R\),随后以\(P_R\)为起点一路取左子节点直到叶子节点,该叶子节点就是该节点的直接后继。因此可以知道:节点p的直接后继没有左子树。
代码:
void delBST(Node* root, Data data){
if(root == nullptr) return;
Node* f = root; // 要删除节点的父节点
Node* p = root; // 要删除的节点
Node* s = nullptr; // 要删除的节点的直接前驱
Node* sf = nullptr; // 直接前驱的父节点
// 首先找到要删除的节点
while(p != nullptr){
if(p->data == data) break;
else if(p->data > data){
f = p;
p = p->l_child;
}else{
f = p;
p = p->r_child;
}
}
if(p == nullptr){
std::cout << "no such element\n";
return;
}
// 如果左子树为空,那么用右子树代替该节点
if(p->l_child == nullptr){
Node* r = p->r_child;
f->l_child == p ? f->l_child = r : f->r_child = r;
delete p;
}
// 如果右子树为空,那么用左子树来代替该节点
else if(p->r_child == nullptr){
Node* l = p->l_child;
f->l_child == p ? f->l_child = l : f->r_child = l;
}
// 此时左右子树都不为空,我们寻找直接前驱(后继)来替换,随后删除前驱(后继)。这里寻找前驱
else{
// 这里寻找前驱的逻辑是:先向左下角转一下,虽有向右下角一直取,一直到叶子节点
s = p->l_child;
sf = p->l_child;
while(s->r_child != nullptr){
sf = s;
s = s->r_child;
}
p->data = s->data; // 用直接前驱的值覆盖要删除的节点的值
// 删除前驱节点
if(s == sf){
p->l_child = nullptr;
delete s;
}else{
sf->r_child = s->l_child;
delete s;
}
}
}
5. 搜索复杂度分析
- 最好的情况:二叉排序树的形态和折半查找的判定树相同,那么平均查找长度和\(log(n)\)成正比,时间复杂度为\(O(log(n))\)
- 最坏的情况:此时先后插入树中的关键字有序,这会导致二叉排序树成为一颗斜树,树的深度为n,平均查找长度为\(\frac{n+1}{2}\),时间复杂度为\(O(n)\),等同于顺序查找。
因此,在使用二叉排序树对一个集合进行查找时,我们希望可以将它构建成一棵平衡的二叉排序树
,也就是平衡二叉树。
五. 平衡二叉树
1. 概述
为什么使用平衡二叉树:
在我们使用二叉排序树对集合排序、查找时,我们发现,如果先后插入树中的关键字有序,这会导致二叉排序树成为一颗斜树,树的深度为n,平均查找长度为\(\frac{n+1}{2}\),时间复杂度为\(O(n)\),等同于顺序查找。这时树退化为了链表,这显然不是我们想要的,我们希望得到一个稳定的、平衡的二叉排序树。
定义:平衡二叉树的定义也是递归的,要满足以下两点:
- 是二叉排序树。
- 任何一个节点的左子树和右子树也都是平衡二叉树(左右高度相差小于1).
这样的定义使得平衡二叉树的深度不会大于$log_2(n)$
,显然,这有利于我们进行查找。
平衡因子(BF):对于节点P而言,其\(平衡因子=左子树高度-右子树高度\)。如果\(|平衡因子|>1\),那么以节点P为根的树是不平衡的二叉排序树。
最小不平衡树:从刚插入的节点P向上回溯,第一个\(平衡因子<1\)的节点,以该节点为根的树是最小不平衡树。
如图:对于刚插入节点F而言,最小的不平衡树为A,对于A,\(BF=1-3=-2\)。
2. 两种旋转方式
如果我们希望在插入过程中随时保证二叉查找树的平衡,首先我们需要了解旋转的概念。
概念:所谓旋转,分为两种情况:
-
左旋:
-
步骤:两步:
- 将要旋转的树的根节点(记作P)相对于其
右子节点
(记作S)向左旋转(逆时针旋转
),使得根节点P成为子节点S的左子节点
。 - 将节点S的
左子树
作为节点P的右子树
。
- 将要旋转的树的根节点(记作P)相对于其
-
EG:将根节点3相对子右子节点5进行左旋:
旋转前: 旋转后:
-
-
右旋:
-
步骤:分两步:
- 将要旋转的树的根节点(记作P)相对于
左子节点
(记作S)向右旋转(顺时针旋转
),使得根节点P成为子节点S的右子节点。 - 将节点S的
右子树
作为节点P的左子树
。
- 将要旋转的树的根节点(记作P)相对于
-
EG:将根节点3相对左子节点1进行右旋:
旋转前: 旋转后:
-
3. 四种旋转纠正类型
在对二叉查找树进行插入时,如果出现了不平衡现象,我们只需要向从刚插入节点(记作P)向上寻找,找到第一个\(|BF|>1\)的节点(此处记作F),以该节点为根节点进行调节。从节点F触发,沿着指向新插入节点的路径记录三个节点
(分别为节点F、节点F的子节点S、节点F的孙子节点G),此时会遇到四种情况:
3.1 LL型
形态:上述三个节点F、S、G一路指向左下,形态如下:
插入节点P!=节点G: 插入节点P==节点G:
调整方法:右旋:
- 将节点F顺时针旋转,充当S的右子节点。
- 让S的右子节点作为F的左节点。
3.2 RR型
形态:F、S、G一路指向右下(后续不再对P和G是否相同进行讨论,因为本质和后续操作都是一样的):
调整方法:左旋
- 让节点F逆时针旋转,充当S的左节点。
- 让S的左子树充当F的右子树。
3.3 LR型
形态:F、S、G先向左、再向右。
调整方法:LR型和RL型比LL、RR型号调整起来更加麻烦,需要进行两次旋转:
- 先令S相对于G进行左旋,将三个节点调整为LL型。
- S相对于G逆时针,S充当节点G的左子树。
- G的左子树充当S的右子树。
- F相对于G进行右旋,调整完毕。
- F相对于G进行顺时针,F充当G的右子树。
- G的右子树充当F的左子树。
图解:
3.4 RL型
形态:F、S、G先向右、再向左。
调整方法:LR型和RL型比LL、RR型号调整起来更加麻烦,需要进行两次旋转:
- 让S相对于G进行右旋,F、S、G变为RR型:
- S相对于G顺时针旋转,S成为G的右子树。
- G的右子树成为S的左子树。
- 让F相对于G左旋,调整完毕:
- F相对于G进行逆时针旋转,F成为G的左子树。
- G的左子树成为F的右子树。
图解:
4. 例题
用{3,2,1,4,5,6,7,10,9,8}
构造一棵平衡二叉树。
解答如下:
-
当插入
{3,2,1}
节点时,树不平衡,出现结构LL型
,将3相对于2进行右旋:- 节点3相对于节点2逆时针旋转,充当节点2的右子树。
- 节点2之前的右子树(这里为空)充当节点3的左子树。
-
当插入
{3,2,1,4,5}
时,树不平衡,出现结构为RR
型,将3相对于进行左旋:- 节点3逆时针旋转,充当节点4的左子树。
- 节点4之前的左子树(这里为空)充当节点3的右子树。
-
当插入节点
{3,2,1,4,5,6}
时,树不平衡,出现结构为RR型
,将2相对于4左旋:-
节点2逆时针旋转,充当节点4的左子树。
-
节点4之前的左子树节点3充当节点2的右子树。
-
-
当插入节点
{3,2,1,4,5,6,7}
时,树不平衡,出现结构为RR
型号,将2相对于6左旋:- 5相对于6逆时针旋转,充当6的左子树。
- 6原本的左子树(这里为空)充当5的右子树。
-
当插入节点
{3,2,1,4,5,6,7,10,9}
时,树不平衡,出现结构为RL
型号,先右旋,后左旋:- 10相对于9右旋。
- 10成为9的右子树。
- 9原本的右子树(这里为空)成为10的左子树。
- 7相对于9左旋。
- 7成为9的左子树。
- 9原本的左子树(这里为空)成为7的右子树。
- 10相对于9右旋。
-
当插入节点
{3,2,1,4,5,6,7,10,9,8}
时,树不平衡,出现结构为RL
型号,先右旋,后左旋:- 9相对于7右旋。
- 9充当7的右子树。
- 7原本的右子树8成为9的左子树。
- 6相对于7左旋。
- 6充当7的左子树。
- 7原本的左子树(这里为空)成为6的右子树。
- 9相对于7右旋。
5. 代码实现
略。