【数据结构进阶】之搜索二叉树(C++实现)

📃博客主页: 小镇敲码人
💞热门专栏:数据结构与算法
🚀 欢迎关注:👍点赞 👂🏽留言 😍收藏
🌏 任尔江湖满血骨,我自踏雪寻梅香。 万千浮云遮碧月,独傲天下百坚强。 男儿应有龙腾志,盖世一意转洪荒。 莫使此生无痕度,终归人间一捧黄。🍎🍎🍎
❤️ 什么?你问我答案,少年你看,下一个十年又来了 💞 💞 💞

🍎 搜索二叉树的定义

二叉搜索树是一种树形结构之一,它是一种特殊的二叉树,它有着这样的性质:
1.根的左子树的值都比根的值要小。
2.根的右子树的值都要比根的值要大。
注意:搜索二叉树的左右子树也是搜索二叉树。

💎 搜索二叉树的性质

这里直接给出结论,那就是搜索二叉树的一个福利(性质),其中序遍历(先遍历左子树,再遍历根节点,最后遍历右子树)为一个排好序了的单调递增序列,这里我们等下根据程序验证这个性质。由于这个性质,搜索二叉树又可以叫做二叉排序树或二叉查找树,注意:空树也当作搜索二叉树

🍎 搜索二叉树的模拟实现

我们想手撕一个搜索二叉树,先要弄清楚这个树需要具备哪些功能?

  1. 插入(递归与非递归)
  2. 删除 (递归与非递归)
  3. 查找(递归与非递归)

然后就是设计这个二叉树的大体框架,我们首先需要定义一个结构体用来表示搜索二叉树的节点,然后我们需要定义一个搜索二叉树的类模板来放置这个搜索二叉树的方法和数据成员,这里我们为了简单,没有将类方法的声明和定义分离。

注意:我们的搜索二叉树为了简单,不支持存储重复元素。

template<class T>//定义模板参数,因为这个
//节点结构体,用来存放一个节点的元素
struct BSTreeNode{
	BSTreeNode(const T& val = T())
		:left_(nullptr),right_(nullptr),val_(val)
	{}
	BSTreeNode<T>* left_;
	BSTreeNode<T>* right_;
	T val_;
};

template<class T>
class BSTree//二叉搜素树的类模板
{
	typedef BSTreeNode<T> Node;
	typedef Node* PNode;
public:
	BSTree() :root_(nullptr);//普通构造函数
	BSTree(const BSTree<int>& copyed);//拷贝构造函数
	BSTree<int>& operator=(BSTree<int> tmp);//赋值运算符重载
	bool Insert(const T& val);//非递归插入
	bool InsertF(const T& val);//递归插入
	PNode Find(const T& val);//非递归查找
	PNode FindF(const T& val);//递归查找
	bool erase(const T& val);//非递归删除
	bool eraseF(const T& val);//递归删除
	~BSTree()//析构函数
private:
	PNode root_;
};

上面是大致的结构,下面我们具体来看一下每一个方法的具体实现

💎 搜索二叉树的插入

插入分为两种情况
1.树为空,这种情况直接创建一个新的节点,然后把它给根节点即可。
2.树不为空,循环这个树如果这个节点值大于根就往右边去找插入位置,反之往左边去找插入位置,如果这个位置为空,就代表可以插入了,注意我们应该保存父节点,否则将无法插入。如果有节点的值等于我们要插入的值,那么被插入的值已经存在,我们直接返回。

在这里插入图片描述

🍁 非递归版本

bool Insert(const T& val) {
		//根节点为空
		if (root_ == nullptr)
		{
			root_ = new Node(val);
		}
		//根节点不为空
		else {
			PNode parent = nullptr;
			PNode child = root_;
			while (child != nullptr)
			{
				if (val > child->val_)
				{
					parent = child;
					child = child->right_;
				}
				else if (val < child->val_)//如果小于当前节点的值,我们就往
				{
					parent = child;
					child = child->left_;
				}
				else//等于说明这个元素已经存在,直接返回false
					return false;
			}
			PNode newnode = new Node(val);
			if (parent->val_ > val)
				parent->left_ = newnode;
			else
				parent->right_ = newnode;
			return true;
		}
	}

