zl程序教程

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

当前栏目

C++:二叉搜索树

C++搜索 二叉
2023-06-13 09:18:05 时间

⭐前言:学习二叉搜索树,是我们学好map和set的前提,因为二叉搜索树是map和set的特性。因此本篇文章意在努力将二叉搜索树相关的内容较完善地写出来!

二叉搜索树的介绍

二叉搜索树(BST, Binary Search Tree)又叫做二叉排序树,它可以是一颗空树,其性质如下:

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

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

③它的左右子树也分是二叉搜索树

④成形的二叉搜索树一般不能用来修改数据。因为一旦修改了,就可能不再是二叉搜索树了。

对二叉搜索树的操作

1. 查找:

①一般查找某个数据,就先根节点的值比较,若是查找的数据val小于根节点,那就去左子树找,val大于根节点的值,那就去右子树找。 ②最多查找高度次,走到到空,还没找到,这个值不存在。

2.插入:

①如果是一颗空树,那么直接新增节点,赋值给root指针 ②不是空树,则按二叉搜索树的性质插入数据

3.删除:

删除的情况就比较复杂,我们慢慢来看。

首先查找元素是否在二叉搜索树中,如果不存在,则返回, 否则要删除的结点可能分下面四种情况:

①要删除的节点无孩子结点。

操作:这种情况比较简单,删除该节点后,让该节点的父亲节点指向该节点的左孩子节点或右孩子节点。都可以,因为都是空指针。

②要删除的节点只有左孩子节点.

操作:删除该节点后,让该节点的父亲节点指向该节点的左孩子节点

 ③要删除的节点只有右孩子节点

操作:删除该节点收,让该节点的父亲节点指向该节点的右孩子节点

 ④要删除的节点有左右孩子。

第一种操作是替换法。 根据二叉搜索树的性质,右子树的值 > 根节点的值 > 左子树的值。因此,替换的意思是,重新选根,那么我们可以从左子树中选最大的那个来当新根,也可以从右子树中选最小的那个来当新根。这里我们使用右子树最小的那个来当新根。 当替换之后,我们就将这个节点删除,删除的时候需要注意,让其父亲节点指向它的右孩子,因为它可能还会带有右孩子。不可能是左孩子,如果是左孩子,那么寻找新根的行为还会往下,会找到最左,即最小的那个节点。

 第二种操作是:使用交换法。将右子树最小的值,跟需要删除的节点的值交换。这也在删除之后就不会毁掉二叉搜索树的结构。然后转化到在右子树上删除节点。不过这种方法需要用到递归。

 二叉搜索树的实现

这一步主要实现的是上面所提到的相关操作。

①先来创建节点。

	template<class K>
	struct BSTreeNode
	{
		BSTreeNode<K>* _left;
		BSTreeNode<K>* _right;
		K _key;

		BSTreeNode(const K& key)
			:_key(key)
			,_left(nullptr)
			,_right(nullptr)
		{}
	};

然后是框架的准备:

	template<class K>
	class BSTree
	{
		typedef BSTreeNode<K> Node;
	private:
		Node* _root = nullptr;
	};
}

②插入操作

非递归版:

先判断是否为空树,如果是,直接插入。如果不是空树,那么需要两个指针,一个是cur指针,用来找插入的位置,一个是parent指针,用来记录父节点,因为后面需要重新链接。

当发现树中已经有了相同的值,那么就直接返回false。

在链接新节点的时候,需要再重新判断一下这个节点的值是否大于父节点的值。

//非递归插入
		bool Insert(const K& key)
		{
			//1.先判断是否为空树,如果是,直接插入
			if (_root == nullptr)
			{
				_root = new Node(key);
				return true;
			}
			//2.不是空树
			Node* cur = _root;
			Node* parent = cur;//用于记录父节点,删除后需要链接被删除节点的孩子节点
		
			//开始找可以插入数据的位置
			while (cur)
			{
				if (cur->_key < key)//插入的值比较大,则去右子树找
				{
					parent = cur;
					cur = cur->_right;
				}
				else if (cur->_key > key)//插入的值比较小,则去左子树找
				{
					parent = cur;
					cur = cur->_left;
				}
				else                    //如果发现已经有这个值了,那么直接返回false;
				{
					return false;
				}

			}

			//找到可以插入的位置后
			cur = new Node(key);
			//链接操作,由于此时不知道cur来到的位置是左孩子还是右孩子,因此还需要再判断一次
			if (parent->_key < key)
			{
				parent->_right = cur;
			}
			else
			{
				parent->_left = cur;
			}

			return true;
		}

递归版:

由于递归,需要用到私有成员变量_root,但私有成员变量不能暴露 出来。因此,这里选择私有插入操作的函数,在公有中额外写一个插入的函数用于调用它。

这里的递归,我认为它是一个点睛之笔,那就是Node*& root。对传入的节点的指针进行了引用!

