zl程序教程

您现在的位置是:首页 >  后端

当前栏目

【C++】:想知道如何实现互译字典吗?来看二叉搜索树

C++搜索 实现 如何 知道 字典 二叉 来看
2023-09-11 14:16:58 时间

二叉搜索树好文!

文章目录


前言

二叉搜索树概念

二叉搜索树又称二叉排序树,它或者是一棵空树 ,或者是具有以下性质的二叉树 :
若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
它的左右子树也分别为二叉搜索树
二叉搜索树也被叫做二叉排序树或二插查找树。
二叉搜索树:一颗二叉树,可以为空,如果不为空,则满足一下性质:
1.非空左子树的所有键值小于其根节点的键值
2.非空右子树的所有键值大于其根节点的键值
3.左,右子树都是二叉搜索树
如下图所示:

一、实现搜索二叉树

我们首先要构建搜索二叉树的框架,先用一个命名空间将我们要写的二叉树放进去,我们需要用struct结构体实现一个节点:

template <class K>
	struct TreeNode
	{
		TreeNode<K>* left;
		TreeNode<K>* right;
		K _key;
		TreeNode(const K& key)
			:left(nullptr)
			, right(nullptr)
			, _key(key)
		{

		}
	};

 一个数的节点分为左子树和右子树和节点值,节点值用模板变量使用的时候就可以随意存储任意变量的节点了。同时我们要实现节点的构造函数,构造函数体内不需要实现,只需要在初始化列表把需要初始化的变量初始化即可,当然我们也可以给变量一个缺省参数:

template <class K>
	struct TreeNode
	{
		TreeNode<K>* left;
		TreeNode<K>* right;
		K _key;
		TreeNode(const K& key = K())
			:left(nullptr)
			, right(nullptr)
			, _key(key)
		{

		}
	};

 然后我们实现搜索二叉树的主体,其实很简单我们只需要有一个私有成员变量根节点即可,当然为了使用节点方便我们将节点名重命名一下:

template <class K>
	class BSTree
	{
		typedef TreeNode<K> Node;
	public:

	private:
		Node* _root = nullptr;
	};

 明白了二叉搜索树的概念我们就实现一个二插搜索树,首先我们实现插入功能,因为二叉搜索树的规律是如果插入的节点比当前节点小我就去当前节点的左树寻找,如果插入的节点比当前节点大我就去当前节点的右树查找,直到找到空指针我就将要插入的节点放在此位置,下面我们直接写代码:

bool insert(const K& val)
		{
			if (_root == nullptr)
			{
				_root = new Node(val);
				return true;
			}
			Node* cur = _root;
			Node* parent = nullptr;
			while (cur)
			{
				if (cur->_key < val)
				{
					parent = cur;
					cur = cur->right;
				}
				else if (cur->_key > val)
				{
					parent = cur;
					cur = cur->left;
				}
				else
				{
					return false;
				}
			}
			if (parent->_key < val)
			{
				parent->right = new Node(val);
			}
			else
			{
				parent->left = new Node(val);
			}
			return true;
		}

首先我们要判断这棵树是否是一颗空树,对一颗空树插入节点只需要给根节点new一个节点即可。当然要记得插入后要返回true,因为我们一次只插入一个节点。接下来我们用一个节点来遍历,遇到插入节点大于当前节点就往当前节点的右子树走,否则就往左子树走,如果发现要插入的节点在数中已经存在那么我们就不进行插入了直接返回false即可。当我们用于遍历的那个节点为空时说明找到要插入的位置了,但是这个时候节点已经从循环中出来了并且为空我们是不能直接连接的,我们需要一个节点来记录遍历节点的前一个位置,然后当遍历节点为空找到合适的位置我们还不能直接插入,因为我们不知道要插入的是在父节点的左子树还是右子树,如下图:

 所以我们在循环完后要先判断要插入的节点是在父节点的左子树还是右子树,然后我们在进行插入,插入成功后返回true即可。

第二个功能我们实现一个中序遍历,这样就可以先测试我们写的插入功能对不对。
 


