目录
二叉搜索树是一种特殊的树形结构 ,二叉搜索树又称二叉排序树,它与我们前面学习的堆机械转码日记【3】同属树形结构,但它不是用来排序的,而是用来搜索的。
二叉搜索树是具有以下性质的二叉树:
- 若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
- 若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
- 它的左右子树也分别为二叉搜索树

为了更好的了解二叉搜索树的原理和构造,我们直接来实现一个二叉搜索树
想要构建一个搜索二叉树,首先我们需要一个树的节点的结构,它里面有节点的值,以及指向左右子节点的指针:
- template<class K>
- struct BSTreeNode
- {
- BSTreeNode
* _left; - BSTreeNode
* _right; -
- K _key;
-
- BSTreeNode(const K& key)
- :_left(nullptr)
- , _right(nullptr)
- , _key(key)
- {
-
- }
- };
然后再封装一个二叉搜索树类,并且将我们刚刚创建的节点作为二叉搜索树的成员:
- template<class K>
- class BSTree
- {
- typedef BSTreeNode
Node;//方便使用,不用写BSTreeNode那么长 - private:
- //成员变量
- Node* _root=nullptr;
- }
增删查改,都是数据结构的基本操作,一般二叉搜索树不支持修改(因为一修改可能大小关系就变了),那么我们先来实现增,也就是插入操作。
二叉搜索树的插入逻辑为:
- 判断根节点是否为空,如果为空,直接构造一个插入值的根节点就行
- 如果根节点非空,那么就需要通过大小判断来找寻正确的插入位置,比根节点大往右子树走,比根节点小往左子树走。(注意找的过程中要记录父亲节点的位置,方便链接父节点和插入的节点)
- 链接父节点和子节点
- bool Insert(const K& key)
- {
- //判断根节点是否为空,如果为空,直接构造一个插入值的根节点就行
- if (_root == nullptr)
- {
- _root = new Node(key);
- return true;
- }
-
- //非空,开始找合适的插入位置
- //定义父节点和目标节点
- Node* parent = nullptr;
- Node* cur = _root;
- //开始寻找合适插入位置
- while (cur != nullptr)
- {
- if (cur->_key < key)//插入的值比根节点大,往右子树走
- {
- parent = cur;
- cur = cur->_right;//更新子节点和父节点
- }
- else if (cur->_key > key)//插入的值比根节点小,往左子树走
- {
- parent = cur;
- cur = cur->_left;//更新子节点和父节点
- }
- else
- {
- return false;//如果插入的值和根节点相等,则不允许插入,返回false
- }
- }
-
- cur = new Node(key);//将节点的键值赋给cur
- //链接子节点个父节点
- if (parent->_key < key)
- {
- parent->_right = cur;
- }
- else
- {
- parent->_left = cur;
- }
- }
除了上面的循环版本,我们还可以写一个递归版本来实现插入操作
- ///递归版本//
- bool InsertR(const K& key)
- {
- return _InsertR(_root, key);
- }
-
- //子函数:
- //引用传参是精髓
- //使用引用,这时候的root就是上一个节点的左右子树的别名
- //修改root的同时也会修改上一个子树的左右节点(完成链接操作)
- //因为我们修改的是指针,所以要实现址传递,也可以用二级指针来完成这个操作,原理相同
- bool _InsertR(Node*& root, const K& key)
- {
- //判断根节点是否为空,如果为空,直接构造一个插入值的根节点就行
- //递归到最后,要插入的那个合适的位置也是空节点
- if (root == nullptr)
- {
- root = new Node(key);
- return true;
- }
-
- //插入值比根节点大,往右子树插入
- if (root->_key < key)
- return _InsertR(root->_right, key);
- //插入值比根节点小,往左子树插入
- else if (root->_key > key)
- return _InsertR(root->_left, key);
- //插入值和根节点相等,不允许插入
- else
- return false;
- }
二叉搜索树的查找的时间复杂度最高为0(N),此种情况为二叉搜索数的插入为有序,最低为O(logn),当二叉搜索树为完全二叉树的时候会出现这种情况。

