二叉搜索树全解:理论剖析、代码实现与场景应用


❤️@燃于AC之乐 来自重庆 计算机专业的一枚大学生

✨专注 C/C++ Linux 数据结构 算法竞赛 AI

🏞️志同道合的人会看见同一片风景!

👇点击进入作者专栏:

《算法画解》

《linux系统编程》

《C++》

🌟《算法画解》算法相关题目点击即可进入实操🌟

感兴趣的可以先收藏起来,请多多支持,还有大家有相关问题都可以给我留言咨询,希望希望共同交流心得,一起进步,你我陪伴,学习路上不孤单!

文章目录

前言(整理学习蓝图)

容器,置物之所也,根据"数据在容器中的排列特性",容器可分为序列式(sequence)关联式(associative) 两种:

前几章的学习,已完成了大部分对序列式容器的深入讲解,现在将进入到关联式容器的学习。

关联式容器:数据(每个元素)都有一个键值(key)和一个实值(value)。

当元素被插入到关联式容器中时,容器内部结构(可能是RB-tree,也可能是hash-table)便依照其键值大小,以某种特定规则将这个元素放置于适当位置。关联式容器没有所谓头尾(只有最大元素和最小元素) ,所以不会有所谓
push_back()、push_front()、pop_back()、pop_front()、begin()、end() 这样的操作行为。

一般而言,关联式容器的内部结构是一个balanced binary tree(平衡二叉树),以便获得良好的搜寻效率。balanced

binary tree有许多种类型,包括AVL-tree、RB-tree,AA-tree.

其中,被STL使用得最广泛的就是红黑树(RB-tree)。

由于后面要学的map,set都是要以一种平衡二叉树(blanced binary tree),红黑树为轮子,我们先引入最基础的二叉搜索树(binary search tree),先了解大的框架,循序渐进,由浅入深的学习是一个比较好的过程。

一、概念

⼆叉搜索树(binary search tree)又称⼆叉排序树,它可以是空树,也可以是具有以下性质的⼆叉树:

若它的左子树不为空,则左子树上所有结点的值都小于等于根结点的值

若它的右子树不为空,则右子树上所有结点的值都大于等于根结点的值

它的左右子树也分别为⼆叉搜索树

⼆叉搜索树中可以支持插入相等的值,也可以不支持插入相等的值,具体看使用场景定义,后续我们学习map/set/multimap/multiset系列容器底层就是⼆叉搜索树,其中map/set不支持插入相等值,multimap/multiset支持插入相等值。

二、性能分析

查找:

二叉搜索树可以提供对数时间的元素插入和访问,但是会受到节点大小的分布限制,如下:

这样的效率显然无法满足实际需求。因此,后续课程将继续讲解二叉搜索

树的优化结构------平衡二叉搜索树AVL树和红黑树,它们适用于在内存中

高效地存储和查询数据。

需要补充的是,二分查找虽然也能达到 O(log₂N) 的查找效率,但其存在两

个显著缺陷:

数据必须存储在支持随机访问且有序的结构中;

插入和删除效率低,由于依赖顺序存储,增删数据往往需要移动大量元

素。

这也正是平衡二叉搜索树的重要价值所在。

三、基础接口实现:

模板定义与中序遍历

cpp 复制代码
template<class K>
struct BSTNode
{
	K _key;
	BSTNode<K>* _left;
	BSTNode<K>* _right;
	BSTNode(const K& key)
		:_key(key)
		, _left(nullptr)
		, _right(nullptr)
	{}
};

//binary search tree  key
template<class K>
class BSTree
{
	//typedef BSTNode<K> Node;
	//C++11
	using Node = BSTNode<K>;

public:
	//类里面递归的写法:
	//root私有,外部无,公开在成员函数中暴露
	void InOrder()
	{
		_InOrder(_root); //*this
		cout << endl;
	}

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

