目录
1、二叉搜索树的概念
2、二叉搜索树的实现
①、结点类
②、二叉搜索树的类
2.1、默认成员函数
构造函数
拷贝构造函数
赋值运算符重载函数
析构函数
2.2、中序遍历
2.3、Insert插入函数
非递归实现
递归实现
2.4、Erase删除函数
非递归实现
递归实现
2.5、Find查找函数
非递归实现
递归实现
3、二叉搜索树的应用
K模型
KV模型
4、二叉搜索树性能分析
5、源码链接
二叉搜索树又称二叉排序树,它或者是一棵空树,或者是具有以下性质的二叉树:
- 若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
- 若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
- 它的左右子树也分别为二叉搜索树
总结:任意一颗子树都满足左子树的值 < 根 < 右子树的值。
示例:
二叉搜索树又称二叉排序树,且任何一颗子树都满足左子树的值 < 根 < 右子树的值,由此我们进行中序遍历(左子树 根 右子树)得到的就是一个升序序列。
要实现一颗二叉搜索树,要实现两个类,一个是结点类,用于存放节点值、左指针、右指针。第二个类专门用于二叉搜索树的增删查改。
结点类主要包含如下内容:
- 成员变量:节点值、左指针、右指针。
- 只需要一个构造函数对成员变量的初始化即可。
template
struct BSTreeNode { BSTreeNode * _left; //左指针 BSTreeNode * _right;//右指针 K _key;//节点值 BSTreeNode(const K& key)//构造函数 :_left(nullptr) , _right(nullptr) , _key(key) {} };
此类主要用于增删查改。
- 基本框架:
template
class BSTree { typedef BSTreeNode Node; public: //…… private: Node* _root = nullptr; };
这里的构造函数直接让编译器默认生成就可以,不需要自己实现,但是后面的拷贝构造函数写了之后编译器就不会默认生成了,但是我们可以强制让它默认生成构造函数,不过要利用C++11的特性,具体看代码:
//强制编译器自己生成构造函数,忽视拷贝构造带来的影响 BSTree() = default;//C++11才支持
注意这里的拷贝构造完成的是深拷贝,这里我们直接用前序递归的方式创建一颗与原来一样的二叉树即可。而递归前序拷贝结点的方式这里我们专门封装一个Copy函数即可。
Node* CopyTree(Node* root) { if (root == nullptr) return nullptr; Node* copyNode = new Node(root->_key);//拷贝根结点 //递归创建拷贝一棵树 copyNode->_left = CopyTree(root->_left);//递归拷贝左子树 copyNode->_right = CopyTree(root->_right);//递归拷贝右子树 return copyNode; } //拷贝构造函数--深拷贝 BSTree(const BSTree
& t) { _root = CopyTree(t._root); }
这里直接给出现代写法:写法很巧妙,假设把t2赋值给t1,t2传参的时候直接利用传值传参调用拷贝构造生成t,t就是t2的拷贝,此时再调用swap函数交换t1和t 的_root根结点即可,而拷贝构造出来的t会在赋值运算符重载结束后自动调用自己的析构函数完成释放。
//赋值运算符重载函数 t1 = t2 BSTree
& operator=(BSTree t)//t就是t2的拷贝 { //现代写法 swap(_root, t._root); return *this; }
析构函数是为了释放二叉搜索树的所有结点,这里我们优先采用后序的递归释放,可以采用封装一个Destory函数来专门用于递归删除结点,如下:
void DestoryTree(Node* root) { if (root == nullptr) return; //通过递归删除所有结点 DestoryTree(root->_left);//递归释放左子树中的结点 DestoryTree(root->_right);//递归释放右子树中的结点 delete root; } //析构函数 ~BSTree() { DestoryTree(_root);//复用此函数进行递归删除结点 _root = nullptr; }
中序遍历的核心宗旨是左子树 -> 根结点 -> 右子树,这里我们采用递归的方式去实现中序遍历。
- 代码如下:
//中序遍历 -- 递归 void InOrder() { _InOrder(_root); cout << endl; } //中序遍历的子树 void _InOrder(Node* root) { if (root == nullptr) return; _InOrder(root->_left);//递归到左子树 cout << root->_key << " ";//访问根结点 _InOrder(root->_right);//递归到右子树 }
结合二叉搜索树的性质,插入的实现非常简单(注意重复的值不允许再次插入,默认不允许冗余)。主要分为两类:
1、如果是空树,直接把插入的结点作为根结点即可。
2、如果不是空树,则按如下规则讨论:首先得找到待插入的值的合适位置,其次找到位置后,将插入的值与此树链接起来。
- 1、寻找待插入的值的合适位置:
定义cur指针从根结点开始(cur指针用于找到待插入的合适位置),定义parent指针最开始为nullptr(parent指针用于找到位置后的链接操作),把待插入的结点值定位key。遍历cur指针
- 若key > cur指向的结点值,让parent走到cur的位置,让cur指针走到右子树,指向_right的位置,继续遍历。
- 若key < cur指向的结点值,让parent走到cur的位置,让cur指针走到左子树,指向_left的位置,继续遍历。
- 若key = cur指向的结点值,说明待插入的结点值与此树当前结点值重合,插入结点失败。返回false。
遍历结束后,说明已经找到要插入的合适的位置(某一颗子树的尾部),接着指向第二步:
- 2、将插入的值与父亲链接起来:
链接的步骤很简单,确保链接位置即可:
- 若插入的值比父亲的值大,链接在父亲的右边
- 若插入的值比父亲的值小,链接在父亲的左边
- 代码如下:
//Insert非递归 bool Insert(const K& key) { if (_root == nullptr)//若一开始树为空 { _root = new Node(key);//直接申请值为key的结点作为二叉搜索树的根结点 return true; } Node* parent = nullptr; Node* cur = _root; //1、找寻插入的合适位置 while (cur) { if (cur->_key < key)//若key大于当前结点值 { parent = cur; cur = cur->_right;//让cur走向左子树 } else if (cur->_key > key)//若key小于当前结点值 { parent = cur; cur = cur->_left;//让cur走向右子树 } else { return false;//若key等于当前结点值,说明插入的值不合法,返回false } } //2、进行与父亲的链接 cur = new Node(key); if (parent->_key < key) { parent->_right = cur;//比父亲的值大连接在右子树 } else { parent->_left = cur;//比父亲的值小链接在左子树 } return true; }
- 补充:搜索二叉树以相对有序的方式插入会比较坑,因为高度太高。
这里依旧是分为两大步骤走,1、先递归到合适位置,确定插入的值链接在何处,2、找到位置后链接即可。
- 1、递归找到插入的正确位置
这里虽是递归,不过走的形式和非递归的找到正确位置整体思路大差不差:
- 若key > root指向的结点值,让root递归到右子树继续遍历。
- 若key < root指向的结点值,让root递归到左子树继续遍历。
- 若key = root指向的结点值,说明待插入的结点值与此树当前结点值重合,插入结点失败。返回false。
当root结点递归到nullptr时,即可进行下一步:链接。
- 2、找到位置后,进行链接插入的结点
先前非递归版本的链接过程中为了要找到新插入结点和父亲的链接关系,我们特地创建了parent指针,让cur结点在不断的遍历中更新parent的指向以此时刻保持parent为cur的父亲,这样链接关系就确认好了,不过这里的递归实现我们并不给与一个parent指针,而是采用一个巧妙的方法:参数为指针的引用!
- 图示说明:
通过这里可以看出传指针的引用已然达到没有父指针,胜似父指针的效果!!!
//递归版删除 bool EraseR(const K& key) { return _EraseR(_root, key); } //插入的子树 bool _InsertR(Node*& root, const K& key)//Node*&为指针的引用 { if (root == nullptr) { root = new Node(key);//当root为空,把自己创建成新结点 return true; } if (root->_key < key) return _InsertR(root->_right, key);//如果比key小,转换到右子树去插入 else if (root->_key > key) return _InsertR(root->_left, key);//如果比key大,转换到左子树去插入 else return false;//如果相等,就返回false }
二叉搜索树的删除函数最为复杂,这里我们主要通过两大步骤进行删除的操作:
- 遍历找到待删值的位置
- 删除找到的位置并链接父亲与剩下的结点
接下来针对这两大步骤展开讨论:
- 一、先找到要删除的结点:
首先定义cur指针指向根结点(cur指针用于找到待删除结点的位置),定义parent指针指向nullptr(parent指针用于删除后的链接操作),定义key为删除结点的值,按如下规则进行遍历:
- 若key > cur指向结点的值,让parent走到cur的位置,让cur走到右子树进行遍历
- 若key < cur指向结点的值,让parent走到cur的位置,让cur走到左子树进行遍历
- 若key = cur指向结点的值,接下来进行删除结点和链接的操作。
此时可以指向第二部,删除找到的位置并链接父亲与剩下的结点。
- 二、删除结点并链接父亲与剩下的结点:
当我删去结点后,一个最值得考虑的问题是,如果待删值还有孩子怎么办呢,因此还要考虑到链接父亲与孩子的问题,并且又要进行如下分类:
- 待删值只有一个孩子 -- 左为空 or 右为空 or 左右均为空
- 待删值两个孩子都在 -- 替换法删除
接下来同样是进行展开讨论:
1、待删值只有一个孩子 -- 左为空 or 右为空 or 左右均为空
我们按如下四步走:
- 如果左孩子为空且删除的值为根结点,直接更新根结点为右孩子(右孩子为空,就相反操作)
- 如果父亲的左孩子为待删值,将父亲的左孩子指向待删值指向的右孩子
- 如果父亲的左孩子不是待删值,将父亲的右孩子指向待删值指向的右孩子
- 删除待删的结点
2、待删值两个孩子都在 -- 替换法删除
替换法删除的目的在于我删除目标结点后,让左子树或右子树其中一个叶结点到删除的位置上来,又要保持其删除后依旧是一个二叉搜索树的特性(左子树 < 根 < 右子树),这就要用到替换法。
准备工作如下:
- 定义myParent指针为cur指针的位置(myParent指针用于链接要删除结点的孩子)
- 定义minRight指针为cur的右孩子结点指针的位置(minRight用于找到右子树的最小值)
具体替换法的操作如下:
- 遍历minRight找到待删结点右子树的最小值(或左子树的最大值结点),中途不断更新myParent
- 找到后,利用swap函数交换此最小值结点的值(minRight->_key)和待删结点的值(cur->_key)
- 交换后,链接父亲myParent指针与minRight结点的孩子
- 最后记得delete删除minRight结点
注意:若整个操作两大步骤遍历一遍找不到要删除的值,直接返回false。
- 代码如下:
//Erase删除 bool Erase(const K& key) { Node* parent = nullptr; Node* cur = _root; while (cur) { //1、先找到要删除的结点 if (cur->_key < key) { parent = cur; //让parent始终为cur的父亲 cur = cur->_right; } else if (cur->_key > key) { parent = cur; //让parent始终为cur的父亲 cur = cur->_left; } else { //找到了,分两类情况讨论: //1、待删值只有一个孩子 -- 左为空 or 右为空 //2、待删值两个孩子都在 -- 替换法删除 if (cur->_left == nullptr) { if (cur == _root) { //如果左孩子为空且删除的值为根结点,直接更新根结点为右孩子 _root = cur->_right; } else { //左孩子为空 if (cur == parent->_left) { //如果父亲的左孩子为待删值,将父亲的左孩子指向待删值指向的右孩子 parent->_left = cur->_right; } else { //如果父亲的左孩子不是待删值,将父亲的右孩子指向待删值指向的右孩子 parent->_right = cur->_right; } } //删除待删的结点 delete cur; } else if (cur->_right == nullptr) { if (cur == _root) { //如果右孩子为空且删除的值为根结点,直接更新根结点为左孩子 _root = cur->_left; } else { //右孩子为空 if (cur == parent->_left) { //如果父亲的左孩子为待删值,将父亲的左孩子指向待删值指向的左孩子 parent->_left = cur->_left; } else { //如果父亲的左孩子不是待删值,将父亲的右孩子指向待删值指向的左孩子 parent->_right = cur->_left; } } //删除待删的结点 delete cur; } else { //待删值的两个孩子都在,替换法删除。 //找右子树的最小值或找左子树的最大值,下面为找右子树最小值 Node* minParent = cur;//右子树的根可能就是minRight,所以这里minParent不能为nullptr, //因为此时不会进入while循环导致minParent就一直为nullptr,最后删除的时候堆野指针的非法访问 Node* minRight = cur->_right; while (minRight->_left) { minParent = minRight; //让minParent始终为minRight的父亲 minRight = minRight->_left; } swap(minRight->_key, cur->_key);//或者cur->_key = minRight->_key; //链接父亲minParent和要删除的结点的孩子 if (minParent->_left == minRight) { //如果minParent的左孩子为待删值,让minParent的左孩子指向minRight的右 minParent->_left = minRight->_right; } else { //如果minParent的右孩子为待删值,让minParent的右孩子指向minRight的右 minParent->_right = minRight->_right; } //删除要删的结点 delete minRight; } return true; } } //遍历一遍找不到要删除的值,直接返回false return false; }
这里和非递归的主要实现思路大差不差,也是分为先找到删除的合适结点位置,找到后将其删除并确保链接关系正确这两大步骤。接下来,详细讨论下:
- 一、先找到要删除的结点:
找到要删除的结点很简单,非递归是通过遍历的方式,只不过这里利用了递归来解决:
- 若当前结点root为空,说明此删除的结点不存在,返回false
- 若key > root指向的结点值,让root递归到右子树继续遍历。
- 若key < root指向的结点值,让root递归到左子树继续遍历。
如果递归到key = root的结点值,接下来即可进入第二大步骤:删除此结点 + 链接父子关系。
- 2、删除此结点 + 链接父子关系:
当我删去结点后,面临和非递归的删除同样一个问题:如果待删值还有孩子怎么办呢,因此还要考虑到链接父亲与孩子的问题,并且又要进行如下分类:
- 待删值只有一个孩子 -- 左为空 or 右为空 or 左右均为空
- 待删值两个孩子都在 -- 替换法删除
- 这里的核心写法和插入的递归实现一样,传参要传指针的引用,接下来,这两种删除情况我都会详细讲解下如何利用好传参要传指针的引用
1、待删值只有一个孩子 -- 左为空 or 右为空 or 左右均为空
我们按如下三步走:
- 1、先把要删除的结点指针root保存为del
- 2、如果root的左孩子为空,执行root = root->_right;
此时的root为指针的引用,即父结点的左指针或右指针,假设root为父结点的右指针。执行此段代码的意思是让父结点的右孩子指针(root)链接到root的右孩子,即可天然借助指针的引用建立了父子的链接关系
- 3、如果root的右孩子为空,执行root = root->_left;
这种情况和上面无任何区别,只是链接方向变了,思路均一样。下面给出图示说明:
2、待删值两个孩子都在 -- 替换法删除
准备工作如下:
- 先把要删除的结点指针root保存为del
- 定义minRight指针为root的右孩子结点指针的位置(minRight用于找到右子树的最小值)
具体替换法的操作如下:
- 遍历minRight找到待删结点右子树的最小值(或左子树的最大值结点)
- 找到后,利用swap函数交换此最小值结点的值(minRight->_key)和待删结点的值(root->_key)
- 交换后,到子树复用递归删除:return _EraseR(root->_right, key);意思是利用递归删除
图示说明:
//递归版删除 bool EraseR(const K& key) { return _EraseR(_root, key); } //删除的子树 bool _EraseR(Node*& root, const K& key) { //1、递归查找删除的位置 if (root == nullptr) { //如果是空就返回false return false; } if (root->_key < key) { return _EraseR(root->_right, key);//如果比key小,转换到右子树去插入 } else if (root->_key > key) { return _EraseR(root->_left, key);//如果比key大,转换到左子树去插入 } //2、确认链接关系 else { Node* del = root;//提前保存root结点的位置 //开始删除 if (root->_left == nullptr) { //如果左为空 root = root->_right; } else if (root->_right == nullptr) { //如果右为空 root = root->_left; } else { Node* minRight = root->_right;//minRight用于找到右子树的最小值 while (minRight->_left) { minRight = minRight->_left; } swap(root->_key, minRight->_key); return _EraseR(root->_right, key); } delete del; return true; } }
Find查找函数的思路很简单,定义cur指针从根部开始按如下规则遍历:
- 若key值小于当前结点的值,则应该在该结点的左子树当中进行查找。
- 若key值大于当前结点的值,则应该在该结点的右子树当中进行查找。
- 若key值等于当前结点的值,则查找成功,返回true。
- 若遍历一圈cur走到nullptr了说明没有此结点,返回false
//Find非递归 bool Find(const K& key) { Node* cur = _root; while (cur) { if (cur->_key < key) { cur = cur->_right;//若key值大于当前结点的值,则应该在该结点的右子树当中进行查找。 } else if (cur->_key > key) { cur = cur->_left;//若key值小于当前结点的值,则应该在该结点的左子树当中进行查找。 } else { return true;//若key值等于当前结点的值,则查找成功,返回true。 } } return false;//遍历一圈没找到返回false }
递归的实现主要是转换成子问题来解决。针对于Find的递归实现,只需遵循如下规则即可:
- 若树为空树,则查找失败,返回nullptr。
- 若key值小于当前结点的值,则递归到该结点的左子树当中进行查找。
- 若key值大于当前结点的值,则递归到该结点的右子树当中进行查找。
- 若key值等于当前结点的值,则查找成功,返回对应结点的地址。
//递归版查找 bool FindR(const K& key) { return _FindR(_root, key); } //查找的子树 bool _FindR(Node* root, const K& key) { if (root == nullptr) return false; if (root->_key < key) { //如果比key小,转换到右子树去找 return _FindR(root->_right, key); } else if (root->_key > key) { //如果比key大,转换到左子树去找 return _FindR(root->_left, key); } else { //找到了 return true; } }
K模型:K模型即只有key作为关键码,结构中只需要存储Key即可,关键码即为需要搜索到的值。
比如:给一个单词word,判断该单词是否拼写正确,具体方式如下:
- 以词库中所有单词集合中的每个单词作为key,构建一棵二叉搜索树
- 在二叉搜索树中检索该单词是否存在,存在则拼写正确,不存在则拼写错误。
其实我前面模拟实现的二叉搜索树就是一个K模型。
KV模型:每一个关键码key,都有与之对应的值Value,即
的键值对 。该种方式在现实生活中非常常见:
- 比如英汉词典就是英文与中文的对应关系,通过英文可以快速找到与其对应的中文,英文单词与其对应的中文
就构成一种键值对 ;- 再比如统计单词次数,统计成功后,给定单词就可快速找到其出现的次数,单词与其出现次数就是
就构成一种键值对 。我们可以针对K模型,在其内部实现进行稍稍修改即可达到KV模型的实现,代码链接直达:key_value模型的模拟实现
接下来给出测试用例:
- 1、英文单词与对应的中文单词:
namespace key_value { void TestBSTree1() { BSTree
ECdict; ECdict.InsertR("root", "根"); ECdict.InsertR("string", "字符串"); ECdict.InsertR("left", "左边"); ECdict.InsertR("insert", "插入"); ECdict.InsertR("ShuanQ", "拴Q"); string str; while (cin >> str) { //BSTreeNode * ret = ECdict.FindR(str); auto ret = ECdict.FindR(str); if (ret != nullptr) { cout << "对应的中文:" << ret->_value << endl; } else { cout << "无此单词,请重写输入" << endl; } } } } int main() { key_value::TestBSTree1(); }
- 2、单词与其出现次数统计
namespace key_value { void TestBSTree2() { string arr[] = { "苹果", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜","苹果", "香蕉", "苹果", "香蕉" }; //统计水果出现的次数 BSTree
countTree; int count = 0; for (const auto& str : arr) { auto ret = countTree.FindR(str); if (ret == nullptr) { countTree.InsertR(str, 1); } else { ret->_value++;//修改value } } countTree.InOrder(); } } int main() { key_value::TestBSTree2(); }
插入和删除操作都必须先查找,查找效率代表了二叉搜索树中各个操作的性能。对有n个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二叉搜索树的深度的函数,即结点越深,则比较次数越多。
但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树:
- 最优情况下,二叉搜索树为完全二叉树(或者接近完全二叉树),其平均比较次数为:logN
- 最差情况下,二叉搜索树退化为单支树(或者类似单支),其平均比较次数为:N / 2
- 综上时间复杂度为O(N)。
问题:如果退化成单支树,二叉搜索树的性能就失去了。那能否进行改进,不论按照什么次序插入关键码,二叉搜索树的性能都能达到最优?那么我们后续章节学习的AVL树和红黑树就可以上场了。
本文链接直达:二叉搜索树完整版:K模型 + KV模型