【二叉搜索树】

[本节目标]

  • 1. 二叉搜索树实现

  • 2.二叉树搜索树应用分析

  • 3. 二叉树进阶面试题

1. 二叉搜索树

1.1 二叉搜索树概念

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

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

【二叉搜索树】_第1张图片

2.2 二叉搜索树操作及实现

下面就是一颗二叉搜索树,我们下面的模拟实现都按照这棵树来实现。

 【二叉搜索树】_第2张图片

int a[] = {8, 3, 1, 10, 6, 4, 7, 14, 13};

根据我们之前学习的二叉树章节,我们是用左孩子右兄弟来描述一个结点的,所以我们这里也先来描述一下结点的信息,之前我们使用的是struct,这里我们就是用class来描述结点信息。首先使用结构体+模板来创建结点,里面需要给出左子树,右子树,结点的值。只需要写一个构造函数对其值赋初值就行了。

// T - type   K - 关键字
template 
//struct BinarySearchTreeNode
struct BSTreeNode
{
	typedef BSTreeNode Node;

	Node* _left;
	Node* _right;
	K _key;

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

1. 二叉搜索树的查找

        a、从根开始比较,查找,比根大则往右边走查找,比根小则往左边走查找。

        b、最多查找高度次,走到到空,还没找到,这个值不存在。

对比我们之前的二分查找算法,我们来看看二分查找算法的缺陷

【二叉搜索树】_第3张图片

我们之前学习的查找就是二分查找,但是该查找的前提必须要求数组有序,所以每次插入一个数据,就可能要导致重新排序,并且在数组中还要挪动数据,非常难以维护,而对于搜索二叉树成本低,查找效率也快,最坏情况查找也只会走二叉搜索树的高度次,而且搜索二叉树的中序遍历就是有序的。

【二叉搜索树】_第4张图片

bool Find(const K& key)//查找key值
{
	Node* _cur = _root;
	while (_cur)
	{
		if (_cur->_key < key)
		{
			_cur = _cur->_right;
		}
		else if(_cur->_key >  key)
		{
			_cur = _cur->_left;
		}
		else
		{
			//找到了key值
			return true;
		}
	}
	//此时cur以已经为空,说明找不到key值
	return false;
}

2. 二叉搜索树的插入

注意:如果插入的key值已经存在二叉搜索树中了,此时我们就认为不能插入了。

        a. 树为空,则直接新增节点,赋值给root指针

        b. 树不空,按二叉搜索树性质查找插入位置,插入新节点

【二叉搜索树】_第5张图片

然后我们来看一下我们的代码有没有什么问题?

bool Insert(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 false;
		}
	}
	//此时_cur为空
	_cur = new Node(key);
	return true;
}

看上去挺对,但是我们忽略了一个问题,我们申请的结点给到_cur指针了,而且它时一个局部变量,出了作用域不仅消耗了,而且还会出现内存泄漏,我们此时要解决问题,就要与父指针进行链接,所以此时我们要找到_cur的父节点。

【二叉搜索树】_第6张图片

bool Insert(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
		{
			//如果要插入的值和当前值相等,那就不能插入了
			return false;
		}
	}
	//此时_cur为空
	_cur = new Node(key);
	if (_parent->_key < key)//要插入的值比当前值大
	{
		_parent->_right = _cur;
	}
	else//要插入的值比当前值小
	{
		_parent->_left = _cur;
	}
	return true;
}

但是我们的代码还存在一个小bug,如果我们的树一开始一个结点都没有,此时为空树,那么while循环体就没有进入,那么此时的_parent就还是空指针,那么此时访问_parent结点里面的元素就会出现错误。

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为空
	_cur = new Node(key);
	if (_parent->_key < key)//要插入的值比当前值大
	{
		_parent->_right = cur;
	}
	else//要插入的值比当前值小
	{
		_parent->_left = cur;
	}
	return true;
}

根据二叉搜索树的中序是有序的,所以我们这里通过走一遍中序来测试我们的程序,所以我们这里先来实现一下中序。

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

