前言:
在现在数据为王的时代,数据的存储量一般都是很大的,为了在大量信息中找到某些值,就需要用到查找技术,为了提高查找效率,需要对数据进行排序。排序和查找的数据处理量几乎是整个数据处理量的80%,故排序和查找的有效性直接影响到基本算法的有效性。因而查找和排序是十分重要的处理技术。
往期基于线性表的排序方法--------》八大排序C语言实现版本
而基于线性表的查找方法一般分为: 顺序查找和二分查找。
接着便是学习基于树结构的查找和排序方法。
基于树结构的查找法是将待查表组织成特定树结构的形式并在树结构上实现查找的方法。主要包括二叉排序树(也叫二叉搜索树)、平衡二叉树(AVL树)、B树等。
二叉搜索树
二叉搜索树概念
二叉搜索树又称二叉排序树,它或者是一棵空树,或者是具有以下性质的二叉树:
- 若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
- 若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
- 它的左右子树也分别为二叉搜索树
这是一个递归定义,注意只要节点之间具有可比性即可。
比如下面这棵树,就是将数组int a [] = {5,3,4,1,7,8,2,6,0,9}按二叉搜索树规则来组织的。
这样的树结构,可以让我们的应对大数据的查找效率提高了质的飞跃。
比如在原始数组里找一个数,要么顺序查找,时间复杂度是O(N),要么先排序O(NlogN),在二分查找。显然在面对大数据的情况下,效率不是很高。但是将其组织成二叉搜索树形式,构造树的时候就完成了排序,只需要二分即可O(logN),效率大大提高。
但是!!
对有n个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二叉搜索树的深度的函数。
但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树:
最优情况下,二叉搜索树为完全二叉树,则可以使用二分查找:
最差情况下,二叉搜索树退化为单支树,就又变回了顺序查找了,反而白费一场。
所以普通二叉排序树有退化成单支树的情况,二叉搜索树的性能也就失去了。那能否进行改进,不论按照什么次序插入关键码,都可以是二叉搜索树的性能最佳?这便引申出来平衡二叉搜索树(AVL),但是也要先把普通的二叉搜索树原理及实现掌握才可以触碰它!
二叉搜索树的实现
节点结构
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() = default;//此语句表示使用默认的构造,因为下面实现了拷贝构造,编译器检测到了后会提示你实现构造函数
BSTree(const BSTree<K>& bst)//拷贝构造
:_root(nullptr)
{
_root=_copy(bst._root);
}
Node* _copy(Node* root)//递归拷贝
{
if (!root)
return nullptr;
Node* newroot = new Node(root->_key);
newroot->_left = _copy(root->_left);
newroot->_right = _copy(root->_right);
return newroot;
}
~BSTree() //析构函数必须要自己写
{
_destroy(_root);
}
void _destroy(Node* root)//递归释放空间
{
if (!root)
return;
_destroy(root->_left);
_destroy(root->_right);
delete root;
}
增(插入)操作
//搜索二叉树的插入操作
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 true;
}
//最终会停在某个满足的节点下,但是要考虑插入到左边还是右边
if (parent->_key < key)
{
parent->_right = new Node(key);
}
else
parent->_left = new Node(key);
return true;
}
//递归插入写法
bool _insertR(Node*& cur, const K& key)//注意参数类型,这个引用就完成了parent的作用 ,即递归到下一层的时候就是和他的父亲节点连接的
{
if (cur == nullptr)
{
cur = new Node(key);
return true;
}
if (cur->_key < key)
{
return _insertR(cur->_right, key);
}
else if (cur->_key > key)
return _insertR(cur->_left, key);
else
return false;
}
bool InsertR(const K& key) //由于递归函数参数的限制,必须写一个内部用的子函数去递归
{
return _insertR(_root, key);
}
查找操作
由于是按照二叉搜索树的规则来建的树结构,所以可以直接使用二分去其左或右子树中去找。
//查找操作
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;
}
//递归查找
Node* _findR(Node*& cur, const K& key)
{
if (cur == nullptr)
return cur;
if (cur->_key < key)
return _findR(cur->_right, key);
else if (cur->_key>key)
return _findR(cur->_left, key);
else
return cur;
}
Node* FindR(const K& key)
{
return _findR(_root, key);
}
删除操作
对于树中的节点无非就是四种情况:
a. 要删除的结点无孩子结点
b. 要删除的结点只有左孩子结点
c. 要删除的结点只有右孩子结点
d. 要删除的结点有左、右孩子结点
在对于要删除的节点分析:要是没找到,不用删除操作;要是找到了,根据剩下三种情况具体分析。
对应三种情况的删除点举例
情况一:删除1:没有右孩子, 只有左孩子
删除该结点且使被删除节点的双亲结点指向被删除节点的左孩子结点
情况二:删除8:没有左孩子, 只有右孩子
删除该结点且使被删除节点的双亲结点指向被删除结点的右孩子结点
情况三:删除5:左右孩子都存在
昔换法删除,左树的最大节点(最右节点)或者是右树的最小节点(最左节点)用它的值填补到被删除节点中,再来处理该结点的删除问题(都是最左或者最右节点了,肯定只有一个孩子或者没有孩子,所以问题就退化到前面的情况了)。
//二叉树删除操作
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 //找到了,根据具体情况删除
{
//1.没有左节点 将此节点的右树替换上来
//2.没有右节点 将此节点的左树替换上来
//3.左右节点都存在 替换法删除 左树的最大节点(最右节点) 或者是右树的最小节点(最左节点)
//
if (cur->_left == nullptr) //1
{
if (parent == nullptr)//也要先判断是不是根节点
{
_root=cur->_right;
}
else //不是根节点了 ,就替换到正确的位置去
{
if (parent->_left == cur)
parent->_left = cur->_right;
else
parent->_right=cur->_right;
}
delete cur;
}
else if (cur->_right == nullptr)//2
{
if (parent == nullptr)
_root = cur->_left;
else
{
if (parent->_left == cur)
parent->_left = cur->_left;
else
parent->_right = cur->_left;
}
delete cur;
}
else//3 左右都在 替换法删除 左树的最大节点(最右节点) 或者是右树的最小节点(最左节点)
{
Node* minparent = cur;
Node* minNode = cur->_right; //这里是去找右树的最左节点
while (minNode->_left)
{
minparent = minNode;
minNode = minNode->_left;
}
swap(cur->_key, minNode->_key);//替换key值,再将此节点删除
//但是此被删除的这个最左节点可能还有右子节点,所以不能直接删除,问题退化到上面的只有左节点 或 右节点的情况
minparent->_left=minNode->_right;
//否则的话,不用处理,可以直接删除
delete minNode;
}
return true;
}
}//此树为空
return false;
}
//递归删除
bool _eraseR(Node*& cur, const K& key)
{
if (cur == nullptr)//递归出口 ,没找到或为空树
return false;
//还是先去找要删除的节点
if (cur->_key < key)
return _eraseR(cur->_right, key);
else if (cur->_key> key)
return _eraseR(cur->_left, key);
else //找到后,还是根据三种情况来正确删除 //递归函数的参数里是节点指针的引用,所以不用再定义一个父节点来协助链接了
{
Node* del = cur;
if (cur->_left == nullptr)
{
cur = cur->_right;
delete del;
return true;
}
else if (cur->_right == nullptr)
{
cur = cur->_left;
delete del;
return true;
}
else
{
//选择再其右子树中找最左节点
Node* minNode = cur->_right;
while (minNode->_left)
{
minNode = minNode->_left;
}
cur->_key = minNode->_key;//注意是赋值,
return _eraseR(cur->_right, minNode->_key);//将问题转换(退化)成上面只有左节点或右节点的情况
}
}
}
bool EraseR(const K& key)
{
return _eraseR(_root, key);
}
改?
由于本身为树结构,且又有排序的规则,故改操作十分不易于实现,且对于排序树本身应用角度来说,改操作意义不大。
二叉搜索树的应用
二叉搜索树便是这些应用的”地基“。
- K模型(set容器):K模型即只有key作为关键码,结构中只需要存储Key即可,关键码即为需要搜索到的值。
比如:给一个单词word,判断该单词是否拼写正确,具体方式如下:
以单词集合中的每个单词作为key,构建一棵二叉搜索树
在二叉搜索树中检索该单词是否存在,存在则拼写正确,不存在则拼写错误。 - KV模型(map容器):每一个关键码key,都有与之对应的值Value,即<Key, Value>的键值对。该种方式在现实生活中非常常见:比如英汉词典就是英文与中文的对应关系,通过英文可以快速找到与其对应的中文,英文单词与其对应的中文<word, chinese>就构成一种键值对;再比如统计单词次数,统计成功后,给定单词就可快速找到其出现的次数,单词与其出现次数就是<word, count>就构成一种键值对。
注意:二叉搜索树需要比较,键值对比较时只比较Key,查询英文单词时,只需给出英文单词,就可快速找到与其对应的value。