【C++】二叉搜索树

在这里插入图片描述
本篇博客由 CSDN@先搞面包再谈爱 原创,转载请标注清楚,请勿抄袭。

前言

本来想先把搁置了一个月的Linux讲讲的,但是里面有些内容需要用到一些比较高级的数据结构,用C写的话比较麻烦,所以还是接着我前面的C++讲。

本篇主要讲二叉搜索树,先说概念,然后直接上手实现。再给一些生活中的场景,最后用这里的二叉搜索树来解前面我写数据结构阶段的两道链表题。

正式开始

二叉搜索树(搜索二叉树),也叫二叉排序树。如果某棵二叉搜索树不是空树,则其具有以下性质:

  1. 若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
  2. 若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
  3. 它的左右子树也分别为二叉搜索树

简单来说就是 左 < 根 < 右。搜索树不允许有重复值,所以没有相等的情况。

二叉搜索树是第一个二叉树的应用,还是比较有用的。概念讲完了,就直接开始实现。

模拟实现

就实现三个功能,一般的数据结构都是增删查改四个基本功能,这里二叉搜索树少了一个改的功能,具体为什么各位等会看其余的三个实现就懂了。

二叉搜索树分为两类,一类是key模型,一类是key/value模型,至于什么意思暂时讲不了,但是你们先看模拟实现就行了, 这里先实现key模型的,看完模拟实现就懂了。

树节点以及树框架

二叉搜索树的英文名字叫binary search tree,缩写就用的是BST。

先是树节点,这模版中的模版参数用的是K,而不是平常的T,主要是为了标志出这里的实现是key模型的实现:

在这里插入图片描述

上面的是struct而不是class是因为等会实现的时候节点中的左右孩子指针和val一直都要用到。跟前面我在list的模拟实现那篇中同理。

然后就是树的框架:
在这里插入图片描述

在里面typedef一下树节点,用起来比较方便。初始情况下root为空。

然后就可以写增删查了。

就是往树里面插入。不过这里有点要求。就是插入树节点的时候要保证 左 < 根 < 右。所以要先找到合适的位置,然后再在该位置上插入。

我们就用 int a[] = {8, 3, 1, 10, 6, 4, 7, 14, 13}; 这几个数来挨个插入。

先把图解画出来:
在这里插入图片描述

那么上面的这棵树就是二叉搜索树,如上面的过程能看懂,那么我觉得二叉搜索树的插入思想你就明白了。

就是找合适的位置,插入即可。

先给个接口:
在这里插入图片描述
用bool作为返回值,因为前面说了搜索树中不能有相同的数值。如果有了相同的数值就返回false。

中间要创建节点值为val的新节点,所以我们可以在BSTreeNode中写一个构造:
在这里插入图片描述

然后就是找合适位置了:
在这里插入图片描述
cur经过如上代码,就可找到要插入的位置。

如果数据结构学的不是很扎实的同学可能会犯如下错误:
在这里插入图片描述
这种情况下直接返回。

屏幕前的你知道哪里出问题了吗?
仔细捋一捋就能发现,cur本来已经找到了该插入的位置的,但是new了之后cur的值就变成了val新节点的地址了,这里根本就没有插入,就只是将cur不断地赋值而已。

那么要改一改,插入的时候要插入到合适的位置,要插入到某一个节点的孩子位置,最重要的是要知道插入位置的父节点。

所以找插入位置的过程要不断记住路程中的父节点,这样才能保证插入的位置是在树上的,而不是随机找个节点插。

最终代码如下:
在这里插入图片描述

再来写一个中序遍历验证一下:

中序遍历

如果写成下面这样:
在这里插入图片描述
调用的时候就有点小问题。

这样的写法在用对象调用的时候必须要将树的根节点指针传过去,但是又有一个问题,我实现的树里面根节点是私有的。

想要解决的话可以给一个接口来专门返回根节点的地址;或者还可以用友元。

有的同学说可以给缺省参数,将函数的缺省参数给为_root,这样的做法是错误的:
在这里插入图片描述

这里有一个最优解法,就是搞一个子函数。
像下面这样:

在这里插入图片描述

就可以直接不传参调用InOrder。
在这里插入图片描述
因为不支持插入重复元素,所以这里绝对不会打印出重复元素。中序打印出来的结果完全就是排好序的。因为左根右的遍历方式打印出来就是有序的,不理解的自己想一想。

然后来说查找。

查找

