二叉搜索树

发布于:2025-02-11 ⋅ 阅读:(151) ⋅ 点赞:(0)

1. 二叉搜索树的介绍

1.1 概念

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

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

下图都为二叉搜索树:
在这里插入图片描述

1.2 二叉搜索树的性能分析

  • 最优情况下,二叉搜索树为完全二叉树(或者接近完全二叉树),其高度为:O(log2N)
  • 最差情况下,二叉搜索树退化为单支树(或者类似单支),其高度为:O(N/2)
  • 所以综合而言二叉搜索树增删查改时间复杂度为:O(N)

那么这样的效率显然是无法满足我们需求的,后面几篇文章会介绍二叉搜索树的变形如:平衡二叉搜索树、AVL树和红黑树,这些才能适用于我们在内存中存储和搜索数据。

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

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

这也从侧面体现出了平衡二叉搜索树的价值。

在这里插入图片描述

2. 二叉搜索树的增删查改

2.1 插入

插入的树结点结构为:

	template<class K, class V>
	struct BSTreeNode// key/value结构
	{
		K _key;// _key是关键字,增删查改都按关键字进行,不允许修改_key值
		V _val;// _val是另存储的值,可支持修改
		struct BSTreeNode<K, V>* _left;
		struct BSTreeNode<K, V>* _right;
		BSTreeNode(const K& key, const V& val)
			:_key(key), _val(val)
			, _left(nullptr), _right(nullptr) {}
	};

插入的具体过程如下:

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

下图是一颗二叉搜索树的插入步骤图(都以没有重复值的为例):
在这里插入图片描述
代码如下:

		//规定按key值比较,比根小插入至左子树,比根大插入至右子树,key值相同不插入并返回false
		bool Insert(const K& key, const V& value)
		{
			if (_root == nullptr)//空树直接插入
			{
				Node* newnode = new Node(key, value);
				_root = newnode;
				return true;
			}
			Node* cur = _root; //遍历
			Node* parent = nullptr;//记录父亲
			while (cur)//循环从根遍历找合适空位
			{
				if (key < cur->_key)//比根小
				{
					parent = cur;
					cur = cur->_left;
				}
				else if (key > cur->_key)//比根大
				{
					parent = cur;
					cur = cur->_right;
				}
				else
					return false;//跟根相等
			}
			//cur为空,说明找到空位,直接用parent链接
			Node* newnode = new Node(key, value);
			if (key < parent->_key)//链接时再进行判断
				parent->_left = newnode;
			else
				parent->_right = newnode;
			return true;
		}

2.2 查找

查找显然有两种方式:

  • 1.遍历查找
  • 2.利用搜索树性质查找
  • 第一种,通过遍历进行查找,时间复杂度最好为O(1),最坏为O(N),平均为O(N)
  • 第二种,利用搜索树特性查找,时间复杂度最好为O(1),近似单支树时是最坏:为O(N),近似完全二叉树时是平均:为O(logN)

这里以第二种实现为例:

  1. 从根开始比较,查找key,key比根的值大则往右边走查找,key比根值小则往左边走查找。
  2. 最多查找高度次,走到为空,还没找到,这个值不存在。
  3. 如果不支持插入相等的值,找到x即可返回。
		Node* Find(const K& key)
		{
			if (_root == nullptr)//空树返回空
				return nullptr;
			Node* cur = _root;
			while (cur)
			{
				if (key < cur->_key)
					cur = cur->_left;
				else if (key > cur->_key)
					cur = cur->_right;
				else
					return cur;
			}
			return nullptr;//找完了没找到,就返回空
		}

