二叉搜索树的概念
二叉搜索树又称二叉排序树,它或者是一棵空树,或者是具有以下性质的二叉树:
1.若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
2.若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
3.它的左右子树也分别为二叉搜索树
为什么又叫二叉排序树呢?
如果对一棵搜索二叉树进行中序遍历的话,其实就能得到一个结点值的升序序列。
中序遍历结果: 1 3 4 6 7 8 10 13 14
二叉搜索树的结构
首先我们来定义一下结点和搜索二叉树的结构:
结点结构:
template<class K>
struct BSTreeNode
{
BSTreeNode<K>* _left;
BSTreeNode<K>* _right;
K _key;
BSTreeNode(const K& key)
:_left(nullptr),
_right(nullptr),
_key(key)
{}
};
树结构:
template<class K>
class BSTree
{
typedef BSTreeNode<K> Node;
public:
//成员函数
private:
Node* _root = nullptr;
};
二叉搜索树的操作
中序遍历
template<class K>
class BSTree
{
typedef BSTreeNode<K> Node;
public:
void InOrderPrint(Node* root)
{
if (root == nullptr)
return;
InOrder(root->_left);
cout << root->_key << " ";
InOrder(root->_right);
}
private:
Node* _root = nullptr;
};
如果是这样写main函数里调用需要传根结点(因为要递归,这个参数不能省),但是类外无法访问私有成员_root
可不可以函数参数给缺省值?
缺省值给一个_root不就行了,是不行的,因为缺省值必须是常量或者全局变量(但一般不用全局变量)而且在参数列表其实根本拿不到成员变量_root, 因为访问非静态成员要用this指针, 而this指针只能在成员函数内部使用, 参数列表也不行。
两个解决方法:
提供一个GetRoot的成员函数/方法,传参的时候通过该方法获取_root。
那其实有另外一种比较简便的方法,给InOrder函数在套一层.
template<class K>
class BSTree
{
typedef BSTreeNode<K> Node;
public:
void InOrderPrint()
{
InOrder(_root);
cout << endl;
}
private:
Node* _root = nullptr;
void InOrder(Node* root)
{
if (root == nullptr)
return;
InOrder(root->_left);
cout << root->_key << " ";
InOrder(root->_right);
}
};
这样调的时候就不用传参了,当然我就可以把
_InOrder
变成私有的了
void test_BSTree1()
{
BSTree<int> t1;
int a[] = { 8, 3, 1, 10, 6, 4, 7, 14, 13 };
for (auto e: a)
{
t1.Insert(e);
}
t1.InOrderPrint();
}
非递归
插入操作
首先对于搜索二叉树来说, 它的插入应该有插入成功和插入失败(因为搜索二叉树一般不允许出现重复元素)两种情况。
插入成功的情况:
在搜索二叉树中,要插入一个元素时,如果可以 插入,那么它插入的位置一定是确定的。
还是以这棵树为例,假设我们现在要插入一个12,其实就是从根结点开始,去找出那个合适的位置,然后把12放进去。
根结点的值是8,12大于8,所以应该去右子树找,8的右子树是10,12依然大于10,那再看10的右子树,是14,此时12小于14,所以就要往14的左子树,14的左子树为13,12小于13,所以再看13的左子树,是空。所以,12就应该放在13的左子树上。
插入失败的情况:
比如, 我们现在要插入一个13, 首先还是根据大小去比较,找合适的位置,但是走到13的位置发现要插入的值和已经存在的值相等,那就直接return false,插入失败。
如果插入的是第一个结点,那就不需要比较了,直接让它成为根结点。
代码实现
bool Insert(const K& key)
{
//如果根结点为空,那就证明是第一次插入,就把它作为根结点
if (_root == nullptr)
{
_root = new Node(key);
retrun true;
}
Node* cur = _root;
Node* parent = nullptr;//定义一个parent结点保存cur的父结点方便后面链接
while (cur)
{
parent = cur;
if (key > cur->_key)
cur = cur->_right;
else if (key < cur->_key)
cur = cur->_left;
else
return false;
}
//走到这里cur为空,是key要插入的位置
if (key > parent->_key)
parent->_right = new Node(key);
else
parent->_left = new Node(key);
return true;
}
查找操作
我们要查找某个结点,那就从根开始比较,查找,比根大则往右边走查找,比根小则往左边走查找,找到的话返回true
bool Find(const K& key)
{
Node* cur = _root;
while (cur)
{
if (key > cur->_key)
cur = cur->_right;
else if (key < cur->_key)
cur = cur->_left;
else
return true;
}
return false;
}
删除操作--难
那如果要删除二叉搜索树中的某个结点,应该怎么处理呢?
情况分类及思路分析
首先查找元素是否在二叉搜索树中,如果不存在,则直接返回false, 否则要删除的结点可能分下面四种情况:
1.删除的结点无子结点(删除叶子结点)
我们现在要删除4这个结点, 那这种情况是不是直接删除就好了,把4这个结点释放,让6的左孩子指向空就行了。
2. 要删除的结点只有右孩子结点,左为空
10要怎么删,那这里关键在于把10删了他的子结点怎么处理?
现在这种情况我们要删除的结点10只有一个孩子,而10被删除的话它的父亲结点8就只剩下一个孩子了,而二叉树中一个结点最多可以有两个孩子,所以10被删除之后,我们是不是可以把它的孩子14托管给10的父亲8, 被删除结点是父结点的右孩子就分配给右, 被删除的结点是父结点的左孩子就分配给左
3. 要删除的结点只有左孩子结点,右为空
比如我们现在要删除6
那这种情况处理方法其实和上一个一样,也是把被删除结点的孩子按情况托管给其父亲结点,另外其实第一种情况也可以归到第二种或第三种里面,第一种是两个孩子都为空,那也可以归到左为空或者右为空的情况里面。
4.要删除的结点有左、右孩子结点
比如我们要删除3或8,怎么删?
首先这里我们上面用到的托管给父结点的方法就不管用了,因为每个结点最多管两个孩子,而现在要删除的这个结点就有两个孩子,如果父亲原本就有两个,那这样父亲要管三个就超了。
况且对于8也就是根结点来说,它根本没有父结点。
那这种情况该如何处理呢?------使用替换法删除法/伪删除法:
以删除8为例,大家看,如果把8删了,谁能够坐到8这个位置呢?
那对于8这棵树来说,其实这个替代者可以有两个人选:1.左子树的最大值, 因为左子树的最大值一定都比左子树其他结点值要大,左子树还一定比右子树结点都小
2.右子树的最小值, 右子树的最小值一定比右子树其他结点值要小, 右子树的值一定比左子树大
那对于当前这棵树其实就是7或者10。
我们会发现,对于一棵搜索二叉树来说:
整棵树中最大的结点就是最右边的那个结点,最小的结点就是最左边的那个结点。
那对于子树来说也是这样,我们看到7其实就是左子树的最右边的结点,10就是右子树最左边的结点。所以这里要想删除8,可以选择用7替换也可以用10。
以用7为例,怎么进行替换删除呢?
把7这个结点的值赋给8这个结点,然后把原始的7结点删除了就行了
整体的框架:
bool Erase(const K& key)
{
Node* parent = nullptr;
Node* cur = _root;
while (cur)
{
if (key > cur->_key)
{
parent = cur;
cur = cur->_right;
}
else if (key < cur->_key)
{
parent = cur;
cur = cur->_left;
}
else
{
//删除
}
}
return false;
}
左为空或者右为空得删除其实比较好处理,托管给父亲结点即可:
需要注意的是如果删除的是根结点需要单独处理, 直接把根结点赋值为根的左/右孩子即可,不然此时parent下面就会对空指针进行解引用
左右都不为空:
那我们上面说了这个替换结点可以是左子树的最大结点(最右边的结点),也可以是右树得最小结点(最左边的结点)。
那这里我选择左子树的最大结点。
这里的替换删除分为两步:
第一步——找到左树的最大结点,然后替换要删除的结点(对应到代码中的swap语句之前)第二步——删除替换结点
那这里其实有一些需要注意的地方:
我们在这里选择的是左树的最大结点,即左子树最右边的结点,那既然是最右边,他一定没有右孩子了,但是,它可能会有左孩子。以删除根节点为例:
所以这里删除替换结点也要用托管的方式去删, 那就需要保存一下替换结点的父亲leftmax_p
那这里还有一些需要特别注意的点:
这个父结点的初始值可以给nullptr
吗?如果看上面那个例子是可以的,因为会进入循环更新parent的值。但是如果是这样的情况呢?如果删除的结点的左结点恰好就是左子树的最大值, 这种情况不会进入while循环,所以leftmax_p不会更新, 那后面托管的时候就会对空指针解引用, 所以这里初始值可以赋cur, 即要进行删除的那个结点(伪删除)。
另外我们上面的那个例子, 和之前不同, 它虽然是左子树的最大值, 但它却是它是leftmax_p的左孩子(之前都是右孩子), 将它的孩子托管的时候, 不像之前把leftmax的左孩子托管到leftmax_p的右孩子, 这次是要把leftmax左孩子托管到leftmax_p的左孩子(其实本质是因为要找左子树的最大值的位置, 第一步必须先把leftmax赋值为要删除结点的左才能去往下接着找, 这种情况恰好不用往下找了, 因为没有右孩子可了, 它就是最大了)两种情况对应的托管代码:
bool Erase(const K& key)
{
Node* parent = nullptr;
Node* cur = _root;
while (cur)
{
if (key > cur->_key)
{
parent = cur;
cur = cur->_right;
}
else if (key < cur->_key)
{
parent = cur;
cur = cur->_left;
}
else
{
//1.左为空
if (cur->_left == nullptr)
{
if (cur == _root)
_root = _root->_right;
else
{
if (key > parent->_key)
parent->_right = cur->_right;
else
parent->_left = cur->_right;
}
delete cur;
}
//2.右为空
else if (cur->_right == nullptr)
{
if (cur == _root)
_root = _root->_left;
else
{
if (key > parent->_key)
parent->_right = cur->_left;
else
parent->_left = cur->_left;
}
delete cur;
}
//3.左右都不为空
else
{
//Node* leftmax_p = nullptr;
Node* leftmax_p = cur;
Node* leftmax = cur->_left;
//寻找左子树的最大值
while (leftmax->_right)
{
leftmax_p = leftmax;
leftmax = leftmax->_right;
}
swap(leftmax->_key, cur->_key);//交换删除结点的值
//如果左子树最大值的父亲不是要删除的结点
if (leftmax_p != cur)
leftmax_p->_right = leftmax->_left;
//如果左子树最大值的父亲是要删除的结点
else
leftmax_p->_left = leftmax->_left;
delete leftmax;
}
return true;
}
}
return false;
}
递归版
查找
查找用递归要怎么写呢?
从根结点开始,如果大于根结点的值,就转换为去右子树里面查找,如果小于根结点,就转换为去左子树查找,如果等于就直接返回;那对于子树也是这样,一步步转换为子问题。
如果最后都没找到,一直到空,那就返回false。
public:
bool FindR(const K& key)
{
return _FindR(_root, key);
}
private:
bool _FindR(Node* root,const K& key)
{
if (root == nullptr)
return false;
if (key > root->_key)
return _FindR(root->_right,key);
else if (key < root->_key)
return _FindR(root->_left,key);
else
return true;
}
插入
那插入用递归怎么做呢?
那也是类似的思路,从根节点开始,如果要插入的结点大于根,就转换为去右子树插入;如果要插入的结点小于根,就转换为去左子树插入;如果相等,返回false;如果一直走到空,那就就在该位置插入就行了。
public:
bool InsertR(const K& key)
{
return _InsertR(_root,key);
}
private:
bool _InsertR(Node* root, const K& key)
{
if (root == nullptr)
{
}
if (key > root->_key)
return _InsertR(root->_right, key);
else if (key < root->_key)
return _InsertR(root->_left, key);
else
return false;
}
但是有一个问题,我们找到空插入的时候,如何和它的父亲链接起来?
一种思路是把父亲作为递归的参数进行传递,或者去判断root的子树为空而不是它本身为空。
但这样更丝滑:
直接传root的引用就可以了。
因为引用的话,走到空,他就是那个位置指针的引用,把这个root赋值就链接上了,而且不用像非递归实现的那样去用key判断要链接到左还是右.
那我们上面非递归的方式,可以用引用吗?
不可以,因为C++中的引用是不能改变指向的,引用一旦引用一个实体之后,就不能再引用其他实体,而这里递归的话,每次递归都相当于创建了一个新的引用,而不是改变上一层的引用的指向。
删除
其实还是我们上面分析的那三种情况:
左为空、右为空或者左右都不为空。传引用的话写起来还是比较简便的。
先写左为空和右为空的情况:
bool _EraseR(Node*& root, const K& key)
{
if (root == nullptr)
return false;
if (key > root->_key)
return _EraseR(root->_right, key);
else if (key < root->_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
{
//..
}
delete del;
return true;
}
}
然后看一下比较麻烦的左右都不为空的情况:
之前非递归版本的实现是,找一个符合条件的结点替换它,然后把替换的结点删除掉,这里也可以用同样的方法, 但传了引用更简便了:
还是先找一个可以替代要删除结点的结点(左子树最大结点或右子树最小),以右子树最小结点替换为例,交换它们两个的值,然后删除右子树里面的这个结点就行了
bool _EraseR(Node*& root, const K& key)
{
if (root == nullptr)
return false;
if (key > root->_key)
return _EraseR(root->_right, key);
else if (key < root->_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* rightmin = root->_right;
while (rightmin->_left)
rightmin = rightmin->_right;
swap(root->_key, rightmin->_key);
return _EraseR(root->_right, key);
}
delete del;
return true;
}
这里一定要是return _EraseR, 否则还会向下执行再删除一次
其它相关成员函数的实现
如果我们想在相对搜索二叉树的对象进行拷贝构造可以吗?
是可以的,虽然我们没写,但是拷贝构造属于默认成员函数,编译器会自动生成,不过默认生成的只完成浅拷贝。如果有析构就会出问题,因为搜索二叉树涉及资源申请,这样如果是浅拷贝的话,在析构的时候就会对一块空间析构两次,所以就会出问题。
构造函数:
public:
BSTree(const BSTree<K>& t)
{
_root = Copy(t._root);
}
private:
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;
}
写了拷贝构造就要写默认构造:
C++11有一个关键字——default,可以强制编译器生成默认构造
BSTree() = default;
析构函数:
public:
~BSTree()
{
Destroy(_root);
}
private:
void Destroy(Node*& root)
{
if (root == nullptr)
return;
Destroy(root->_left);
Destroy(root->_right);
delete root;
root = nullptr;
}
赋值重载:
BSTree<K>& operator=(BSTree<K> t)
{
if (_root == t._root)
return *this;
std::swap(_root, t._root);
return *this;
}
测试:
void test_BSTree1()
{
BSTree<int> t1;
int a[] = { 8, 3, 1, 10, 6, 4, 7, 14, 13 };
for (auto e: a)
{
t1.Insert(e);
//t1.InsertR(e);
}
BSTree<int> t2(t1);
BSTree<int> t3;
t3 = t2;
t1.InOrderPrint();
t2.InOrderPrint();
t3.InOrderPrint();
}
二叉搜索树的性能分析
插入和删除操作都必须先查找,所以查找效率就代表了二叉搜索树中各个操作的性能
搜索二叉树的查找的时间复杂度是多少?
那这个其实在不同情况下是不一样的:
1. 如果二叉搜索树处于比较平衡的情况(接近完全二叉树)这种情况最坏的查找无非也就查找高度次(那如果结点数量为N,它的高度通常保持在logN的水平), 所以这样它的时间复杂度就是O(logN)
。2. 二叉搜索树退化为单支树(或者接近单支),那这时查找的时间复杂度就应该是
O(N)
。
如果退化成单支树,二叉搜索树的性能就失去了。那能否进行改进,不论按照什么次序插入关键码,二叉搜索树的性能都能达到最优?AVL树和红黑树就可以上场了, 后面再说
二叉搜索树的应用
K模型
搜索二叉树的第一个应用是K模型,什么是K模型呢
,介绍一下:
其实就是一个在不在的问题,
K模型:K模型即只有key作为关键码,结构中只存储Key,关键码即为需要搜索的值。
比如:
给一个单词word,判断该单词是否拼写正确,具体方式如下:
以词库中所有单词集合中的每个单词作为key, 建一棵二叉搜索树, 在二叉搜索树中检索该单词是否存在, 存在则拼写正确, 不存在则拼写错误
KV模型
KV模型即每一个关键码key, 都有与之对应的值Value, 即结构中存储<Key, Value>的键值对。
该种方式在现实生活中非常常见:
比如英汉词典就是英文与中文的对应关系,通过英文可以快速找到与其对应的中文,英文单词与其对应的中文<word, chinese>就构成一种键值对;
再比如统计单词次数,统计成功后,给定单词就可快速找到其出现的次数,单词与其出现次数就是<word, count>就构成一种键值对。
那接下来我们就来改造一下我们上一篇文章实现的搜索二叉树,改造成KV模型来简单解决一下上面提到的两个问题。
增加一个模板参数,结点里面增加一个成员_val;
然后涉及到的模板的地方和相关的成员函数改一下就行了
1.插入的函数肯定得改一下 :
2.查找要改一下:
因为现在是KV模型,应该是查到返回对应的结点,通过结点我们可以拿到对应的val,查不到返回nullptr。
3.然后删除就不用改了,因为删除还是用key去找对应的结点删除就行了
4.中序遍历的函数稍微改一下, 打印出对应的键值对:
5.构造函数和赋值重载的参数改一下:
测试一下:
void test_BSTree2()
{
kv::BSTree<string, string> t;
t.Insert("苹果", "apple");
t.Insert("香蕉", "banana");
t.Insert("排序", "sort");
t.Insert("插入", "insert");
t.Insert("删除", "delete");
string str;
while (cin >> str)
{
auto ret = t.Find(str);
if (ret)
{
cout <<":"<< ret->_value << endl;
}
else
{
cout << "查找不到" << endl;
}
}
}
int main()
{
test_BSTree2();
return 0;
}
统计次数
接下来我们再来搞一个,还是用KV模型:
现在有一些水果,我们想统计每种水果出现的次数。
我们还是用KV来存储键值对,key是水果的名字,,al是出现的次数。
遍历存储水果的string数组,判断每一个水果在不在countTree里面,不在,就添加进去,把val置成1,在的话,就只让val++就行了,这样遍历一遍,就统计出次数了。
void test_BSTree3()
{
// 统计水果出现的次数
string arr[] = { "苹果", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜",
"苹果", "香蕉", "苹果", "香蕉" };
kv::BSTree<string, int> t;
for (const auto& str : arr)
{
auto ret = t.Find(str);
if (ret)
{
ret->_value++;
}
else
{
t.Insert(str, 1);
}
}
t.InOrderPrint();
}