查找是这三个里面最简单的。

在这里插入图片描述

这里不需要返回节点什么的,只要能判断在不在就可以了,这也是key模型的关键所在,等会也会讲对应的应用场景。

测试一下:
在这里插入图片描述

再来说删除。

删除

这个最麻烦,主要是删除一个数后要保持其仍然是一个二叉搜索树。

被删除的节点可以分三种情况:

  1. 没有孩子
  2. 有一个孩子
  3. 有两个孩子

分别来画图看看:

没有孩子

节点删除之后将树中的该位置改为nullptr就行。
在这里插入图片描述
实现起来的话,先找到13,删除13,再让14的左指向空。

有一个孩子

子替换父即可。
在这里插入图片描述

实现起来的话,就是先找到14,然后让10的右指向13,再删除14。

有两个孩子

删除的时候要用到替换法。
最麻烦的就在这里。

两种解决方式:

  1. 让删除节点的左树中最大的替换到删除节点处
  2. 让删除节点的右树中最小的替换到删除节点处

观看理论比较晦涩,看图:
在这里插入图片描述
这样替换下来,仍能够保持其是一棵二叉搜索树。

实现起来的话,两种方法:

  1. 左子树:先找到3,再去3的左子树中找最大值1,然后让二者的值交换,这样1就跑到了根,3就跑到左子树上了,再删除交换后的3处的节点。
  2. 右子树:先找到3,再去3的左子树中找最小值4,然后让二者的值交换,这样4就跑到了根,3就跑到右子树上了,再删除交换后的3处的节点。

再来个例子:
在这里插入图片描述

树的根节点的删除比较特殊,这里没看懂的话没关系,等会会详谈。

根据上面的思想,删除两个孩子的节点方法可以总结如下:

  1. 先找到删除的节点
  2. 删除的时候只用选择 去左树中找最大值 或者 去右树中找最小值 就行了。
    如果去左树中,那么就是左树的最右边,就是左树的最大值。
    如果去右树中,那么就是右树的最左边,就是右树的最小值。

上面孩子的三种情况都要先找到删除的节点,然后再分情况讨论即可。
那就可以写代码了:

因为删除后要让删除的位置为空,所以要定义出一个不断更新的父节点,来找到最后删除位置的父节点。

在这里插入图片描述

根据二叉搜索树的特性,先找到节点:

在这里插入图片描述

然后再分孩子的情况讨论,我们这里可以把没有孩子的和有一种孩子的放到一块,先不说为什么,各位看图:

没有孩子,比如删13的话,此时就是这样:
在这里插入图片描述
删除13,然后让14的左为空,可以直接让14指向13的任意一个节点,因为13的任意节点的值都为空。

有一个孩子,比如删14的话,此时就是这样:
在这里插入图片描述
如果删除14的话,可以让10的右指向14的左13,然后再删除14。

二者都能让 parent节点的左/右 指向 cur的左/右 ,就能实现替换这一过程,替换之后再删除cur即可。

如下:
在这里插入图片描述

然后内部还要分cur是parent的左还是右:
在这里插入图片描述
上面删除cur的地方代码冗余了,等会再搞。

但是还有问题,如果是删除根节点的话,上面的代码就出bug了。
比如说这样:
在这里插入图片描述

因为如果val就是根节点的值话,cur的while循环就进不去,那么parent此时就是nullptr,上面的代码就解引用空指针。所以还要分parent是否为空的情况:

在这里插入图片描述

再来说左右都不为空的节点,对应删除3:在这里插入图片描述
这里我们以找右树的最小值为例:

右子树的最左边就是最小值:
在这里插入图片描述

然后将3和4的值交换,然后再删除min节点就可以了,但是还要将6的左置空,所以又得产生一个不断变换的父节点来记录min的父节点。

所以最终就是这样:
在这里插入图片描述

这里不用判断parent是否为空的情况,因为节点的数值交换了。

代码:
在这里插入图片描述

这样删除工作就做好了,可以说还是比较麻烦的。

测试一下:
在这里插入图片描述
成功。

这里把完整的删除代码给出来:

