文章目录
- 一、二叉搜索树的概念
- 二、二叉搜索树的结构
- 二叉搜索树的性能分析
- 二叉搜索树的插入
- 二叉搜索树的查找
- 二叉搜索树的删除
- 三、二叉搜索树key和key/value使用场景
- 四、二叉搜索树的练习
- 将二叉搜索树就地转化为已排序的双向循环链表
- 从前序与中序遍历序列构造二叉树
- 二叉树的前序遍历(非递归)
- 五、完整代码
- 二叉搜索树的实现代码
- key/value二叉搜索树代码实现
- 六、总结
一、二叉搜索树的概念
二叉搜索树又称二叉排序树(Binary Search Tree,BST),它或者是一棵空树,或者是具有以下性质的⼆叉树:
- 若它的左子树不为空,则左子树上所有结点的值都小于等于根结点的值
- 若它的右子树不为空,则右子树上所有结点的值都大于等于根结点的值
- 它的左右子树也分别为二叉搜索树
- 二叉搜索树中可以支持插入相等的值,也可以不支持插入相等的值,具体看使用场景定义,后续学习map/set/multimap/multiset系列容器底层就是二叉搜索树,其中map/set不支持插入相等值,multimap/multiset支持插入相等值
二、二叉搜索树的结构
二叉搜索树的性能分析
最优情况下,二叉搜索树为完全二叉树(或者接近完全⼆叉树),其高度为:O(log2N)
最差情况下,二叉搜索树退化为单支树(或者类似单支),其高度为:O(N/2)
所以综合而言⼆叉搜索树增删查改时间复杂度为:O(N)
那么这样的效率显然是无法满足需求的,后续讲解⼆叉搜索树的变形,平衡二叉搜索树AVL树和红黑树,才能适用于我们在内存中存储和搜索数据。另外需要说明的是,二分查找也可以实现O(logN) 级别的查找效率,但是二分查找有两大缺陷:
- 需要存储在支持下标随机访问的结构中,并且有序。
- 插入和删除数据效率很低,因为存储在下标随机访问的结构中,插⼊和删除数据⼀般需要挪动数据。这里也就体现出了平衡⼆叉搜索树的价值。
二叉搜索树的插入
插入的具体过程如下:
- 树为空,则直接新增结点,赋值给root指针
- 树不空,按二叉搜索树性质,插入值比当前结点大往右走,插入值比当前结点小往左走,找到空位置,插入新结点。
- 如果支持插入相等的值,插入值跟当前结点相等的值可以往右走,也可以往左走,找到空位置,插入新结点。(要注意的是要保持逻辑⼀致性,插入相等的值不要⼀会往右走,⼀会往左走)
int a[] = {8, 3, 1, 10, 6, 4, 7, 14, 13};
二叉搜索树的查找
- 从根开始比较,查找x,x比根的值大则往右边走查找,x比根值小则往左边走查找。
- 最多查找高度次,走到到空,还没找到,这个值不存在。
- 如果不支持插入相等的值,找到x即可返回
- 如果支持插入相等的值,意味着有多个x存在,⼀般要求查找中序的第⼀个x。如下图,查找3,要找到1的右孩子的那个3返回
二叉搜索树的删除
首先查找元素是否在二叉搜索树中,如果不存在,则返回false。
如果查找元素存在则分以下四种情况分别处理:(假设要删除的结点为N)
- 要删除结点N左右孩子均为空
- 要删除的结点N左孩子位空,右孩子结点不为空
- 要删除的结点N右孩子位空,左孩子结点不为空
- 要删除的结点N左右孩子结点均不为空
对应以上四种情况的解决方案:
- 把N结点的父亲对应孩子指针指向空,直接删除N结点(情况1可以当成2或者3处理,效果是⼀样的)
- 把N结点的父亲对应孩子指针指向N的右孩子,直接删除N结点
- 把N结点的父亲对应孩子指针指向N的左孩子,直接删除N结
- 无法直接删除N结点,因为N的两个孩子无处安放,只能用替换法删除。找N左子树的值最大结点R(最右结点)或者N右子树的值最小结点R(最左结点)替代N因为这两个结点中任意⼀个,放到N的位置,都满足⼆叉搜索树的规则。替代N的意思就是N和R的两个结点的值交换,转而变成删除R结点,R结点符合情况2或情况3,可以直接删除。
三、二叉搜索树key和key/value使用场景
key搜索场景:
只有key作为关键码,结构中只需要存储key即可,关键码即为需要搜索到的值,搜索场景只需要判断key在不在。key的搜索场景实现的二叉树搜索树支持增删查,但是不支持修改,修改key破坏搜索树结构了。
场景1:小区无人值守车库,小区车库买了车位的业主车才能进小区,那么物业会把买了车位的业主的车牌号录入后台系统,车辆进入时扫描车牌在不在系统中,在则抬杆,不在则提示非本小区车辆,无法进入。
场景2:检查⼀篇英文章章单词拼写是否正确,将词库中所有单词放入⼆叉搜索树,读取文章中的单词,查找是否在⼆叉搜索树中,不在则波浪线标红提示。
key/value搜索场景:
每⼀个关键码key,都有与之对应的值value,value可以任意类型对象。树的结构中(结点)除了需要存储key还要存储对应的value,增/删/查还是以key为关键字走二叉搜索树的规则进行比较,可以快速查找到key对应的value。key/value的搜索场景实现的二叉树搜索树支持修改,但是不支持修改key,修改key破坏搜索树结构了,可以修改value。
场景1:简单中英互译字典,树的结构中(结点)存储key(英文)和vlaue(中文),搜索时输入英文,则同时查找到了英文对应的中文。
场景2:商场无人值守车库,入口进场时扫车牌,记录车牌和入场时间,出口离场时,扫描车牌,查找入场时间,用当前时间-入场时间计算出停车时长,计算出停车费用,缴费后抬杆,车辆离场。
场景3:统计⼀篇文章中单词出现的次数,读取⼀个单词,查找单词是否存在,不存在这个说明第⼀次出现,(单词,1),单词存在,则++单词对应的次数。
四、二叉搜索树的练习
将二叉搜索树就地转化为已排序的双向循环链表
将一个 二叉搜索树 就地转化为一个 已排序的双向循环链表 。
对于双向循环列表,你可以将左右孩子指针作为双向循环链表的前驱和后继指针,第一个节点的前驱是最后一个节点,最后一个节点的后继是第一个节点。
特别地,我们希望可以 就地 完成转换操作。当转化完成以后,树中节点的左指针需要指向前驱,树中节点的右指针需要指向后继。还需要返回链表中最小元素的指针。
思想:
- 搜索二叉树走中序是有序的,本题目要求原地修改,也就是不能创建新的结点。
- 思路1:中序遍历搜索⼆叉树,遍历顺序是有序的,将⼆叉树的结点指针放到⼀个vector中,再把前后结点的链接关系进⾏修改。这个思路最简单,但是需要消耗O(N)的空间复杂度。
- 思路2:依旧中序遍历搜索二叉树,遍历顺序是有序的,遍历过程中修改左指针为前驱和右指针为后继指针。记录⼀个cur和prev,cur为当前中序遍历到的结点,prev为上⼀个中序遍历的结点,cur->left指向prev,cur->right⽆法指向中序下⼀个,因为不知道中序下⼀个是谁,但是prev->right指向cur;也就是说每个结点的左是在中遍历到当前结点时修改指向前驱的,但是当前结点的右,是在遍历到下⼀个结点时,修改指向后继的。
代码实现
class Solution {
public:
void InOrderConvert(Node* cur, Node*& prev)
{
if (cur == nullptr)
return;
// 中序遍历
InOrderConvert(cur->left, prev);
// 当前结点的左,指向前⼀个结点
cur->left = prev;
// 前⼀个结点的右,指向当前结点
if (prev)
prev->right = cur;
prev = cur;
InOrderConvert(cur->right, prev);
}
Node* treeToDoublyList(Node* root) {
if (root == nullptr)
return nullptr;
Node* prev = nullptr;
InOrderConvert(root, prev);
// 从根开始往左⾛,找到第⼀个结点
Node* head = root;
while (head->left)
{
head = head->left;
}
// head为第⼀个结点,prev是最后⼀个结点
// 题⽬要求为循环链表,进⾏⼀些链接
head->left = prev;
prev->right = head;
return head;
}
};
从前序与中序遍历序列构造二叉树
给定两个整数数组 preorder 和 inorder ,其中 preorder 是二叉树的先序遍历, inorder 是同一棵树的中序遍历,请构造二叉树并返回其根节点。
思路:
先序遍历的顺序是根节点、左子树、右子树。这意味着先序遍历的第一个元素就是根节点的值。
中序遍历的顺序是左子树、根节点、右子树。通过在中序遍历中找到根节点的位置,可以确定左子树和右子树的元素范围。
可以根据先序遍历和中序遍历构建出二叉树。这个过程利用了先序遍历确定根节点和中序遍历划分左右子树的特点,通过递归的方式逐步构建出整个二叉
class Solution {
public:
TreeNode* _buildTree(vector<int>& preorder, vector<int>& inorder, int&
prei, int inbegin, int inend) {
if (inbegin > inend)
return nullptr;
// 前序确定根
TreeNode* root = new TreeNode(preorder[prei++]);
// 根分割中序左右⼦区间
int rooti = inbegin;
while (rooti <= inend)
{
if (inorder[rooti] == root->val)
break;
else
rooti++;
}
// 递归左右⼦区间,递归构建左右⼦树
// [inbegin, rooti-1] rooti [rooti+1, inend]
root->left = _buildTree(preorder, inorder, prei, inbegin, rooti - 1);
root->right = _buildTree(preorder, inorder, prei, rooti + 1, inend);
return root;
}
TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {
int i = 0;
TreeNode* root = _buildTree(preorder, inorder, i, 0, inorder.size() - 1);
return root;
}
};
二叉树的前序遍历(非递归)
给你二叉树的根节点 root ,返回它节点值的 前序 遍历。
非递归迭代实现思想:
要迭代非递归实现⼆叉树前序遍历,首先还是要借助递归的类似的思想,只是需要把结点存在栈中,方便类似递归回退时取父路径结点。跟这里不同的是,这里把⼀棵⼆叉树分为两个部分:
- 先访问左路结点
- 再访问左路结点的右子树
这里访问右子树要以循环从栈依次取出这些结点,循环子问题的思想访问左路结点的右子树。中序和后序跟前序的思路完全⼀致,只是前序先访问根还是后访问根的问题。后序稍微麻烦⼀些,因为后序遍历的顺序是左子树右子树根,当取到左路结点的右子树时,需要想办法标记判断右子树是否访问过了,如果访问过了,就直接访问根,如果访问过需要先访问右子树
class Solution {
public:
vector<int> preorderTraversal(TreeNode* root) {
stack<TreeNode*> s;
vector<int> v;
TreeNode* cur = root;
while (cur || !s.empty())
{
// 访问⼀颗树的开始
// 1、访问左路结点,左路结点⼊栈
while (cur)
{
v.push_back(cur->val);
s.push(cur);
cur = cur->left;
}
// 2、从栈中依次访问左路结点的右⼦树
TreeNode* top = s.top();
s.pop();
// 循环⼦问题⽅式访问左路结点的右⼦树 --
cur = top->right;
}
return v;
}
};
class Solution {
public:
vector<int> inorderTraversal(TreeNode* root) {
stack<TreeNode*> st;
TreeNode* cur = root;
vector<int> v;
while (cur || !st.empty())
{
// 访问⼀颗树的开始
// 1、左路结点⼊栈
while (cur)
{
st.push(cur);
cur = cur->left;
}
// 访问问左路结点 和 左路结点的右⼦树
TreeNode* top = st.top();
st.pop();
v.push_back(top->val);
// 循环⼦问题⽅式访问右⼦树
cur = top->right;
}
return v;
}
};
class Solution {
public:
vector<int> postorderTraversal(TreeNode* root) {
TreeNode* cur = root;
stack<TreeNode*> s;
vector<int> v;
TreeNode* prev = nullptr;
while (cur || !s.empty())
{
// 1、访问⼀颗树的开始
while (cur)
{
s.push(cur);
cur = cur->left;
}
TreeNode* top = s.top();
// top结点的右为空 或者 上⼀个访问结点等于他的右孩⼦
// 那么说明(空)不⽤访问 或者 (不为空)右⼦树已经访问过了
// 那么说明当前结点左右⼦树都访问过了,可以访问当前结点了
if (top->right == nullptr || top->right == prev)
{
s.pop();
v.push_back(top->val);
prev = top;
}
else
{
// 右⼦树不为空,且没有访问,循环⼦问题⽅式右⼦树
cur = top->right;
}
}
return v;
}
};
五、完整代码
二叉搜索树的实现代码
// Binary Search Tree
template<class K>
class BSTree
{
typedef BSTNode<K> Node;
public:
bool Insert(const K& key)
{
if (_root == nullptr)
{
_root = new Node(key);
return true;
}
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
{
return false;
}
}
cur = new Node(key);
if (parent->_key < key)
{
parent->_right = cur;
}
else
{
parent->_left = cur;
}
return true;
}
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 Erase(const K& key)
{
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
{
// 0-1个孩⼦的情况
// 删除情况1 2 3均可以直接删除,改变⽗亲对应孩⼦指针指向即可
if (cur->_left == nullptr)
{
if (parent == nullptr)
{
_root = cur->_right;
}
else
{
if (parent->_left == cur)
parent->_left = cur -> _right;
else
parent->_right = cur -> _right;
}
delete cur;
return true;
}
else if (cur->_right == nullptr)
{
if (parent == nullptr)
{
_root = cur->_left;
}
else
{
if (parent->_left == cur)
parent->_left = cur -> _left;
else
parent->_right = cur -> _left;
}
delete cur;
return true;
}
else
{
// 2个孩⼦的情况
// 删除情况4,替换法删除
// 假设这⾥我们取右⼦树的最⼩结点作为替代结点去删除
// 这⾥尤其要注意右⼦树的根就是最⼩情况的情况的处理,对应课件图中删除8的情况
// ⼀定要把cur给rightMinP,否会报错。
Node* rightMinP = cur;
Node* rightMin = cur->_right;
while (rightMin->_left)
{
rightMinP = rightMin;
rightMin = rightMin->_left;
}
cur->_key = rightMin->_key;
if (rightMinP->_left == rightMin)
rightMinP->_left = rightMin -> _right;
else
rightMinP->_right = rightMin -> _right;
delete rightMin;
return true;
}
}
}
return false;
}
void InOrder()
{
_InOrder(_root);
cout << endl;
}
private:
void _InOrder(Node* root)
{
if (root == nullptr)
{
return;
}
_InOrder(root->_left);
cout << root->_key << " ";
_InOrder(root->_right);
}
private:
Node* _root = nullptr;
};
key/value二叉搜索树代码实现
template<class K, class V>
struct BSTNode
{
// pair<K, V> _kv;
K _key;
V _value;
BSTNode<K, V>* _left;
BSTNode<K, V>* _right;
BSTNode(const K& key, const V& value)
:_key(key)
, _value(value)
, _left(nullptr)
, _right(nullptr)
{}
};
template<class K, class V>
class BSTree
{
typedef BSTNode<K, V> Node;
public:
BSTree() = default;
BSTree(const BSTree<K, V>& t)
{
_root = Copy(t._root);
}
BSTree<K, V>& operator=(BSTree<K, V> t)
{
swap(_root, t._root);
return *this;
}
~BSTree()
{
Destroy(_root);
_root = nullptr;
}
bool Insert(const K& key, const V& value)
{
if (_root == nullptr)
{
_root = new Node(key, value);
return true;
}
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
{
return false;
}
}
cur = new Node(key, value);
if (parent->_key < key)
{
parent->_right = cur;
}
else
{
parent->_left = cur;
}
return true;
}
Node* 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 cur;
}
}
return nullptr;
}
bool Erase(const K& key)
{
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 (parent == nullptr)
{
_root = cur->_right;
}
else
{
if (parent->_left == cur)
parent->_left = cur -> _right;
else
parent->_right = cur -> _right;
}
delete cur;
return true;
}
else if (cur->_right == nullptr)
{
if (parent == nullptr)
{
_root = cur->_left;
}
else
{
if (parent->_left == cur)
parent->_left = cur -> _left;
else
parent->_right = cur -> _left;
}
delete cur;
return true;
}
else
{
Node* rightMinP = cur;
Node* rightMin = cur->_right;
while (rightMin->_left)
{
rightMinP = rightMin;
rightMin = rightMin->_left;
}
cur->_key = rightMin->_key;
if (rightMinP->_left == rightMin)
rightMinP->_left = rightMin -> _right;
else
rightMinP->_right = rightMin -> _right;
delete rightMin;
return true;
}
}
}
return false;
}
void InOrder()
{
_InOrder(_root);
cout << endl;
}
private:
void _InOrder(Node* root)
{
if (root == nullptr)
{
return;
}
_InOrder(root->_left);
cout << root->_key << ":" << root->_value << endl;
_InOrder(root->_right);
}
void Destroy(Node* root)
{
if (root == nullptr)
return;
Destroy(root->_left);
Destroy(root->_right);
delete root;
}
Node* Copy(Node* root)
{
if (root == nullptr)
return nullptr;
Node* newRoot = new Node(root->_key, root->_value);
newRoot->_left = Copy(root->_left);
newRoot->_right = Copy(root->_right);
return newRoot;
}
private:
Node* _root = nullptr;
};
void test1()
{
BSTree<string, string> dict;
//BSTree<string, string> copy = dict;
dict.Insert("left", "左边");
dict.Insert("right", "右边");
dict.Insert("insert", "插⼊");
dict.Insert("string", "字符串");
string str;
while (cin >> str)
{
auto ret = dict.Find(str);
if (ret)
{
cout << "->" << ret->_value << endl;
}
else
{
cout << "无此单词,请重新输⼊" << endl;
}
}
}
void test2()
{
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();
}
int main()
{
test1();
test2();
return 0;
}
六、总结
本章节和之后的文章使用的语言从C变为C++
优点与缺点:
- 优点:
- 查找、插入、删除操作的时间复杂度在平均情况下为 ,效率较高,特别是对于经常进行动态插入和查找操作的数据集合非常适用。
- 中序遍历可以方便地得到有序的数据序列,无需额外的排序操作。
- 缺点:
- 最坏情况下(例如树退化为链表),查找、插入、删除操作的时间复杂度会退化到 ,性能下降严重。
- 二叉搜索树的平衡情况依赖于插入和删除操作的顺序,如果数据的插入顺序不合理,可能会导致树的不平衡,影响操作的效率。
- 应用场景:
- 二叉搜索树常用于实现动态集合的数据结构,例如在数据库索引、文件系统的目录结构、编译器的符号表等场景中广泛应用,能够快速地进行数据的查找、插入和删除操作。
二叉搜索树在 C++ 中是一种重要的数据结构,掌握其基本操作和遍历方式对于解决各种实际问题具有重要意义。