但是我们这里的中序无法调用,因为我们的中序要求传入根结点,如果不传入根节点我们这里就无法递归了,我们这里有两种解决方法,第一个方法是写一个获取根节点的函数,因为我们的根节点是私有的,或者我们还可以套一层子函数

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

void InOrder()
{
	_InOrder(_root);
    cout << endl;
}

现在我们来测试一下此时输出的结果。

根据二叉搜索树的中序是有序的,所以我们上面的结果是正确的。对于我们的插入和查找都是比较简单的,而对于二叉搜索树的删除才是最棘手的。

3. 二叉搜索树的删除

【二叉搜索树】_第7张图片

我们首先来看第一种情况,当左为空,将右托付给父亲,此时是托付给父亲的右还是父亲的左呢?

【二叉搜索树】_第8张图片

所以此时我们还要进行判断,若要删除的结点左为空,就将要删除结点的右托付给父亲。

【二叉搜索树】_第9张图片

再来看一下第二种情况,当右为空,将左托付给父亲,此时是托付给父亲的右还是父亲的左呢?同样的要分情况讨论。

【二叉搜索树】_第10张图片

【二叉搜索树】_第11张图片

现在我们再来看一下我们的第三种情况,它也是最复杂的,我们下面采用第二种:右子树的最左结点,此时该节点一定是左为空,右不一定为空。

【二叉搜索树】_第12张图片

我们能不能直接将结点3和结点4进行交换,然后再删除结点3呢?这样是不行的,因为交换之后按照二叉搜索树的特点,我们就不能找到3结点了。然后我们再来看看我们上面的写法有没有上面问题。如果我们删除的是根节点8呢?

【二叉搜索树】_第13张图片

如果我们删除的是根节点8,此时右树的根就为最左结点,此时循环体就没有进入,由于rightMinParent赋值为空指针,后面访问就会出现崩溃。所以我们要想解决,就不能将rightMinParent赋值为空,而需要将其赋值为_cur,并且加一个判断,rightMin有可能为rightMinParent的左,有可能为右,然后单独链接rightMin的右,这样即可解决。

【二叉搜索树】_第14张图片

这样就解决了问题,我们来测试一下。

【二叉搜索树】_第15张图片

我们再来测试一下其他情况,

【二叉搜索树】_第16张图片

如果我们把所有的值都删除呢?

【二叉搜索树】_第17张图片

此时我们发现我们的程序崩溃了,为什么呢?

【二叉搜索树】_第18张图片

我们发现此时删除最后一个结点出现了问题,父结点是空的,因为我们此时没有进入循环,直接走的else语句,由于我们赋值parent是空指针,所以此时就出现了问题,这个问题就好比我们下面的场景。

【二叉搜索树】_第19张图片

第一种情况要删除的节点左为空,需要将右托付给父亲;第二种情况要删除的节点右为空,需要将左托付给父亲,但是由于此时是根节点,父指针为空,此时要解决就要单独处理。

【二叉搜索树】_第20张图片【二叉搜索树】_第21张图片

此时我们再来测试一下结果

【二叉搜索树】_第22张图片

现在我们再来写一下递归的形式,但是要注意这里的递归都必须要套一层,因为我们这里的_root是私有的,外部不能使用。

【二叉搜索树】_第23张图片

bool _InsertR(Node*& root, const K& key)
{
	//当走到空,就可以插入了
	if (root == nullptr)
	{
		// 如何与父亲进行链接呢?可以在传root使用传引用
		// 此时要进行链接的时候,root刚好是root->_left或者root->_right的别名
		// 此时刚好就可以把要插入的结点与父节点相链接
		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;
}

图解:

【二叉搜索树】_第24张图片

然后我们再来看一下删除的递归写法:

情况1:删除的节点右为空

【二叉搜索树】_第25张图片

情况2:删除的节点左为空

【二叉搜索树】_第26张图片

情况3:删除的节点左右都不为空

假如我们要删除的是节点是3,此时节点3刚好是root,能不能使用引用的方法,让右树的最左节点4成为root的别名,然后再替换删除节点呢?

【二叉搜索树】_第27张图片

我们这里思路是依然找到右树的最小节点rightMin,然后将rightMin与root交换,然后复用删除的代码即可

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->_right == nullptr)
			root = root->_left;
		else if (root->_left == nullptr)
			root = root->_right;
		else
		{
			// 这里不能加引用
			// 引用不能改变指向
				
			// 找右树的最小节点
			Node* rightMin = root->_right;

			Node*& rightMin = root->_right;
			while (rightMin->_left)
			{
				rightMin = rightMin->_left;
			}

			swap(root->_key, rightMin->_key);

			return _EraseR(root->_right, key);
		}

		delete del;
		return true;
	}
}