bool Erase(const K& val)
{
	Node* parent = nullptr;
	Node* cur = _root;

	while (cur)
	{
		if (cur->_val < val)
		{
			parent = cur;
			cur = cur->_right;
		}
		else if (cur->_val > val)
		{
			parent = cur;
			cur = cur->_left;
		}
		else // cur 就是要删除的节点
		{
			if (cur->_left == nullptr)
			{ // 左树为空的情况,分两种
			  // 1.包含了左右都为空 2.只有左为空 对应到图中就是删除13和删除10

				// 判断val是否为_root的val
				if (parent == nullptr) // 也可用 cur == _root 来判断
				{ // 这里cur左右都为空的情况也成立
					_root = cur->_right;
				}
				else
				{
					// 看cur是parent的左树还是右树
					if (cur == parent->_left) // cur是parent的左树
						parent->_left = cur->_right;
					else // cur是parent的右树
						parent->_right = cur->_right;

				}
				delete cur;
				cur = nullptr;
			}
			else if (cur->_right == nullptr)
			{ // 右树为空的情况,上面已经包含左右都为空的情况,所以这里只有一种情况
			  // 就是只有右树为空的情况,对应到图中就是删除14

				// 判断val是否为_root的val
				if (parent == nullptr)
				{
					_root = cur->_left;
				}
				else
				{
					// 看cur是parent的左树还是右树
					if (cur == parent->_left) // cur是parent的左树
						parent->_left = cur->_left;
					else // cur是parent的右树
						parent->_right = cur->_left;
				}

				delete cur;
				cur = nullptr;
			}
			else 
			{ // 左右都不为空

				Node* min = cur->_right;
				Node* parentMin = cur;
				// 去右树中找最小值
				while (min->_left)
				{
					parentMin = min;
					min = min->_left;
				}

				swap(min->_val, cur->_val);
				if (parentMin->_left == min)
					parentMin->_left = min->_right;
				else
					parentMin->_right = min->_left;

				delete min;
				min = nullptr;
			}

			// 删除成功
			return true;
		}
	}

	// 没有删除的节点
	return false;
}

三个功能均已经实现了,我们还可以用递归的方式实现。

递归实现增删查

先说最简单的查。

在这里插入图片描述

再说插入

直接看代码:
在这里插入图片描述

这里非常巧妙运用了引用。

第一个参数root类型为Node*&,什么意思呢,就是一个Node的引用,也就是一个Node变量的别名。

当我们找到了要插入的位置的时候,一定是一个子节点,传过来的一定是root->_left 或者 root->_right 。所以引用的就是父节点左/右的指针。

所以当root为空的时候就是要插入的时候,这时候root就是父节点左/右的指针,就可以直接用new将开辟的空间赋值给root,等价于直接将开辟的空间赋值给了父节点左/右的指针。

先给出大致框架:
在这里插入图片描述

然后跟上面非递归的删除一样,也要判断孩子的情况:
在这里插入图片描述

又因为我们删除节点之后还要置空,但是递归想要找父节点还要多传一个参数,我们此时就可以再将参数改为&的。也就是Node*& root。这样root就直接变成了父节点的左/右指针了。

这里也不需要再考虑删除的位置是否为数的根了,看代码:
在这里插入图片描述

整个递归erase的代码如下:

bool _EraseR(Node*& root, const K& val)
{
	if (root == nullptr)
		return false;

	if (root->_val == val)
	{
		if (root->_left == nullptr)
		{
			Node* right = root->_right;
			delete root;
			root = right;
		}
		else if (root->_right == nullptr)
		{
			Node* left = root->_left;
			delete root;
			root = left;
		}
		else
		{
			Node* min = root->_right;
			while (min->_left)
				min = min->_left;

			swap(min->_val, root->_val);
			_EraseR(root->_right, val);
		}

		return true;
	}

	if (root->_val > val)
		return _EraseR(root->_left, val);

	if (root->_val < val)
		return _EraseR(root->_right, val);
}

到这里这三个功能正式讲完。

注意上面的所写的函数都是子函数,都是私有的,公有的只提供了接口。
在这里插入图片描述

再说点别的。

析构

给出如下代码:

在这里插入图片描述
运行结束之后会崩掉吗?

答案是不会,因为我还没有写析构。

那么二叉树的析构,很简单。后序递归即可。

但是析构函数没有参数,所以也是搞一个子函数就行。
在这里插入图片描述
然后上面的代码运行起来就崩掉了,因为拷贝构造是默认生成的,内置类型做浅拷贝。只是把cp的根节点指向了bst的根节点上,两个值相同。所以析构就崩掉了。

拷贝构造

也是递归构造,要写子函数。
在这里插入图片描述

在这里插入图片描述

