【C++笔记】二叉搜索树的模拟实现

【C++笔记】二叉搜索树的模拟实现

  • 一、二叉搜索树的概念
  • 二、二叉搜索树的模拟实现
    • 2.0、定义二叉树节点
    • 2.1、非递归接口实现
      • 2.1.1、插入
      • 2.1.2、查找
      • 2.1.3、删除
    • 2.2、递归接口实现
      • 2.2.1、插入
      • 2.2.2、查找
      • 2.2.3、删除
  • 三、升级为K-V模型

一、二叉搜索树的概念

二叉搜索树的概念:

二叉搜索树又称二叉排序树,它或者是一棵空树,或者是具有以下性质的二叉树:
若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
它的左右子树也分别为二叉搜索树

就比如下面这一棵树就是一颗二叉搜索树:
【C++笔记】二叉搜索树的模拟实现_第1张图片
学习二叉搜索树其实也是为了后面学习AVL树和红黑树打下基础,因为AVL树和红黑树都是有二叉搜索树延伸出来的。

基于二叉搜索树的的特性,如果要在二叉搜索树中查找某一个数,最多只需要查找到最底层(如果比根大就去右子树去查找,如果比根小就去左子树中查找),很容易得出查找的时间复杂度为logn。所以在应用中二叉搜索树也常常用于查找。

但是二叉搜索树查找的时间复杂度也并非一直稳定在logn,如果是一些比较特殊的树,时间复杂度可能会升到n,就比如下面这棵树:
【C++笔记】二叉搜索树的模拟实现_第2张图片
所以AVL树和红黑树就是为了解决二叉搜索树的这个缺陷而设计出来的。

而且我们后面要学到的set和map就是用红黑树封装出来的,所以学好二叉搜索树也就变成了基础中的基础。

二、二叉搜索树的模拟实现

2.0、定义二叉树节点

搜索二叉树的节点其实也和传统的二叉树节点一样,只是我们处理这些节点的规则变了而已:

// 搜索二叉树节点
template <class K>
struct BSTreeNode {
	BSTreeNode<K>* _left;
	BSTreeNode<K>* _right;
	K _val;
	BSTreeNode(const K& val)
		:_left(nullptr)
		, _right(nullptr)
		, _val(val)
	{}
};

然后我们在搜索二叉树的类中就只需要封装一个根节点的指针即可:

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

2.1、非递归接口实现

因为搜索二叉树可以说是为了应用而产生的,所以它主要的接口不多,只需要实现三个:插入、查找、删除即可。其他的一些接口和普通二叉树是一样的。

2.1.1、插入

根据二叉搜索输的规则,我们很容易就能想出插入该怎么写:
我们只需要从根节点出发,一直遍历,如果要插入的节点比当前节点小那我们就往左子树走,如果如果比当前节点大就往右子树走,知道当前节点cur走到空就确定了要插入的位置:
【C++笔记】二叉搜索树的模拟实现_第3张图片
但是这样子,只是cur走到了空,我们并不是道cur是最有一个节点的做孩子还是右孩子,所以我们还需要一个parent指针一路记录cur的父亲,这样到cur走到空的时候就能判断是插入在左边还是右边了:
【C++笔记】二叉搜索树的模拟实现_第4张图片

注意: 搜索二叉树是不能插入相同节点的。

代码实现:

// 非递归插入
	bool insert(const K& val) {
		if (nullptr == _root) {
			_root = new Node(val);
			return true;
		}
		Node* cur = _root;
		Node* parent = _root;
		while (cur) {
			parent = cur;
			if (val < cur->_val) {
				cur = cur->_left;
			}
			else if (val > cur->_val) {
				cur = cur->_right;
			}
			else { // 不能插入相同节点,如果相同直接返回false
				return false;
			}
		}
		// 判断插入在左还是在右
		if (val < parent->_val) {
			parent->_left = new Node(val);
		}
		else {
			parent->_right = new Node(val);
		}
		return true;
	}

2.1.2、查找

查找我想应该是最简单的了,我们按规矩走一遍就行了,如果找到就返回true,走到空就表示找不到,返回false即可:

// 非递归查找
bool Find(const K& val) {
	Node* cur = _root;
	while (cur) {
		if (val < cur->_val) {
			cur = cur->_left;
		}
		else if (val > cur->_val) {
			cur = cur->_right;
		}
		else {
			return true;
		}
	}
	return false;
}

2.1.3、删除

但删除就没有这么简单了,因为这得涉及到对这棵树局部的重新调整。
可能大家也能想到,删除叶子结点一定是最简单的,删除左右都不为空的节点一定是最难的。
但是叶子结点是左右都为空,我们其实可以将它分类到左为空或者右为空中,因为它两个条件都满足嘛。