此时测试一下我们的程序

【二叉搜索树】_第28张图片

现在我们再来写一下Destory函数

void Destory(Node* root)
{
	if (root == nullptr)
		return;
	Destory(root->_left);
	Destory(root->_right);
}

通过这个Destory函数来完成我们的析构函数。

~BSTree()
{
	Destory(_root);
}

如果我们要对二叉搜索树进行拷贝呢?很明显,我们这里没有写拷贝构造函数,那就使用的是默认的拷贝构造函数,此时是浅拷贝,必然会出现问题,我们这里要使用深拷贝的写法。

BSTree(const BSTree& 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;
}

我们来测试一下

【二叉搜索树】_第29张图片

对于赋值的话就比较简单了,直接用一个现代写法就足够了

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

4 二叉搜索树的应用

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

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

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

2. KV模型:每一个关键码key,都有与之对应的值Value,即的键值对。该种方 式在现实生活中非常常见:

  • 比如英汉词典就是英文与中文的对应关系,通过英文可以快速找到与其对应的中文,英 文单词与其对应的中文就构成一种键值对;
  • 再比如统计单词次数,统计成功后,给定单词就可快速找到其出现的次数,单词与其出 现次数就是就构成一种键值对。

我们上面的写的二叉搜索树的模型就是我们的K模型,现在我们来改造一下,让它是一个KV模型的数。修改我们对于插入的逻辑没有什么变化,但是在查找的时候我们的返回值需要改一下,因为我们还要找打到value,其他的变化就存储结点的结构体变化一下

// T - type   K - 关键字
template 
//struct BinarySearchTreeNode
struct BSTreeNode
{
	typedef BSTreeNode Node;

	Node* _left;
	Node* _right;
	K _key;
	V _value;

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

我们来测试一下

void TestBSTree1()
{
	// 输入单词,查找单词对应的中文翻译
	keyvalue::BSTree dict;
	dict.Insert("string", "字符串");
	dict.Insert("tree", "树");
	dict.Insert("left", "左边、剩余");
	dict.Insert("right", "右边");
	dict.Insert("sort", "排序");
	// 插入词库中所有单词
	string str;
	while (cin >> str)
	{
		keyvalue::BSTreeNode* ret = dict.Find(str);
		if (ret == nullptr)
		{
			cout << "单词拼写错误,词库中没有这个单词:" << str << endl;
		}
		else
		{
			cout << str << "中文翻译:" << ret->_value << endl;
		}
	}
}

运行结果:

【二叉搜索树】_第30张图片

同时我们还可以使用KV模型统计次数。

void TestBSTree4()
{
	// 统计水果出现的次数
	string arr[] = { "苹果", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜",
   "苹果", "香蕉", "苹果", "香蕉" };
	keyvalue::BSTree countTree;
	for (const auto& str : arr)
	{
		// 先查找水果在不在搜索树中
		// 1、不在,说明水果第一次出现,则插入<水果, 1>
		// 2、在,则查找到的节点中水果对应的次数++
		//BSTreeNode* ret = countTree.Find(str);
		auto ret = countTree.Find(str);
		if (ret == NULL)
		{
			countTree.Insert(str, 1);
		}
		else
		{
			ret->_value++;
		}
	}
	countTree.InOrder();
}

我们来看一下运行结果

【二叉搜索树】_第31张图片

为什么我们这里的数字无序呢?中序不是有序的嘛?这里要注意一点,中序有序值得是key有序,而不是value有序。

5 二叉搜索树的性能分析

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

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

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

你可能感兴趣的:(C++初阶,c++)