测试:
在这里插入图片描述
出错了,编译器说我没有默认的构造函数可用。
因为生成了一个构造函数之后编译器就不再提供默认的构造函数了。拷贝构造也算构造。所以此时加上一个构造函数就行。

此时运行就崩不了。

赋值重载

这个还是老方法,直接参数传值,交换即可。

在这里插入图片描述

下面说说引用场景。

时间复杂度分析

二叉搜索树,听名字就能知道主要是用来搜索的。那么其查找的时间复杂度是多少呢?

可能有的同学认为是logN,其实不是,当树不是接近满二叉树或者完全二叉树时,效率可能比较低,比如棵单边树:
在这里插入图片描述
这样查找效率就很低了,就是O(N)的。

总的来说二叉搜索树的查找效率是取决于树形状的。

所以二叉搜索树控制插入的根节点的值非常重要,但是一般很难决定。后面还有AVL树来平衡整棵树。

应用场景

上面写的是key模型的,主要用来判断关键字在不在,比如说

  1. 学生刷卡进宿舍楼。
    这里就是学生卡中记录学生的某一项信息,比如学号,记录到卡的芯片中,然后数卡的时候通过二叉搜索树来查找是否存在,如果二叉搜索树比较均匀的话(满或完全二叉树),查找的效率就非常高,当然,AVL树,比二叉搜索树方便点,但原理都一样。
  2. 检查一段英文中每个单词拼写是否正确。
    记录正确的拼写,然后查找单词是否存在就行了。

还有一种模型是key/value模型,其原理是通过key来找value。key模型和key/value模型非常相似,key/value模型还是通过key比较,value只是一个附加项。例子有:

  1. 英文单词译为中文

  2. 统计……出现的次数

这里简单写一个key/value模型

代码如下:

template<class K, class V>
struct BSTreeNode
{
	BSTreeNode<K, V>* _left;
	BSTreeNode<K, V>* _right;
	K _key;
	V _value;

	BSTreeNode(const K& key, const V& value)
		:_left(nullptr)
		, _right(nullptr)
		, _key(key)
		, _value(value)
	{}
};

template<class K, class V>
class BSTree
{
	typedef BSTreeNode<K, V> Node;
public:
	bool Insert(const K& key, const V& value)
	{
		if (_root == nullptr)
		{
			_root = new Node(key, value);
			return true;
		}

		Node* parent = nullptr;
		Node* cur = _root;
		while (cur)
		{
			if (cur->_key < key)
			{
				parent = cur;
				cur = cur->_right;
			}
			else if (cur->_key > key)
			{
				parent = cur;
				cur = cur->_left;
			}
			else
			{
				return false;
			}
		}

		cur = new Node(key, value);
		if (parent->_key < key)
		{
			parent->_right = cur;
		}
		else
		{
			parent->_left = cur;
		}

		return true;
	}

	Node* Find(const K& key)
	{
		Node* cur = _root;
		while (cur)
		{
			if (cur->_key < key)
			{
				cur = cur->_right;
			}
			else if (cur->_key > key)
			{
				cur = cur->_left;
			}
			else
			{
				return cur;
			}
		}

		return nullptr;
	}

	bool Erase(const K& key)
	{
		//...

		return true;
	}

	void InOrder()
	{
		_InOrder(_root);
		cout << endl;
	}
private:

	void _InOrder(Node* root)
	{
		if (root == nullptr)
		{
			return;
		}

		_InOrder(root->_left);
		cout << root->_key << ":" << root->_value << endl;
		_InOrder(root->_right);
	}
private:
	Node* _root = nullptr;
};

拿第一个例子:

在这里插入图片描述

在这里插入图片描述

插入的时候是按照英文字符串进行比较的。

两道题

这两道题说一下思路:

链表相交

key模型,先入一个链表,再遍历另一个链表查找某节点是否存在,若存在就返回存在的节点,不存在就继续遍历链表,直至遍历完毕。

复制带随机指针的链表

key/value模型,建立原节点和拷贝节点的映射关系。

比如:

在这里插入图片描述

黑色为原节点,蓝色为拷贝节点。1和1,2和2,3和3,建立映射。

1的random为3,那么蓝色的1random也为3,我们可以通过映射关系,通过黑色的3找蓝色的3,继而找到蓝色的random,然后连接1、3即可。其余同理。

到此结束。。。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

先搞面包再谈爱

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

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

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

打赏作者

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

抵扣说明:

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

余额充值