文章目录
- 二叉搜索树的结构
- 二叉搜索树的实现
- 节点的定义
- 二叉搜索树的框架
- 构造函数
- 拷贝构造函数
- 赋值运算符重载
- 析构函数
- 搜索操作
- 插入操作
- 删除操作
- 二叉搜索树的应用
- 二叉搜索树的效率
二叉搜索树的结构
在浅学一下二叉树链式存储结构的遍历_链式存储二叉树按层次遍历_LeePlace的博客-CSDN博客一文中简单介绍了一下普通二叉树的三种遍历方式。
我们知道普通二叉树是没有什么实用性的,
但是如果在普通二叉树的基础上对其结构进行一些改进,
或许能发挥巨大的价值。
二叉搜索树(BinarySearchTree/二叉排序树)就是普通二叉树的一种变种。
我们规定一个普通二叉树具有如下性质:
- 若它的左子树不为空,则它左子树上所有节点的值都小于根节点的值
- 若它的右子树不为空,则它右子树上所有节点的值都大于根节点的值
- 它的左右子树也具有上面两条性质
那么这颗二叉树就是一颗二叉搜索树。
下面就是一颗简单的二叉搜索树:
此时可以观察到二叉搜索树有一个良好的性质,
当我们按中序去遍历这棵树时,
遍历到的数据是以此有序的,
所以二叉搜索树可以进行排序。
另一方面,当我们想要查询某个数据时,只需要走一条路径即可,
举个例子,假设我要寻找7
,
从根节点8
开始,7
比8
小,那么7
应该在左子树,
此时往左走,根节点变成3
,7
比3
大,那么7
应该在右子树,
现在往右走,根节点变成6
,7
比6
大,那么7
应该在右子树,
再往右走,根节点变成7
,和我们要查找的一样,就找到了。
如果沿着某条路径没有找到,说明树中没有该目标值。
如果这棵树是一颗满二叉树,那么查询的效率就来到logN
。
当然,普通二叉树是很难做到这一点的,
具体情景会在最后进行分析,
所以想要实现高效的查询,还需要对普通的二叉搜索树进行改造,
也就是后续会讲到的AVL树和红黑树。
二叉搜索树的实现
说明一下,下面实现的搜索二叉树存放的都是相异的值,
也就是说不会插入重复的值,
想要处理相同值也很简单,后面会提一下。
节点的定义
普通二叉搜索树的每个节点都要存放数据,
并且要链接子树,
所以普通二叉搜索树的节点很简单:
template<class T>
struct BSTreeNode
{
T _key;
BSTreeNode<T>* _left;
BSTreeNode<T>* _right;
BSTreeNode(const T& x)
: _key(x)
, _left(nullptr)
, _right(nullptr)
{}
};
注意要定义节点的构造函数,
因为插入的时候会有Node* newnode = new Node(x)
这种操作,
如果不写构造函数的话就new
不出新节点。
二叉搜索树的框架
我们要实现一个二叉搜索树类型,
类的成员只要一个指向根节点的指针就可。
考虑一下四个默认构造函数能不能完成任务:
插入一个新节点时要先
new
一个新节点出来,也就意味着如果进行拷贝构造一颗搜索二叉树的话要进行深拷贝,
所以拷贝构造函数需要我们自己写,
赋值运算符重载同理。
成员是指针,指针指向一堆动态开辟的节点,
所以析构函数也需要我们自己写,
析构掉一个个节点。
对于二叉搜索树,我们主要完成三个功能:搜索、插入、删除。
每一个功能我们既可以通过非递归的方式实现,
也可以通过递归的方式实现,
所以我们主要完成三个功能的递归和非递归版本。
所以代码的基本框架就有了:
(这里只是给出一个大致的框架,不用纠结于参数设计,在下面具体实现的时候会进行讨论)
template <class T>
class BSTree
{
typedef BSTreeNode<T> Node;
public:
// 构造函数
BSTree();
// 拷贝构造函数
BSTree(const BSTree& t);
// 赋值运算符重载函数
BSTree<T>& operator=(BSTree<T> t);
//析构函数
~BSTree();
//搜索
bool Find(const T& key); //非递归
bool FindR(const T& key); //递归
//插入
bool Insert(const T& key); //非递归
bool InsertR(const T& key); //递归
//删除
bool Erase(const T& key); //非递归
bool EraseR(const T& key); //递归
private:
Node* _root;
};
构造函数
构造函数还是比较简单的,
只需要把_root
初始化为nullptr
就OK:
public:
BSTree()
: _root(nullptr)
{}
拷贝构造函数
拷贝构造函数需要我们完成深拷贝,
需要用一棵树构造出一颗一模一样的树。
一个思路是遍历树的每个节点,
然后将遍历到的节点的值作为参数,
调用Insert
函数插入到新树中,
这样就只能用前序遍历,
虽然可行,但是效率太低。
不妨想一下,还是用前序遍历,
遍历到一个节点就拷贝new
一个新节点,
对新节点的左子树和右子树也执行上述这个过程:
public:
BSTree(const BSTree<T>& t)
{
_root = Copy(t._root);
}
private:
Node* Copy(Node* root)
{
if (root == nullptr)
return nullptr;
Node* newNode = new Node(root->_key); //拷贝根节点
newNode->_left = Copy(root->_left); //拷贝左子树
newNode->_right = Copy(root->_right); //拷贝右子树
return newNode; //返回根节点
}
因为是要递归构造,
所以这里定义了一个辅助函数。
赋值运算符重载
对于赋值运算符重载函数就不需要写得很复杂了,
我们已经有了拷贝构造函数,
所以使用一种很妙的写法就行:
public:
BSTree<T>& operator=(BSTree<T> t)
{
swap(_root, t._root);
return *this;
}
这里参数没有设置成const BSTree<T>& t
,
而是用了一个用实参拷贝构造出来的临时对象,
把两棵树进行换根,
临时对象就变成了原来的树,
出了函数要对临时对象进行析构,
原来的那些节点也就释放掉了。
析构函数
析构函数要完成的就是释放掉每一个节点,
这个需要通过后序遍历来完成:
先释放左子树,再释放右子树,最后释放根节点,
同样需要递归完成,
所以同样需要一个辅助函数:
public:
~BSTree()
{
Destroy(_root);
}
private:
void Destroy(Node*& root)
{
if (root == nullptr)
return;
Destroy(root->_left); //先释放左子树
Destroy(root->_right); //再释放右子树
delete root; //最后释放根节点
root = nullptr; //释放后把根节点指针置空
}
搜索操作
搜索操作还是很简单的,
通过传入的key
值直接沿着路径寻找就好,
如果当前节点的值比key
小,那就向右走,
如果当前节点的值比key
大,那就向左走,
如果当前节点的值与key
相等,那就找到了,
如果走到空了还没有找到那就是不存在:
非递归搜索代码如下:
public:
bool Find(const T& key)
{
Node* cur = _root;
while (cur)
{
if (cur->_key > key)
cur = cur->_left;
else if (cur->_key < key)
cur = cur->right;
else
return true;
}
return false;
}
当然我们还可以通过递归实现,
如果当前节点的值比key
小,那就去它的右子树中查找,
如果当前节点的值比key
大,那就去它的左子树中查找,
如果当前节点的值与key
相等,那就找到了,
如果走到空了还没有找到那就是不存在。
需要注意,递归版本的参数是要有一个Node*
的,
而Node* _root
是私有成员,
在外面调用函数传参的时候访问不到,
所以需要在内部定义一个搜索函数,
对外开放的接口只需要调用这个内部函数就OK:
public:
bool findR(const T& key)
{
return _findR(_root, key);
}
private:
bool _FindR(Node*& root, const T& key)
{
if (root == nullptr)
return false;
if (root->_key > key)
return _FindR(root->_left, key);
else if (root->_key < key)
return _FindR(root->_right, key);
else
return true;
}
插入操作
插入一个新节点,
要在叶子节点下面的空节点进行插入,
还是以上图的例子,假设要插入9
,
还是跟查找一样的逻辑先找到待插入的位置,
然后创建新节点并进行链接:
有几个细节需要注意一下:
- 当树为空时,直接在根节点插入
- 最后还要和上一个节点链接,这里没有用三叉链结构,所以还需要一个
parent
指针保存上一个节点的地址 - 链接节点时要判断一下是链到父节点的左边还是右边
非递归插入代码如下:
public:
bool Insert(const T& key)
{
if (_root == nullptr) //树为空,直接在根节点插入
{
_root = new Node(key);
return true;
}
Node* parent = nullptr;
Node* cur = _root;
while (cur)
{
if (cur->_key > key) //cur的值更大,要在cur的左侧插入
{
parent = cur;
cur = cur->_left;
}
else if (cur->_key < key) //cur的值更小,要在cur的右侧插入
{
parent = cur;
cur = cur->_right;
}
else //要插入的节点已经存在,插入失败
return false;
}
cur = new Node(key);
if (parent->_key > key) //如果比父节点小就链到左边
parent->_left = cur;
else //如果比父节点大就链到右边
parent->_right = cur;
return true;
}
当然还可以递归插入,
如果比当前节点大就去左树插入,
如果比当前节点小就去右树插入,
如果走到空就进行插入:
public:
bool InsertR(const T& key)
{
return _InsertR(_root, key);
}
private:
bool _InsertR(Node*& root, const T& 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;
}
注意这里辅助函数的参数类型是Node*& root
,
它是父节点的左右节点的别名,
以上面插入9
为例,
当走到空的时候它是Node(10)->left
的别名,
所以我们直接在这儿插入就省去了链接这一过程。
而如果把参数设置成Node* root
的话一方面要面临链接逻辑的问题,
另一方面当树为空的时候就无法完成插入,
因为root
是_root
的一个别名,
因此无法改变_root
的指向!
删除操作
搜索二叉树的删除操作是比较麻烦的,
如果删除的节点是叶子节点的话直接delete
掉很简单,
但如果删除的节点不是叶子结点就需要进行讨论了。
-
待删除的节点左不为空右为空:
以下面删除
14
节点为例:14
的父节点是10
,14
是10
的右子树的根节点,说明
14
的左子树节点都比10
大,所以直接将左子树链到
10
的右边,这是待删除节点是父节点的右孩子时的处理方式。
如果待删除节点是父节点的左孩子,
那就把待删除节点的左子树链到父节点的左边。
此时 需要考虑一下父节点为空的情况,
也就是待删除节点是根节点,
因为它的右子树为空,
所以直接另
_root
指向它的左子树就好。这里还是涉及到了和父节点进行链接的步骤,
所以还是需要一个
parent
节点记录当前节点的父节点。下面的代码省去了找到待删除节点的步骤,
cur
是待删除节点,parent
是待删除节点的父节点,直接做这种情况下的删除操作:
if (cur == _root) _root = cur->_left; else { if (parent->_left == cur) parent->_left = cur->_left; else parent->_left = cur->_right; } delete cur;
-
待删除的节点右不为空左为空:
这种情况跟上面大差不差,
就不做详细的分析了:
还是直接看代码,
cur
是待删除节点,parent
是待删除节点的父节点:if (cur == _root) _root = cur->_right; else { if (parent->_left == cur) parent->_left = cur->_right; else parent->_right = cur->_right; } delete cur;
-
待删除节点的左右都不为空:
下面以删除
8
节点为例:此时就不能进行简单的删除然后处理子树,
因为待删除节点有两个子树不好处理。
所以这种情况下考虑一种新的方法——替换法。
首先考虑当前中序的遍历顺序为
1 3 4 6 7 8 10 13 14
,删除掉
8
之后8
的位置应当被7
或10
取而代之,而
7
和10
又是什么呢?是左子树的最大节点或右子树的最小节点!所以第一步是先找到左子树的最大节点或右子树的最小节点,
左子树的最大节点只需要从左子树的根节点出发,
一路向右走,走到叶子节点就找到了:
寻找右子树的最小节点就是一路向左走,
这里就不做演示了。
然后我们不真正删除待删除节点,
只是把它的值替换成我们查找到的左右子树的最大或最小值,
然后删掉左右子树的最大或最小值。
但是左右子树的最大或最小节点不能直接删除,
因为他们可能还有子树需要处理,
不过方便的是它们只有单边存在子树:
左子树的最大节点只可能存在左子树,右子树的最小节点只可能存在右子树,
那么对于被替换的节点的删除又可以采取此前的方法,
将左子树或右子树托付给它们的父节点。
下面以用右子树的最小节点替换为例进行演示:
cur
是待删除的节点,minRight
记录待删除的节点的右树的最小节点,parent
记录右树的最小节点的父节点,便于链接最小节点的右子树,代码如下:
Node* 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;
综上三种情况,非递归方式的删除就处理完毕了,
对于要删除的节点是叶子结点,其实处理方式跟一二两种情况完全一样。
完整代码如下:
bool Erase(const T& key)
{
//寻找待删除节点
Node* cur = _root;
Node* parent = nullptr; //这里的parent是便于一二两种情况托付孩子
while (cur)
{
if (cur->_key > key)
{
parent = cur;
cur = cur->_left;
}
else if (cur->_key < key)
{
parent = cur;
cur = cur->_right;
}
else //到这里说明找到了要删除的节点
{
if (cur->_left == nullptr) //左子树是空的情况
{
if (cur == _root) //如果待删除的节点是根节点直接换根
_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) //如果待删除的节点是根节点直接换根
_root = cur->_left;
else //如果待删除节点不是根节点则需要托付孩子
{
if (parent->_left == cur)
parent->_left = cur->_left;
else
parent->_left = cur->_right;
}
delete cur;
}
else //左右子树都不为空
{
Node* parent = cur; //记录最小节点的父节点,便于托付最小节点的右孩子
Node* minRight = cur->_right; //记录待删除节点的右树的最小节点
while (minRight->_left)
{
parent = minRight;
minRight = minRight->_left;
}
cur->_key = minRight->_key;
if (minRight == parent->_left) //托付最小节点的右孩子给parent
parent->_left = minRight->_right;
else
parent->_right = minRight->_right;
delete minRight;
}
return true;
}
}
return false;
}
以上是非递归方法,下面介绍一下递归方法。
同递归插入一样,我们还是以Node*& root
作为参数,
以const T& key
作为待删除的参考值,
代码如下,后面做解释:
public:
bool EraseR(const T& key)
{
return _EraseR(_root, key);
}
private:
bool _EraseR(Node*& root, const T& key)
{
if (root == nullptr)
return false;
if (root->_key > key)
return _EraseR(root->_left, key);
else if (root->_key < key)
return _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* minRight = root->_right;
while (minRight->_left)
minRight = minRight->_left;
swap(root->_key, minRight->_key);
return _EraseR(root->_right, key);
}
delete del;
return true;
}
}
如果root->_key
比key
大,那就去root
的左树去删除;
如果root->_key
比key
小,那就去root
的右树去删除;
如果root
走到空了,说明树中不存在要删除的节点,直接return false
;
如果找到了待删除的节点,那就进行同上面一样的分类讨论:
假设待删除节点的父节点是parent
,
那这个待删除节点root
就是parent->left
或parent->right
的别名,
所以我们改变root
就是改变的parent->left
或parent->right
,
对于左右子树有一个为空的情况,我们就可以直接进行链接,
对于左右子树都不为空的情况,
我们可以先交换待删除节点的值和右子树的最小节点的值(或左子树最大节点的值),
此时待删除节点就变到了原来右子树最小节点所在位置,
我们就可以通过递归去当前替换后的节点的左树(用左子树最大节点替换)或右树(用右子树最小节点替换)进行删除,
也就是_EraseR(root->_right, key)
。
二叉搜索树的应用
上面管于基本的二叉搜索树的实现已经讲解完了,
下面就是一些简单应用。
有时候我们只需要存储单个的值,
比如我们可以想验证一个单词的拼写正不正确,
我们可以把单词一个个的插入到搜索树中建立一个单词库,
然后直接在搜索树中查找要验证的单词。
此时上面搜索树的结构足以完成这个任务。
而有时我们又需要存储多个值,
比如我们要做一个英语词典,
给我一个英文单词我能给出它的汉语意思,
此时可能搜索树的一个节点就要存储多个数据,
比如其中一个数据string word
是英文单词,
另一个数据vector<string>
就是该单词对应的所有汉语翻译,
此时我们就需要对二叉树进行一些简单的改造:
此时对应的存储模型其实就是key_value模型,
一个key对应一个value,
key就是我们查找、插入、删除的键值,
value就是键值对应的数据。
比如上面那个场景下我们就可以使用英文单词作为key,
汉语翻译合集作为value。
此时的二叉树节点就要存放两个类型的数据
string
和vector<string>
,后续的查找插入删除操作也许进行些许改动,
改变一下比较
key
的方式,都是些小打小闹的改动,大框架还是不变的。
下面是各个部分改造后的代码:
-
节点:
template<class K, class V> struct BSTreeNode { K _key; V _value; BSTreeNode<K, V>* _left; BSTreeNode<K, V>* _right; BSTreeNode(const K& key, const V& val) : _key(key) , _value(val) , _left(nullptr) , _right(nullptr) {} };
-
框架:
template<class K, class V> class BSTree { typedef BSTreeNode<K, V> Node; public: //构造函数 BSTree(); //拷贝构造函数 BSTree(const BSTree<K, V>& t); //赋值运算符重载 BSTree<K, V>& operator=(BSTree<K, V> t); //析构函数 ~BSTree(); //搜索 bool Find(const K& key); //非递归 bool FindR(const K& key); //递归 //插入 bool Insert(const K& key); //非递归 bool InsertR(const K& key); //递归 //删除 bool Erase(const K& key); //非递归 bool EraseR(const K& key); //递归 private: Node* _root = nullptr;
-
构造函数
public: BSTree() : _root(nullptr) {}
-
拷贝构造函数
public: BSTree(const BSTree<K, V>& t) { _root = Copy(t._root); } private: Node* Copy(Node* root) { if (root == nullptr) return nullptr; Node* newNode = new Node(root->_key, root->_value); newNode->_left = Copy(root->_left); newNode->_right = Copy(root->_right); return newNode; }
-
赋值运算符重载
public: BSTree<K, V>& operator=(BSTree<K, V> t) { swap(_root, t._root); return *this; }
-
析构函数
public: ~BSTree() { Destroy(_root); } private: void Destroy(Node*& root) { if (root == nullptr) return; Destroy(root->_left); Destroy(root->_right); delete root; root = nullptr; }
-
搜素操作
//非递归搜素 puclic: Node* Find(const K& key) { Node* cur = _root; while (cur) { if (cur->_key > key) cur = cur->_left; else if (cur->_key < key) cur = cur->_right; else return cur; } return nullptr; } //递归搜索 puclic: Node* FindR(const K& key) { return _FindR(_root, key); } private: Node* _FindR(Node* root, const K& key) { if (root == nullptr) return nullptr; if (root->_key > key) return _FindR(root->_left, key); else if (root->_key < key) return _FindR(root->_right, key); else return root; }
-
插入操作
//非递归插入 public: bool Insert(const K& key, const V& val) { if (_root == nullptr) { _root = new Node(key, val); return true; } Node* parent = nullptr; Node* cur = _root; while (cur) { if (cur->_key > key) { parent = cur; cur = cur->_left; } else if (cur->_key < key) { parent = cur; cur = cur->_right; } else return false; } cur = new Node(key, val); if (parent->_key > key) parent->_left = cur; else parent->_right = cur; return true; } //递归插入 public: bool InsertR(const K& key, const V& val) { return _InsertR(_root, key, val); } private: bool _InsertR(Node*& root, const K& key, const K& val) { if (root == nullptr) { root = new Node(key, val); return true; } if (root->_key < key) return _InsertR(root->_right, key, val); else if (root->_key > key) return _InsertR(root->_left, key, val); else return false; }
-
删除操作
//非递归删除 public: bool Erase(const K& key) { Node* cur = _root; Node* parent = nullptr; while (cur) { if (cur->_key > key) { parent = cur; cur = cur->_left; } else if (cur->_key < key) { parent = cur; cur = cur->_right; } else { if (cur->_left == nullptr) { if (cur == _root) _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) _root = cur->_left; else { if (parent->_left == cur) parent->_left = cur->_left; else parent->_left = cur->_right; } delete cur; } else { Node* 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; } } return false; } //递归删除 public: 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->_left, key); else if (root->_key < key) return _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* minRight = root->_right; while (minRight->_left) minRight = minRight->_left; swap(root->_key, minRight->_key); return _eraseR(root->_right, key); } delete del; return true; } }
二叉搜索树的效率
二叉搜索树的查找插入或删除的效率取决于找到目标节点的效率,
而二叉树要找到目标节点只需要遍历一条路径即可,
最坏的情况就是遍历最长的一条路径。
所以二叉搜索树的效率就取决于最长路径。
如果搜索树始终始终是满二叉树的形态,那么查找效率就来到了O(logN)
,
此时效率是最高的。
而如果搜索树来到了只有一条路径的形态,那查找效率就退化到了O(N)
。
甚至对于同一组数据按照不同顺序插入都可能有不同结果:
所以保持搜索二叉树高性能的关键就在于如何保持二叉树的左右平衡,
这个问题就交给名声在外的AVL树和红黑树解决。