二叉树进阶---二叉搜索树Binary Search Tree

前言:

在数据结构二叉树中,我们首次接触到了树形结构,并且学习了最基本的一个存储数据的结构:二叉树,二叉树是一种极为高效的存储数据的结构,对于数据来说,增删查改的效率决定了其数据结构本身的优缺点,因此,在二叉树的基础上,我今天要说的就是它的第一种变形结构:搜索二叉树Binary Search Tree.

1.二叉搜索树的定义:

那么首先的一个问题,什么是二叉搜索树呢?
一棵普通的二叉树的效率其实是很一般的,但假如我们可以对数据进行一个简单的排序呢?比如根节点的左节点数值都小于根节点,而反之右节点的数值都大于根节点,这样我们查找数据的时候就可以排除一半进行查找,效率就得到了提升(log2的N次方,即一半一半的查找).
因此我们得出二叉搜索树的定义如下:
二叉搜索树又称二叉排序树,它或者是一棵空树,或者是具有以下性质的二叉树:
若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
它的左右子树也分别为二叉搜索树

如图所示:
在这里插入图片描述
比如我画的这棵树就是一个典型标准的搜索二叉树:
在这里插入图片描述
以12为分界线,12左节点为8 ,右节点为16,然后8的左节点为6,右节点为11,16的左节点为15,右节点为23。
这样,数据按照大小进行了排序,这样就对数据进行了第一步初步的整理,这也是提高了效率的原因,同时,它也满足了一颗基本的二叉树的性质。

2.二叉搜索树的实现及其原理:

那下面我们便来实现一下二叉搜索树的基本的一些功能,其中包括我们的增加节点,删除节点,查找节点及修改节点:

1.二叉搜索树的节点结构体构建:

和常规的二叉树构建节点一样,我们的结构体里依旧使用左右指针以及一个存储数据的模板变量,如下:
(我们此次使用C++去写,故在这里我使用了构造函数以及模板,提高了数据存储的泛用性)

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

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

2.二叉搜索树的插入:

我们的二叉搜索树功能实现依托另一个类:class BSTree,同时让我们构建的结构体的私有成员变成一个指向根节点的指针_root,如下:

	template<class K>
	class BSTree
	{
		typedef BSTreeNode<K>  Node;
	public:
	之后的函数功能都在这里实现,但是一些递归版本的需要特殊处理让其无参数传入调用
		
	private:
		Node* _root = nullptr;
    };

我们实现插入的操作很简单,大致的思路就是:
如果是空树,即_root如果是nullptr,则构建一个对应数据的节点,让其等于_root,否则就是依托二叉搜索树的特点,大数据往右走,小数据往左走,不过需要注意的是,我们构建完节点后,需要让其和之前存在的节点链接起来,同时要符合规律的链接,因此在这里我们采取前后指针法,再构建一个parent,每一次都指向cur上一步指向的位置,这样当cur指向空即找到空位的时候,parent就指向上一个符合链接规律的节点,此时只需要判断cur数值和parent数值的大小,就可以连接在左边或者右边了

	bool Insert(const K& key)//搜索二叉树插入
	{
		if (_root == nullptr)
		{
			_root = new Node(key);
			return true;
		}
		Node* parent = nullptr;
		Node* cur = _root;
		while (cur)
		{
			parent = cur;//利用一个父亲指针一前一后走起来,方便记录上一个节点,首先搜索二叉树规定树中不存在相等的节点,每一个节点都是不相同的
			if (cur->_key > key)
			{
				cur = cur->_left;
			}
			else if (cur->_key < key)
			{
				cur = cur->_right;
			}
			else//当遇到重复值的时候,即判定插入失败
			{
				return false;
			}
		}//先找位置
		cur = new Node(key);
		if (parent->_key > key)//再给值
		{
			parent->_left = cur;
		}
		else
		{
			parent->_right = cur;
		}
		return true;
	}

