【C++】二叉搜索树



前言

        学习二叉搜索树是为了给后面学习 AVL树红黑树 打基础的,因此还是比较重要的。


一、二叉搜索树的概念

二叉搜索树又称二叉排序树,它或者是一棵空树,或者是具有以下性质的二叉树:

  • 若它的左子树不为空,则左子树上所有结点的值都小于等于根结点的值
  • 若它的右子树不为空,则右子树上所有结点的值都大于等于根结点的值
  • 它的左右子树也分别为二叉搜索树
  • 二叉搜索树中可以支持插入相等的值,也可以不支持插入相等的值,具体看使用场景定义,后续我们学习map/set/multimap/multiset系列容器底层就是二叉搜索树,其中map/set不支持插入相等值,multimap/multiset支持插入相等值

因为这些性质,使得其在查找数据时效率较高

二叉搜索树示意图:

左:不支持相等值        右:支持相等值

简单来说:二叉搜索树的每一个节点,它的左子树节点全是小于等于它的,它的右子树节点全是大于等于它的。关于等于,取决于该二叉搜索树是否支持相等值。

关于排序:

前面所说,二叉搜索树又称二叉排序树,这是因为二叉搜索树输出时一般按照中序遍历输出

  • 如上图左边树按照中序遍历:1->3->4->6->7->8->10->13->14
  • 右边树按照中序遍历:1->3->3->6->7->8->10->10->13

很明显,二叉搜索树中的数据按照中序输出就是有序的。


二、二叉搜索树的效率

最优情况下:二叉搜索树为完全二叉树(或者接近完全二叉树),其高度为: 。效率最好。

如:此时可以通过与根节点大小比较快速锁定目标节点


 最差情况下:二叉搜索树退化为单支树(或者类似单支),其高度为: N

如:此时效率最低,假如查找1,基本遍历了所有数据 


总结: 

  • 所以综合而言二叉搜索树增删查改时间复杂度为: O(N)
  • 根据上图很容易发现二叉搜索树的缺陷,这样的效率显然是无法满足我们需求的,我们后续课程需要继续讲解二叉搜索树的变形,平衡二叉搜索树AVL树和红黑树,才能适用于我们在内存中存储和搜索数据。

补充说明:

需要说明的是,二分查找也可以实现 级别的查找效率,但是二分查找有两大缺陷:

  1. 需要存储在支持下标随机访问的结构中,并且有序。
  2. 插入和删除数据效率很低,因为存储在下标随机访问的结构中,插入和删除数据一般需要挪动数据。

这里也就体现出了平衡二叉搜索树的价值。


三、二叉搜索树的模拟实现

注:我们先主要实现不支持相等数据的二叉搜索树

1.基本框架

//定义节点
template<class K>
struct BSNode
{
	K _key;
	BSNode<K>* _left;
	BSNode<K>* _right;

	BSNode(const K& key)//构造
		:_key(key)
		, _left(nullptr)
		, _right(nullptr)
	{
	}
};

//二叉搜索树
template<class K>
class BSTree
{
	typedef BSNode<K> Node;//重命名方便书写
public:

private:
	Node* _root = nullptr;
};
  • 关于节点,_key用于存储数据, _left 和 _right 分别是左子树和右子树的节点指针。
  • 搜索二叉树的成员变量只要一个,就是根节点。
  • 注意上面模版参数都是 K,因为该模板参数主要控制 _key 的类型。


2.二叉搜索树的插入

插入的具体过程如下:

  1. 树为空,则直接新增结点,赋值给_root指针
  2. 树不为空,按二叉搜索树性质,插入值比当前结点大往右走,插入值比当前结点小往左走,找到空位置,插入新结点。
  3. 如果支持插入相等的值,插入值跟当前结点相等的值可以往右走,也可以往左走,找到空位置,插入新结点。(要注意的是要保持逻辑一致性,插入相等的值不要⼀会往右走,一会往左走)

比如:在下面二叉搜索树中插入 9

9比8大,所以往8的右子树走,9比10小,所以往10的左子树走,10的左子树为空,所以9应该插入这里。

代码实现:

