1. 二叉搜索树的概念
在此之前,我们了解到二叉树的基础知识,今天将要了解到一个特殊的二叉树结——二叉搜索树(BST,Binary Search Tree)。
二叉搜索树又叫二叉排序树,它或者是一棵空树,或者是一个具有以下性质的二叉树:
- 若左子树不为空,则左子树上所有节点的值都小于根节点的值
- 若右子树不为空,则右子树上所有节点的值都大于根节点的值
- 它的左右子树分别都是二叉搜索树
2. 二叉搜索树的操作与实现
1. 二叉搜索树的结构
对于二叉搜索树的结构,和普通二叉树是相同的,首先需要定义一个node类,然后在把node按照二叉的方式连接起来,代码如下
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;//只需要一个根节点就可以管理整棵树
2. 二叉搜索树的接口实现
首先,为了让我们的二叉搜索树尽快构建起来,方便我们测试,就先实现一个插入
1. 二叉搜索树的插入
这里的插入是要保证插入之后仍然保持二叉搜索树的结构的,类似堆的插入,也是要保证特殊结构仍然符合的。所以有了以下的实现思路
- 树为空,直接构造节点,赋值给_root
- 树不为空,按照树的结构查找合适的插入位置(插入的值大于根节点,就找左子树,小于根节点,找右子树,直到叶子节点位置),插入新增节点
- 一般二叉搜索树中不存在相同值的节点,所以遇到相同值就不插入
迭代实现
bool Insert(const K& key)//插入成功返回true,插入失败返回false { if (_root == nullptr)//当树为空的情况 { _root = new node(key); return true; } //这里找到叶子节点的时候cur为空,需要让cur的父节点指向新节点,所以需要一个parent保存父节点 node* parent = nullptr; node* cur = _root; while (cur)//找到插入位置 { parent = cur; if (cur->_key > key)//在左树插入 { cur = cur->_left; } else if (cur->_key < key)//在右树插入 { cur = cur->_right; } else//有重复值,不插入 { return false; } } //new一个新节点,并决定插入到左还是右,保证二叉搜索树的结构 cur = new node(key); if (parent->_key < key) { parent->_right = cur; } else { parent->_left = cur; } return true; }
上面的代码是迭代的玩法,想到之前我们学习二叉树的时候,经常喜欢递归的玩法。虽然能使用迭代就尽量不使用递归,但是在这里我们尝试一下递归,maybe会出现惊喜😜
递归实现
递归实现的思路和迭代类似,对于树不为空的情况,都是要先找到插入位置,然后插入。那么现在就开始进入代码实现的环节
首先,第一步就出现了一个问题:递归实现也是要先遍历,递归遍历是要传递根的,但是在类外面没办法访问到private成员,所以这里我们写一个无参的插入函数,让它来调用递归函数
bool InsertR(const K& key) { return _Insert(_root, key); }然后我们再实现一个\_InsertR函数即可。
但是这里又遇到一个问题,当我们找到插入位置的时候,似乎没办法找到该位置的父节点啊,哇趣,这可是个大问题,在迭代的方法中我们可以使用一个parent变量来保存,但是这里是递归啊,难道还要加一个参数?不不不,这里有一个巧妙的办法:在设计参数的时候,传root的引用,那么传进来的root就相当于父节点的left或者right的别名,此时如果插入的话,修改root的值也就相当于修改父节点的left或right,就完美解决问题啦!
bool _InsertR(node*& root, const K& key) { if (root == nullptr) { root = new node(key); return true; } if (root->_key < key) _FindR(root->_right, key); else if (root->_key > key) _FindR(root->_left, key); else return false; }
2. 二叉搜索树的中序遍历
中序遍历是老生常谈的话题啦,很简单嘛,直接递归解决问题,同样的使用一个无参的函数来调用递归的函数,代码如下:
void InOrder() { _InOrder(_root); cout << endl; } void _InOrder(node* root) { if (root == nullptr) return; _InOrder(root->_left); cout << root->_key << " "; _InOrder(root->_right); }
3. 二叉搜索树的查找
二叉搜索树的查找是非常方便的,由于结构的原因,我们在遍历的时候就能够知道没找到的时候需要在当前节点的某一个子树中找。这里也就不过多赘述了,直接上代码:
//迭代 bool 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 true; } } return false; } //递归 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) return _FindR(root->_right, key); else if (root->_key < key) return _FindR(root->_left, key); else return true; }
4. 二叉搜索树的删除(重点)
二叉搜索树中,最难的一个点就是删除,因为删除要保证二叉搜索树的结构不变,需要想的东西就多了,首先需要找到这个节点的位置,然后删除此节点,要删除的节点有以下几种情况
- 要删除的节点为叶子节点
- 要删除的节点只有一个子树
- 要删除的节点左右有两个子树
那么针对上述的三种情况,分别找出的解决方案是什么呢?我们用下面这个图来辅助思考
- 对于没有子树的节点,直接delete,然后将父节点的指向nullptr即可
- 要删除的节点只有一个子树的时候,进行“托孤”,把该节点的子树交给父节点去管理,然后删除该节点即可
- 有两个子树的时候,就要从该节点的子树中找到一个与该节点最接近的值来代替这个位置,然后删除该节点。这里我们能找到的代替的节点有左子树的最右节点和右子树的最左节点,对于上图中的8来说就是7和10。首先执行两个节点值的交换(或者直接把要删除的节点的值覆盖成左子树最右节点的值/右子树最左节点的值),然后删除重复节点即可。
按照上述思路,那么代码如下
//1.对于没有子树的节点,直接delete,然后将父节点的指向nullptr即可 //2.要删除的节点只有一个子树的时候,进行“托孤”,把该节点的子树交给父节点去管理,然后删除该节点即可 //3.有两个子树的时候,就要从该节点的子树中找到一个与该节点最接近的值来代替这个位置,然后删除该节点。 bool Erase(const K& key) { //当树为空时 if (_root == nullptr) return false; 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//找到要删除的节点 { if (cur->_left == nullptr)//只有右子树 { if (cur == _root)//如果要删除的节点为根节点/parent==nullptr _root = cur->_right; else { if (parent->_left == cur)//当前节点是左树 parent->_left = cur->_right; else //当前节点是右树 parent->_right = cur->_right; } delete cur; } else if (cur->_right == nullptr)//只有左子树 { if (cur == _root)//如果要删除的节点为根节点/parent==nullptr _root = cur->_right; else { if (parent->_left == cur)//当前节点是左树 parent->_left = cur->_left; else parent->_right = cur->_left; } delete cur; } else//有两个子树 { //假设我们这里找右子树的最左节点/最小节点 parent = cur; node* minRight = cur->_right;//初始化成右子树的根节点 //找到右子树的最小节点 while (minRight->_left) { parent = minRight; minRight = minRight->_left; } cur->_key = minRight->_key;//覆盖掉要删除的节点的值 //删除重复的值 if (minRight == parent->_left) { parent->_left = minRight->_right; } else { parent->_right = minRight->_right; } delete minRight; } return true;//成功删除,返回true } } //如果没找到/没有,返回false return false; }
这里同样可以采用递归的方法来实现
//首先,同样的把问题交给类里面的删除函数 bool EraseR(const K& key) { return _EraseR(_root, key); } bool _EraseR(node*& root, const K& key)//注意这里root的声明类型是引用 { if (root = nullptr)//没找到符合的节点/树为空 { return false; } //递归找要删除的节点 if (root->_key < key) { return _EraseR(root->_right, key); } else if (root->_key > 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//左右都不为空 { //找到最小右子树节点,与root交换,删除minRight node* minRight = root->_right; while (minRight->_left) { minRight = minRight->_left; } // swap(root->_key, minRight->_key); // 交换之后,右子树也是符合二叉搜索树的,而且符合没有子树或者只有一个子树的情况 return _EraseR(root->_right, key); } delete del; return true; } }
5. 二叉搜索树的构造与析构
完成了上述的主要操作之后,我们现在来完善一下这个类
1. 默认构造
我们后面需要显示写拷贝构造,所以如果不实现默认构造,编译器就不会自动生成了
BSTree() :_root(nullptr) {}//非常简单,一行代码搞定
2. 拷贝构造
BSTree(const BSTree<K>& t) { _root = _Copy(t._root); } node* Copy(node* root) { if (root == nullptr) return nullptr; //前序遍历并new新节点插入到树中 node* newRoot = new node(root->_key); newRoot->_left = Copy(root->_left); newRoot->_right = Copy(root->_right); return newRoot; }
3. 赋值重载
//直接使用现代写法 BSTree<K>& operator=(BSTree<K> t) { swap(_root, t._root); return *this; }
4. 析构函数
~BSTree()//这里借助Destory函数释放所有节点 { Destory(_root); _root = nullptr; } void Destory(node* root) { if (root == nullptr) return; //后序遍历删除节点,先删除子树,再删除根 Destory(root->_left); Destory(root->_right); delete root; }
3. 二叉搜索树的应用
1.K模型
K模型即只有即只有key作为关键码,结构中只需要存储Key即可,关键码即为需要搜索到的值
实际应用场景:在很多编辑器下面都会有拼写检查这个功能,给定一个单词,判断它在不在词库中,如果在的话就显示拼写正确,否则就拼写错误
使用实例:
void Test_Spell() { zht::BSTree<string> chack; //创建一个词库,在实际应用中可以直接导入 chack.Insert("apple"); chack.Insert("beside"); chack.Insert("car"); chack.Insert("desktop"); chack.Insert("erase"); chack.Insert("find"); chack.Insert("group"); vector<string> word = { "apple", "beside", "cccd", "force" , "find" }; cout << "拼写错误的单词有:"; for (auto e : word) { if (! chack.Find(e)) { cout << e << " "; } } cout << endl; }
2. KV模型
KV模型就是在每个key都会有一个value与之对应,形成<key,value>键值对,在实际应用中,英汉词典就是一种键值对<English,chinese>,除此之外,对于值出现的次数,我们也可以使用类似的方法<value, count>。
当然,在使用KV模型的时候我们刚刚实现的二叉搜索树就不太适用了,需要改写以下,这里就不改写了,可以考虑使用STL中封装的map,关于map和set的使用我们后面再说。
4. 二叉搜索树的性能分析
插入和删除操作都必须先查找,查找效率代表了二叉搜索树中各个操作的性能。对有n个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二叉搜索树的深度的函数,即结点越深,则比较次数越多。但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树:
最优情况下,二叉搜索树为完全二叉树(或者接近完全二叉树),其平均比较次数为:log2N
最差情况下,二叉搜索树退化为单支树(或者类似单支),其平均比较次数为:N/2
如果退化成单支树,二叉搜索树的性能就失去了。那能否进行改进,不论按照什么次序插入关键码,二叉搜索树的性能都能达到最优?这就是AVL树和红黑树存在的意义了。关于AVL树和红黑树,我们后面再说。