本来想先把搁置了一个月的Linux讲讲的,但是里面有些内容需要用到一些比较高级的数据结构,用C写的话比较麻烦,所以还是接着我前面的C++讲。
本篇主要讲二叉搜索树,先说概念,然后直接上手实现。再给一些生活中的场景,最后用这里的二叉搜索树来解前面我写数据结构阶段的两道链表题。
二叉搜索树(搜索二叉树),也叫二叉排序树。如果某棵二叉搜索树不是空树,则其具有以下性质:
- 若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
- 若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
- 它的左右子树也分别为二叉搜索树
简单来说就是 左 < 根 < 右。搜索树不允许有重复值,所以没有相等的情况。
二叉搜索树是第一个二叉树的应用,还是比较有用的。概念讲完了,就直接开始实现。
就实现三个功能,一般的数据结构都是增删查改四个基本功能,这里二叉搜索树少了一个改的功能,具体为什么各位等会看其余的三个实现就懂了。
二叉搜索树分为两类,一类是key模型,一类是key/value模型,至于什么意思暂时讲不了,但是你们先看模拟实现就行了, 这里先实现key模型的,看完模拟实现就懂了。
二叉搜索树的英文名字叫binary search tree,缩写就用的是BST。
先是树节点,这模版中的模版参数用的是K,而不是平常的T,主要是为了标志出这里的实现是key模型的实现:
上面的是struct而不是class是因为等会实现的时候节点中的左右孩子指针和val一直都要用到。跟前面我在list的模拟实现那篇中同理。
在里面typedef一下树节点,用起来比较方便。初始情况下root为空。
然后就可以写增删查了。
就是往树里面插入。不过这里有点要求。就是插入树节点的时候要保证 左 < 根 < 右。所以要先找到合适的位置,然后再在该位置上插入。
我们就用 int a[] = {8, 3, 1, 10, 6, 4, 7, 14, 13}; 这几个数来挨个插入。
那么上面的这棵树就是二叉搜索树,如上面的过程能看懂,那么我觉得二叉搜索树的插入思想你就明白了。
就是找合适的位置,插入即可。
先给个接口:
用bool作为返回值,因为前面说了搜索树中不能有相同的数值。如果有了相同的数值就返回false。
中间要创建节点值为val的新节点,所以我们可以在BSTreeNode中写一个构造:
然后就是找合适位置了:
cur经过如上代码,就可找到要插入的位置。
如果数据结构学的不是很扎实的同学可能会犯如下错误:
这种情况下直接返回。
屏幕前的你知道哪里出问题了吗?
仔细捋一捋就能发现,cur本来已经找到了该插入的位置的,但是new了之后cur的值就变成了val新节点的地址了,这里根本就没有插入,就只是将cur不断地赋值而已。
那么要改一改,插入的时候要插入到合适的位置,要插入到某一个节点的孩子位置,最重要的是要知道插入位置的父节点。
所以找插入位置的过程要不断记住路程中的父节点,这样才能保证插入的位置是在树上的,而不是随机找个节点插。
再来写一个中序遍历验证一下:
这样的写法在用对象调用的时候必须要将树的根节点指针传过去,但是又有一个问题,我实现的树里面根节点是私有的。
想要解决的话可以给一个接口来专门返回根节点的地址;或者还可以用友元。
有的同学说可以给缺省参数,将函数的缺省参数给为_root,这样的做法是错误的:
这里有一个最优解法,就是搞一个子函数。
像下面这样:
就可以直接不传参调用InOrder。
因为不支持插入重复元素,所以这里绝对不会打印出重复元素。中序打印出来的结果完全就是排好序的。因为左根右的遍历方式打印出来就是有序的,不理解的自己想一想。
然后来说查找。
查找是这三个里面最简单的。
这里不需要返回节点什么的,只要能判断在不在就可以了,这也是key模型的关键所在,等会也会讲对应的应用场景。
再来说删除。
这个最麻烦,主要是删除一个数后要保持其仍然是一个二叉搜索树。
被删除的节点可以分三种情况:
分别来画图看看:
没有孩子
节点删除之后将树中的该位置改为nullptr就行。
实现起来的话,先找到13,删除13,再让14的左指向空。
有一个孩子
实现起来的话,就是先找到14,然后让10的右指向13,再删除14。
有两个孩子
删除的时候要用到替换法。
最麻烦的就在这里。
两种解决方式:
观看理论比较晦涩,看图:
这样替换下来,仍能够保持其是一棵二叉搜索树。
实现起来的话,两种方法:
- 左子树:先找到3,再去3的左子树中找最大值1,然后让二者的值交换,这
样1就跑到了根,3就跑到左子树上了,再删除交换后的3处的节点。- 右子树:先找到3,再去3的左子树中找最小值4,然后让二者的值交换,这样4就跑到了根,3就跑到右子树上了,再删除交换后的3处的节点。
树的根节点的删除比较特殊,这里没看懂的话没关系,等会会详谈。
根据上面的思想,删除两个孩子的节点方法可以总结如下:
上面孩子的三种情况都要先找到删除的节点,然后再分情况讨论即可。
那就可以写代码了:
因为删除后要让删除的位置为空,所以要定义出一个不断更新的父节点,来找到最后删除位置的父节点。
根据二叉搜索树的特性,先找到节点:
然后再分孩子的情况讨论,我们这里可以把没有孩子的和有一种孩子的放到一块,先不说为什么,各位看图:
没有孩子,比如删13的话,此时就是这样:
删除13,然后让14的左为空,可以直接让14指向13的任意一个节点,因为13的任意节点的值都为空。
二者都能让 parent节点的左/右 指向 cur的左/右 ,就能实现替换这一过程,替换之后再删除cur即可。
然后内部还要分cur是parent的左还是右:
上面删除cur的地方代码冗余了,等会再搞。
但是还有问题,如果是删除根节点的话,上面的代码就出bug了。
比如说这样:
因为如果val就是根节点的值话,cur的while循环就进不去,那么parent此时就是nullptr,上面的代码就解引用空指针。所以还要分parent是否为空的情况:
再来说左右都不为空的节点,对应删除3:
这里我们以找右树的最小值为例:
然后将3和4的值交换,然后再删除min节点就可以了,但是还要将6的左置空,所以又得产生一个不断变换的父节点来记录min的父节点。
这里不用判断parent是否为空的情况,因为节点的数值交换了。
这样删除工作就做好了,可以说还是比较麻烦的。
这里把完整的删除代码给出来:
bool Erase(const K& val)
{
Node* parent = nullptr;
Node* cur = _root;
while (cur)
{
if (cur->_val < val)
{
parent = cur;
cur = cur->_right;
}
else if (cur->_val > val)
{
parent = cur;
cur = cur->_left;
}
else // cur 就是要删除的节点
{
if (cur->_left == nullptr)
{ // 左树为空的情况,分两种
// 1.包含了左右都为空 2.只有左为空 对应到图中就是删除13和删除10
// 判断val是否为_root的val
if (parent == nullptr) // 也可用 cur == _root 来判断
{ // 这里cur左右都为空的情况也成立
_root = cur->_right;
}
else
{
// 看cur是parent的左树还是右树
if (cur == parent->_left) // cur是parent的左树
parent->_left = cur->_right;
else // cur是parent的右树
parent->_right = cur->_right;
}
delete cur;
cur = nullptr;
}
else if (cur->_right == nullptr)
{ // 右树为空的情况,上面已经包含左右都为空的情况,所以这里只有一种情况
// 就是只有右树为空的情况,对应到图中就是删除14
// 判断val是否为_root的val
if (parent == nullptr)
{
_root = cur->_left;
}
else
{
// 看cur是parent的左树还是右树
if (cur == parent->_left) // cur是parent的左树
parent->_left = cur->_left;
else // cur是parent的右树
parent->_right = cur->_left;
}
delete cur;
cur = nullptr;
}
else
{ // 左右都不为空
Node* min = cur->_right;
Node* parentMin = cur;
// 去右树中找最小值
while (min->_left)
{
parentMin = min;
min = min->_left;
}
swap(min->_val, cur->_val);
if (parentMin->_left == min)
parentMin->_left = min->_right;
else
parentMin->_right = min->_right;
delete min;
min = nullptr;
}
// 删除成功
return true;
}
}
// 没有删除的节点
return false;
}
三个功能均已经实现了,我们还可以用递归的方式实现。
先说最简单的查。
再说插入
这里非常巧妙运用了引用。
第一个参数root类型为Node*&,什么意思呢,就是一个Node的引用,也就是一个Node变量的别名。
当我们找到了要插入的位置的时候,一定是一个子节点,传过来的一定是root->_left 或者 root->_right 。所以引用的就是父节点左/右的指针。
所以当root为空的时候就是要插入的时候,这时候root就是父节点左/右的指针,就可以直接用new将开辟的空间赋值给root,等价于直接将开辟的空间赋值给了父节点左/右的指针。
又因为我们删除节点之后还要置空,但是递归想要找父节点还要多传一个参数,我们此时就可以再将参数改为&的。也就是Node*& root。这样root就直接变成了父节点的左/右指针了。
整个递归erase的代码如下:
bool _EraseR(Node*& root, const K& val)
{
if (root == nullptr)
return false;
if (root->_val == val)
{
if (root->_left == nullptr)
{
Node* right = root->_right;
delete root;
root = right;
}
else if (root->_right == nullptr)
{
Node* left = root->_left;
delete root;
root = left;
}
else
{
Node* min = root->_right;
while (min->_left)
min = min->_left;
swap(min->_val, root->_val);
_EraseR(root->_right, val);
}
return true;
}
if (root->_val > val)
return _EraseR(root->_left, val);
if (root->_val < val)
return _EraseR(root->_right, val);
}
到这里这三个功能正式讲完。
再说点别的。
给出如下代码:
答案是不会,因为我还没有写析构。
那么二叉树的析构,很简单。后序递归即可。
但是析构函数没有参数,所以也是搞一个子函数就行。
然后上面的代码运行起来就崩掉了,因为拷贝构造是默认生成的,内置类型做浅拷贝。只是把cp的根节点指向了bst的根节点上,两个值相同。所以析构就崩掉了。
测试:
出错了,编译器说我没有默认的构造函数可用。
因为生成了一个构造函数之后编译器就不再提供默认的构造函数了。拷贝构造也算构造。所以此时加上一个构造函数就行。
此时运行就崩不了。
这个还是老方法,直接参数传值,交换即可。
下面说说引用场景。
二叉搜索树,听名字就能知道主要是用来搜索的。那么其查找的时间复杂度是多少呢?
可能有的同学认为是logN,其实不是,当树不是接近满二叉树或者完全二叉树时,效率可能比较低,比如棵单边树:
这样查找效率就很低了,就是O(N)的。
总的来说二叉搜索树的查找效率是取决于树形状的。
所以二叉搜索树控制插入的根节点的值非常重要,但是一般很难决定。后面还有AVL树来平衡整棵树。
上面写的是key模型的,主要用来判断关键字在不在,比如说
- 学生刷卡进宿舍楼。
这里就是学生卡中记录学生的某一项信息,比如学号,记录到卡的芯片中,然后数卡的时候通过二叉搜索树来查找是否存在,如果二叉搜索树比较均匀的话(满或完全二叉树),查找的效率就非常高,当然,AVL树,比二叉搜索树方便点,但原理都一样。- 检查一段英文中每个单词拼写是否正确。
记录正确的拼写,然后查找单词是否存在就行了。
还有一种模型是key/value模型,其原理是通过key来找value。key模型和key/value模型非常相似,key/value模型还是通过key比较,value只是一个附加项。例子有:
英文单词译为中文
统计……出现的次数
这里简单写一个key/value模型
代码如下:
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)
{
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;
}
bool Erase(const K& key)
{
//...
return true;
}
void InOrder()
{
_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);
}
private:
Node* _root = nullptr;
};
拿第一个例子:
插入的时候是按照英文字符串进行比较的。
这两道题说一下思路:
链表相交
key模型,先入一个链表,再遍历另一个链表查找某节点是否存在,若存在就返回存在的节点,不存在就继续遍历链表,直至遍历完毕。
复制带随机指针的链表
key/value模型,建立原节点和拷贝节点的映射关系。
比如:
黑色为原节点,蓝色为拷贝节点。1和1,2和2,3和3,建立映射。
1的random为3,那么蓝色的1random也为3,我们可以通过映射关系,通过黑色的3找蓝色的3,继而找到蓝色的random,然后连接1、3即可。其余同理。
到此结束。。。