void Inorder()
		{
			_Inorder(_root);
		}

void _Inorder(Node* root)
		{
			if (root == nullptr)
			{
				return;
			}
			_Inorder(root->left);
			cout << root->_key << " ";
			_Inorder(root->right);
		}

这里我们为什么要用一个函数调用另一个函数呢?因为我们要遍历一棵树是需要根节点的,但是我们要让用户调用的时候应该用对象调用,不再传参了如下图:

 所以我们需要一个无参的函数封装,中序遍历很简单,遇到空树就返回,然后先遍历左子树打印其节点值再遍历右子树,然后在封装的函数中传入根节点即可。

3.删除节点

对于搜索二叉树来讲,删除节点应该是最复杂的,因为要分多种情况,我们一个一个来看:

第一种情况:删除叶子节点

要删除上图中的叶子节点只需要通过搜索二叉树的规律找到其节点,然后将其释放掉,同时要让删除节点的父节点的某个子树为空。

第二种情况:

如果要删像6和14这样的节点该怎么办呢?这样的节点的规律是只有一个子树,所以我们需要将节点删除后将他们的子树托付给他的父节点,比如删除6就需要将7托付给3的右子树,比如说要删14就要将14的左子树托付给10的右子树,因为我们不能确定要删除的节点有左子树还是右子树,并且也不能确定他是他的父节点的左右哪一颗子树,所以需要我们单独判断。

第三种情况:

第三种情况是最复杂的,要删除的节点既有左子树,又有右子树,在这种情况下我们就需要找一个合适的节点来代替要删除的节点的位置,而了解二叉搜索树的规律后我们发现,这个合适的节点只有两个,一个是要删除节点的左子树中的最大值,第二个是要删除节点的右子树中的最小值。比如上图中要删除8可以用左树中的7替换,也可以用右子树中的10替换。再比如要删除3,那就用3的左子树的1替换或者右树中的4替换,而简单的替换也是不行的,我们发现要帮被删除节点带孩子的节点也有可能有自己的孩子,比如说8如果用10来替换,10也有自己的14这个孩子,所以这个时候我们还需要将10的孩子托付给被替换后的节点如下图:

 了解了以上几点我们就可以写代码了:

bool erase(const K& val)
	{
		if (_root == nullptr)
		{
			return false;
		}
		Node* cur = _root;
		Node* parent = nullptr;
		while (cur)
		{
			if (cur->_key < val)
			{
				parent = cur;
				cur = cur->right;
			}
			else if (cur->_key > val)
			{
				parent = cur;
				cur = cur->left;
			}
			else
			{
				//找到了,有三种情况,1.要删的是叶子节点 2.要删的节点有一个子树  
                //3.要删的节点有2个子树  叶子节点和要删除的节点有一个子树可以一起判断
				//叶子节点
				//要删除的节点只有一个子树
				if (cur->left == nullptr || cur->right == nullptr)
				{
					if (cur->left)
					{
						//要防止歪脖子数,歪脖子树要删除根节点parent为空
						if (parent == nullptr)
						{
							_root = cur->left;
						}
						else if (parent->left == cur)
						{
							parent->left = cur->left;
						}
						else
						{
							parent->right = cur->left;
						}
					}
					else
					{
						if (parent == nullptr)
						{
							_root = cur->right;
						}
						else if (parent->left == cur)
						{
							parent->left = cur->right;
						}
						else
						{
							parent->right = cur->right;
						}
					}
					delete cur;
					return true;
				}
				else
				{
					//要删除的节点有两个子树,这个时候需要左子树中的最大值或者右子树中的最小 
                    值来托管
					//假如让右子树的最小值托管,右子树的最小值一定没有左子树但是可能有右子 
                    //树,所以需要记录右子树最小值的父亲来托管右子树最小值的右子树
					Node* prevparent = cur;
					Node* tmp = cur->right;
					while (tmp->left)
					{
						prevparent = tmp;
						tmp = tmp->left;
					}
					if (prevparent->left == tmp)
					{
						prevparent->left = tmp->right;
					}
					else
					{
						prevparent->right = tmp->right;
					}
					cur->_key = tmp->_key;
					delete tmp;
					return true;
				}
			}
		}
		return false;
	}

 首先如果这棵树本来就为空那么是无法删除的,返回false即可,然后我们用一个节点遍历二叉树,同时还要用一个节点记录遍历节点走之前的前一个位置也就是找父节点,先去找要删除的节点,找到后有三种情况但是我们发现要删除的节点只有一个节点和叶子节点刚好可以一起判断,所以当左树为空或者右树为空的时候就进循环,进入循环后还要判断要删除的节点到底是有左子树还是有右子树,然后再判断要删除节点是其父节点的左右哪颗子树,只有知道了这个我们才能将要删除节点的孩子正确的托管给父节点。从代码中的注释我们也可以看到,还有一种情况是当删除的节点是根节点并且这棵树要不只有左树要不只有右树,这种树在一开始遍历的时候parent节点就为空,所以当发现是这种情况直接就让根节点变成这个数的左子树或右子树即可。判断完前两种情况后我们就判断当要删除的节点左右子树都有的情况,我们以用右子树的最小值为例,这种情况有个特例:

 当要删除8这个节点的时候,本来找右子树的最小值托管是找到其右子树然后一直往这棵树的左子树递归即可,然后还要有一个节点记录递归的最小子树的父节点,找到父节点让其托管右子树最小节点的右子树,但是如上图所示会有无左子树的情况,这种情况下prevparent如果初始化为空指针就会对空指针进行解引用,所以要将这个节点初始化为要删除的节点,这样才能解决上图中删8的情况。最后在托管的时候一定要判断右子树的最小值是其父节点的左子树还是右子树,这些条件都判断完后把要删除的节点释放即可。下面还有另一种实现删除的代码,大家容易理解哪个就写哪个:

bool erase(const K& val)
		{
			Node* cur = _root;
			Node* parent = nullptr;
			while (cur)
			{
				if (cur->_key < val)
				{
					parent = cur;
					cur = cur->right;
				}
				else if (cur->_key > val)
				{
					parent = cur;
					cur = cur->left;
				}
				else
				{
					//找到要删除的节点
					if (cur->left == nullptr)
					{
						if (parent == nullptr)
						{
							_root = cur->right;
							return true;
						}
						if (parent->left == cur)
						{
							parent->left = cur->right;
						}
						else
						{
							parent->right = cur->right;
						}
						delete cur;
						return true;
					}
					else if (cur->right == nullptr)
					{
						if (parent == nullptr)
						{
							_root = cur->left;
							return true;
						}
						if (parent->left == cur)
						{
							parent->left = cur->left;
						}
						else
						{
							parent->right = cur->left;
						}
						delete cur;
						return true;
					}
					else
					{
						//要删除的节点有两个子树,需要右树的最小节点或者左树的最大节点托管
						Node* minRight = cur->right;
						Node* PrevParent = cur;
						while (minRight->left)
						{
							PrevParent = minRight;
							minRight = minRight->left;
						}
						cur->_key = minRight->_key;
						if (PrevParent->left == minRight)
						{
							PrevParent->left = minRight->right;
						}
						else
						{
							PrevParent->right = minRight->right;
						}
						delete minRight;
						return true;
					}
				}
			}
			return false;
		}

4.查找

下面我们实现查找函数:

Node* find(const K& val)
		{
			Node* tmp = _find(_root, val);
			return tmp;
		}
Node* _find(Node* root, const K& val)
		{
			Node* cur = root;
			while (cur)
			{
				if (cur->_key < val)
				{
					cur = cur->right;
				}
				else if (cur->_key > val)
				{
					cur = cur->left;
				}
				else
				{
					return cur;
				}
			}
			return  nullptr;
		}

查找同样需要封装一下在使用,在实现查找函数的时候需要注意当根节点为空时返回nullptr即可。

5.用递归实现查找函数

Node* findR(const K& val)
		{
			Node* tmp = _findR(_root, val);
			return tmp;
		}