左为空:
比如在下面这棵树中我们要删除10这个节点:
【C++笔记】二叉搜索树的模拟实现_第5张图片
根据搜索二叉树的规则,我们会发现对于一个根节点,其左子树的所有节点都是小于它的,右子树的所有节点都是大于它的。
所以对于当前的情况,我们只需要将当前cur节点删除,然后让其父亲的左指针或右指针指向cur的右孩子即可:
【C++笔记】二叉搜索树的模拟实现_第6张图片
右为空:
同理,右为空的处理和左为空类似:
【C++笔记】二叉搜索树的模拟实现_第7张图片

左右都不为空:
左右都不为空的情况就有点儿复杂了,因为这种情况cur牵扯到的节点一定是最多的,有可能cur的做右子树都是很大的树,可想要做的调整应该是很大的!?

但其实我们并需要做多大的调整,调整这种情况我们只需要把注意力放在搜索二叉树的“规则”上。

对于每一棵子树,它的左孩子一定比它小,有孩子一定比它大。
而从这个规律中我们也能得到另个一规律,就是对于一个根而言,它的左子树的所有节点都一定比它小,它的右子树的所有节点都一定比它大。

我们从第二个规律入手就可以很好的解决了,那怎样做到调整之后保持这个规律呢?

先给出结论:

找出左/右字数中最大/小的节点,然后与cur交换,然后那个最小的节点(值已经交换,所以也就达到了删除cur的目的)。

以找到右子树的最小节点为例,我们来解释一下,这样做为什么行,比如在下面这棵树中,我们要上出节点3:
【C++笔记】二叉搜索树的模拟实现_第8张图片
我们这里用subLeft来表示右子树的最小节点,cur表示当前要删除的节点。

由搜索二叉树的规律我们可知,一个树的最小节点一定是这棵树的最左节点,所以它一定是个叶子结点,所以删除subLeft就可以转化成和上面左/右为空的情况。
而一棵树的最小节点一定比这棵树中的所有节点都要小,又由上面的第二个结论,**我们可知subLeft一定是大于cur左子树中任意的节点的。**所以交换cur和subleft后的整棵子树依旧符合搜索二叉树的定义。

同理,选择右子树的最大节点也是同样的证明。

代码实现:

