一、介绍
二叉搜索树又称二叉排序树,它或者是一棵空树,或者是具有以下性质的二叉树:
1.若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
2.若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
3.它的左右子树也分别为二叉搜索树
因此,在二叉搜索树中,每个值都有对应且唯一的位置。
二、二叉搜索树的模拟实现
1.Insert 插入
插入一个数据,根据二叉搜索树的要求,需要找到合适的位置,并且进行链接
首先是找到要插入的位置,用一个cur遍历,当插入的值cur小走左边,比cur大则走右边,找到对应位置后,我们还需要链接,因此还需要一个父节点,可以定义一个parent一起走,找到后定义一个新节点插入数据,进行链接即可。
//插入数据bool Insert(const K& key){node* cur = _root;node* parent = nullptr;while (cur){if (key > cur->_key){parent = cur;cur = cur->_right;}else if (key < cur->_key){parent = cur;cur = cur->_left;}else{return false;}}cur = new node(key);if (key < parent->_key){parent->_left = cur;}else{parent->_right = cur;}}
2.InOrder 中序遍历
实现了数据的插入以后,为了验证实现的有没有bug,可以先将中序遍历给实现,根据二叉搜索树的特性,中序变量刚好是升序排序,将数据乱序插入后,中序打印,观察是否完成排序
中序遍历的实现是通过递归方式,在类里面实现递归需要用到_root作为参数,但外部接口一般不允许其获得内部成员变量,哪怕是提供一个函数接口获取,使用上也比较麻烦,因此可以在实现的时候再嵌套一层函数,在内部传参
//中序遍历void InOrder(){_InOrder(_root);cout << endl;}void _InOrder(const node* root){if (root == nullptr){return;}_InOrder(root->_left);cout << root->_key << " ";_InOrder(root->_right);}
3.Find 查找
查找的实现非常简单,只需要cur变量去按照二叉搜索树的特性去遍历即可,若是没找到则返回空指针,找到返回节点地址
node* Find(const K& key){node* cur = _root;while (cur){if (key < cur->_key){cur = cur->_left;}else if (key > cur->_key){cur = cur->_right;}else{return cur;}}return nullptr;}
4.Erase 删除
删除是本章模拟实现部分的重点,也是最为复杂的,要分类讨论被删除对象的各种情况,条件判断上较为复杂
1.被删除的节点是叶子节点(既没有左孩子,也没有右孩子),则直接删除即可
2.被删除的节点有一个孩子节点,则此时需要将孩子进行托孤(将孩子节点与被删除的节点的父节点进行链接),此时需要分类讨论,当被删除节点是其父节点的左孩子时,则托孤给父节点的左指针,被删除节点是右孩子时,则托孤给右节点
观察发现,其实情况1和情况2可以被并为一类,只有一个孩子或者没有孩子时,都按照托孤的思路执行,并且由于需要链接,因此在往下找被删除节点时,会同时记录它的父节点,父节点需要根据情况分类讨论用哪个指针继承孩子,并且还有考虑到一种特殊情况,就是删除根时,要特殊处理
3.当被删除的节点有两个孩子时,则需要用替代法,即找被删除节点左边最大的值,或者右边最小的值与被删除的值进行替换,然后再将用于替换位置的节点删掉
注意,左边最大值,就是从左子树的根开始,一直往右找,右边最小值,就从右子树的根开始,一直往左找,因此,拿右边最小值举例,右边最小值一定没有左孩子,而可能有右孩子需要链接,右孩子的链接同样需要分类讨论
参考代码:
//删除bool Erase(const K& key){node* cur = _root;node* parent = nullptr;while (cur){if (key < cur->_key){parent = cur;cur = cur->_left;}else if (key > cur->_key){parent = cur;cur = cur->_right;}else//找到要删除的节点了{if (cur->_left == nullptr || cur->_right == nullptr)//托孤{if (cur == _root)//被删的为根时,需要更新根{if (cur->_left != nullptr){_root = cur->_left;}else{_root = cur->_right;}}else//分情况链接{if (parent->_left == cur)//要删除的节点在父节点的左边,则无论如何都是链接左边{if (cur->_left != nullptr){parent->_left = cur->_left;}else{parent->_left = cur->_right;}}else if (parent->_right == cur)//要删除的节点在父节点的左边,则无论如何都是链接左边{if (cur->_left != nullptr){parent->_right = cur->_left;}else{parent->_right = cur->_right;}}}delete cur;return true;}else//要删除的节点有两个孩子{node* min_right = cur->_right;node* pmin_right = cur;while (min_right->_left){pmin_right = min_right;min_right = min_right->_left;}cur->_key = min_right->_key;if (pmin_right->_left == min_right){pmin_right->_left = min_right->_right;}else{pmin_right->_right = min_right->_right;}delete min_right;return true;}}}//找不到要删除的对象return false;}
三、递归实现接口
递归实现在思路上相对没那么直接,但是在代码上会简洁很多,当然在类里面递归都需要封装一层
1.Finer
递归实现查找很简单,遇到空则说明没找到,则返回nullptr,值比根小则往左子树找,值比根大则在右子树找,找到返回地址。
node* Findr(const K& key){return _Findr(_root, key);}node* _Findr(node* root, const K& key){if (root == nullptr){return nullptr;}if (key < root->_key){return _Findr(_root->_left, key);}else if (key > root->_key){return _Findr(_root->_right, key);}else{return root;}}
2.Insertr
在插入数据中,有个很巧妙的对引用的运用,就是在传root指针时,传引用,则往下递归的root就是上一层指针的别名,因此在找到需要插入的位置时,不需要多记录一个父节点去进行链接,而是此时的root就是上一层父节点的指针的别名,因此可以直接new一个节点进行链接
bool Insertr(const K& key){return _Insertr(_root, key);}bool _Insertr(node*& root, const K& key){if (root == nullptr){root = new node(key);return true;}if (key < root->_key){return _Insertr(root->_left, key);}else if(key > root->_key){return _Insertr(root->_right, key);}else{return false;}}
3.Eraser
删除的思路和非递归的一样,递归部分主要可以利用root指针传引用的巧妙之处去省下很多处理
参考代码:
bool Eraser(const K& key){return _Eraser(_root, key);}bool _Eraser(node*& root, const K& key){if (root == nullptr)return false;if (key < root->_key){return _Eraser(root->_left, key);}else if (key > root->_key){return _Eraser(root->_right, key);}else{node* del = root;if (del->_left == nullptr){root = root->_right;}else if (del->_right == nullptr){root = root->_left;}else{node* min_right = del->_right;while (min_right->_left){min_right = min_right->_left;}root->_key = min_right->_key;return _Eraser(root->_right, min_right->_key);}delete del;return true;}}
四、构造与析构
1.构造函数
默认的构造即可,写一份出来是为了写拷贝构造时也能调默认的构造
BSTree():_root(nullptr){}
2.拷贝构造
拷贝构造只需要递归遍历,前序遍历拷贝节点即可,可以用引用传root
BSTree(const BSTree& n):_root(nullptr){copy(_root, n._root);}void copy(node*& root,const node* n){if (n == nullptr)return;root = new node(n->_key);copy(root->_left, n->_left);copy(root->_right, n->_right);}
3.赋值重载operator=
BSTree& operator=(BSTree tmp){swap(_root, tmp._root);return *this;}
4.析构函数
~BSTree(){Destroy(_root);}void Destroy(node*& root){if (root == nullptr)return;Destroy(root->_left);Destroy(root->_right);delete root;root = nullptr;}
五、二叉搜索树的应用场景
1. K模型
K模型即只有key作为关键字,结构中只需要存储Key即可,关键字即为需要搜索到的值。
比如:给一个单词word,判断该单词是否拼写正确,具体方式如下: 以词库中所有单词集合中的每个单词作为key,构建一棵二叉搜索树,在二叉搜索树中检索该单词是否存在,存在则拼写正确,不存在则拼写错误。
K模型针对的是“在不在”的问题
2.KV模型
KV模型比起K模型多了一个关键字val,每一个关键码key,都有与之对应的值Value,即<Key,Value>的键值对。
比如英汉词典就是英文与中文的对应关系,通过英文可以快速找到与其对应的中文,英文单词与其对应的中文就构成一种键值对;
再比如统计单词次数,统计成功后,给定单词就可快速找到其出现的次数,单词与其出现次数就是就构成一种键值对;
3.实际场景应用
对应K模型的底层,就是最初我们实现的那一个版本,只需要稍微修改一下,就可以改成KV模型
在输入部分(构造,拷贝构造,节点定义,插入等等),多加一个参数value,基本逻辑都是用key去操作,因此不需要做大变动
例一:中英互译
void TestBSTree3()
{// 输入单词,查找单词对应的中文翻译BSTree<string, string> dict;dict.Insert("string", "字符串");dict.Insert("tree", "树");dict.Insert("left", "左边、剩余");dict.Insert("right", "右边");dict.Insert("sort", "排序");// 插入词库中所有单词string str;while (cin>>str){BSTreeNode<string, string>* ret = dict.Find(str);if (ret == nullptr){cout << "单词拼写错误,词库中没有这个单词:" <<str <<endl;}else{cout << str << "中文翻译:" << ret->_value << endl;}}
}
例二:水果统计
void TestBSTree4()
{// 统计水果出现的次数string arr[] = { "苹果", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜",
"苹果", "香蕉", "苹果", "香蕉" };BSTree<string, int> countTree;for (const auto& str : arr){// 先查找水果在不在搜索树中// 1、不在,说明水果第一次出现,则插入<水果, 1>// 2、在,则查找到的节点中水果对应的次数++//BSTreeNode<string, int>* ret = countTree.Find(str);auto ret = countTree.Find(str);if (ret == NULL){countTree.Insert(str, 1);}else{ret->_value++;}}countTree.InOrder();
}
六、二叉搜索树的性能分析
插入和删除操作都必须先查找,查找效率代表了二叉搜索树中各个操作的性能。
对有n个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二 叉搜索树的深度的函数,即结点越深,则比较次数越多。
但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树:
最优情况下,二叉搜索树为完全二叉树(或者接近完全二叉树),其平均比较次数为:O(log2 N)
最差情况下,二叉搜索树退化为单支树(或者类似单支),其平均比较次数为:O(N)
因此后续会继续学习平衡搜索二叉树:AVL树和红黑树,解决上面歪脖子的问题
总结
本章介绍了搜索二叉树的特性,并且用C++模拟实现,详细分析了代码思路