简介
二叉搜索树(Binary Search Tree,BST)是一种常用的数据结构,本文将介绍二叉搜索树的原理与特性,并给出C++代码实现,最后对其性能进行详细的分析。
文章目录
🙋♂️ 作者:@Ggggggtm 🙋♂️
👀 专栏:C++ 👀
💥 标题:二叉搜索树💥
❣️ 寄语:与其忙着诉苦,不如低头赶路,奋路前行,终将遇到一番好风景 ❣️
一、二叉搜索树的概念
二叉搜索树又称二叉排序树,二叉搜索树是一种二叉树,其中每个节点的值大于其左子树中的任何节点,并且小于其右子树中的任何节点。这个特性使得二叉搜索树具有高效的查找、插入和删除操作。下图即为二叉搜索树:
![]()
二、二叉搜索树的操作及实现
由于二叉搜索树的特性,使得二叉搜索树具有高效的查找、插入和删除操作。在我们分析各个操作的效率和实现原理之前,我们先把二叉树的大体结构列出,代码如下:
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: BSTree() :_root(nullptr) {} private: Node* _root; };
2、1 二叉搜索树的插入
2、1、1 插入的原理
插入一个新的值时,我们需要遵守二叉搜索树的特性。首先,我们从根节点开始找到合适的插入位置。具体操作是,将新值与当前节点的值比较,若新值小于当前节点的值,则往左子树方向找到合适的叶子节点进行插入;反之,若新值大于当前节点的值,则往右子树方向找到合适的叶子节点进行插入。
合适的叶子节点指的是一直往下查找,直到该位置为空(nullptr)时,此时新值就应该插入该位置。即使我们找到了合适的位置,如果不知道该位置的父节点的话,似乎并不能连接到该树中。所以在查找合适位置的同时,还需要维护一个父节点。但是我们需要注意,二叉搜索树中没有重复的值。如果插入重复的值,那么就会插入失败。
同时,我们再插入前,要判断该树是否为空。否则就会出现意想不到的bug。
2、1、2 插入的代码实现
我们看代码实现:
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; }
2、2 二叉搜索树的查找
2、2、1 查找的原理
其实在上述的插入中,我们不就进行了查找吗?!为了查找一个特定的值,我们从根节点开始向下遍历二叉树,根据当前节点的值与目标值的大小关系来选择往左子树或者右子树进行遍历。如果找到目标值,则返回成功;否则,如果遍历到叶子节点还未找到目标值,则返回失败。
2、2、2 查找的代码实现
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; }
2、3 二叉搜索树的删除
2、3、1 删除的原理
删除操作是相对复杂的,因为我们需要处理不同的情况。具体步骤如下:
- 如果要删除的节点没有子节点,直接删除即可。
- 如果要删除的节点只有一个子节点,将子节点替换为要删除的节点即可。
- 如果要删除的节点有两个子节点,需要用其右子树中最小的节点替换要删除的节点,并且删除右子树中最小的节点。
对上述的情况在进行分析和总结,一共可分为如下情况:
- 要删除的结点只有左孩子结点 。删除该结点且使被删除节点的双亲结点指向被删除节点的左孩子结点--直接删除。
要删除的结点只有右孩子结点 。删除该结点且使被删除节点的双亲结点指向被删除结点的右孩子结点--直接删除。- 要删除的结点有左、右孩子结点。在它的右子树中寻找中序下的第一个结点(关键码最小),用它的值填补到被删除节点中,再来处理该结点的删除问题--替换法删除
为什么是上述的三种情况呢?我们详细分析一下是为什么。
假如我们要删除的节点没有子节点,我们可以把这种情况看成要删除的结点只有左孩子结点或者只有右孩子结点。把另一个存在的孩子看成空(nullptr)。这样删除后,直接可让其父节点指向空(nullptr),而不是野指针。
要删除的结点只有左孩子结点或者要删除的结点只有右孩子结点是两种不同的情况。因为他们的操作是不同的。
要删除的结点有左、右孩子结点这种情况较为复杂。首先我们应该找到能够填充该位置的节点。根据二叉搜索树的特性每个节点的值大于其左子树中的任何节点,并且小于其右子树中的任何节点,我们找到的值应该也满足此特点。有两个节点的只满足该情况:该节点左子树的最大值、该节点右子树的最小值。本篇文章讲述的是左子树的最大值。找左子树的最大值,就是该子树最右边的节点。找到后交值换再删除。
要删除的结点有左、右孩子结点这种情况,在找左子树的最大值时也应该维护一个父节点。为什么呢?因为我们找到左子树的最大值时,与要删除的节点的值交换后,要删除该节点(交换前的左子树最大值的节点)。此时该节点的右节点一定为空(nullptr),只需要关心左节点就行。
2、3、2 删除的代码实现
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 (cur == _root) { _root = cur->_right; } else { if (parent->_right == cur) { parent->_right = cur->_right; } else { parent->_left = cur->_right; } } }// 右为空 else if (cur->_right == nullptr) { if (cur == _root) { _root = cur->_left; } else { if (parent->_right == cur) { parent->_right = cur->_left; } else { parent->_left = cur->_left; } } } // 左右都不为空 else { // 找替代节点 Node* parent = cur; Node* leftMax = cur->_left; while (leftMax->_right) { parent = leftMax; leftMax = leftMax->_right; } swap(cur->_key, leftMax->_key); if (parent->_left == leftMax) { parent->_left = leftMax->_left; } else { parent->_right = leftMax->_left; } cur = leftMax; } delete cur; return true; } } return false; }
2、4 二叉搜索树的中序遍历
二叉搜索树又称二叉排序树,为什么又名二叉排序树呢?二叉搜索树的中序遍历的结果就是一个有序的结果。代码如下:
public: Inorder() { _Inorder(_root); } private: void _Inorder(Node* root) { if(root==nullptr) { return ; } _Inorder(root->left); cout<<root->_key<<" "; _Inorder(root->right); }
2、5 递归实现二叉树的操作
我们上述讲解的是非递归形式的二叉搜索树的各个操作。当我们了解非递归形式的二叉搜索树的各个操作后,我们下面给出递归形式的二叉搜索树的各个操作的代码,思路就不在讲解:
public: bool eraseR(const K& key) { return _eraseR(_root,key); } bool insertR(const K& key) { return _insertR(_root,key); } bool findR(const K& key) { return _findR(_root,key); } private: bool _findR(Node* root,const K& key) { if(root==nullptr) { return false; } if(root->_key>key) { _findR(root->left,key); } else if(root->_key<key) { _findR(root->right,key); } else { return true; } } bool _eraseR(Node*& root,const K& key) { if(root==nullptr) { return false; } if(root->_key>key) { _eraseR(root->left,key); } else if(root->_key<key) { _eraseR(root->right,key); } else { Node* del=root; if(root->left==nullptr) { root=root->right; } else if(root->right==nullptr) { root=root->left; } else { Node* min=root->right; while(min->left) { min=min->left; } swap(root->_key,min->_key); return _eraseR(root->right,key); } delete del; return true; } } bool _insertR(Node*& root,const K& key) { if(root==nullptr) { root=new Node(key); return true; } if(root->_key>key) { _insertR(root->left,key); } else if(root->_key<key) { _insertR(root->right,key); } else { return false; } }
三、二叉搜索树的性能分析
插入和删除操作都必须先查找,查找效率代表了二叉搜索树中各个操作的性能。对有n个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二叉搜索树的深度的函数,即结点越深,则比较次数越多。 但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树:
通过上述我们也发现,二叉搜索树的性能主要取决于树的平衡度。最理想的情况下,树是完全平衡的,即左子树节点数目和右子树节点数目相差不超过1。在这种情况下,查找、插入和删除操作的平均时间复杂度为 O(log n)。但是,最坏情况下,树可能变得非平衡,导致这些操作的时间复杂度退化为O(n),其中n是树中节点的总数。
为了避免二叉搜索树在使用过程中出现不平衡的情况,可以使用自平衡的二叉搜索树,如红黑树或AVL树。这些树通过旋转、调整节点颜色等策略来保持树的平衡度,从而提高了整体性能。
总结起来,二叉搜索树在C++编程语言中的实现非常灵活且易于理解。但需要注意的是,对于大型数据集合,建议使用自平衡的二叉搜索树,以确保操作的效率和性能。