对传入的节点的指针进行引用,意味着此时的root,是上一个root的左孩子或右孩子的别名,当此时的root到达了空指针,进行插入数据的操作后,就意味着是上一个root的左孩子或右孩子进行了插入数据!就不需要记录父节点来链接了,因为在插入数据的时候就已经链接成功了!

	public:
		bool InsertR(const K& key)
		{
			return _InsertR(_root, key);
		}

	private:
		//注意这里的引用,这是这里的递归操作的点睛之笔
		bool _InsertR(Node*& root, const K& key)
		{
			//老规矩,先判断是否为空树
			if (root == nullptr)
			{
				root = new Node(key);
				return true;
			}
			//不是空树,就去找位置
			if (root->_key < key)
				return _InsertR(root->_right, key);
			else if (root->_key > key)
				return _InsertR(root->_left, key);
			else
				return false;
		}

③删除操作

非递归版本

非递归的版本代码量比递归版本的多。

代码的思路是:①先找到要删除的节点。②然后判断这个要被删除的节点是如何的,是只有左子树,还是只有右子树,还是左右孩子都有。另外,叶子节点被包含在了前两个情况里面。③如果是左右孩子都有的节点,那么还需要找以这个被删除的节点为根,找它右子树的最小值,然后让这个值跟被删除的节点的值替换,此时在逻辑上,被替换掉的不仅仅是值,连要删除的节点也被替换掉了,被替换成这个右子树的最小值的节点了。④接着在链接的时候,需要判断被删除的节点是父节点的左孩子还是右孩子。

需要注意的额外情况是:被删除的节点可能是根节点。

//非递归
bool Erase(const K& key)
{
	//用父节点来记录被删的节点的父节点
	Node* parent = nullptr;
	Node* cur = _root;

	//先找要删除的节点
	while (cur)
	{
		if (cur->_key < key)
		{
			parent = cur;
			cut = cur->_right;
		}
		else if (cur->_key > key)
		{
			parent = cur;
			cur = cur->_left;
		}
		else          //找到了
		{
			//1.要删除的节点的左子树为空
			//2.右子树为空
			//3.左右都不为空,替换删除

			//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 (root == cur)
				{
					_root = cur->_left;
				}
				else
				{
					if (parent->_left == cur)
					{
						parent->left = cur->_left;
					}
					else
					{
						parent->_right = cur->_left;
					}
				}
				delete cur;
			}
			else//3.左右孩子都不为空---替换法
			{
				//要找到右子树的最小节点
				Node* parent = cur;
				Node* minRight = cur->_right;
				while (minRight->_left)
				{
					parent = minRight;
					minRight = minRight->_left;
				}

				//将根节点的值替换掉
				cur->_key = minRight->_key;
				//然后删除这个最小的节点
				//如果它是父节点的左孩子,那就让父节点的左孩子的指针指向它的右孩子
				//因为它已经是最左的那个了,没有左孩子了,可能会有右孩子
				if (minRight == parent->_left)
				{
					parent->_left = minRight->_right;
				}
				else//右子树的最小节点,不一定是最左的节点。
				{
					parent->_right = minRight->_right;
				}
				delete minRight;

			}
			return true;
		}
	}
	return false;
}

递归的版本:

代码的思路是:①需要先判断树是否为空。②找要删除的节点。③找到后,判断这个节点是那种情况,然后进行链接。如果是左右都有孩子的节点,那么就使用交换法,让右子树最小值跟要删除的节点的值交换,此时原本要删除的那个节点,从物理上变成了原本的右子树的最小值的节点。然后通过递归,去右子树找这个节点。最后进行链接节点的右孩子或左孩子,最后删除。

	public:
		bool EraseR(const K& key)
		{
			return _EraseR(_root, key);
		}


	private:
		bool _EraseR(Node*& root, const K& key)
		{
			if (root == nullptr)
			{
				return false;
			}

			if (root->_key < key)
			{
				return _Erase(root->_right, key);
			}
			else if (root->_key > key)
			{
				return _Erase(root->_left, key);
			}
			else
			{
				Node* del = root;
				if (root->_right == nullptr)
				{
					root = root->_left;
				}
				else if (root->_left == nullptr)
				{
					root = root->_right;
				}
				else//左右孩子都不为空---交换法
				{
					Node* minRight = root->_right;
					while (minRight->_left)
					{
						minRight->_left;
					}

					//交换值
					swap(root->_key, minRight->_key);
					//交换后,此时原本要是删除的key,去到了minRight的节点上
					//接下来进行递归,交给右子树去找这个节点
					return _EraseR(root->_right, key);
				}
				delete del;
				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;
		}

递归版本:

	public:
			bool FindR(const K& key)
			{
				return _FindR(_root, key);
			}
	private:
		bool _FindR(Node* root, const K& key)
		{
			if (root == nullptr)
			{
				return false;
			}
			if (root->_key < key)
				return _FindR(root->_right, key);
			if (root->_key > key)
				return _FindR(root->_left, key);
			else
				return true;
		}

⑤.中序遍历

中序遍历的顺序是左根右。学过简单数据结构中的二叉树的话,这里就洒洒水啦!

		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);

		}