同时要注意,搜索二叉树默认不允许存在数值相同的点,因此重复插入直接按照插入失败判断即可

3.二叉搜索树的搜索:

我们实现寻找一个存在于二叉搜索树的值功能也很简单,和插入的找寻对应空位的思路差不多:大了往右走,小了往左走,遇到对应的数值就找到了,反之就是没有找不到,具体实现如下:

bool find(const K& key)//找某个数值是否存在
{
	Node* cur = _root;
	while (cur)
	{
		if (cur->_key > key)
		{
			cur = cur->_left;
		}
		else if (cur->_key < key)
		{
			cur = cur->right;
		}
		else
		{
			return true;
		}
	}
	return false;
}

4.二叉搜索树的删除:

在讲解二叉搜索树的删除这里,我们提供非递归和递归两种方式

方法一:非递归法:

思路分析:
非递归法主要分为两个步骤:1.找到 2.删除,第一步的找到和我们的插入数据的找到类似,大的往右走,小的往左走,删除的关键在于找到了对应的数值后如何删除,这里的情况很多,我将其分为以下的几种情况:

先把前面的搜索代码给出:

bool Erase(const K& key)//节点删除
{
if (_root == nullptr)//树为空,无法删除
{
	return false;
}
Node* parent = nullptr;
Node* cur = _root;
while (cur)
{
	if (cur->_key > key)
	{
		parent = cur;
		cur = cur->_left;
	}
	else if (cur->_key < key)
	{
		parent = cur;
		cur = cur->_right;
	}
	else
	{
	   //找到了对应的位置,开始考虑删除
	   return true;
	}
  return false;
}

而我们要处理的就是else里面删除的逻辑.

第一种:目标节点的左指针为空(左右指针都为空也符合这个规律):
当我们的cur找到了对应的位置的时候,此时cur对应的节点的其中一种情况便是左边没节点,如图:
在这里插入图片描述
在这种情况下,我们的目标是要删除cur节点,只需要让parent去判断cur是它的左边还是右边,让parent对应的指针去链接cur的右边(因为此时cur的左边为空,是没有数据的,而右边数据不管有没有,链接都没问题,如下:
在这里插入图片描述
但是,还有一种特殊的情况,倘若cur的位置就是_root,即cur没有对应的parent,或者说parent是nullptr呢?
如图:
在这里插入图片描述
那么,在这种情况下,我们只需要重新规定_root的指向即可,同时这种情况就是常规情况的else情况而存在。
因此,我们这种情况的代码就可以完整写出了,如下:

	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;//删除节点
}

第三种:目标节点的左右两指针都不为空:
如果目标节点的左右两指针都不为空,此时我们删除的一个问题就是如何再删除之后依旧能保持住二叉搜索树的规律,我们在非递归这里使用了替换法和托管法的方式,联想到我们在堆的删除和增加那里使用的交换后调整的方式,在这里我们也采取类似的方法:即交换数值和要删除的数值,然后删除要删除的数值即可,我们想要删除对应的数值,则需要用临近的大值或者小值来替代,他们的位置分别处于左子树的最右边,右子树的最左边,我在这里采取寻找右子树的最左边的方法来替换
也就是说,我们找到了对应的cur后,要继续从cur开始,找到以cur为根的右子树的最左边的节点,找到后与cur进行交换,交换后将对应的后续节点连接好后删除即可,因此在这里我们依然采取前后双指针的方式,只要涉及到链接的问题我们采取的最优解就是双指针链接记录。
在这里插入图片描述
如上图所示,就是我们的常规情况,但在这里还有一种特殊情况,我们说过,我们重新设置的parent指向cur,而向后找最左节点的指针subleft初始时是指向cur的right的,因此就存在这种情况:
在这里插入图片描述
此时subleft不在parent的左指针位置,也就是说subleft的初始位置就是右子树的最左节点了,此时只需要让parent的右指针链接subleft的右指针即可(因此此时subleft的左边必定没有节点,否则就向左继续向下找了)
由此,我们的代码如下:

else//左右都不为空,找左树的最大节点(最右节点)或者右树的最小节点(最左节点)去替换即可
{//在这里我采取使用右子树的最小节点去替换
				Node* parent = cur;
				Node* subleft = cur->_right;
				while (subleft->_left)
				{
					parent = subleft;
					subleft = subleft->_left;
				}
				swap(subleft->_key, cur->_key);
				if (subleft == parent->_left)//这里处理的是常规情况,即我们要找的值不是root的最右边的第一个节点就是右子树最小节点的情况
				{
					parent->_left = subleft->_right;
				}
				else//当parent不进入循环,即我们的cur为root时,此时的parent指向root,而subleft此时为parent的right,此时要将subleft的右边托管给parent(左边不可能有值,如果有循环就会向下进行了)
				{
					parent->_right = subleft->_right;
				}
				delete subleft;//删除节点
}

方法二:递归法:

递归法的思路很好理解,它主要方便在于不需要非递归法一样去使用parent去记录上一个位置,而且其删除的逻辑就遵循左空和右空两种情况,代码如下:

bool _EraseR(Node*& root, const K& key)//递归版本删除
{
	if (root == nullptr)
	{
		return false;
	}
	if (root->_key < key)
	{
		return _EraseR(root->_right, key);
	}
	else if (root->_key > key)
	{
		return _EraseR(root->_left, key);
	}
	else//相等,开始删除
	{
		if (root->_left == nullptr)
		{
			Node* del = root;
			root = root->_right;
			delete del;
			return true;
		}
		else if (root->_right == nullptr)
		{
			Node* del = root;
			root = root->_left;
			delete del;
			return true;
		}
		else//左右节点都不为空
		{
			Node* subleft = root->_right;
			while (subleft->_left)
			{
				subleft = subleft->_left;
			}
			swap(subleft->_key, root->_key);
			return _EraseR(root->_right, key);//交换后,为了能删除subleft的数据,直接转换为在root->right即右子树里面删除对应的subleft节点
		}
	}
}

关键点!!!!!!
在这里你可能会好奇,root=root->_right和root=root->_left这种写法不是根本链接不起来二叉树么?
那我希望你看一看我的传参是什么,root指针的别名Node
& root,也就是说,这里的root不单单是一个值指代一个位置,它是上一个节点的右指针的别名,因此我这里的root=riit->_right,实际上就是root->_right=root->_right->_right,也就相当于有了parent的存在完成了构建,这个便是引用的妙处所在,它可以代表一个位置,但同时又代表一个别名的存在,这样就不需要parent了,root=root->_left也是同理,这便是递归删除法最核心的部分所在
*
如图理解:
在这里插入图片描述

5.二叉搜索树的其他功能:(巧用引用的特点实现递归,这样就可以实现不需要parent依旧可以链接的方式)

在这里我不过多赘述了,需要注意的就是C++中我们一般是不传参的,因此可以在private中写函数然后在public中封装一层提供给用户使用:
public中的提供给用户的函数部分,利用private里面的函数封装,这样的目的是让用户直接使用,不用传一个_root,模拟用户的使用场景


		bool findR(const K& key)//递归版本查找
		{
			return _findR(_root, key);
		}

		void InOrder()//遍历函数,C++特有的调用的方式,因为递归需要参数,但是正常调用的时候不要参数,所以这样去调用,再嵌套一层
		{
			_InOrder(_root);
			cout << endl;
		}

		bool InsertR(const K& key)//递归版本插入
		{
			return _InsertR(_root, key);
		}

		bool EraseR(const K& key)//递归版本删除
		{
			return _EraseR(_root, key);
		}

		~BSTree()//析构函数,后序遍历析构
		{
			Destroy(_root);
		}

		BSTree() = default;//默认构造,加上default,强制生成默认构造,C++11新的关键字,当然,你直接写出一个默认的空的构造也是可以的,这个没影响,大差不差



		BSTree(const BSTree<K>& t)//拷贝构造也算构造,这导致默认的构造不生成了,故需要我们手写一个无参数的默认构造
		{
			_root = copy(t._root);
		}

		BSTree<K>& operator=(BSTree<K> t)//赋值现代写法,有了拷贝构造,赋值运算就好写了
		{
			swap(_root, t._root);
			return *this;
		}

private中的实际定义部分:

	void Destroy(Node*& root)//销毁树
	{
		if (root == nullptr)
		{
			return;
		}
		Destroy(root->_left);
		Destroy(root->_right);
		delete root;
		root = nullptr;
	}

	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;
	}
	bool _InsertR(Node*& root, const K& key)//递归版本插入
{
	if (root == nullptr)
	{
		root = new Node(key);
		return true;
	}

	if (root->_key > key)
	{
		return _InsertR(root->_left, key);
	}
	else if (root->_key < key)
	{
		return _InsertR(root->_right, key);
	}
	else
	{
		return false;
	}
}

