您现在的位置是:首页 >技术杂谈 >【数据结构】二叉搜索树网站首页技术杂谈

【数据结构】二叉搜索树

世间是否此山最高 2024-06-04 12:00:02
简介【数据结构】二叉搜索树

目录

一、二叉搜索树概念

二、二叉搜索树操作

1、整体构造

2、查找

3、插入

4、遍历

5、 删除

5.1、直接删除

5.2、替换法删除

5.3、实现代码

5.4、递归代码 

6、构造与拷贝构造

7、析构函数

8、赋值运算符重载

三、二叉搜索树的应用

1、K模型

2、KV模型

3、性能分析


一、二叉搜索树概念

 二叉搜索树又称二叉排序树,它或者是一棵空树,或者是具有以下性质的二叉树:

  • 若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
  • 若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
  • 它的左右子树也分别为二叉搜索树

二、二叉搜索树操作

1、整体构造

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

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

private:
	Node* _root = nullptr;
};

2、查找

查找的过程为:

  1. 从根开始比较,查找,比根大则往右边走查找,比根小则往左边走查找。
  2. 最多查找高度次,走到到空,还没找到,这个值不存在。

 非递归代码:

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 _FindR(Node* root, const K& key)
{
	if (root == nullptr)
		return false;

	if (root->_key == key)
		return true;

	if (root->_key < key)
		return _FindR(root->_right, key);

	else
		return _FindR(root->_left, key);
}

bool FindR(const K& key)
{
	return _FindR(_root, key);
}

3、插入

插入的具体过程如下:

  1. 树为空,则直接新增节点,赋值给root指针
  2. 树不空,按二叉搜索树性质查找插入位置,插入新节点

当找到新节点应该在的位置后,创建新节点,并用其父节点把它链接起来:

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

 需要注意的是,在最后使用父节点链接新节点时,需要判断新节点数据与父节点数据的大小,以确认链接到父节点的左侧还是右侧。

 递归版本代码:

bool _InsertR(Node*& root, const K& key)
{
	if (root == nullptr)
	{
		root = 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 InsertR(const K& key)
{
	return _InsertR(_root, key);
}

注意这里递归函数的参数列表中使用的是根节点指针的引用。 

4、遍历

 以中序遍历为例:

//BSTree.h
void InOrder()
{
	_InOrder(_root);
}
void _InOrder(Node* root = _root)
{
	if (root == nullptr)
		return;

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

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

	t1.InOrder();
}

 以上为通过递归方式中序遍历的代码。

 由于对象 t1 中本就天然存在根节点,故在传参时不需要传递根节点,但是因为递归函数需要不断更换根节点,所以又需要传递根节点。为了解决这个问题,可以把中序遍历函数拆分成两部分,第一部分获取根节点,第二部分负责递归。

 关于这个问题,或许有人认为可以直接在中序遍历的函数参数列表中增加缺省值来解决,比如这种写法:

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

	InOrder(root->_left);
	std::cout << root->_key << " ";
	InOrder(root->_right);
}

但这种写法是错误的,错误原因主要有两点:

 首先 this 指针只能在非静态成员函数的内部使用,这里参数列表中的缺省值 _root 是使用 this 指针传递的,与之矛盾。其次缺省值只能是全局变量,而 this 指针是形参,一开始就根本传不过来。

5、 删除

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

  1.  要删除的结点无孩子结点
  2.  要删除的结点只有左孩子结点
  3.  要删除的结点只有右孩子结点
  4.  要删除的结点有左、右孩子结点

 看起来有待删除节点有4中情况,实际情况1可以与情况2或者3合并起来,因此真正的删除过程
如下:

  1.  删除该节点且使被删除节点的父节点指向被删除节点的左孩子节点--直接删除
  2.  删除该节点且使被删除节点的父节点指向被删除节点的右孩子节点--直接删除
  3.  在它的右子树中寻找中序下关键码最小的节点(最左节点),或者在它的左子树中寻找中序下关键码最大的节点(最右节点)。用它的值填补到被删除节点中,再来处理该节点的删除问题--替换法删除

5.1、直接删除

例如如下情况:

 适用于直接删除的场景是,当删除目标节点后,父节点需要领养的子节点个数小于等于 1 。比如: 删除 4 后,让 6 的左节点指针指向空。删除 6 后,让 3 的右节点指针指向 7 。删除 14 后让 10 的右节点指向 13 。

5.2、替换法删除

例如如下情况:

 与直接删除的场景不同,在这种情况下,删除目标节点后,父节点需要领养的子节点个数是 2 个,无法直接处理。

 这就需要使用替换删除法,即在它的右子树中寻找中序下关键码最小的节点(最左节点),或者在它的左子树中寻找中序下关键码最大的节点(最右节点)。用它的值填补到被删除节点中,再来处理该节点的删除问题。

 比如:删除 3 时,先交换节点 3 与 节点 1 的关键码,再删除此时身为叶子节点的 3 。删除 8 时,先交换节点 8 与节点 10 的关键码,再删除节点 8 。此时删除节点 8 就可以使用上面所讲的直接删除法。

5.3、实现代码

 以下是代码的初级版,实现了大部分功能,但是在特殊情况下有很多bug:

 上面的代码可以解决一般问题,但是仍有地方需要改动,例如:

 二叉搜索树根节点 8 的右子树为空,在删除节点 8 时,会执行代码:

 但是此时因为 parent 指向空,这里属于对空指针解引用,从而导致程序崩溃。为了避免这种情况,需要增加一层判断,以及时改变 _root 节点。左为空时的代码同理:

 上面代码中,第二个需要改动的地方在于,当目标节点的左右子树都不为空时,例如:

 会执行如下代码:

 此时 cur 节点就是节点 8 。 minRight 节点就是节点 10 。因为节点 10 的左子树为空,所以不会进入 while 循环,直接执行下面的指令,这样会导致 pminRight 仍然为空指针,对空指针解引用导致程序崩溃。除此之外,也不能直接让 pminRight->_left 指向 minRight 的右子节点。比如还是删除节点 8 ,此时就应该让节点 8 的 _right 指向 minRight 的右子节点。应该增加一层判断:

 故完整代码如下:

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、左为空
			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;
			}
			//2、右为空
			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;
			}
			//3、左右都不为空
			else
			{
				//找右树的最小节点(最左节点),或者左树的最大节点(最右节点)
				Node* pminRight = cur;
				Node* minRight = cur->_right;
				while (minRight->_left)
				{
					pminRight = minRight;
					minRight = minRight->_left;
				}
				cur->_key = minRight->_key;

				if (pminRight->_left == minRight)
				{
					pminRight->_left = minRight->_right;
				}
				else
				{
					pminRight->_right = minRight->_right;
				}

				delete minRight;
			}
			return true;
		}
	}
	return false;
}