🍁 递归版本

public:
bool InsertF(const T& val)
	{
		return Insert_(val, root_);
	}
private:
bool Insert_(const T& val, PNode& root) 
	{
		if (root == nullptr)//找到了把新节点的地址直接传给父节点左节点或者右节点指针的引用,此时的root是它们的别名改变root的值,就相当于改变了左节点或者右节点的保存地址
		{
			root = new Node(val);
			return true;
		}
		else if (val > root->val_)//大于当前节点,继续遍历它的右子树
			return Insert_(val, root->right_);
		else if (val < root->val_)
			return Insert_(val, root->left_);
		else
			return false;
	}

这里引用起到很大的作用,它是节点指针的别名,改变它保存的地址值,就是改变了相应父亲节点的指向,如果你对引用不太熟悉,可以看一下博主这篇文章,对引用有着清晰的解释。

至于上面为什么要用函数套一层,因为递归要用到根节点,根节点又是private,受类访问限定符的限制,在外面是调不到的,所以只好在里面套一层,那个不直接调的函数Insert_我们也要放在private修饰。

💎 二叉搜索树的查找

查找应该如何实现?只要理清楚下面的几点,查找搜索二叉树中的某个值就变的异常简单了。

  • 找到那个值就返回true,找不到就返回false(当前节点为空指针)。
  • 如果要寻找的目标值大于当前节点的值就去继续遍历它的右子树,否则遍历它的左子树。

🍁 非递归版本

bool Find(const T& val)
	{
		PNode root = root_;
		while (root)
		{
			if (val > root->val_)
				root = root->right_;
			else if (val < root->val_)
				root = root->left_;
			else
				return true;//找到了返回true
	    }
		return false;//根节点为空,我们直接返回空指针
	}

🍁 递归版本

public:
bool FindF(const T& val) 
	{
		return Find_(root_,val);
	}
private:
bool Find_(PNode root, const T& val)
	{
		if (root == nullptr)
			return false;
		if (val > root->val_)
			return Find_(root->right_, val);
		else if (val < root->val_)
			return Find_(root->left_, val);
		else
			return true;
	}

这里套一层是为了解决同样的问题,递归需要根节点,但是根节点无法从外面传。这里小伙伴对递归可能有些疑问,在博主的二叉树其它系列文章里有详细的对递归展开图的展示,如果你有疑问,请自行翻阅,这里我们只给出粗略图:

在这里插入图片描述
在这里插入图片描述

💎 二叉搜索树的删除

删除就比其它两个方法要复杂一点,但是只要我们弄清楚下面的情况,这个问题也就迎刃而解了:
1.我们要删除的那个节点只有一个节点,我们使用托孤法,将这个节点的子节点托给它的父亲。

在这里插入图片描述
2.我们要删除的那个节点一个节点都没有,其实这种情况和情况1一致,都是使用托孤法来解决。

在这里插入图片描述
3.我们要删除的节点有两个子节点(都不为空),此时使用托孤法明显行不通了(你的父亲最多帮你管一个孩子,能力有限(dog)),或者比较复杂,我们换一种方法,使用替换法。

在这里插入图片描述

🍁 非递归版本

bool erase(const T& val)
	{
		PNode parent = root_;
		PNode target = root_;
		while (target != nullptr)
		{
			if (val > target->val_)
			{
				parent = target;
				target = target->right_;
			}
			else if (val < target->val_)
			{
				parent = target;
				target = target->left_;
			}
			else
				break;
		}
		if (target == nullptr)//没找到直接返回false
			return false;
		if (target->left_ == nullptr)//托孤
		{
			if (parent->val_ < val)
				parent->right_ = target->right_;
			else if (parent->val_ > val)
				parent->left_ = target->right_;
			else
				root_ = target->right_;
			delete target;
		}
		else if (target->right_ == nullptr)//托孤
		{
			if (parent->val_ < val)
				parent->right_ = target->left_;
			else if (parent->val_ > val)
				parent->left_ = target->left_;
			else
				root_ = target->left_;
			delete target;
		}
		else//替换法
		{
			PNode tmp = target->right_;
			PNode parent_ = target;
			while (tmp->left_)
			{
				parent_ = tmp;
				tmp = tmp->left_;
			}
			swap(tmp->val_, target->val_);//交换待删除和替代节点
			if (parent_ == target)//右子树没有左节点
				target->right_ = tmp->right_;
			else//右子树有左节点此时,我们把替换节点的右子树给父亲节点的左子树即可,不用关心它是否为空
			parent_->left_ = tmp->right_;
			delete tmp;
		}
		return true;
	}