bool _findR(Node*& root, const K& key)//递归版本查找
{
	if (root == nullptr)
	{
		return false;
	}
	if (root->_key > key)
	{
		return _findR(root->_left, key);
	}
	else if (root->_key < key)
	{
		return _findR(root->_right, key);
	}
	else
	{
		return true;
	}
}

void _InOrder(Node*& root)//中序遍历
{
	if (root == nullptr)
	{
		return;
	}
	_InOrder(root->_left);
	cout << root->_key << " ";
	_InOrder(root->_right);
}

3.二叉搜索树的应用:KV模型的构建:

KV模型的定义:

1. K模型:K模型即只有key作为关键码,结构中只需要存储Key即可,关键码即为需要搜索到
的值。
比如:给一个单词word,判断该单词是否拼写正确,具体方式如下:
以词库中所有单词集合中的每个单词作为key,构建一棵二叉搜索树
在二叉搜索树中检索该单词是否存在,存在则拼写正确,不存在则拼写错误。
2. KV模型:每一个关键码key,都有与之对应的值Value,即<Key, Value>的键值对。该种方
式在现实生活中非常常见:
比如英汉词典就是英文与中文的对应关系,通过英文可以快速找到与其对应的中文,英
文单词与其对应的中文<word, chinese>就构成一种键值对;
再比如统计单词次数,统计成功后,给定单词就可快速找到其出现的次数,单词与其出
现次数就是<word, count>就构成一种键值对