Node* _findR(Node* root, const K& val)
		{
			if (root == nullptr)
			{
				return nullptr;
			}
			if (root->_key == val)
			{
				return root;
			}
			if (root->_key < val)
			{
				return _findR(root->right, val);
			}
			else
			{
				return _findR(root->left, val);
			}
		}

递归实现起来其实代码会更简洁,当遇到空就返回空,找到了就返回节点,如果当前节点小于要查找的节点就递归当前节点的右子树查找,否则就去左子树查找。

6.用递归实现插入函数

bool insertR(const K& val)
		{
			bool result = _insertR(_root, val);
			return result;
		}
bool _insertR(Node*& root, const K& val)
		{
			if (root == nullptr)
			{
				root = new Node(val);
				return true;
			}
			if (root->_key < val)
			{
				return _insertR(root->right, val);
			}
			else if (root->_key > val)
			{
				return _insertR(root->left, val);
			}
			else
			{
				return false;
			}
		}

插入函数就是当要插入的节点大于当前节点就递归当前节点的右子树,否则就是左子树,当节点相等我们不插入返回false,等到当前节点为空我们就给当前节点new一个节点,插入成功后返回true即可,下面我们画个递归图解释为什么要用引用:

 7.用递归实现删除函数

bool eraseR(const K& val)
		{
			bool result = _eraseR(_root, val);
			return result;
		}
bool _eraseR(Node*& root, const K& val)
		{
			if (root == nullptr)
			{
				return false;
			}
			if (root->_key < val)
			{
				return _eraseR(root->right, val);
			}
			else if (root->_key > val)
			{
				return _eraseR(root->left, val);
			}
			else
			{
				Node* del = root;
				if (root->left == nullptr)
				{
					root = root->right;
				}
				else if (root->right == nullptr)
				{
					root = root->left;
				}
				else
				{
					Node* maxLeft = root->left;
					while (maxLeft->right)
					{
						maxLeft = maxLeft->right;
					}
					std::swap(root->_key, maxLeft->_key);
					return _eraseR(root->left, val);
				}
				delete del;
				return true;
			}
		}

递归版的删除函数大致思想还是与我们刚刚的一样,只不过当我们要删除的时候情况变了,当要删除的节点左子树为空我们就将当前节点变成当前节点的右子树(因为等会会将这个要删除的节点释放),当要删除的节点的右子树为空我们就将当前节点变成当前节点的左子树。当要删除的节点两个子树都不为空时,我们以用左树的最大值托管为例,先创建一个节点为当前节点的左子树节点,然后让这个节点去遍历右子树,因为左子树的最大值一定在左子树的右子树中,遍历完直接将左子树的最大值和要删除的节点的值交换,因为已经交换了如下图:

所以我们直接递归7的左子树删除这个为8的节点即可,在递归版需要注意的是节点一定要用引用,下面我们画个递归图解释:

 8.析构函数:

~BSTree()
		{
			Destruct(_root);
		}

void Destruct(Node*& root)
		{
			if (root == nullptr)
			{
				return;
			}
			Destruct(root->left);
			Destruct(root->right);
			delete root;
			root = nullptr;
		}

 析构函数的实现就很简单了,当遇到空就返回,否则就先递归左子树再递归右子树,最后是否root节点。也就是说整体就是一个后序遍历从最后一个节点开始删除,这里用引用的作用是当我们最后将root节点置为nullptr那么_root根节点也会被置为空。因为root是_root的别名。

9.构造函数

BSTree()
			:_root(nullptr)
		{

		}

构造函数只需要将根节点置为空即可。这里必须要实现的原因是等会要实现拷贝构造函数,如果我们不写构造函数当我们实现拷贝构造就不会生成默认的构造函数了。

10.拷贝构造函数

BSTree(const BSTree<K,V>& t)
		{
			_root = copy(t._root);
		}

Node* copy(Node* root)
		{
			if (root == nullptr)
			{
				return nullptr;
			}
			Node* newroot = new Node(root->_key);
			newroot->left = copy(root->left);
			newroot->right = copy(root->right);
			return newroot;
		}