🍁 递归版本

递归遍历主要用到了引用,代码相对于非递归版本会少不少,逻辑上大体一致,只不过实现上有一点细微区别,引用的使用让我们的递归代码看起来更加的简洁。

bool eraseF(const T& val)
    {
		return erase_(root_, val);
	}
bool erase_(PNode& root, const T& val)
	{
		if (root == nullptr)//root等于空说明二叉树搜索树为空或者没找到这个节点,我们直接返回空
			return false;
		if (val > root->val_)//被删除值比当前节点值大去它的右树找
			return erase_(root->right_, val);
		else if (val < root->val_)//被删除值比当前节点值小去它的左树找
			return erase_(root->left_, val);
		else//找到了我们开始执行删除操作
		{
			
			if (root->left_ == nullptr)//被删除节点只有一个子树,托孤法
			{
				PNode tmp = root;//提前保存,防止内存泄漏
				root = root->right_;
				delete tmp;
			}
			else if (root->right_ == nullptr)//被删除节点只有一个子树,托孤法
			{
				PNode tmp = root;//提前保存,防止内存泄漏
				root = root->left_;
				delete tmp;
			}
			else//被删除节点有两个子节点,替换法
			{
				//先找右子树的最左节点,并把当前根节点值和它的值进行交换
				PNode tmp = root->right_;
				PNode parent = root;
				while (tmp->left_)
				{
					parent = tmp;
					tmp = tmp->left_;
				}
				swap(tmp->val_, root->val_);//交换
				//此时我们要删除的目值所在节点至多只有一个子节点了,我们使用托孤法完成删除
				if (parent == root)//目标节点的右子树只存在一个左节点
					parent->right_ = tmp->right_;
				else
				{
					parent->left_ = tmp->right_;
				}
				delete tmp;
			}
			return true;//删除成功,返回true
		}
	}

大体思路一致,注意细节:如引用的理解,这里和插入的非递归引用的使用是一样的,因为引用本质是PNode指针的别名,改变了root,就相当于改变了某个父节点的右节点或者左节点的指针值,注意:一个引用变量不能作为多个值的引用,所以如果这里如果是情况3,目标节点有两个子节点,引用就不能发挥作用了,因为我们只能使用临时变量保存,用二级指针可能可行。

💎 拷贝构造函数

有时候,我们可能要用一个已经存在搜索二叉树类对象来初始化另外一个新创建的搜索二叉树对象,这个时候就需要用到拷贝构造函数,正常情况下系统会默认生成一个拷贝构造函数。

在这里插入图片描述
上图是我们没有写拷贝构造函数,编译器默认生成的拷贝构造函数,这里有小伙伴就会有疑问了,既然编译器默认生成的可以正常运行,那么重新写一个不是多次一举吗,然而并不是这样,这里是因为我们还没有写析构函数(释放空间),如果写了析构函数这里就会出现二次析构的问题(释放不属于你的空间),因为系统默认生成的拷贝构造是值拷贝,这里需要深拷贝,这个问题我们放到下面的析构函数去将,下面我们来对拷贝构造函数进行一下模拟实现:

public:
BSTree(const BSTree<int>& copyed)
	{
		preorder_(root_,copyed.root_);//这里我们走一个前序来构建一下搜索二叉树
	}
private:
void preorder_(PNode& root,const PNode& copyroot)
	{
		if (copyroot == nullptr)
			return;
		root = new Node(copyroot->val_);
		preorder_(root->left_,copyroot->left_);
		preorder_(root->right_,copyroot->right_);
	}