		_InOrder(root->_left);
		cout << root->_key << " ";
		_InOrder(root->_right);
	}

private:
	Node* _root = nullptr;
};

3.1 二叉树的插入(insert)

1.树为空,新增节点赋值给root指针;

2.树不空,按⼆叉搜索树性质,插入值比当前节点大往右走,插入值比当前节点小往左走,找到空位置,插入新节点。

3.支持插入等值(左右走规则要一致)或者不支持(这里实现)。

cpp 复制代码
bool Insert(const K& key)
{
	if (_root == nullptr)
	{
		_root = new Node(key);
		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);
	if (parent->_key < key)
	{
		parent->_right = cur;
	}
	else
	{
		parent->_left = cur;
	}
	return true;
}

3.2 二叉搜索树的查找(find)

1.从根开始比较,查找x,x比根的值大则往右边走查找,x比根值小则往左边走查找。

2.最多查找高度N次,走到到空,还没找到,这个值不存在。

3.如果不支持插入相等的值,找到x即可返回

4.如果支持插入相等的值,意味着有多个x存在,⼀般要求查找中序的第⼀个x。(这里实现的是不支持相等值)

cpp 复制代码
bool 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 true;
		}
	}
	return false;
}

四、进阶接口------删除(erase)实现

对于二叉搜索树的erase接口实现才是一大难点,在力扣的题目中也考察过,非常的重要。

4.1 对4种情况分析

首先查找元素是否纯在,不存在返回false,其次大致要分为四种情况

  1. 要删除结点 N 左右孩子均为空

    解决方案:

    把 N 结点的父亲对应孩子指针指向空,直接删除 N 结点(情况 1 可以当成 2 或者 3 处理,效果是一样的)

  2. 要删除的结点 N 左孩子为空,右孩子结点不为空

    解决方案:

    把 N 结点的父亲对应孩子指针指向 N 的右孩子,直接删除 N 结点 。

  3. 要删除的结点 N 右孩子为空,左孩子结点不为空

    解决方案:

    把 N 结点的父亲对应孩子指针指向 N 的左孩子,直接删除 N 结点 。

  4. 要删除的结点 N 左右孩子结点均不为空

    解决方案:

    无法直接删除 N 结点,因为 N 的两个孩子无处安放,只能用替换法删除 。找 N 左子树的值最大结点 R(最右结点)或者 N 右子树的值最小结点 R(最左结点)替代 N(这里使用 N 右子树的值最小结点 R最左结点),因为这两个结点中任意一个,放到 N 的位置,都满足二叉搜索树的规则。替代 N 的意思就是 N 和 R 的两个结点的值交换,转而变成删除 R 结点,R 结点符合情况 2 或情况 3,可以直接删除。

4.2 代码实现

cpp 复制代码
bool Erase(const K& key)
{
	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
		{
			//情况123(0-1个孩子)
			if (cur->_left == nullptr) //左为空
			{
				if (cur == _root)
				{
					_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 (cur == _root)
				{
					_root = cur->_left;
				}
				else
				{
					if (parent->_left == cur)
					{
						parent->_left = cur->_left;
					}
					else
					{
						parent->_right = cur->_left;
					}
				}
				delete cur;
			}

			else //左右都不为空
			{
				//找替代节点(这里用右子树的最左节点)
				Node* replace = cur->_right;
				//Node* replaceParent = nullptr; 这里不能给空,有右边第一个就是
				//右边最左的情况,就不会进入循环更新父节点,而父节点不为空
				Node* replaceParent = cur;
				while (replace->_left)
				{
					replaceParent = replace;
					replace = replace->_left;
				}

				cur->_key = replace->_key;

				if (replaceParent->_left == replace)
					replaceParent->_left = replace->_right;
				else
					replaceParent->_right = replace->_right;
				delete replace;
			}
			return true;
		}
	}
	return false;
}

五、测试用例及结果