2.3 删除(重点)

  • 讨论:
    Ⅰ.首先查找元素是否在二叉搜索树中,如果不存在,则返回false。
    Ⅱ.如果查找元素存在则分以下四种情况分别处理:(假设要删除的结点为N)
  1. 要删除结点N左右孩子均为空
  2. 要删除的结点N左孩子为空,右孩子结点不为空
  3. 要删除的结点N右孩子为空,左孩子结点不为空
  4. 要删除的结点N左右孩子结点均不为空
  • 解决:
  1. 把N结点的父亲对应孩子指针指向空,直接删除N结点(情况1可以当成2或者3处理,效果是一样的)
  2. 把N结点的父亲对应孩子指针指向N的右孩子,直接删除N结点
  3. 把N结点的父亲对应孩子指针指向N的左孩子,直接删除N结点
  4. 无法直接删除N结点,因为N的两个孩子无处安放,只能用替换法删除。找N的左子树的最右结点R(左子树最大结点)或者N的右子树的最左结点L(右子树最小结点)替代N,因为这两个结点中任意一个,放到N的位置,都满足二叉搜索树的规则。替代N的意思就是N和L(或R)的两个结点的值交换,转而变成删除L(或R)结点,替换后L(或R)结点符合情况2或情况3,可以直接删除。

对于为什么情况1可以看成2或3来处理,解释如下:

  • (1)N的左直接看成空,把N的右孩子特殊,看成是个空的孩子,就变成情况2
  • (2)或者把N的左孩子特殊,看成是个空的孩子,右直接看成空,就变成情况3

下图是一颗二叉搜索树的删除步骤图(涉及替换时,红色结点为删除结点,绿色结点为替换结点):
在这里插入图片描述
附上代码(替换结点为N的左子树的最右结点):

		bool Erase(const K& key)//特别考验逻辑与思考全面性
		{
			if (_root == nullptr)//空树删除不了
				return false;
			Node* cur = _root;
			Node* parent = cur;
			while (cur)//循环去找要删除的结点
			{
				if (key < cur->_key)
				{
					parent = cur;
					cur = cur->_left;
				}
				else if (key > cur->_key)
				{
					parent = cur;
					cur = cur->_right;
				}
				else //找到要删除的结点
				{
					 //有四种情况---对应解决法
					 //1.删除结点N左右都为空---N的父亲对应N的指针指向为空,直接删除N,
					 //  这种可以看成一种特殊情况的2或3,即:
					 //  (1)N的左直接看成空,把N的右孩子特殊,看成是个空的孩子,
					 //  (2)或者把N的左孩子特殊,看成是个空的孩子,右直接看成空
					 //  故这里可以不用实现情况1
					 //2.删除结点N左为空,右不为空---N的父亲对应N的指针指向N的右孩子,直接删除N
					if (cur->_left == nullptr)
					{
						if (_root == cur)// 删除根时
							_root = cur->_right;
						else
						{
							if (parent->_left == cur)//这里还要判断要删除的N对应是其父亲的左还是右孩子
								parent->_left = cur->_right;
							else
								parent->_right = cur->_right;
						}
						delete cur;
					}
					//3.删除结点N左不为空,右为空---N的父亲对应N的指针指向N的左孩子,直接删除N
					else if (cur->_right == nullptr)
					{
						if (_root == cur)
							_root = cur->_left;
						else
						{
							if (parent->_left == cur)
								parent->_left = cur->_left;
							else
								parent->_right = cur->_left;
						}
						delete cur;
					}
					//4.删除结点N左右均不为空---不能直接删除N,删除会破坏结构,只能用对应结点替换法,
					//  替换不是直接交换结点,而是交换结点内的key和val值,替换规则如下:
					//  (1) 若N的左子树不为空,则找N的左子树的最大结点,也就是N的左子树的最右结点
					//  (2) 若N的右子树不为空,则找N的右子树的最小结点,也就是N的右子树的最左结点
					//  然后执行替换结点内的值,此时要删除结点就变成了交换后的结点,删除情况就变为情况2或3了,这里实现(1)
					else //左右都不为空
					{
						Node* find = cur->_left;//找替换结点
						Node* find_parent = cur;//记录替换结点的父亲
						while (find->_right)//一直在N的左子树中找右结点,直至最右结点(也就是左子树最大结点)
						{
							find_parent = find;
							find = find->_right;
						}
						std::swap(find->_key, cur->_key);
						std::swap(find->_val, cur->_val);
						if (find->_left == nullptr)
						{
							if (find_parent->_left == find)//这里还要判断替换的结点对应是其父亲的左还是右孩子
								find_parent->_left = find->_right;
							else
								find_parent->_right = find->_right;
						}
						else
						{
							if (find_parent->_left == find)
								find_parent->_left = find->_left;
							else
								find_parent->_right = find->_left;
						}
						delete find;
					}
					return true;
				}
			}
			return false;//出了循环,说明没找到要删除的结点
		}
  • 代码优化:
    基于情况4的特性,发现用的是删除结点N的左子树的最右结点作替换,也就是说这个替换结点的右孩子一定为空,于是上述代码就不要再逐一判断替换后它是属于情况2还是情况3。简单说,经过上述替换操作后,删除结点按情况3来直接进行删除就行。 其次,swap操作直接改为赋值操作效率也会更高些。