这里我们使用二叉树的前序遍历递归构建了一个新的二叉搜索树,并且空间都是新开的,是深拷贝。

💎 赋值运算符重载

在类和对象中我们已经介绍了,关于为什么要赋值运算符重载的一些知识,同样的如果我们不显式的去写,编译器也会默认生成一个赋值运算符重载的函数,但是同样的会存在深拷贝的问题:

在这里插入图片描述

这里程序没有崩溃,同样是因为我们还没有写析构函数,等会我们会来验证,这里我们先模拟实现一下赋值运算符重载函数:

BSTree<int>& operator=(BSTree<int> tmp)
	{
		swap(tmp.root_,root_);
		return *this;
	}

这里我们使用的是现代的写法(ps:比较高级的意思hhhh),首先看参数不是引用(使用引用会导致外面那个值改变,这是不符合我们的需求的),而是外面对象的一个拷贝(这里是深拷贝,因为我们已经实现了深拷贝的拷贝构造函数),直接交换它们的根节点地址,然后返回this指针的解引用就完成了赋值,外面调用还是这样:

在这里插入图片描述

💎 析构函数

终于到我们的二叉搜索树的析构函数了,这个函数不用多说,自然是用来释放我们在堆上面的申请的节点的空间,直接开始模拟实现,这里我们走一个二叉树的后序遍历来释放空间:

public:
~BSTree()//析构函数
	{
		cout << "root_:" << root_ << endl;
		Postorder_(root_);
	}
private:
void preorder_(PNode& root,const PNode& copyroot)
	{
		if (copyroot == nullptr)
			return;
		root = new Node(copyroot->val_);
		preorder_(root->left_,copyroot->left_);
		preorder_(root->right_,copyroot->right_);
	}

这里析构函数在递归走后序遍历之前,我们打印了一下根节点的地址,这里就是为了验证是否我们对同一片空间进行了两次内存的释放。

我们先来验证赋值运算重载函数我们我们不进行深拷贝的实现,会出现什么问题,我们先将我们写好的注释,调用编译器默认生成的。

在这里插入图片描述
这是没注释时:

在这里插入图片描述
程序正常运行。

再注释掉写好的拷贝构造函数,使用编译器自己默认生成的拷贝构造函数:

在这里插入图片描述

可以看到出现了一样的问题,由于没有进行深拷贝,而导致我们对已经释放的空间进行了二次释放,这里我们不对深拷贝进行更详细的探讨,在C++类和对象(二)中会有更全面的解释。

这是没注释时:

在这里插入图片描述
程序正常运行。

🍎 搜索二叉树的应用

💎 K模型

搜索二叉树的K模型和我们刚刚实现的搜索二叉树没有什么区别,它的应用有很多,比如我们的门禁,就是将你的信息插入到门禁系统里,再查找你这个人存不存在。

💎 K-V模型

K-V模型也叫做Key-value模型,它会存两个值,一个就是Key(关键字),另外一个是Value,常见的应用像字典的实现,商场的车库(将你车辆的信息作为key,此时的时间记作value,出来时根据现在的时间和value来收取费用)。

这两个模型都是用搜索二叉树实现的,我们这里不做过多阐述,下面来分析一下搜索二叉树的性能。

🍎 搜索二叉树的性能分析

我们可以看到搜索二叉树的三个关键方法,插入、删除和查找,其中插入和删除都是用到了查找的,所以查找的时间复杂度就决定了整个搜索二叉树的性能如何,这里查找的最坏情况是O(N),具体请看下面这幅图。

在这里插入图片描述
此时查找2和8都程序都需要执行N/2次,是O(N)量级的,更极端的情况如下:

在这里插入图片描述

O(N)的时间复杂度意味着20亿的数据我们可能就要查找20亿(总之很大),有什么好的方法可以解决这个问题,让查找的效率达到O(logN)呢,敬请期待我们下期的平衡搜索二叉树(AVL树)。

  • 30
    点赞
  • 34
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 12
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 12
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小镇敲码人

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值