【C++】二叉搜索树

【C++】二叉搜索树

文章目录

一、二叉搜索树的概念

二叉搜索树又称二叉排序树 ,是一棵自带排序规则 的二叉树。最大的特点是查找速度接近二分查找 ,同时插入、删除不用像数组一样批量挪动数据。

二叉搜索树要么是一棵空树,要么是具有以下性质的二叉树

  1. 若左子树不为空,则左子树上所有结点的值都小于等于根结点的值
  2. 若右子树不为空,则右子树上所有结点的值都大于等于根结点的值
  3. 它的左、右子树也必须是二叉搜索树

特点二叉搜索树的中序遍历(左-根-右)结果是有序递增的 ,这是其最关键的特征,也是验证二叉搜索树正确性的重要方法

二叉搜索树可以支持也可以不支持插入重复值如set/map不支持重复值 ,插入相同值直接失败;multiset/multimap支持相等值,统一把重复数据插到右子树,保证排序逻辑不乱

本文基础代码实现不支持重复值

二、二叉搜索树的性能分析

二叉搜索树的增删查改效率由树的高度决定,存在两种极端情况:

  1. 最好情况(平衡树):树接近完全二叉树,高度为log2N,增删查时间复杂度为 O(logN)
  2. 最差情况(单链树):如果按有序数字依次插入(1、2、3、4、5......),树会变成一条斜着的单链表,高度等于节点总数 N,操作复杂度退化到 O(N),和普通链表一样慢

正因为普通二叉搜索树最坏效率太差,后来衍生出平衡二叉树(AVL 树、红黑树),C++STL 的 map/set 底层就是红黑树

另外需要说明的是,二分查找也可也实现O(logN)的查找效率 ,但是二分查找有致命短板

  1. 数组必须连续存储,插入 / 删除中间数据需要大批量挪动元素,效率极低
  2. 二叉搜索树只需要修改指针指向,新增、删除节点不会影响其他数据

三、二叉搜索树的核心操作

二叉搜索树的核心操作围绕插入、查找、删除展开,所有操作均遵循左小右大的核心规则

1、数据结构定义

(1)结点结构

每个节点存一个数据 key,外加左右孩子指针,构造函数简化初始化

cpp 复制代码
template<class K>
struct BSTNode
{
	////构造函数
	BSTNode(const K& key)
		:_key(key)
		, _left(nullptr)
		, _right(nullptr)
	{ }

	K _key;				//节点储存的键
	BSTNode<K>* _left;  //左孩子指针
	BSTNode<K>* _right; //右孩子指针
};
(2)树管理类

对外提供简单接口,递归遍历等复杂逻辑封装成私有函数,用户不用关心底层递归细节

cpp 复制代码
template<class K>
class BSTree
{
	typedef BSTNode<K> Node;

public:
	//公有接口:插入、查找、删除、中序遍历
	bool Insert(const K& key);		// 插入数据
	bool Find(const K& key);		// 查找数据是否存在
	bool Erase(const K& key);		// 删除指定数据
	void InOrder();				// 中序遍历(打印有序序列)

private:
//私有递归函数,中序遍历(对外隐藏递归细节)
	void _InOrder(Node* root);

	//根结点
	Node* _root = nullptr;
};

2、中序遍历(验证搜索二叉树的正确性)

利用二叉搜索树中序遍历有序的特性 ,通过递归实现遍历,既是对树结构的遍历,也是验证代码实现是否正确的重要手段(若遍历结果无序,说明实现存在问题)

在二叉搜索树的结构体中实现

cpp 复制代码
//中序遍历
	//公有接口,对外隐藏递归细节
	void InOrder()
	{
		_InOrder(_root);
		cout << endl;
	}

private:
	//私有递归函数,从指定结点开始中序遍历
	void _InOrder(Node* root)
	{
		if (root == nullptr)
			return;
		_InOrder(root->_left);//先遍历左子树
		cout << root->_key << " "; //再访问根节点
		_InOrder(root->_right); //最后遍历右结点
	}