KV模型的代码书写:

namespace kv//KV模型
{
	template<class K,class V>
	struct BSTreeNode
	{
		BSTreeNode<K,V>* _left;
		BSTreeNode<K,V>* _right;
		K _key;
		V _value;

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

	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)
			{
				parent = cur;//利用一个父亲指针一前一后走起来,方便记录上一个节点,首先搜索二叉树规定树中不存在相等的节点,每一个节点都是不相同的
				if (cur->_key > key)
				{
					cur = cur->_left;
				}
				else if (cur->_key < key)
				{
					cur = cur->_right;
				}
				else
				{
					return false;
				}
			}//先找位置
			cur = new Node(key,value);
			if (parent->_key > key)//再给值
			{
				parent->_left = cur;
			}
			else
			{
				parent->_right = cur;
			}
			return true;
		}

		Node* find(const K& key)//找某个key是否存在
		{
			Node* cur = _root;
			while (cur)
			{
				if (cur->_key > key)
				{
					cur = cur->_left;
				}
				else if (cur->_key < key)
				{
					cur = cur->_right;
				}
				else
				{
					return cur;
				}
			}
			return nullptr;
		}

		bool Erase(const K& key)//节点删除
		{
			if (_root == nullptr)//树为空,无法删除
			{
				return false;
			}
			Node* parent = nullptr;
			Node* cur = _root;
			while (cur)
			{
				if (cur->_key > key)
				{
					parent = cur;
					cur = cur->_left;

				}
				else if (cur->_key < key)
				{
					parent = cur;
					cur = cur->_right;
				}
				else//找到了,准备删除,这里运用了托管和替换法来完成删除的后续处理工作
				{
					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* parent = cur;
						Node* subleft = cur->_right;
						while (subleft->_left)
						{
							parent = subleft;
							subleft = subleft->_left;
						}
						swap(subleft->_key, cur->_key);
						if (subleft == parent->_left)//这里处理的是常规情况,即我们要找的值不是root的最右边的第一个节点就是右子树最小节点的情况
						{
							parent->_left = subleft->_right;
						}
						else//当parent不进入循环,即我们的cur为root时,此时的parent指向root,而subleft此时为parent的right,此时要将subleft的右边托管给parent(左边不可能有值,如果有循环就会向下进行了)
						{
							parent->_right = subleft->_right;
						}
						delete subleft;//删除节点
					}
					return true;
				}
			}
			return false;
		}

		void InOrder()//遍历函数,C++特有的调用的方式,因为递归需要参数,但是正常调用的时候不要参数,所以这样去调用,再嵌套一层
		{
			_InOrder(_root);
			cout << endl;
		}

	private:

		void _InOrder(Node*& root)//中序遍历
		{
			if (root == nullptr)
			{
				return;
			}
			_InOrder(root->_left);
			cout << root->_key << ":" << root->_value << endl;
			_InOrder(root->_right);
		}
		Node* _root = nullptr;
	};
}