bool Insert(const K& key)
{
	//树为空,直接作为根节点
	if (_root == nullptr)
	{
		_root = new Node(key);
		return true;
	}

	//树不为空,需要查找插入位置
	//查找需要两个指针,cur用于找到目标位置,perent用于标记cur的父节点
	//这样就可以链接新节点
	Node* perent = nullptr;
	Node* cur = _root;
	while (cur)
	{
		if (key > cur->_key)//比当前值大,往右子树找
		{
			perent = cur;//先更新父节点
			cur = cur->_right;//走到右孩子节点
		}
		else if (key < cur->_key)//比当前值小,往左子树找
		{
			perent = cur;//先更新父节点
			cur = cur->_left;//走到左孩子节点
		}
		else
		{
			return false;//如果是相等值,则插入失败,因为我们是实现不支持重复值的
		}
	}

	//走到这里,说明cur找到了
	cur = new Node(key);//先创建结点
	//使用父节点进行链接,因为不确定是父节点的左孩子还是右孩子
	//所以需要单独判断,比较插入值与父节点值的大小即可,大插右,小插左
	if (key > perent->_key)
	{
		perent->_right = cur;
	}
	else
	{
		perent->_left = cur;
	}

	return true;//插入成功返回true
}
  • 代码实现中给出了详细注释,注意查看。
  • 插入操作相对较简单


3.二叉搜索树的查找

不知道你有没有发现,其实在插入操作中,我们同样进行了查找操作,我们需要根据参数找到其在二叉树中的位置。作业关于二叉树的查找操作,可以直接复制插入操作。

代码实现:

//查找
bool Find(const K& key)
{
	Node* cur = _root;//cur还是用于遍历查找
	while (cur)//查找策略与插入查找一致,不再赘述
	{
		if (key > cur->_key)
		{
			cur = cur->_right;
		}
		else if (key < cur->_key)
		{
			cur = cur->_left;
		}
		else
		{
			return true;
		}
	}
	return false;
}
  • 查找简单点说就是,大往右,小往左
  • 因为查找就是寻找相等值,以上是不支持数据重复。如果支持重复数据,那么返回的就是重复数据在中序遍历中的第一个。


4.二叉搜索树的删除

注:删除应该是二叉搜索树中最复杂的一部分了

删除操作主要复杂在,当我们删除一个节点后,我们需要继续保持搜索二叉树的性质,那么删除一个节点后就需要对树进行调整。

示例1: 

  • 如上图,如果我们删除1,那么结构是不受影响的
  • 当我们删除10时,我们就需要进行调整,此时我们可以将10的右子树替代10原来的位置

删除后:

根据以上两种情况,可以总结两条情况:

  1. 如果删除节点的左右孩子都为空,那么该节点可以直接删除
  2. 如果删除节点的左孩子为空,那么可以使用该节点的右孩子代替原位置

示例2:

  • 删除14,我们可以用14的左子树代替原来位置
  • 所以,当删除节点的右子树为空时,我们可以将其左孩子代替原节点

示例3:

  • 当删除节点的左右子树都不为空时,这时候就比较棘手了
  • 解决方法就是,找出删除节点左子树中最大值,或者找出删除节点右子树中最小的值,只有满足这两种情况的值,才能替换删除节点,保持搜索二叉树的性质

如:找出 3 右子树中最小的值进行替换


总结:

  • 将以上情况的解决方法总结一下,情况1和2,3其实可以合并在一起
  1. 左子树为空:把N结点的父亲对应孩子指针指向N的右孩子,直接删除N结点
  2. 右子树为空:把N结点的父亲对应孩子指针指向N的左孩子,直接删除N结点
  3. 左右子树都不为空:无法直接删除N结点,因为N的两个孩子无处安放,只能用替换法删除。找N左子树的值最大结点 R(最右结点)或者N右子树的值最小结点R(最左结点)替代N,因为这两个结点中任意⼀个,放到N的位置,都满足二叉搜索树的规则。替代N的意思就是N和R的两个结点的值交换,转而变成删除R结点,R结点符合情况2或情况3,可以直接删除。

左右子树都为空的情况没有单独讨论,因为它可以在左子树为空或者右子树为空的情况中被解决。左右孩子都为空,那么也可以说用其左右任意空指针代替原位置然后删除。

代码实现:

//删除
bool Erase(const K& key)
{
	Node* cur = _root;//cur遍历查找目标位置
	Node* perent = nullptr;//跟踪记录cur的父节点
	while (cur)
	{
		if (key > cur->_key)
		{
			perent = cur;
			cur = cur->_right;
		}
		else if (key < cur->_key)
		{
			perent = cur;
			cur = cur->_left;
		}
		else
		{
			//找到了
			//1.左子树为空
			if (cur->_left == nullptr)
			{
				if (cur == _root)//如果删除节点为根节点
				{
					_root = cur->_right;//用不为空的右子树替代
				}
				else//不是根节点
				{
					//需要单独判断,确认cur是其父节点左右哪个孩子节点
					if (perent->_left == cur)
					{
						perent->_left = cur->_right;//更新父节点的左孩子,变相删除了cur
					}
					else if (perent->_right == cur)
					{
						perent->_right = cur->_right;
					}
				}
				delete cur;//替换后,释放节点
				return true;
			}
			else if (cur->_right == nullptr)//2.右子树为空
			{
				if (cur == _root)//根节点依旧要单独判断
				{
					_root = cur->_left;
				}
				else
				{
					if (perent->_left == cur)
					{
						perent->_left = cur->_left;
					}
					else if (perent->_right == cur)
					{
						perent->_right = cur->_left;
					}
				}
				delete cur;
				return true;
			}
			else//3.左右子树都不为空
			{
				//找右子树最小节点
				Node* minRight = cur->_right;//minRight查找最小节点
				Node* minRightPerent = cur;//跟踪记录minRight的父节点
				while (minRight->_left)//一直遍历左孩子就能找到最小节点
				{
					minRightPerent = minRight;
					minRight = minRight->_left;
				}

				cur->_key = minRight->_key;//找到节点后先替换目标值
				//依旧需要确认是父节点的哪个孩子
				if (minRightPerent->_left == minRight)
				{
					minRightPerent->_left = minRight->_right;//替换,变相删除
				}
				else
				{
					minRightPerent->_right = minRight->_right;
				}
				delete minRight;//释放节点
				return true;
			}
		}
	}
	return false;//没有找到,返回false
}

注释解释了大部分代码,但还有一些问题在这里解释:

  • 1.为什么在左子树为空的情况下,或者右子树为空的情况下,如果查找到的是根节点需要单独判断,这是因为此时父节点perent=nullptr,因为cur是根节点所以导致perent无法更新,因此需要单独判断这种情况。
  • 2.为什么左右子树都不为空的情况下,又不需要单独判读根节点情况,因为我们在初始化时,让 minRightPerent=cur,所以不存在父节点为空的情况。
  • 3.那为什么 perent 不初始化为 _root,你可以自己代入查看一下,行不通。
  • 4.perent 和 minRightPerent 都是为了记录要删除节点的父节点,这样方便删除和链接节点,但是我们并不能确认究竟是父节点的哪一个节点是要删除的节点,即便有一个节点为空,我们也不能提前得知,所以在涉及更新父节点的孩子节点时,都需要判断一下。


5.二叉搜索树的输出打印

  • 因为二叉搜索树又称二叉排序树嘛,所以输出是按照中序输出。
  • 关于中序遍历有任何问题可以查看我以前关于二叉树的文章。

问题:

  • 中序遍历是需要根节点的,而根节点 _root 属于私有成员,外界不能访问。

解决方案:

  • 为了确保类的封装性,我们不能将 _root 公开,这里有两种方法解决:
  1. 增加一个 GetRoot 函数获取根节点。
  2. 将中序打印函数写在私有限定符里,并在公有限定符中再封装一层,利用类成员函数可以直接访问私有成员变量,这样外面调用中序打印时,就可以不用传参直接使用了。

很明显,第二种方案更有优势。

代码实现:

//二叉搜索树
template<class K>
class BSTree
{
	typedef BSNode<K> Node;
public:
	//中序遍历
	void InOrder()
	{
		_InOrder(_root);
		cout << endl;
	}

private:
	//中序打印
	void _InOrder(Node* root)
	{
		if (root == nullptr)
		{
			return;
		}
		_InOrder(root->_left);
		cout << root->_key << " ";
		_InOrder(root->_right);
	}

	Node* _root = nullptr;
};
  • 经过一层封装解决,就是这么巧妙。


四、完整代码和使用演示

SerchBinaryTree.h

#pragma once

//定义节点
template<class K>
struct BSNode
{
	K _key;
	BSNode<K>* _left;
	BSNode<K>* _right;

	BSNode(const K& key)//构造
		:_key(key)
		, _left(nullptr)
		, _right(nullptr)
	{
	}
};