3、二叉搜索树的插入

插入规则

  1. 若树为空,直接新建结点作为根结点
  2. 若树非空 ,按左小右大规则查找插入位置,从根结点开始遍历,比当前结点小则左走,比当前结点大则右走
  3. 找到空位置后,新建结点并通过双亲结点指针挂载到树中
  4. 若遇到相等值,直接返回失败(本文不支持重复值场景)
cpp 复制代码
//插入
bool Insert(const K& key)
{
	//1、空树,直接创建根节点
	if (_root == nullptr)
	{
		_root = new Node(key);
		return true;
	}

	//2、非空树,查找插入位置
	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为空时,跳出循环,找到可以插入的位置了,新建结点并挂载
	cur = new Node(key);
	if (parent->_key < key)
	{
		parent->_right = cur;  //插入到双亲结点右侧
	}
	else
	{
		parent->_left = cur;	//插入到双亲结点左侧
	}
	return true;
}

测试

cpp 复制代码
void test01()
{
	int a[] = { 8, 3, 1, 10, 6, 4, 7, 14, 13 };
	BSTree<int> t;
	
	for (auto e : a)
	{
		bool ret = t.Insert(e);
	}
	//中序遍历验证,有序就是插入正确
	cout << "中序遍历:"<<endl;
	t.InOrder();
}

4、查找操作

查找操作的遍历逻辑与插入完全一致

查找规则

  1. 从根结点开始,按左小右大规则遍历:比当前结点小则左走,比当前结点大则右走
  2. 若找到与目标值匹配的结点,直接返回true(找到)
  3. 若遍历至空结点仍未找到,返回false(未找到)
  4. 若支持重复值 ,一般要求查找中序遍历的第一个匹配值(保证结果唯一性)
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;  //遍历结束未找到,返回失败
}

测试

cpp 复制代码
void test02()
{
	int a[] = { 8, 3, 1, 10, 6, 4, 7, 14, 13 };
	BSTree<int> t;

	for (auto e : a)
	{
		t.Insert(e);
	}

	//查找测试
	int arr[] = { 6,20,3,14,5 };
	for (auto key : arr)
	{
		bool ret = t.Find(key);
		cout << "查找" << key << ":" << (ret ? "存在" : "不存在") << endl;
	}
}

5、删除操作

删除是二叉搜索树最复杂的操作,核心原则是:删除结点后,仍需保持二叉搜索树左小右大的排序规则

操作分为两步先查找待删除结点,若不存在则返回false;若存在,分4种情况处理(可合并为3种核心情况)

4种删除情况 :设待删除结点为cur,其双亲结点为parent,4种情况覆盖了所有可能,其中情况1可合并到情况2/3,简化代码逻辑

  1. cur左右孩子均为空(叶子结点):将parent对应孩子指针置空,直接删除cur
  2. cur左孩子为空,右孩子不为空:将parent对应孩子指针指向cur的右孩子,直接删除cur
  3. cur右孩子为空,左孩子不为空:将parent对应孩子指针指向cur的左孩子,直接删除cur
  4. cur左右孩子均不为空:无法直接删除(会导致树结构断裂),采用替换法:找cur右子树的最小结点(最左结点) 或左子树的最大结点(最右结点) 替换cur,因为这两个结点中任意一个放到cur位置,都满足二叉搜索树的规则。再删除该替换结点(替换结点必满足情况2/3,可直接删除)
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
		{
			//第二步,找到结点后分情况删除
			//情况1+2:左孩子为空或者左右孩子均空(叶子节点)
			if (cur->_left == nullptr)
			{
				if (cur != _root)
				{
					//非根节点,parent结点指向cur的右孩子
					//叶子结点的右孩子是空,所以可以合并处理
					if (cur == parent->_left)
						parent->_left = cur->_right;
					else
						parent->_right = cur->_right;
				}
				else
				{
					//根结点,更新根为cur的右孩子
					_root = cur->_right;
				}
				delete cur;
			}

			//情况3:右孩子为空,左孩子不为空
			else if (cur->_right == nullptr)
			{
				if (cur != _root)
				{
					//非根节点,parent指向cur的左孩子
					if (cur == parent->_left)
						parent->_left = cur->_left;
					else
						parent->_right = cur->_left;
				}
				else
				{
					//根节点,更新根为cur的左孩子
					_root = cur->_left;
				}
				delete cur;
			}

			//情况4:左右孩子均不为空,替换法删除
			else
			{
				//找到cur右子树的最小结点(也就是右子树的最左结点,值最接近cur)
				Node* minRightParent = cur;
				Node* minRight = cur->_right;
				while (minRight->_left)
				{
					minRightParent = minRight;
					minRight = minRight->_left;
				}
				//交换待删除结点和最小结点的键值
				swap(cur->_key, minRight->_key);
				//删除最小结点(必须是左空结点/叶子结点,符合情况2/3)
				if (minRight == minRightParent->_left)
					minRightParent->_left = minRight->_right;
				else
					minRightParent->_right = minRight->_right;
				delete minRight;
			}
			return true;
		}
			
	}

	//未找到待删除结点,返回失败
	return false;
}