⑥构造函数

不需要写什么构造函数,只需用初始化列表初始化一下私有成员变量_root即可。

		BSTree()
			:_root(nullptr)
		{}

⑦拷贝构造

写一个Copy函数,返回值是Node*,也就是返回节点的指针。先创建一个新的节点,赋值为拷贝目标的根的值,然后让其左孩子和右孩子递归,链接下一个值。

	public:
		BSTree(const BSTree<K>& t)
		{
			_root = Copy(t._root);
		}
	private:
		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;
		}

⑧赋值

operator=()函数的话,不要传引用,然后交换一下_root即可。注意这里有些隐藏条件:this指针的_root必须初始化成空指针。

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

⑨析构函数

	public:
		~BSTree()
		{
			Destory(_root);
			_root = nullptr;
		}
	private:
		void Destory(Node* root)
		{
			if (root == nullptr)
				return;
			Destory(root->_left);
			Destory(root->_right);
			delete root;
		}

 二叉搜索树的应用

1.K模型

K模型:K模型即只有key作为关键码,结构中只需要存储Key即可,关键码即为需要搜索到的值。

比如:给一个单词word,判断该单词是否拼写正确,具体方式如下:

用若干个单词(字典)组成一棵二叉搜索树, 然后在里面查找单词word,找到返回true,找不到返回false;

2.KV模型

KV模型:每一个关键码key,都有与之对应的值Value,即<Key, Value>的键值对。

只需在上面的代码的基础上,加上一个_value即可。

namespace KV
{
	template<class K, class V>
	struct BSTreeNode
	{
		BSTreeNode<K, T>* _left;
		BSTreeNode<K, T>* _right;
		K _key;
		V _value;

		BSTreeNode(const K& key, const V& value)
			:_key(key)
			, _value(value)
			, _left(nullptr)
			, _right(nullptr)
		{}
	};

	template<class K, class V>
	class BSTree
	{
		typedef BSTreeNode<K, V> Node;
	public:
		bool Insert(const K& key, const V& value)
		{
			if (_root == nullptr)
			{
				_root = new Node(key, value);
				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, value);
			if (parent->_key < key)
			{
				parent->_right = cur;
			}
			else
			{
				parent->_left = cur;
			}

			return true;
		}

		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;
		}

		void Inorder()
		{
			_Inorder(_root);
		}

		void _Inorder(Node* root)
		{
			if (root == nullptr)
				return;

			_Inorder(root->_left);
			cout << root->_key << ":" << root->_value << endl;
			_Inorder(root->_right);
		}
	private:
		Node* _root = nullptr;
	};
}

比如英汉词典就是英文与中文的对应关系,通过英文可以快速找到与其对应的中文,英文单词与其对应的中文<word, chinese>就构成一种键值对;

	KV::BSTree<string, string> dict;
	dict.Insert("sort", "排序");
	dict.Insert("string", "字符串");
	dict.Insert("left", "左边");
	dict.Insert("right", "右边");

	string str;
	while (cin >> str)
	{
		KV::BSTreeNode<string, string>* ret = dict.Find(str);
		if (ret)
		{
			cout << ret->_value << endl;
		}
		else
		{
			cout << "无此单词" << endl;
		}
	}

再比如统计单词次数,统计成功后,给定单词就可快速找到其出现的次数,单词与其出现次数就是<word, count>就构成一种键值对。

// 统计水果出现的次数
	string arr[] = { "苹果", "西瓜", "香蕉", "草莓","苹果", "西瓜", "苹果", "苹果", "西瓜", "苹果", "香蕉", "苹果", "香蕉" };

	KV::BSTree<string, int> countTree;
	for (auto e : arr)
	{
		auto* ret = countTree.Find(e);
		if (ret == nullptr)
		{
			countTree.Insert(e, 1);
		}
		else
		{
			ret->_value++;
		}
	}

	countTree.Inorder();

二叉搜索树的性能分析

插入和删除操作都必须先查找,查找效率代表了二叉搜索树中各个操作的性能。

对有n个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二叉搜索树的深度的函数,即结点越深,则比较次数越多。

但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树:

最优情况下,二叉搜索树为完全二叉树(或者接近完全二叉树),树是平衡的,则n个节点的二叉搜索树的高度为

,其查找效率为

,近似于折半查找.其平均比较次数为:O(long(N))

最差情况下,二叉搜索树退化为单支树(或者类似单支),深度达到N,其平均比较次数为:O(N)。