二叉搜索树(BST树)
文章目录
1.二叉搜索树的概念
二叉搜索树(BST)也叫二叉排序树,它是一颗空树或者具有以下特征的二叉树:
- 若它的左子树不为空,则左子树上所有节点的值都 小于「根节点的值」
- 若它的右子树不为空,则右子树上所有节点的值都 大于「根节点的值」
- 它的所有子树也都是二叉搜索树
简言之:左子树比根节点小,右子树比根节点大
2.二叉搜索树的结构定义
2.1 二叉搜索树结点模板的定义
#include<iostream>
using namespace std;
// 定义二叉搜索树节点
template<class K>
struct BinarySearchTreeNode
{
K _key;
BinarySearchTreeNode<K>* _left;
BinarySearchTreeNode<K>* _right;
// 构造函数
BinarySearchTreeNode(const K& key)
:_key(key)
,_left(nullptr)
,_right(nullptr)
{}
};
2.2 二叉搜索树类模板的定义
// 定义二叉搜索树
template<class K>
class BinarySearchTree
{
typedef BinarySearchTreeNode<K> Node; // 重命名树节点类名
private:
Node* _root = nullptr; // 根节点
public:
// 构造函数
BinarySearchTree();
// 拷贝构造函数
BinarySearchTree(const BinarySearchTree<K>& tree); // 引用
// 赋值运算符重载函数
BinarySearchTree<K>& operator=(BinarySearchTree<K> tree); // 传值
// 析构函数
~BinarySearchTree();
// 插入元素key
bool Insert(const K& key); // 常引用:减少传参时的拷贝,保护形参不会被更改
// 查找元素key,查找到了返回节点地址,否则返回nullptr
Node* Find(const K& key);
// 删除元素key
bool Erase(const K& key);
// 插入元素key(递归版本)
bool InsertR(const K& key);
// 查找元素key(递归版本)
Node* FindR(const K& key);
// 删除元素key(递归版本)
bool EraseR(const K& key);
// 中序遍历
void InOrder();
private:
// 拷贝构造子函数
Node* _Copy(Node* _root);
// 析构子函数
void _Destroy(Node* root);
// 插入元素key子函数(递归版本)
bool _InsertR(Node*& root, const K& key); // 形参为引用,这里很妙
// 查找元素key子函数(递归版本)
Node* _FindR(Node* root, const K& key);
// 删除元素key子函数(递归版本)
bool _EraseR(Node*& root, const K& key); // 形参为引用,这里很妙
// 中序遍历子函数
void _InOrder(Node* _root);
};
3.二叉搜索树的效率
- 对有n个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是与树的深度有关,即树越深,则比较次数越多
- 但对于同一个关键码集合,如果各关键码插入的顺序不同,可能得到不同结构的二叉搜索树
- 比如:
虽然二叉搜索树的查找是很快的,但是它很依赖于树的形状,比如:
因此:
- 最优情况下:有 n 个结点的二叉搜索树为完全二叉树,其平均比较次数为:O(log2N)
- 最坏情况下:有 n 个结点的二叉搜索树退化为单支树,其平均比较次数为:O(N)
- 如果退化成单支树,二叉搜索树的性能就失去了,就得用平衡二叉搜索树(AVL)来处理
4.二叉搜索树的默认成员函数实现
4.1 BST的构造函数实现
//BST构造函数
public:
// 构造函数
BinarySearchTree()
:_root(nullptr)
{}
4.2 BST的拷贝构造函数实现
public:
// 拷贝构造函数
BinarySearchTree(const BinarySearchTree<K>& tree)
{
// 深拷贝,用已存在的树tree去拷贝一个新树,然后返回新树的根
_root = _Copy(tree._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;
}
4.3 BST的赋值运算符重载函数实现
public:
// 赋值运算符重载函数
BinarySearchTree<K>& operator=(BinarySearchTree<K> tree) // 传值
{
// 现代写法
// 比如 t1 = t2,tree是t2的深拷贝,tree就是t1想要的
// 所以t1和tree换个头(根节点地址),但不换身体(整颗树),t1就指向了tree整棵树,然后返回去
std::swap(_root, tree._root);
return *this;
}
4.4 BST的析构函数实现
public:
// 析构函数
~BinarySearchTree()
{
_Destroy(_root);
}
private:
// 析构子函数
void _Destroy(Node* root)
{
// 根节点不为空
if (root)
{
// 建议使用后序遍历,左-右-根
_Destroy(root->_left);
_Destroy(root->_right);
delete root;
root = nullptr;
}
}
5.二叉搜索树API实现
BST的常用接口有:
- BST的查找:Node * Find(const K& key);
- BST的插入:bool Insert(const K& key);
- BST的中序遍历:void _InOrder(Node * root);
- BST的删除:bool Erase(const K& key);
思考:为什么要使用中序遍历呢?
因为:插入数据顺序不同,树的结构不同,前序遍历与后序遍历结果不一样,但是中序遍历是一样的结果
对于中序遍历来说,可以用下标线法,直接把元素向下对应标线就可以确定位置
5.1 BST的查找实现
BST的查找分为两种:递归查找和非递归查找
1.BST的非递归查找:
基本思路:
- 如果根节点为空,返回 nullptr
- 如果根节点不为空,从根节点开始,查找 key:
- 如果 key 比当前节点小,则去当前节点的左子树中查找
- 如果 key 比当前节点大,则去当前节点的右子树中查找
- 如果 key 等于当前节点,返回节点地址
// 查找元素key,查找到了返回节点地址,否则返回nullptr Node* Find(const K& key) { // 树为空 if (_root == nullptr) { return nullptr; } // 树不为空,从根节点开始查找元素key Node* cur = _root; while (cur) // 当cur为空,停止循环,说明没找到 { if (key > cur->_key) { cur = cur->_right; } else if (key < cur->_key) { cur = cur->_left; } else // 查找到了,返回节点地址 { return cur; break; } } // 没有找到 return nullptr; }
2.BST的非递归查找:
基本思路:分而治之的想法
每一级递归时,在我们眼中,当前树就是这样的,只有
root
、left
、right
三个节点
// 定义二叉搜索树 template<class K> class BinarySearchTree { typedef BinarySearchTreeNode<K> Node; // 重命名为Node private: Node* _root = nullptr; // 根节点 public: // 查找元素key(递归版本) // 调用函数需要传递树的根,根是私有成员,所以套一层函数InsertR来间接调用,从而保护根 Node* FindR(const K& key) { return _FindR(_root, key); } private: // 查找元素key子函数(递归版本) Node* _FindR(Node* root, const K& key) { // 递归出口(终止条件),当前树的根节点为空 if (root == nullptr) { return nullptr; // 没找到,返回nullptr } // 当前树的根节点不为空 if (key > root->_key) { return _FindR(root->_right, key); } else if (key < root->_key) { return _FindR(root->_left, key); } else { // 找到了,返回该节点地址 return root; } } }
5.2 BST的插入实现
BST的插入分为:递归插入和非递归插入
1.BST的非递归插入:
基本思路:
- 树为空,直接插入
- 树不为空,根据「二叉搜索树性质」,从根节点开始,查找到适合插入 key 的空位置,然后插入
// 插入元素key bool Insert(const K& key) // 常引用:减少传参时的拷贝,保护形参 不会被更改 { // 树为空 if (_root == nullptr) { _root = new Node(key); // 直接插入新节点 return true; } // 树不为空,从根节点开始,先查找到插入key的位置 Node* cur = _root; // 记录cur的父节点,因为新节点最终会插入在cur的父节点左右孩子的位置 Node* parent = nullptr; while (cur) // 当cur为空,说明找到插入key的位置了 { if (key < cur->_key) // key比当前节点小 { parent = cur; cur = cur->_left; // 去当前节点的左子树查找 } else if (key > cur->_key) // key比当前节点大 { parent = cur; cur = cur->_right; // 去当前节点的右子树查找 } else { // key等于当前节点,说明元素已经在树中存在,二叉搜索树不允许冗余,则返回false return false; } } // 申请一个新节点 cur = new Node(key); // 判断下新节点应该链接在其父节点的左边还是右边 if (key > parent->_key) { parent->_right = cur; // key比父节点大,链接在右边 } else { parent->_left = cur; // key比父节点小,链接在左边 } // 插入成功,返回true return true; }
2.BST的递归插入:
基本思路:分而治之,每一级递归时,在我们眼中,当前树就是这样的,只有
root
、left
、right
三个节点
递归算法思路:
- 如果当前树的根节点为空,则直接插入
- 如果当前树的根节点不为空
- 插入的值 key 如果比当前树的根节点大,则去往当前树的右子树中插入
- 插入的值 key 如果比当前树的根节点小,则去往当前树的左子树中插入
// 定义二叉搜索树 template<class K> class BinarySearchTree { typedef BinarySearchTreeNode<K> Node; // 重命名 private: Node* _root = nullptr; // 根节点 public: // 插入元素key(递归版本) // 调用函数需要传递树的根,根是私有成员,所以套一层函数InsertR来间接调用,从而保护根 bool InsertR(const K& key) { return _InsertR(_root, key); } private: // 插入元素key子函数(递归版本) bool _InsertR(Node*& root, const K& key) // 形参是根节点的引用,这里很巧妙 { // 当前树的根节点为空 if (root == nullptr) { root = new Node(key); // 插入新节点 return true; // 返回true } // 当前树的根节点不为空 if (key > root->_key) { // 去往当前树的右子树中插入 return _InsertR(root->_right, key); } else if (key < root->_key) { // 去往当前树的左子树中插入 return _InsertR(root->_left, key); } else { // 二叉搜索树不允许数据冗余,返回false return false; } } }
对于函数
bool _InsertR(Node*& root,const K& key)
使用参数引用的好处剖析:
结论:我们在函数体内就不用定义一个变量来保存要插入的节点的父节点了
如下图:我通过改变 root,从而控制 root 父节点(节点 9)右指针的指向
5.3 BST的中序遍历实现
template<class K>
class BinarySearchTree
{
typedef BinarySearchTreeNode<K> Node; // 重命名树节点类名
private:
Node* _root = nullptr; // 根节点
public:
// 其它成员函数......
// 中序遍历
// void InOrder(Node* _root)
// 调用函数需要传递树的根,根是私有成员,所以套一层无参函数InOrder()来间接调用,从而保护根
void InOrder()
{
_InOrder(_root); // 调用中序遍历子函数
cout << endl;
}
private:
// 中序遍历子函数
void _InOrder(Node* root)
{
if (root)
{
_InOrder(root->_left);
cout << root->_key << " ";
_InOrder(root->_right);
}
}
};
解释:我们在类外面,用对象调用函数时需要传递树的根,但根是私有成员,只能再去写一个 GetRoot 接口来传递树的根,但这样根又被暴露出去了,所以我们在这里,套一层无参函数 InOrder()
来调用有参函数 _InOrder(Node* _root)
,从而保护了根节点
5.4 BST的删除
BST的删除分为:递归删除和非递归删除
二叉搜索树的删除比较复杂,要分4种情况讨论:
- 要删除的结点「无孩子」结点(叶子结点)
- 要删除的结点「只有左孩子」结点
- 要删除的结点「只有右孩子」结点
- 要删除的结点「有左右孩子」结点
1.BST的非递归删除:
情况一和情况二准确来说是一种情况::1.要删除的结点「无孩子」结点(叶子结点) 2.要删除的结点「只有左孩子」结点
基本思路:
- 先判断被删除节点 cur 是父节点 parent 的 左孩子 还是 右孩子
- 让父结点 parent 的左 / 右指针指向被删除节点的 左孩子 (我被删除了,我的父亲要帮我接管左孩子)
- 然后删除该节点
极端情况考虑:删除的是根节点,cur 没有父节点,所以直接把 cur 的左孩子变为根
情况三:要删除的结点「只有右孩子」结点
基本思路:
- 先判断被删除节点 cur 是父节点 parent 的 左孩子 还是 右孩子
- 让父结点 parent 的左 / 右指针指向被删除节点的右孩子(我被删除了,我的父亲要帮我接管右孩子)
- 然后删除该节点
极端情况考虑:删除的是根节点,cur 没有父节点,所以直接把 cur 的右孩子变为根
情况四:要删除的结点「有左右孩子」结点
基本思路:有两个孩子,不好直接删除,所以我们用替代法删除
替代法删除:找一个「替代节点」,比被删除节点的左孩子值大,比被删除节点右孩子的值小。即找被删除节点左子树中的最大节点或者右子树中的最小节点
左子树中的最大节点 --> 即左子树的最右侧节点(它的右孩子一定为空)
右子树中的最小节点 --> 即右子树的最左侧节点(它的左孩子一定为空)
「替代节点」找到后,将替代节点中的值赋给「要的删除节点」,转换成删除替代节点,如下图:
特别注意:第三步中
- 先要判断一下最大节点 maxleft 是父节点 maxleft_parent 的 左孩子 还是 右孩子
- 让父结点 maxleft_parent 的左 / 右指针指向被删除节点的 左孩子 (我被删除了,我的父亲要帮我接管左孩子,因为左子树的最大节点没有的右孩子),如下图:
具体实现代码:
// 删除元素key bool Erase(const K& key) { // 树为空,删除失败 if (_root == nullptr) { return false; } // 树不为空,从根节点开始,查找元素key Node* cur = _root; // 记录元素key的位置 Node* parent = nullptr; // 记录cur的父节点 while (cur) // 如果cur为空,说明没有找到元素key的位置 { if (key > cur->_key) { parent = cur; cur = cur->_right; } else if (key < cur->_key) { parent = cur; cur = cur->_left; } else // 找到要删除的元素key了,分为以下几种情况: { // 1、要删除的节点没有左右孩子,或者要删除的节点只有一个左孩子 if (cur->_right == nullptr) { if (cur == _root) // 如果要删除的cur是树的根节点,cur没有父节点 { _root = cur->_left; } else { // 判断下 if (cur == parent->_left) // 被删除节点cur是父节点的左孩子 { parent->_left = cur->_left; // 让父节点左指针指向cur左孩子 } else // 被删除节点cur是父节点的右孩子 { parent->_right = cur->_left; // 让父节点右指针指向cur左孩子 } } // 删除 delete cur; } // 2、要删除的节点只有一个右孩子 else if (cur->_left == nullptr) { if (cur == _root) // 如果要删除的cur是树的根节点,cur没有父节点 { _root = cur->_right; } else { // 判断下 if (cur == parent->_left) // 被删除节点cur是父节点的左孩子 { parent->_left = cur->_right; // 让父节点左指针指向cur右孩子 } else // 被删除节点cur是父节点的右孩子 { parent->_right = cur->_right; // 让父节点左指针指向cur右孩子 } } // 删除 delete cur; } // 3、要删除的节点有左、右两个孩子 else { // 找替代节点:被删除节点的左子树中的最大节点,即左子树的最右节点(它的右孩子一定为空) Node* maxleft = cur->_left; // 从左子树的根节点开始找 Node* maxleft_parent = cur; // 记录最大节点的父亲 // 3.1 找最大节点 while (maxleft->_right) // 右孩子为空时,说明找到最大节点了 { maxleft_parent = maxleft; maxleft = maxleft->_right; // 继续往右找 } // 3.2 把最大节点的值赋给被删除节点 cur->_key = maxleft->_key; // 3.3 判断一下 if (maxleft == maxleft_parent->_left) // 如果最大节点是父节点左孩子 { // 让父节点左指针指向maxleft左孩子 maxleft_parent->_left = maxleft->_left; } else // 如果最大节点是父节点的右孩子 { // 让父节点右指针指向maxleft左孩子 maxleft_parent->_right = maxleft->_left; } // 3.4 删除 delete maxleft; } // 删除成功,返回true return true; } } // 没有找到元素key,删除失败,返回false return false; }
2.BST的递归删除:
基本思想:分而治之,每一级递归时,在我们眼中,当前树就是这样的,只有
root
、left
、right
三个节点// 定义二叉搜索树 template<class K> class BinarySearchTree { typedef BinarySearchTreeNode<K> Node; // 重命名 private: Node* _root = nullptr; // 根节点 public: // 删除元素key(递归版本) bool EraseR(const K& key) { return _EraseR(_root, key); // 保护根 } private: // 删除元素key子函数(递归版本) 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; // 保存当前节点的地址 // 1、当前节点没有左右孩子,或者当前节点只有一个左孩子 if (root->_right == nullptr) { root = root->_left; } // 2、当前节点只有一个右孩子 else if (root->_left == nullptr) { root = root->_right; } // 3、当前节点有左右两个孩子 else { // 找到当前节点的右子树中最小节点替代删除 Node* minRight = root->_right; while (minRight->_left) { minRight = minRight->_left; } // 替代节点值赋给当前节点 root->_key = minRight->_key; // 转换成,在当前节点的右子树中去删除替代节点 return _EraseR(root->_right, minRight->_key); } delete del; return true; } } }
特别注意:
6.二叉搜索树的应用
6.1 K模型(基于set)
K (Key) 模型:确定一个值在不在一个集合中,K 模型即只有 Key 作为关键码,二叉搜索树结构中只需要存储 Key 即可,关键码即为需要搜索到的值
举个例子1:我们在食堂打饭用的饭卡里面就绑定欸连我们的学号,如果学号匹配不对,就不能使用
上面的BST实现就是使用的K模型
6.2 KV查找模型(基于map)
KV (Key/Value) 模型:每一个关键码 Key,都有与之对应的值 Value,即 < Key, Value > 的键值对
该种方式在现实生活中非常常见:
- 比如英汉词典就是英文与中文的对应关系,通过英文可以快速找到与其对应的中文,英文单词与其对应的中文 < word, chinese > 就构成一种键值对
- 再比如高铁站买票后刷身份证进站,你的身份证中并没有买票信息,那是如何进站的呢,通过身份证号,查找用这个身份证号买的车票信息,通过 < 身份证号, 车票信息 > 键值对
KV模型中,二叉搜索树的每个节点不仅要存放 key,还要存放 value,但是在插入、删除的时候,还是按照 key 值来查找到该节点,对其进行插入、删除操作
所以我们要对上面的二叉搜索树进行改造,主要是这几个改动:
- 1、节点类模板
- 2、树类模板中的插入节点函数、中序遍历函数
- 3、查找、删除函数无需改动
举例英汉词典:
void TestTree2()
{
// KV模型 -- 英汉词典,通过英文找到与其对应的中文
KEY_VALUE::BinarySearchTree<string, string> dict;
// 插入 < 单词,中文含义 > 构建二叉搜索树
dict.Insert("boy", "男孩");
dict.Insert("left", "左边");
dict.Insert("right", "右边");
dict.Insert("tree", "树");
dict.Insert("boy", "男孩");
// 查找单词对应中文含义
string word;
while (cin >> word)
{
if (word == "q") // 输入q退出查找
{
cout << "quit!" << endl;
break;
}
else
{
// 查找该单词
auto ret = dict.Find(word);
// 这样写可以,不过太烦了 KEY_VALUE::BinarySearchTreeNode<string, string>* ret = dict.Find(word);
// 判断有没有查找到
if (ret == nullptr) // 没有查找到
{
cout << "词典中无此单词,请重新输入" << endl;
}
else // 查找到了
{
cout << ret->_key << " -- " << ret->_value << endl;
}
}
}
}
举例统计单词出现的次数:
void TestTree3()
{
string str[] = { "sort","sort", "tree","sort", "node", "tree","sort", "sort" };
// KV模型 -- 统计str中每个单词出现的次数
KEY_VALUE::BinarySearchTree<string, int> tree;
// 遍历str
for (auto& e : str) // 传引用,避免string深拷贝jiang
{
// 先检查当前准备插入的单词,是否已经在二叉搜索树tree中了
auto ret = tree.Find(e);
if (ret == nullptr) // 不在树中
{
// 插入 < 单词,单词出现次数 >
tree.Insert(e, 1); // 当前次数是1次
}
else // 在树中
{
ret->_value++; // 修改value,出现次数+1
}
}
// 打印每个 < 单词,单词出现次数 >
tree.InOrder();
}