测试

cpp 复制代码
void test03()
{
	int a[] = { 8, 3, 1, 10, 6, 4, 7, 14, 13 };
	BSTree<int> t;

	for (auto e : a)
	{
		t.Insert(e);
	}

	//删除测试
	cout << "初始中序遍历";
	t.InOrder();

	t.Erase(1);
	cout << "删除叶子结点1后:";
	t.InOrder();

	t.Erase(14);
	cout << "删除单孩子结点14后:";
	t.InOrder();

	t.Erase(8);
	cout << "删除左右孩子均不为空的结点8后:";
	t.InOrder();
}

四、二叉搜索树的两种经典使用场景

1、key搜索场景

节点只存单个 key,业务只需要完成存在 / 不存在校验 ,无配套数据

典型案例:

  1. 小区车库车牌验证:判断车牌是否是本小区车辆
  2. 英文单词拼写检查 :判断单词是否在词库中(检查拼写正确)
    支持增删查操作,不支持修改key,修改key会破坏二叉搜索树的排序规则,导致所有操作失效

单词拼写检查

cpp 复制代码
void test04()
{
	//模拟词库
	string dict[] = { "apple","banana","orange", "pear", "grape" };
	BSTree<string> spellCheck;
	for (auto& word : dict)
	{
		spellCheck.Insert(word);
	}

	//拼写检查测试
	string testWords[] = { "apple", "orage", "banana", "peer" };
	for (auto& word : testWords)
	{
		bool ret = spellCheck.Find(word);
		cout << "单词\"" << word << "\":" << (ret ? "拼写正确" : "拼写错误") << endl;
	}
}

单词拼写检查的核心逻辑------将词库存入二叉搜索树,通过查找操作判断输入单词是否在词库中

2、存key/value的场景

适用场景

每个key关联一个对应的value(value可为任意类型),通过key快速查找对应的value,典型案例:

  1. 中英互译字典:key为英文单词,value为中文释义;
  2. 统计单词出现次数:key为单词,value为出现次数;
  3. 车库停车计费:key为车牌,value为入场时间。

支持增、删、查操作,不支持修改key,但可修改value(value不参与树的排序,修改不会影响树结构)

仅需修改结点结构和树管理类,增加模板参数V表示value类型,核心遍历和操作逻辑与仅存key版本完全一致,仅插入和遍历需处理value

(1)键值对结点结构
cpp 复制代码
template<class K,class V>
struct BSTNode
{
	//构造函数,初始化键值对
	BSTNode(const K& key,const V& value )
		:_key(key)
		,_value(value)
		, _left(nullptr)
		, _right(nullptr)
	{}

	K _key;				//节点储存的键
	V _value;			//关联数据,不参与排序
	BSTNode<K,V>* _left;  //左孩子指针
	BSTNode<K,V>* _right; //右孩子指针
};
(2)键值对树管理类核心接口
cpp 复制代码
template<class K,class V>
class BSTree
{
	typedef BSTNode<K,V> Node;

public:
	//公有接口:插入、查找、删除、中序遍历