拷贝构造的实现我们只需要用一个函数去用前序遍历依次创建节点即可,(默认的拷贝构造是浅拷贝)当节点为空就返回空指针,然后创建与一个newroot节点,此节点用传来的root节点的值初始化,然后分别递归建立左子树和右子树,最后返回新树即可。

11.赋值重载函数

BSTree<K,V>& operator=(BSTree<K,V> t)
		{
			std::swap(_root, t._root);
			return *this;
		}

赋值重载函数我们直接用现代写法,传参用传值传参,这样会拷贝构造一个树,然后我们直接交换这个被拷贝构造的数的根节点和我们的this指针指向的节点,然后返回即可,这里记得返回要引用返回否则还会多调用一次拷贝构造。

以上就是二叉搜索树的全部函数接口实现,下面我们讲讲二插搜索树的应用。

二、二叉搜索树的应用

1.K模型

. K 模型: K 模型即只有 key 作为关键码,结构中只需要存储 Key 即可,关键码即为需要搜索到
的值
比如: 给一个单词 word ,判断该单词是否拼写正确 ,具体方式如下:
以词库中所有单词集合中的每个单词作为 key ,构建一棵二叉搜索树
在二叉搜索树中检索该单词是否存在,存在则拼写正确,不存在则拼写错误。

2.KV模型

KV 模型:每一个关键码 key ,都有与之对应的值 Value ,即 <Key, Value> 的键值对 。该种方
式在现实生活中非常常见:
比如 英汉词典就是英文与中文的对应关系 ,通过英文可以快速找到与其对应的中文,英
文单词与其对应的中文 <word, chinese> 就构成一种键值对;
再比如 统计单词次数 ,统计成功后,给定单词就可快速找到其出现的次数, 单词与其出
现次数就是 <word, count> 就构成一种键值对。
对于KV模型我们给出以下两个例子:
1.输入单词,查找单词对应的中文翻译:
namespace key_value
{
	template <class K,class V>
	struct TreeNode
	{
		TreeNode<K,V>* left;
		TreeNode<K,V>* right;
		K _key;
		V _value;
		TreeNode(const K& key,const V& value)
			:left(nullptr)
			, right(nullptr)
			, _key(key)
			,_value(value)
		{

		}
	};
	template <class K,class V>
	class BSTree
	{
		typedef TreeNode<K,V> Node;
	public:
		BSTree()
			:_root(nullptr)
		{

		}
		BSTree(const BSTree<K,V>& t)
		{
			_root = copy(t._root);
		}
		BSTree<K,V>& operator=(BSTree<K,V> t)
		{
			std::swap(_root, t._root);
			return *this;
		}
		~BSTree()
		{
			Destruct(_root);
		}
		bool insert(const K& val, const V& value)
		{
			if (_root == nullptr)
			{
				_root = new Node(val,value);
				return true;
			}
			Node* cur = _root;
			Node* parent = nullptr;
			while (cur)
			{
				if (cur->_key < val)
				{
					parent = cur;
					cur = cur->right;
				}
				else if (cur->_key > val)
				{
					parent = cur;
					cur = cur->left;
				}
				else
				{
					return false;
				}
			}
			if (parent->_key < val)
			{
				parent->right = new Node(val,value);
			}
			else
			{
				parent->left = new Node(val,value);
			}
			return true;
		}
		bool erase(const K& val)
		{
			assert(_root != nullptr);
			Node* cur = _root;
			Node* parent = nullptr;
			while (cur)
			{
				if (cur->_key < val)
				{
					parent = cur;
					cur = cur->right;
				}
				else if (cur->_key > val)
				{
					parent = cur;
					cur = cur->left;
				}
				else
				{
					//找到要删除的节点
					if (cur->left == nullptr)
					{
						if (parent == nullptr)
						{
							_root = cur->right;
							return true;
						}
						if (parent->left == cur)
						{
							parent->left = cur->right;
						}
						else
						{
							parent->right = cur->right;
						}
						delete cur;
						return true;
					}
					else if (cur->right == nullptr)
					{
						if (parent == nullptr)
						{
							_root = cur->left;
							return true;
						}
						if (parent->left == cur)
						{
							parent->left = cur->left;
						}
						else
						{
							parent->right = cur->left;
						}
						delete cur;
						return true;
					}
					else
					{
						//要删除的节点有两个子树,需要右树的最小节点或者左树的最大节点托管
						Node* minRight = cur->right;
						Node* PrevParent = cur;
						while (minRight->left)
						{
							PrevParent = minRight;
							minRight = minRight->left;
						}
						cur->_key = minRight->_key;
						if (PrevParent->left == minRight)
						{
							PrevParent->left = minRight->right;
						}
						else
						{
							PrevParent->right = minRight->right;
						}
						delete minRight;
						return true;
					}
				}
			}
			return false;
		}
		Node* find(const K& val)
		{
			Node* tmp = _find(_root, val);
			return tmp;
		}
		void Inorder()
		{
			_Inorder(_root);
		}
	protected:
		Node* copy(Node* root)
		{
			if (root == nullptr)
			{
				return nullptr;
			}
			Node* newroot = new Node(root->_key);
			newroot->left = copy(root->left);
			newroot->right = copy(root->right);
			return newroot;
		}
		void Destruct(Node*& root)
		{
			if (root == nullptr)
			{
				return;
			}
			Destruct(root->left);
			Destruct(root->right);
			delete root;
			root = nullptr;
		}
		Node* _find(Node* root, const K& val)
		{
			Node* cur = root;
			while (cur)
			{
				if (cur->_key < val)
				{
					cur = cur->right;
				}
				else if (cur->_key > val)
				{
					cur = cur->left;
				}
				else
				{
					return cur;
				}
			}
			return  nullptr;
		}
		void _Inorder(Node* root)
		{
			if (root == nullptr)
			{
				return;
			}
			_Inorder(root->left);
			cout << root->_key << ":"<<root->_value<<" ";
			_Inorder(root->right);
		}