其实在插入的时候我们就已经嵌入了查找的逻辑了,在这里再说一遍。
实现二叉搜索树的查找的逻辑为:
- 从根节点开始查找
- 如果查找的目标值比根节点大,往右子树找,比根节点小就往左子树找。
- 找到最后不为空就找到了,为空就找不到
- bool Find(const K& key)//搜索的时间复杂度:O(N),当有序时,为O(n)
- {
- 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;
- }
- }
-
要说二叉搜索树的增删查,逻辑最复杂最难搞的还得是删除,它的逻辑是:
- 先找到需要删除的元素,如果没找到,就返回false,找到了,需要分以下情况进行处理:
- 要删除的结点无孩子结点
- 要删除的结点只有左孩子结点
- 要删除的结点只有右孩子结点
- 要删除的结点有左、右孩子结点
看起来有待删除节点有4种情况,实际情况a可以与情况b或者c合并起来,因此真正的删除过程如下:
- 要删除的结点只有左孩子结点或无孩子节点,则删除该结点且使被删除节点的双亲结点指向被删除节点的左孩子结点——我们把它归类为直接法删除
- 要删除的结点只有右孩子结点或无孩子节点,则删除该结点且使被删除节点的双亲结点指向被删除结点的右孩子结点——我们把它归类为直接法删除
- 要删除的结点有左、右孩子结点,则找到它左子树中的最大子节点或右子树的最小子节点,与要删除的节点的值替换,再来处理该节点的删除问题——我们把它归类为替代法删除

