引言
本文将用c++的代码演示二叉树的线索化全过程。我们在创建一棵非常普通的二叉树之后,会发现一个很大的缺点,就是我们的结点是有左右孩子的,但是对于叶子结点来说,它的左孩子或者右孩子是空的,没有存任何的数据,但是我们也知道,指针的大小是4字节,也就是说如果这棵树很大,那么就会存在很多很多的空指针占用了内存空间,很浪费。
所以我们必须要利用着一些指针,让这一些指针有作用。而线索二叉树的本质就是如果左孩子是空,那么就让左指针指向本节点的前驱节点,同理,右节点指向本节点的后继节点。
简单计算
假设我们有n个结点,我们可以计算一下有多少个空指针。如果一个指针有孩子,那么反过来可以说这个结点有父节点,所以有多少个结点有父节点呢?
很明显,除了根节点以外所有节点都有父节点,所以数量是n-1,那么总指针2n,所以空指针有2n-(n-1)
结构
既然有的结点是指向了前驱,有的结点是指向了孩子,那我们怎么知道这个结点的指针到底是指向的什么呢?所以我们需要一个标志来记录一下,也就是flag
cpp
struct BTNode{
char data;
BTNode* left;
BTNode* right;
int lflag, rflag; // flag = 0指针域指向孩子,flag = 1指针域指向线索节点
};
然后我们既然要连接前驱结点和后继结点,那么我们很显然是需要双指针进行操作的,一个指向这个结点的前面,一个指向当前的指针。
然后还有一个很重要的点,就是遍历的顺序。我们二叉树遍历顺序是十分重要的,一个好的遍历顺序可以大大的减少我们头发掉落的可能性。
我们这里选择是中序遍历,至于为什么,我们会放在代码的最后讲解
代码
OK,既然思路有了,那么我们就开始工作!!!
首先初始化一个结点
cpp
BTNode* InitBTree(char value){
BTNode* s = new BTNode();
s->data = value;
s->lflag = s->rflag = 0;
s->left = s->right = nullptr;
return s;
}
然后保证我们之后可以找到我们想要的数据
注意一下:这里的查找都是要保证指针是指向的孩子,而不是前驱结点或者后继结点。
我们查找数据的方式是左边查找完,查找右边的,当然,我们把这个函数的出口,也就是处理这个结点的过程,放在了最开始
cpp
BTNode* Find(BTNode* root, char fx){ // 查找叶子节点fx
if(root->data == fx){ // 每一次处理结点都是在开始,这个也就是递归的出口
return root;
}
BTNode* ans = nullptr;
if(root->left != nullptr && root->lflag == 0){ // 先左边找
ans = Find(root->left, fx);
if(ans != nullptr){
return ans;
}
}
if(root->right != nullptr && root->rflag == 0){ // 再右边找
ans = Find(root->right, fx);
if(ans != nullptr){
return ans;
}
}
return nullptr;
}
然后就是构建这一棵树的过程。不过为了知道我们到底是插在了左孩子还是右孩子,我们选择在传入参数的时候,传入一个flag,来记录操作者想要插入的位置
cpp
BTNode* Insert(BTNode* root, char x, char fx, int flag){ // 我们这里的插入都是在叶子结点进行的插入
BTNode* f = Find(root, fx);
BTNode* s = new BTNode();
s->data = x;
s->lflag = s->rflag = 0;
s->left = s->right = nullptr;
if(flag == 0){
f->left = s;
} else {
f->right = s;
}
return root;
}
然后就是中序遍历的整个基本框架,先遍历左边,然后处理结点,再遍历右边
cpp
void InOrderBTree(BTNode* root){
if(root == nullptr){
return;
}
InOrderBTree(root->left);
Visit(root);
InOrderBTree(root->right);
}
处理这个结点的操作如下:我们不仅仅要连接两个结点,还要改变指针的意义。
不过要注意的是,前驱结点的第一个是nullptr,所以如果对于一个空指针进行操作,就会报错,所以我们在处理前驱结点的时候需要时刻小心
cpp
void Visit(BTNode* p){
// p和pre是一对前驱和后继的关系
if(p->left == nullptr){ // 给p添加前驱线索
p->lflag = 1;
p->left = pre;
}
if(pre != nullptr && pre->right == nullptr){ // 给pre添加后继线索(要保证pre不是空的,因为一开始是根节点,肯定是空的)
pre->rflag = 1;
pre->right = p;
}
pre = p;
}
然后我们有了整个线索二叉树之后,我们来处理一下找前驱和找后继的操作:
找前驱:
如果一个结点的左孩子是空,那么说明这是第一个结点,因为除了第一个结点,其他结点的左指针要么是左孩子,要么是前驱节点。
如果不为空,并且还正好标记是1,直接拿数据就可以了
但是如果是左孩子,那么我们就需要利用中序遍历的特点了。中序遍历的特点是左中右,如果右结束了,那么这一棵左子树就结束了,这颗左子树结束就到了中间结点,也就是我们的目标节点。 就是如果一个结点有左子树,那么这颗左子树的最右边那一个结点就是它的前驱结点。
cpp
// 找前驱
BTNode* p = Find(root, x);
if(p->left == nullptr){
std::cout << "no exist" << std::endl;
} else {
if(p->lflag == 1){
std::cout << p->left->data << std::endl;
} else {
// p->left指向的是左孩子,不是前驱,并且p->left不是空,此时前驱一定是在p的左子树中,而且一定是左子树中最靠右的结点
BTNode* q = p->left;
// q 只要是右孩子存在,就一直往右走,直到右孩子不存在
while(q->right != nullptr && q->rflag == 0){
q = q->right;
}
std::cout << q->data << std::endl;
}
}
同理,对于找后继结点,我们找右子树的最左边这个结点。不过要注意的是,后继节点有可能是空结点,所以我们还是要多多小心
cpp
// 找后继
if(p->right == nullptr){
std::cout << "no exist" << std::endl;
} else {
if(p->rflag == 1 && p->right != nullptr){
std::cout << p->right->data << std::endl;
} else {
// 此时后继一定在右子树中,且一定是第一个遍历的结点,所以一定是右子树中的最左边的结点
BTNode* q = p->right;
// q 只要是左孩子存在,就一直往左走,直到左孩子不存在
while(q->left != nullptr && q->lflag == 0){
q = q->left;
}
std::cout << q->data << std::endl;
}
}
总结
大家其实在整个代码的过程中,也慢慢发现了中序遍历的优势,因为中序遍历把父节点插在了中间,导致我们找前驱节点和找后继节点更加容易。所以这也就是我们为什么选择中序遍历的原因
本篇文章到这里就结束了,希望可以帮助到大家理解线索二叉树!!!!