	//插入:需同时传入key和value
	bool Insert(const K& key,const V& value)
	{
		//1、空树,直接创建根节点
		if (_root == nullptr)
		{
			_root = new Node(key,value);
			return true;
		}

		//2、非空树,查找插入位置
		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为空时,跳出循环,找到可以插入的位置了,新建结点并挂载
		cur = new Node(key,value);
		if (parent->_key < key)
		{
			parent->_right = cur;  //插入到双亲结点右侧
		}
		else
		{
			parent->_left = cur;	//插入到双亲结点左侧
		}
		return true;
	}

	//查找:返回结点指针,可通过指针修改value
	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;  //遍历结束未找到,返回空
	}


	//中序遍历:输出key:value,检验同时查看关联数据
	void InOrder()
	{
		_InOrder(_root);
		cout << endl;
	}

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

	//根结点
	Node* _root = nullptr;
};
(3)单词次数统计测试

单词统计的核心逻辑------通过查找key判断是否已存在,存在则修改value(次数+1),不存在则插入新键值对

cpp 复制代码
//单词次数统计测试
void test05()
{
	string arr[] = { "苹果", "西瓜", "苹果", "西瓜", "苹果", "香蕉", "苹果" };
	BSTree<string, int> countTree;
	for (const auto& str : arr)
	{
		auto ret = countTree.Find(str);
		if (ret == nullptr)
		{
			//单词首次出现,插入<单词, 1>
			countTree.Insert(str, 1);
		}
		else
		{
			// 单词已存在,value++统计次数
			ret->_value++;
		}
	}
	// 中序遍历输出统计结果
	cout << "单词出现次数统计:";
	countTree.InOrder();
}

五、搜索二叉树完整代码

1、BinarySearchTree.h头文件

cpp 复制代码
template<class K>
struct BSTNode
{
	////构造函数
	BSTNode(const K& key)
		:_key(key)
		, _left(nullptr)
		, _right(nullptr)
	{
	}

	K _key;				//节点储存的键

	BSTNode<K>* _left;  //左孩子指针
	BSTNode<K>* _right; //右孩子指针
};


template<class K>
class BSTree
{
	typedef BSTNode<K> Node;

public:
	//公有接口:插入、查找、删除、中序遍历

	//插入
	bool Insert(const K& key)
	{
		//1、空树,直接创建根节点
		if (_root == nullptr)
		{
			_root = new Node(key);
			return true;
		}

		//2、非空树,查找插入位置
		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为空时,跳出循环,找到可以插入的位置了,新建结点并挂载
		cur = new Node(key);
		if (parent->_key < key)
		{
			parent->_right = cur;  //插入到双亲结点右侧
		}
		else
		{
			parent->_left = cur;	//插入到双亲结点左侧
		}
		return true;
	}

	//查找
	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;  //遍历结束未找到,返回失败
	}

	//删除
	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
			{
				//第二步,找到结点后分情况删除
				//情况1+2:左孩子为空(包含叶子结点)
				if (cur->_left == nullptr)
				{
					if (cur != _root)
					{
						//非根节点,parent结点指向cur的右孩子
						if (cur == parent->_left)
							parent->_left = cur->_right;
						else
							parent->_right = cur->_right;
					}
					else
					{
						//根结点,更新根为cur的右孩子
						_root = cur->_right;
					}
					delete cur;
				}

				//情况3:右孩子为空,左孩子不为空
				else if (cur->_right == nullptr)
				{
					if (cur != _root)
					{
						//非根节点,parent指向cur的左孩子
						if (cur == parent->_left)
							parent->_left = cur->_left;
						else
							parent->_right = cur->_left;
					}
					else
					{
						//根节点,更新根为cur的左孩子
						_root = cur->_left;
					}
					delete cur;
				}

				//情况4:左右孩子均不为空,替换法删除
				else
				{
					//找到cur右子树的最小结点(也就是右子树的最左结点,值最接近cur)
					Node* minRightParent = cur;
					Node* minRight = cur->_right;
					while (minRight->_left)
					{
						minRightParent = minRight;
						minRight = minRight->_left;
					}
					//交换待删除结点和最小结点的键值
					swap(cur->_key, minRight->_key);
					//删除最小结点(必须是左空结点/叶子结点,符合情况2/3)
					if (minRight == minRightParent->_left)
						minRightParent->_left = minRight->_right;
					else
						minRightParent->_right = minRight->_right;
					delete minRight;
				}
				return true;
			}
			
		}

		//未找到待删除结点,返回失败
		return 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;
};