- //搜索树最大的问题是删除
- bool Erase(const K& key)
- {
- Node* parent = nullptr;
- Node* cur = _root;
- //找到这个节点和它的父亲
- while (cur != nullptr)
- {
- if (cur->_key < key)
- {
- parent = cur;
- cur = cur->_right;
- }
- else if (cur->_key > key)
- {
- parent = cur;
- cur = cur->_left;
- }
- //等于说明找到了
- else
- {
- //分 情况
- //一个孩子,分为左孩子和右孩子,if和else if值执行一个,小心root节点,没孩子也属于一个孩子的特殊点,也可以实现
- if (cur->_left == nullptr)//只有右孩子
- {
- //cur为root需要特殊处理
- if (cur == _root)
- {
- _root = cur->_right;
- }
- //再分cur是parent的什么孩子
- else
- {
- if (parent->_left == cur)
- {
- parent->_left = cur->_right;//和右孩子链接上
- }
- else if (parent->_right == cur)
- {
- parent->_right = cur->_right;//和右孩子链接上
- }
-
- }
- }
- else if (cur->_right == nullptr)//只有一个左孩子
- {
- //cur为root需要特殊处理
- if (cur == _root)
- {
- _root = cur->_left;
- }
- //再分cur是parent的什么孩子
- else
- {
- if (parent->_left == cur)
- {
- parent->_left = cur->_left;//和左孩子链接上
- }
- else if (parent->_right == cur)
- {
- parent->_right = cur->_left;//和左孩子链接上
- }
- }
- }
- else//两个孩子
- {
- //找右子树的最小节点
- Node* minnode_parent = cur;
- Node* minnode = cur->_right;
- while (minnode->_left != nullptr)
- {
- minnode_parent = minnode;
- minnode = minnode->_left;
- }
- swap(minnode->_key, cur->_key);
- //Erase(minnode->_key);//交换之后不符合搜索树的规则,会找不到
- if (minnode_parent->_left == minnode)
- {
- minnode_parent->_left = minnode->_right;//链接子节点
- }
- else
- {
- minnode_parent->_right = minnode->_right;
- }
-
- delete minnode;//删掉需要删除的节点
- }
- return true;
- }
- }
- return false;
- }
- bool EraseR(const K& key)
- {
- return _EraseR(_root, key);
- }
-
- //子函数
- bool _EraseR(Node*& root, const K& key)
- {
- 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;
- // 删除
- //由于是引用传参,root既是父亲指向子节点指针的别名,同时也是要删除节点指针的别名
- if (root->_left == nullptr)//左为空,
- {
- root = root->_right;//链接父亲和要删除节点的孩子
- }
- else if (root->_right == nullptr)//右为空
- {
- root = root->_left;//链接父亲和要删除节点的孩子
- }
- //找右子树的最小节点
- else
- {
- Node* minRight = root->_right;
- while (minRight->_left)
- {
- minRight = minRight->_left;
- }
-
- swap(root->_key, minRight->_key);//替换法删除
-
- return _EraseR(root->_right, key);//到右子树删除,一定能找到
- }
-
- delete del;
- return true;
- }
- }
-
析构函数其实很简单,我们只需要递归释放树的每个节点就可以:
- ~BSTree()
- {
- DestoryTree(_root);
- _root = nullptr;
- }
-
- void DestoryTree(Node* root)
- {
- if (root == nullptr)
- {
- return;
- }
- DestoryTree(root->_left);
- DestoryTree(root->_right);
- delete root;
- }
如果我们自己不写拷贝构造,编译器会生成默认的拷贝构造,这个时候生成的是浅拷贝函数,浅拷贝会有造成野指针的风险,因此我们必须显式写出一个深拷贝构造,与析构函数递归类似,我们也可以写一个递归拷贝构造:
- BSTree(const BSTree
& t) - {
- _root = CopyTree(t._root);
- }
-
- 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
& operator=(BSTree t) - {
- swap(_root, t._root);
- return *this;
- }
搜索有两种模型,K模型和KV模型
1. K模型:K模型即只有key作为关键码,结构中只需要存储Key即可,关键码即为需要搜 索到的值。
比如:查找一篇文章中是否有错误的单词,具体方式如下:
- 以词库中所有单词集合中的每个单词作为key,构建一棵二叉搜索树
- 在二叉搜索树中检索该单词是否存在,存在则拼写正确,不存在则拼写错误
K模型简而言之就是查找关键字在不在,如扫车牌系统,就是搜索看看也没有车牌的关键字,有就抬杆。
2.KV模型:每一个关键码key,都有与之对应的值Value,即的键值对。该种方式在现实 生活中非常常见:
- 比如英汉词典就是英文与中文的对应关系,通过英文可以快速找到与其对应的中文,英 文单词与其对应的中文就构成一种键值对
- 再比如统计单词次数,统计成功后,给定单词就可快速找到其出现的次数,单词与其出 现次数就是就构成一种键值对
不难看出,我们刚刚在上面实现的二叉搜索树是K模型,那么我们可不可以改造一下,将上面的K模型改成KV模型呢?
- template<class K, class V>
- class BSTree
- {
- typedef BSTreeNode
Node; - public:
-
- void InOrder()
- {
- _InOrder(_root);
- cout << endl;
- }
-
- ///
- Node* FindR(const K& key)
- {
- return _FindR(_root, key);
- }
-
- bool InsertR(const K& key, const V& value)
- {
- return _InsertR(_root, key, value);
- }
-
- bool EraseR(const K& key)
- {
- return _EraseR(_root, key);
- }
-
- private:
- bool _EraseR(Node*& root, const K& key)
- {
- 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
- {
- Node* minRight = root->_right;
- while (minRight->_left)
- {
- minRight = minRight->_left;
- }
-
- swap(root->_key, minRight->_key);
-
- return _EraseR(root->_right, key);
- }
-
- delete del;
- return true;
- }
- }
-
- bool _InsertR(Node*& root, const K& key, const V& value)
- {
- if (root == nullptr)
- {
- root = new Node(key, value);
- return true;
- }
-
- if (root->_key < key)
- return _InsertR(root->_right, key, value);
- else if (root->_key > key)
- return _InsertR(root->_left, key, value);
- else
- return false;
- }
-
- Node* _FindR(Node* root, const K& key)//返回Node*,可以修改value
- {
- if (root == nullptr)
- return nullptr;
-
- if (root->_key < key)
- {
- return _FindR(root->_right, key);
- }
- else if (root->_key > key)
- {
- return _FindR(root->_left, key);
- }
- else
- {
- return root;
- }
- }
-
- void _InOrder(Node* root)
- {
- if (root == nullptr)
- return;
-
- _InOrder(root->_left);
- cout << root->_key << ":" << root->_value << endl;
- _InOrder(root->_right);
- }
- private:
- Node* _root = nullptr;
- };
-
- void TestBSTree1()
- {
- BSTree
ECDict; - ECDict.InsertR("root", "根");
- ECDict.InsertR("string", "字符串");
- ECDict.InsertR("left", "左边");
- ECDict.InsertR("insert", "插入");
- //...
- string str;
- while (cin >> str) //while (scanf() != EOF)多组输入
- {
- //BSTreeNode
* ret = ECDict.FindR(str);//写这种也行 - auto ret = ECDict.FindR(str);
- if (ret != nullptr)
- {
- cout << "对应的中文:" << ret->_value << endl;
- }
- else
- {
- cout << "无此单词,请重新输入" << endl;
- }
- }
- }
-
- void TestBSTree2()
- {
- string arr[] = { "苹果", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜", "苹果", "香蕉", "苹果", "香蕉" };
- //统计水果出现的次数
- BSTree
int> countTree; - for (const auto& str : arr)
- {
- auto ret = countTree.FindR(str);//找到这个水果,
- if (ret == nullptr)//如果为空,说明不存在,则插入,value+1
- {
- countTree.InsertR(str, 1);
- }
- else
- {
- ret->_value++; //修改value
- }
- }
-
- countTree.InOrder();//中序遍历打印出水果和它的出现次数
- }
- }
在STL的库里面,使用K模型的数据结构是set,使用KV模型的数据结构是map,在后面几篇博客中我们将会学习到它们。
插入和删除操作都必须先查找,查找效率代表了二叉搜索树中各个操作的性能。对有n个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二叉搜索树的深度的函数,即结点越深,则比较次数越多。
但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树:

最优情况下,二叉搜索树为完全二叉树(或者接近完全二叉树),其平均比较次数为:O(log2N)
最差情况下,二叉搜索树退化为单支树(或者类似单支),其平均比较次数为:O(N/2)