5.4、递归代码 

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
	{
		Node* del = root;
		//开始删除
		if (root->_left == nullptr)
			root = root->_right;
		else if (root->_right == nullptr)
			root = root->_left;
		else
		{
			//这里找右子树的最左节点
			Node* minRight = root->_right;
			while (minRight->_left)
			{
				minRight = minRight->_left;
			}
			std::swap(root->_key, minRight->_key);
			return _EraseR(root->_right, key);
		}

		delete del;
		return true;
	}
}

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

递归代码有几点需要注意的地方: 

 在递归的删除节点中,需要交换右子树的最左节点与目标节点的值,而不是把最左节点的值直接赋值给目标节点就可以了。

 另外在递归时,传递给下一层递归的参数是 root->_right ,而不能是 minRight ,这是因为递归代码的参数使用的是引用,传递 root->_right 可以使每一层递归的根节点都是上一层的右孩子,从而与上一层建立联系,而如果使用形参 minRight 的话,与上层递归没有任何联系,造成程序崩溃。

6、构造与拷贝构造

BSTree() = default; //强制生成默认构造

BSTree(const BSTree<K>& t)
{
	_root = Copy(t._root);
}

Node* Copy(Node* root)
{
	if (root == nullptr)
		return nullptr;

	Node* newnode = new Node(root->_key);
	newnode->_left = Copy(root->_left);
	newnode->_right = Copy(root->_right);

	return newnode;
}

7、析构函数

~BSTree()
{
	Destory(_root);
	_root = nullptr;
}

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

	Destory(root->_left);
	Destory(root->_right);

	delete root;
}

8、赋值运算符重载

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

三、二叉搜索树的应用

1、K模型

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

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

  1. 以词库中所有单词集合中的每个单词作为key,构建一棵二叉搜索树。
  2. 在二叉搜索树中检索该单词是否存在,存在则拼写正确,不存在则拼写错误。

我们上面所实现的二叉搜索树,实际上就是 K 模型。 

2、KV模型

 每一个关键码 Key ,都有与之对应的值 Value ,即 <Key, Value> 的键值对。例如:

  • 例一、英汉词典就是英文与中文的对应关系,通过英文可以快速找到与其对应的中文,英文单词与其对应的中文<word, chinese>就构成一种键值对。
  • 例二、统计单词次数,统计成功后,给定单词就可快速找到其出现的次数,单词与其出现次数就是<word, count>就构成一种键值对。

 与 K 模型不同, KV 模型的二叉搜索树每一个节点都增加了一个成员变量 value 。区别仅在于此,除此之外包括插入删除等等都是以 key 的值为标准,与 K 模型的处理逻辑相同。

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:
    //函数实现与K模型基本相同,这里不再重复书写
    bool Insert(const K& key, const V& value);
    Node* Find(const K& key);  //这里返回目标节点
    bool Erase(const K& key);
    void InOrder();
private:
	Node* _root = nullptr;
}

例一:

例二:

3、性能分析

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

 对有个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二叉搜索树的深度的函数,即结点越深,则比较次数越多。但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树:

  •  最优情况下,二叉搜索树为完全二叉树(或者接近完全二叉树),其时间复杂度为:O(log N)
  •  最差情况下,二叉搜索树退化为单支树(或者类似单支),其时间复杂度为:O(N)

关于二叉搜索树的相关内容就讲到这里,希望同学们多多支持,如果有不对的地方欢迎大佬指正,谢谢。

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。