2、test.c测试文件

cpp 复制代码
#include<iostream>
using namespace std;
#include"BinarySearchTree.h"

void test01()
{
	int a[] = { 8, 3, 1, 10, 6, 4, 7, 14, 13 };
	BSTree<int> t;
	
	for (auto e : a)
	{
		bool ret = t.Insert(e);
	}
	//中序遍历验证,有序就是插入正确
	cout << "中序遍历:"<<endl;
	t.InOrder();
}

void test02()
{
	int a[] = { 8, 3, 1, 10, 6, 4, 7, 14, 13 };
	BSTree<int> t;

	for (auto e : a)
	{
		t.Insert(e);
	}

	//查找测试
	int arr[] = { 6,20,3,14,5 };
	for (auto key : arr)
	{
		bool ret = t.Find(key);
		cout << "查找" << key << ":" << (ret ? "存在" : "不存在") << endl;
	}
}


void test03()
{
	int a[] = { 8, 3, 1, 10, 6, 4, 7, 14, 13 };
	BSTree<int> t;

	for (auto e : a)
	{
		t.Insert(e);
	}

	//删除测试
	cout << "初始中序遍历";
	t.InOrder();

	t.Erase(1);
	cout << "删除叶子结点1后:";
	t.InOrder();

	t.Erase(14);
	cout << "删除单孩子结点14后:";
	t.InOrder();

	t.Erase(8);
	cout << "删除左右孩子均不为空的结点8后:";
	t.InOrder();
}


void test04()
{
	//模拟词库
	string dict[] = { "apple","banana","orange", "pear", "grape" };
	BSTree<string> spellCheck;
	for (auto& word : dict)
	{
		spellCheck.Insert(word);
	}

	//拼写检查测试
	string testWords[] = { "apple", "orage", "banana", "peer" };
	for (auto& word : testWords)
	{
		bool ret = spellCheck.Find(word);
		cout << "单词\"" << word << "\":" << (ret ? "拼写正确" : "拼写错误") << endl;
	}
}
int main()
{
	//test01();
	//test02();
	//test03();
	test04();
	return 0;
}
相关推荐
LongtengGensSupreme1 小时前
C#图像内存高速拷贝:使用Marshal.AllocHGlobal与ArrayPool实现内存拷贝的几个方法
开发语言·数码相机·c#
Java面试题总结1 小时前
Python,单引号和双引号有何区别
开发语言·python
鱼子星_1 小时前
【C++】内存管理:内存分布,new/delete的使用及细节处理,new/delete的底层,定位new表达式
开发语言·c++·笔记
学逆向的1 小时前
汇编——函数
开发语言·汇编·网络安全
鱼子星_2 小时前
【C++】string(上):string的基本使用
c++·笔记·字符串
咸鱼翻身小阿橙2 小时前
多线程解析
java·开发语言
AOwhisky2 小时前
Python 学习笔记(第四期)——字符串:格式化、操作与文本处理——核心知识点自测与详解
开发语言·笔记·python·学习·列表·元组·字典
@@@@@@@@542 小时前
JavaScript 的缺点与 TypeScript 的弥补
开发语言·javascript·typescript
神仙别闹3 小时前
编写基于C++ Huffman 算法的无损压缩程序和解压程序
java·c++·算法