// 非递归删除
bool Erase(const K& val) {
	Node* cur = _root;
	Node* parent = _root;
	// 先找到要删除的节点
	while (cur) {
		if (val < cur->_val) {
			parent = cur;
			cur = cur->_left;
		}
		else if (val > cur->_val) {
			parent = cur;
			cur = cur->_right;
		}
		else {
			// 开始删除
			if (nullptr == cur->_left) { // 左为空
				if (cur == _root) { // 如果cur等于_root
					_root = _root->_right;
				}
				else {
					if (cur == parent->_left) {
						parent->_left = cur->_right;
					}
					else {
						parent->_right = cur->_right;
					}
				}
				delete cur;
			}
			else if (nullptr == cur->_right) { // 右为空
				if (cur == _root) {
					_root = _root->_left;
				}
				else {
					if (cur == parent->_left) {
						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->_val, cur->_val);
				// 转化成去删除subLeft
				if (subLeft == parent->_left) {
					parent->_left = subLeft->_right;
				}
				else {
					parent->_right = subLeft->_right;
				}
				delete subLeft;
			}
			return true;
		}
	}
	return false;
}

2.2、递归接口实现

我们以前的二叉树都是用递归实现的,二搜索二叉树也是二叉树,当然也可以使用递归实现。

2.2.1、插入

插入的递归实现其实很好写,如果要插入的节点比当前的节点小就转化成在左子树插入,如果要插入的节点把当前的节点大就转化成在右子树插入,如果相等就返回false即可。
用递归实现还有一个好处就是,我们没必要记录遍历到的每个节点的parent了,因为递归用传参的方式就很好的帮我们解决了。

先看代码实现:

// 递归版insert
bool insertR(const K& val) {
	return _insertR(_root, val); // 因为_root在类里面是私有的,在类外部不好传参,所以在这里设计了一个子函数
}
bool _insertR(Node *& root, const K & val) {
	if (nullptr == root) {
		root = new Node(val);
	}
	if (val < root->_val) {
		return _insertR(root->_left, val);
	}
	else if (val > root->_val) {
		return _insertR(root->_right, val);
	}
	return false;
}

不知道大家有没有注意到,我的_insertR子函数里的root指针给的是一个引用,为什么要给引用呢?如果不给引用的话会有什么问题呢?

先给出回答:这里的root必须给引用,如果不给引用的话新插入的节点和之前的节点就连接不上了。

为什么:
虽说现在已经是C++了,但是有时候解决一些问题还是需要用到C语言的知识的。
这里其实涉及到了C语言中变量的“左值”和“右值”的知识。

如下图:
【C++笔记】二叉搜索树的模拟实现_第9张图片
如果我们想要将新的节点连接上parent,要执行parent->_right = cur,这其实是将cur放到parent->_right指向的 “空间”,也就是要用到parent->_right的 “左值”(“左值”表示空间,“右值”表示数据)。

而如果我们不适用引用传参,则我们传过来的值是一个局部变量,是是实参的拷贝:
【C++笔记】二叉搜索树的模拟实现_第10张图片
也就是说只传指针的话,我们只是将新节点放到了局部变量的空间中,所以就没有连接上。

如果想要连接成功,也可以像C语言一样使用二级指针,但是现在有C++的引用可以用,谁还用二级指针啊,二级指针多恶心啊!

所以我们这里就需要使用引用传参,引用是对象的别名,所以用的还是一样的左值,一样的空间。

2.2.2、查找

查找也是很简单的,几乎和循环一样。
如果要查找的节点比当前节点小,就递归到左子树查找,如果要查找的节点比当前节点大,就递归到右子树查找,如果递归到空节点,则说明找不到,返回false即可:

// 递归版查找
bool FindR(const K& val) {
	return _FindR(_root, val);
}
bool _FindR(Node* root, const K& val) {
	if (nullptr == root) {
		return false;
	}
	if (val < root->_val) {
		return _FindR(root->_left, val);
	}
	else if (val > root->_val) {
		return _FindR(root->_right, val);
	}
	return true;
}

2.2.3、删除

递归删除也是先要找到节点才行,这个不多说。
递归删除虽说是递归,但是我们只是借助递归帮我们查找节点而已,删除操作其实是和迭代的方式一样的,唯一不同的是,当删除到左右都不为空的节点时,我们交换后可以利用递归转化成在左子树或右子树中删除同样值:

// 递归版删除
bool EraseR(const K& val) {
	return _EraseR(_root, val);
}
bool _EraseR(Node*& root, const K& val) {
	if (root == nullptr) {
		return false;
	}
	if (val < root->_val) {
		return _EraseR(root->_left, val);
	}
	else if (val > root->_val) {
		return _EraseR(root->_right, val);
	}
	else {
		if (nullptr == root->_left) {
			// 开始删除(和迭代的方式一样)
			Node* del = root;
			root = root->_right;
			delete del;
			return true;
		}
		else if (nullptr == root->_right) {
			Node* del = root;
			root = root->_left;
			delete del;
			return true;
		}
		else {
			Node* subLeft = root->_right;
			// 找到右子树最小的节点(右子树中最左边的节点)
			while (subLeft->_left) {
				subLeft = subLeft->_left;
			}
			// 交换
			swap(subLeft->_val, root->_val);
		}
		// 转化成在右子树删除同样的值
		return _EraseR(root->_right, val);
	}
}

三、升级为K-V模型

我们上面所实现的其实可以称作是K模型的二叉搜索树,但二叉搜索树最多的应用其实是K-V模型。
K-V模型其实就是一个键对应一个值,比如英汉字典就是典型的K-V模型,中文是键,英文是值,在查找的时候只需要找到中文就可以得到对应的英文了。

搜索二叉树的K-V模型也是一样,在查找的时候我们只需要给出对应的key,就能找到对应的value。

想要将我们上面写的二叉搜索树改成K-V模型其实很简单,只需要多加一个模板参数,然后将对应的接口修改一下即可:

修改节点:

// 搜索二叉树节点
template <class K, class V>
struct BSTreeNode {
	BSTreeNode<K>* _left;
	BSTreeNode<K>* _right;
	K _key;
	V _val;
	BSTreeNode(const K& keyl, const V& val)
		:_left(nullptr)
		, _right(nullptr)
		, _key(key)
		,_val(val)
	{}
};

修改插入(非递归):

// 非递归插入
bool insert(const K& key, const V& val) {
	if (nullptr == _root) {
		_root = new Node(key, val);
		return true;
	}
	Node* cur = _root;
	Node* parent = _root;
	while (cur) {
		parent = cur;
		if (keyl < cur->_key) {
			cur = cur->_left;
		}
		else if (key > cur->_key) {
			cur = cur->_right;
		}
		else {
			return false;
		}
	}
	if (key < parent->_key) {
		parent->_left = new Node(key, val);
	}
	else {
		parent->_right = new Node(key, val);
	}
	return true;
}

因为K-V模型只是通过key找到val,所以比较的时候置于key有关。

因为我们的插入和删除都是依照key来判断的,而删除并不涉及到修改,所以也就与val无关,所以删除的逻辑是一样的,不用修改。

修改查找:
因为我们这里改成了K-V模型,查找的话最好是能查找出一个键值对这样的效果更好,所以我们查找成功的话就直接返回节点的指针即可:

// 非递归查找
Node* Find(const K& keyl) {
	Node* cur = _root;
	while (cur) {
		if (keyl < cur->_key) {
			cur = cur->_left;
		}
		else if (keyl > cur->_key) {
			cur = cur->_right;
		}
		else {
			return cur;
		}
	}
	return nullptr;
}

修改完后我们可以浅浅的来测试一下,实现一个简易的单词查找功能:

【C++笔记】二叉搜索树的模拟实现_第11张图片

还有一个简易的统计频数的功能:
【C++笔记】二叉搜索树的模拟实现_第12张图片

你可能感兴趣的:(C++之路,c++,笔记,c语言,1024程序员节,开发语言)