类似下图:
在这里插入图片描述

改正代码如下:

bool Erase(const K& key)
{
	if (_root == nullptr)
		return false;
	Node* cur = _root;
	Node* parent = cur;
	while (cur)
	{
		if (key < cur->_key)
		{
			parent = cur;
			cur = cur->_left;
		}
		else if (key > cur->_key)
		{
			parent = cur;
			cur = cur->_right;
		}
		else
		{
			if (cur->_left == nullptr)
			{
				if (_root == cur)
					_root = cur->_right;
				else
				{
					if (parent->_left == cur)
						parent->_left = cur->_right;
					else
						parent->_right = cur->_right;
				}
				delete cur;
			}
			else if (cur->_right == nullptr)
			{
				if (_root == cur)
					_root = cur->_left;
				else
				{
					if (parent->_left == cur)
						parent->_left = cur->_left;
					else
						parent->_right = cur->_left;
				}
				delete cur;
			}
			else
			{
				Node* find = cur->_left;
				Node* find_parent = cur;
				while (find->_right)
				{
					find_parent = find;
					find = find->_right;
				}
				cur->_key = find->_key;
				cur->_val = find->_val;
				if (find_parent->_left == find)
					find_parent->_left = find->_left;
				else
					find_parent->_right = find->_left;
				delete find;
			}
			return true;
		}
	}
	return false;
}

扩展:感兴趣的读者可以尝试用递归删除

3. 二叉搜索树的应用(了解)

3.1 key搜索场景

只有key作为关键码,结构中只需要存储key即可,关键码即为需要搜索到的值,搜索场景只需要判断key在不在。key的搜索场景实现的⼆叉树搜索树支持增删查,但是不支持修改,修改key破坏搜索树结构了。

  • 场景1:小区无人值守车库,小区车库买了车位的业主车才能进小区,那么物业会把买了车位的业主的⻋牌号录⼊后台系统,⻋辆进入时扫描车牌在不在系统中,在则抬杆,不在则提示非本小区⻋辆,无法进入。
  • 场景2:检查⼀篇英文文章单词拼写是否正确,将词库中所有单词放入二叉搜索树,读取文章中的单词,查找是否在二叉搜索树中,不在则波浪线标红提示。

3.2 key/value搜索场景

每⼀个关键码key,都有与之对应的值value,value可以任意类型对象。树的结构中(结点)除了需要存储key还要存储对应的value,增/删/查还是以key为关键字走二叉搜索树的规则进行比较,可以快速查找到key对应的value。key/value的搜索场景实现的⼆叉树搜索树支持修改,但是不支持修改key,修改key破坏搜索树结构了,修改value则可以。

  • 场景1:简单中英互译字典,树的结构中(结点)存储key(英文)和vlaue(中文),搜索时输入英文,则同时查找到了英文对应的中文。
  • 场景2:商场无人值守车库,入口进场时扫描车牌,记录车牌和入场时间,出口离场时,扫描车牌,查找入场时间,用当前时间-入场时间计算出停车时长,计算出停车费用,缴费后抬杆,车辆离场。
  • 场景3:统计一篇文章中单词出现的次数,读取一个单词,查找单词是否存在,不存在这个说明第一次出现,(单词,1),单词存在,则++单词对应的次数。

网站公告

今日签到

点亮在社区的每一天
去签到