//二叉搜索树
template<class K>
class BSTree
{
	typedef BSNode<K> Node;//重命名方便书写
public:
	bool Insert(const K& key)
	{
		//树为空,直接作为根节点
		if (_root == nullptr)
		{
			_root = new Node(key);
			return true;
		}

		//树不为空,需要查找插入位置
		//查找需要两个指针,cur用于找到目标位置,perent用于标记cur的父节点
		//这样就可以链接新节点
		Node* perent = nullptr;
		Node* cur = _root;
		while (cur)
		{
			if (key > cur->_key)//比当前值大,往右子树找
			{
				perent = cur;//先更新父节点
				cur = cur->_right;//走到右孩子节点
			}
			else if (key < cur->_key)//比当前值小,往左子树找
			{
				perent = cur;//先更新父节点
				cur = cur->_left;//走到左孩子节点
			}
			else
			{
				return false;//如果是相等值,则插入失败,因为我们是实现不支持重复值的
			}
		}

		//走到这里,说明cur找到了
		cur = new Node(key);//先创建结点
		//使用父节点进行链接,因为不确定是父节点的左孩子还是右孩子
		//所以需要单独判断,比较插入值与父节点值的大小即可,大插右,小插左
		if (key > perent->_key)
		{
			perent->_right = cur;
		}
		else
		{
			perent->_left = cur;
		}

		return true;//插入成功返回true
	}

	//查找
	bool Find(const K& key)
	{
		Node* cur = _root;//cur还是用于遍历查找
		while (cur)//查找策略与插入查找一致,不再赘述
		{
			if (key > cur->_key)
			{
				cur = cur->_right;
			}
			else if (key < cur->_key)
			{
				cur = cur->_left;
			}
			else
			{
				return true;
			}
		}
		return false;
	}

	//删除
	bool Erase(const K& key)
	{
		Node* cur = _root;//cur遍历查找目标位置
		Node* perent = nullptr;//跟踪记录cur的父节点
		while (cur)
		{
			if (key > cur->_key)
			{
				perent = cur;
				cur = cur->_right;
			}
			else if (key < cur->_key)
			{
				perent = cur;
				cur = cur->_left;
			}
			else
			{
				//找到了
				//1.左子树为空
				if (cur->_left == nullptr)
				{
					if (cur == _root)//如果删除节点为根节点
					{
						_root = cur->_right;//用不为空的右子树替代
					}
					else//不是根节点
					{
						//需要单独判断,确认cur是其父节点左右哪个孩子节点
						if (perent->_left == cur)
						{
							perent->_left = cur->_right;//更新父节点的左孩子,变相删除了cur
						}
						else if (perent->_right == cur)
						{
							perent->_right = cur->_right;
						}
					}
					delete cur;//替换后,释放节点
					return true;
				}
				else if (cur->_right == nullptr)//2.右子树为空
				{
					if (cur == _root)//根节点依旧要单独判断
					{
						_root = cur->_left;
					}
					else
					{
						if (perent->_left == cur)
						{
							perent->_left = cur->_left;
						}
						else if (perent->_right == cur)
						{
							perent->_right = cur->_left;
						}
					}
					delete cur;
					return true;
				}
				else//3.左右子树都不为空
				{
					//找右子树最小节点
					Node* minRight = cur->_right;//minRight查找最小节点
					Node* minRightPerent = cur;//跟踪记录minRight的父节点
					while (minRight->_left)//一直遍历左孩子就能找到最小节点
					{
						minRightPerent = minRight;
						minRight = minRight->_left;
					}

					cur->_key = minRight->_key;//找到节点后先替换目标值
					//依旧需要确认是父节点的哪个孩子
					if (minRightPerent->_left == minRight)
					{
						minRightPerent->_left = minRight->_right;//替换,变相删除
					}
					else
					{
						minRightPerent->_right = minRight->_right;
					}
					delete minRight;//释放节点
					return true;
				}
			}
		}
		return false;//没有找到,返回false
	}

	//中序遍历
	void InOrder()
	{
		_InOrder(_root);
		cout << endl;
	}

private:
	//中序打印
	void _InOrder(Node* root)
	{
		if (root == nullptr)
		{
			return;
		}
		_InOrder(root->_left);
		cout << root->_key << " ";
		_InOrder(root->_right);
	}

	Node* _root = nullptr;
};

使用演示:

#include <iostream>
using namespace std;
#include "SerchBinaryTree.h"

int main()
{
	BSTree<int> b;
	b.Insert(6);
	b.Insert(9);
	b.Insert(1);
	b.Insert(3);

	b.InOrder();
	b.Erase(1);
	b.InOrder();

	return 0;
}

运行结果:


总结

        以上就是本文的全部内容了,感谢你的支持!

评论 21
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值