cpp 复制代码
#include "BST.h"

int main()
{
	key::BSTree<int> t;
	int a[] = { 8, 3, 1, 10, 1, 6, 4, 7, 14, 13 };
	for (auto e : a)
	{
		t.Insert(e);
	 }

	t.InOrder();
	t.Insert(16);
	t.InOrder();

	t.Insert(3);
	t.InOrder();

	t.Erase(3);
	t.InOrder();

	t.Erase(8);
	t.InOrder();

	for (auto e : a)
	{
		t.Erase(e);
		t.InOrder();
	}
	
	return 0;
}

六、二叉搜索树key和key/value使用场景

5.1 基于关键码 key 的搜索场景

当搜索仅依赖关键码 key,且只需判断 key 是否存在时,数据结构中仅存

储 key 即可。此时关键码即为搜索目标,搜索过程只关注 key 是否存在于

结构中。在该场景下,二叉搜索树支持插入、删除与查找,但不支持修改

操作,因为修改 key 会破坏搜索树的有序结构。

场景示例:

场景1:

小区无人值守车库

业主购买车位后,物业会将其车牌号录入后台系统。车辆进入时扫描车牌,若系统中有记录则抬杆放行;若无记录则提示"非本小区车辆,禁止进入"。

场景2:

英文单词拼写检查

将词典中所有单词存入二叉搜索树。读取文章中的每一个单词,若单词存在于二叉搜索树中,则拼写正确;否则以波浪线标红提示拼写错误。

5.2 基于关键码 key/value 的搜索场景:

在 key/value 搜索场景中,每个关键码 key 都对应一个值 value,value 可

以是任意类型的对象。树的结点中除了存储 key,还需存储对应的

value。数据的增、删、查操作仍基于 key 按二叉搜索树规则进行比较,

并借此快速定位并获取对应的 value。

在此类场景中,二叉搜索树支持修改 value,但不能修改 key,否则会破坏搜索树的有序性质。

场景示例:

场景1:

简单中英互译词典

树结点中存储 key(英文单词)与 value(中文释义)。搜索时输入英文单词,系统即可同时获取对应的中文翻译。

场景2:

商场无人值守车库

车辆入场时扫描车牌,系统记录车牌号(key)与入场时间(value)。出场时再次扫描车牌,根据 key 查找入场时间,用当前时间减去入场时间计算停车时长,并生成停车费用。缴费后抬杆放行。

场景3:

统计文章中单词出现次数

依次读取文章中的单词,若单词未存在于树中,则插入该单词并初始化次数为 1;若单词已存在,则将其对应次数加一,从而完成词频统计。

加油!志同道合的人会看到同一片风景。

看到这里请点个赞关注 ,如果觉得有用就收藏一下吧。后续还会持续更新的。 创作不易,还请多多支持!

相关推荐
寻寻觅觅☆9 小时前
东华OJ-基础题-106-大整数相加(C++)
开发语言·c++·算法
fpcc10 小时前
并行编程实战——CUDA编程的Parallel Task类型
c++·cuda
ceclar12311 小时前
C++使用format
开发语言·c++·算法
lanhuazui1011 小时前
C++ 中什么时候用::(作用域解析运算符)
c++
charlee4411 小时前
从零实现一个生产级 RAG 语义搜索系统:C++ + ONNX + FAISS 实战
c++·faiss·onnx·rag·语义搜索
老约家的可汗11 小时前
初识C++
开发语言·c++
crescent_悦12 小时前
C++:Product of Polynomials
开发语言·c++
小坏坏的大世界12 小时前
CMakeList.txt模板与 Visual Studio IDE 操作对比表
c++·visual studio
乐观勇敢坚强的老彭12 小时前
c++寒假营day03
java·开发语言·c++
愚者游世13 小时前
brace-or-equal initializers(花括号或等号初始化器)各版本异同
开发语言·c++·程序人生·面试·visual studio