其实说实话,大差不差,就是通过key去找对应的value,原理和我们基本的二叉搜索树模型是一样的,它的使用一半如下:

void testkv()//字典
{
	kv::BSTree<string, string> dict;
	dict.Insert("sort", "排序");
	dict.Insert("left", "左");
	dict.Insert("right", "右");
	dict.Insert("insert", "插入");
	dict.Insert("key", "关键字");
	string str;
	while (cin >> str)
	{
		kv::BSTreeNode<string, string>* tmp = dict.find(str);
		if (tmp)
		{
			cout << tmp->_value << endl;
		}
		else
		{
			cout << "无此单词" << endl;
		}
	}
}
void testcount()
{
	string arr[] = { "西瓜","苹果","香蕉","西瓜","香蕉","西瓜","西瓜" };
	kv::BSTree<string, int> countTree;
	for (auto& e : arr)
	{
		kv::BSTreeNode<string, int>* ret = countTree.find(e);
		if (ret == nullptr)
		{
			countTree.Insert(e, 1);
		}
		else
		{
			ret->_value++;
		}
	}
	countTree.InOrder();
}

可以用来起到充当字典或者统计字符串出现次数的一个数据存储模型

4.搜索二叉树的性能分析:

插入和删除操作都必须先查找,查找效率代表了二叉搜索树中各个操作的性能。
对有n个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二
叉搜索树的深度的函数,即结点越深,则比较次数越多。
但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树:
在这里插入图片描述

最优情况下,二叉搜索树为完全二叉树(或者接近完全二叉树),其平均比较次数为: l o g 2 N log_2 N log2N
最差情况下,二叉搜索树退化为单支树(或者类似单支),其平均比较次数为: N 2 \frac{N}{2} 2N,那样就跟一个一个查找区别不大了,比如图中右边的二叉树的情况
问题:如果退化成单支树,二叉搜索树的性能就失去了。那能否进行改进,不论按照什么次序插入关键码,二叉搜索树的性能都能达到最优?此时就需要AVL树和RB树了

总结:

本篇我们系统的讲解了一下二叉树变体的第一个模型,二叉搜索树,之后的AVL树,RB红黑树都依托于二叉搜索树的一些原理进一步提高效率,包括map set也是以树形结构为基础展开的,所以,我们应该完全掌握树形结构的原理及其实现,因为在很多数据的存储中都使用了树形结构。

  • 20
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值