	private:
		Node* _root = nullptr;
	};
}
void test5()
{
	key_value::BSTree<string, string> dict;
	dict.insert("sort", "排序");
	dict.insert("insert", "插入");
	dict.insert("left", "左边");
	dict.insert("right", "右边");
	dict.insert("erase", "删除");
	string str;
	while (cin >> str)
	{
		auto ret = dict.find(str);
		if (ret)
		{
			cout << ":"<<ret->_value << endl;
		}
		else
		{
			cout << "找不到此单词" << endl;
		}
	}
}

我们看看上述代码的结果:

KV的实现只需要添加一个模板参数,修改遍历中序打印函数和插入函数,插入的时候我们需要将新参数也插入,我们在节点中初始化的时候记得初始化新的参数即可。

简单的中文互译我们就搞定了,再看看统计次数的例子 。

2.统计水果出现的次数:
void test6()
{
	string arr[] = { "苹果", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜",
"苹果", "香蕉", "苹果", "香蕉" };
	key_value::BSTree<string, int> ct;
	for (auto& ch : arr)
	{
		auto ret = ct.find(ch);
		if (ret)
		{
			ret->_value++;
		}
		else
		{
			ct.insert(ch, 1);
		}
	}
	ct.Inorder();
}

统计次数很简单,当数组中的水果在树中存在我们就让水果的次数++,如果不存在我们就将这个水果插入数中,最后打印出来的顺序是按照ascll码进行比较的,因为中文的底层是ascll。 


总结

二叉搜索树的性能分析:

插入和删除操作都必须先查找,查找效率代表了二叉搜索树中各个操作的性能。
对有 n 个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二
叉搜索树的深度的函数,即结点越深,则比较次数越多。
但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树。
最优情况下,二叉搜索树为完全二叉树 ( 或者接近完全二叉树 ) ,其平均比较次数为:O(log N)
最差情况下,二叉搜索树退化为单支树 ( 或者类似单支 ) ,其平均比较次数为:O(N)