二叉搜索树的理解以及AVL树的模拟实现
Posted Booksort
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了二叉搜索树的理解以及AVL树的模拟实现相关的知识,希望对你有一定的参考价值。
AVL树,全称平衡搜索二叉树。
要认识AVL树,先认识二叉搜索树。
目录
二叉搜索树
先来介绍一下搜索二叉树。AVL树是在二叉搜索树的基础上建立的。
对于搜索二叉树而言,任意一个根节点的左子树上的Key值都比根节点的Key值小,右子树上的Key值都比根节点上的Key值大。
定义:二叉搜索树是一颗二叉树,可能为空。
但是对于一颗非空的二叉搜索树而言,其满足以下特征
- 每个元素都哦于一个关键字,并且任意两个元素的关键字都是不同的。也就是说二叉搜索树中,所有元素的关键字都是不同的,都是唯一的。
- 在根节点的左子树中,元素的关键字(如果存在的话)都小于根节点的关键字
- 在根节点的右子树中,元素的关键字(如果存在的话)都大于根节点的关键字
- 根节点的左右子树都是二叉树,也都满足这4个特征
会存在二叉搜索树中关键字存在一样的特殊的二叉搜索树,但是和不存在一样的关键字的二叉搜索树是大同小异的,这个后面再讨论。
对于一颗二叉树,除了对节点的值的分布有些要求,其他的和普通的二叉树没什么区别。
理解、认识二叉搜索树
根据描述的二叉搜索树的特征而言,也就是说,当我们去找一个节点时,拿目标值去与遍历到的节点的值去比较,
- 如果相等,那么找到了
- 如果目标值小于节点值,那么我们就可以去节点的左子树去查找
- 如果目标值大于节点值,那么我们就可以去节点的右子树去查找
对于二叉搜索树而言,其就是用来查找数据而设计的,用于快速查找数据。
对于一个数组而言,如果是一个有序数组,我们可以考虑使用二分法来快速查找数据,时间复杂度O(logN)。但是现实情况是,有序是数组并不是一个常见的情况,反而无序数组才是常见的情况。
而,二叉搜索树这个情况,并不是特意构建的结构,而是根据二叉搜索树的定义,去构建了这个结构,只不过顺便变成了这个类似有序的情况
对于一颗二叉搜索树进行查找某个元素,每完成一个节点的比较,就要排除掉一层节点。对于一颗N个节点的二叉树,其高度大概为 logN 。所以,对于一颗N个节点的二叉搜索树的查找,那么最多查找 logN 次。无论找到,还是没找到。
所以,二叉搜索树查找的时间复杂度就是O(logN)。
当然,也存在一些极端的情况,例如
这样的二叉树的结构,其查找的时间复杂度就会退化成O(N)。这个在AVL树中就不会出现这种极端情况,AVL树很好的解决了这个问题。
二叉搜索树的结构
这个二叉搜索树的结构和普通的二叉树并没有说明区别,
依旧是一个Key值,
一个指针指向左子树,
一个指针指向右子树。
维护二叉搜索树的节点的类
//维护节点的类
template <class T>
struct BinarySearchTreeNode
T _key;
BinarySearchTreeNode<T>* _left = nullptr;
BinarySearchTreeNode<T>* _right = nullptr;
BinarySearchTreeNode(T val = 0)
:_key(val)
,_left(nullptr)
,_right(nullptr)
~BinarySearchTreeNode()
_left = _right = nullptr;
;
定义了一个struct类,这个类默认是public,可以被类外访问的。
维护二叉搜索树的类
需要一个类来维护二叉搜素树的节点之间的关系,也就是对二叉搜索树进行封装。我们之后使用就直接使用这个类来创建一可二叉搜索树,直接使用即可,不用使用者去维护节点之间的关系。
//维护二叉树结构的类
template <class T>
class BinarySearchTree
typedef BinarySearchTreeNode<T> Node;
public:
BinarySearchTree()
:_root(nullptr)
, _size(0)
~BinarySearchTree()
_root = nullptr;
private:
Node* _root;
size_t _size = 0;
;
对于这个类的成员变量,一个指向二叉搜索树的指针即可,然后再来一个计算二叉搜索树的结点数。
其构造函数与析构函数都是对_root
节点操作即可
二叉搜索树实现查找
根据我们上面描述的,与根节点去比较,比根节点的Key值大,就去右子树找,比Key小就去左子树找,如果遍历到空节点,那就说明根本就没有目标值,就找不到。
Node* Find(const T& key_val) const
Node* p = _root;
while (p)
if (p->_key < key_val)
p = p->_right;
else if (p->_key > key_val)
p = p->_left;
else
return p;
return nullptr;
如果找到了,就返回指向那个节点的指针,找不到就返回空指针。
二叉搜索树的结构优势
二叉搜索树为什么能查找的这么快?
这个完全取决于二叉搜索树的结构,也就是二叉搜索树的插入与删除。
我们通常所说的对于一个数据结构要完成的四个最基本的操作增、删、查、改
。对于查和改,只要实现了查找,修改就也实现了。
二叉搜索树的插入
二叉树的操作基本都能靠递归和迭代来完成,但这里我们只实现迭代版本,递归大同小异。
二叉搜索树的插入也算要按照规矩来,满足二叉搜索树的特征。
对于一颗二叉搜索树去查找插入一个节点,其一就要与二叉树的节点进行比较,如果比节点的Key值大,那就往右边,如果小,就往左边。这个实际上就是一个查找到过程,当查找不到的时候,也就是移动到空指针处,就是可以插入了。但是还有一个问题,如果仅仅考查找来完成,那么只是知道有个nullptr,那么是没有用的,还需要知道其待插入到节点的根节点的位置,才能实现节点插入。
bool Insert(const T& val_key)
if (_root == nullptr)//空树
_root = new Node(val_key);
_size++;
return true;
Node* parent = nullptr;
Node* cur = _root;
while (cur)
parent = cur;
if (cur->_key < val_key)
cur = cur->_right;
else if (cur->_key > val_key)
cur = cur->_left;
else//有一样的,不用插入,插入失败
return false;
cur = new Node(val_key);
_size++;
if (parent->_key > val_key)
parent->_left = cur;
else
parent->_right = cur;
return true;
准备一个父指针来保存cur节点的父节点。同时还要防备如果这是一颗空树的情况。
还有最关键的就是对于节点是根节点的左子树还是右子树的判断,这需要一个操作来判断,最简单就是看Key值。
二叉搜索树的删除
对于删除节点操作,即插入要复杂一些,因为对于一个要删除的节点,我们存在多种情况,不处理会破坏二叉搜索树的结构,我们要保证无论怎样操作,搜索树的结构是正常的。
要删除的节点,简称目标节点
- 目标节点没有左右子树
- 目标节点只有一颗子树
- 目标节点有左右子树
如果,要删除的目标节点没有左右子树,那么通过Key值直接找那个节点,然后直释放空间,然后把其根节点的指针置空即可。
如果目标节点有一棵子树的话,那么只需将其根节点指向目标节点的指针指向其子树的根节点即可,然后delete掉目标节点
对于目标节点有左右两颗子树时,就要找一个新的符合条件的节点来代替这个位置。综合考虑搜索树节点之间的关系,有两个方案,实际上差不多。目标节点的
左子树的最右节点(最大的Key值节点)或者
右子树的最左节点(最小的Key值节点)
把某个节点移动到目标节点的位置,然后删除目标节点,同时把目标节点与其他节点之间的关系给新移上来的节点。然后把移动节点的某个子树处理一下,即可。
这里选用了右子树的最左节点
但是我们并不用想理论上描述的如此复炸,可以找到右子树的最小节点,然后和目标节点做一个Key值的交换,这样就不用去处理节点之间的关系,然后下一步,就是删除交换后的右子树的最小值节点,这个删除就是删除只有一颗子树的根节点的模式。
bool Erase(const T& val_key)
//先要找到删除节点的位置
if (_root == nullptr)
return false;
Node* parent = nullptr;
Node* cur = _root;
while (cur)
if (cur->_key > val_key)
parent = cur;
cur = cur->_left;
else if (cur->_key < val_key)
parent = cur;
cur = cur->_right;
else//找到了
break;
Node* root = cur;
if (cur == nullptr)//没找到
return false;
if (cur->_left != nullptr && cur->_right != nullptr)//情况3
//方案一:右子树的最小值,右子树的最左边
Node* p = cur->_right;
Node* pt = cur;
while (p->_left)
pt = p;
p = p->_left;
//p就是右子树的最小值,pt是其父节点
cur->_key = p->_key;//替换
//删掉右子树的最小节点
Node* tmp;
if (p->_left)
tmp = p->_left;
else
tmp = p->_right;
if (pt->_left == p)
pt->_left = tmp;
else
pt->_right = tmp;
//
delete p;
p = nullptr;
_size--;
return true;
Node* tmp;
if (cur->_left)
tmp = cur->_left;
else
tmp = cur->_right;
if (cur == _root)//防止删除根节点,且情况一二的样子
_root = tmp;
else
if (parent->_left == cur)
parent->_left = tmp;
else
parent->_right = tmp;
_size--;
delete cur;
cur = nullptr;
return true;
虽然情况一与情况二是两种情况,但是实际上都是可以通过代码一起操作的。
对于要删除的节点,可以加一个判断,如果目标节点cur->left==nullptr,那么parent可以指向cur->right,反之,依然。如果是情况一,这样会直接指向空,符合要求,二情况而,这样是为了找出子树,然后接入根节点。
而对于情况三,先找到目标节点的右子树的最左节点,然后将其与其根节点保存记录下来。将在这个节点的Key赋值给目标节点。剩下的操作就是执行情况一与情况二的操作,将保留下来的节点删除。
但是如果删除的是整个搜索树的根节点的话,那么还要将_root
指针指向更新后的节点。
这样就介绍完了关于二叉搜索树的全部操作了。同时还可以介绍一点,如果对二叉搜索树进行中序遍历,那么得到的是一个有序的数组,所以可以通过中序遍历去验证是否是一颗二叉搜索树。
对于有相同关键字节点的二叉搜索树
如果想要达到这个条件,只需要对插入进行一点修改即可,把判断存在的条件删除,让等于关键字的条件并入大于等于的判断条件中。
注意点
对于一颗二叉搜索树而言,如果插入Key的顺序不一样的话,这个树的结构也可能不一样,不然为什么会出现上面极端的情况。可以自己举例试试。
K模型与KV模型
这个也不是什么复杂的知识。
这个主要是针对二叉树的节点的值的。
Key - 关键字
Value - 值
就是加一个成员变量的是,不过也可以考虑使用**pair<K,V>**连表示这个KV成员变量。
不过对于搜索树的操作,都是使用Key进行比较,对于Value都是不使用的。
二叉搜索树的封装BinarySearchTree.h
#pragma once
/*
* 需要维护一个节点的类
* 还有一个维护节点之间关系的类
*/
//维护节点的类
template <class T>
struct BinarySearchTreeNode
T _key;
BinarySearchTreeNode<T>* _left = nullptr;
BinarySearchTreeNode<T>* _right = nullptr;
BinarySearchTreeNode(T val = 0)
:_key(val)
,_left(nullptr)
,_right(nullptr)
~BinarySearchTreeNode()
_left = _right = nullptr;
;
//维护二叉树结构的类
template <class T>
class BinarySearchTree
typedef BinarySearchTreeNode<T> Node;
public:
BinarySearchTree()
:_root(nullptr)
, _size(0)
~BinarySearchTree()
_root = nullptr;
//二叉搜索数的查找-非递归版本
//遵循左子树小于根,右子树大于根
Node* Find(const T& key_val) const
Node* p = _root;
while (p)
if (p->_key < key_val)
p = p->_right;
else if (p->_key > key_val)
p = p->_left;
else
return p;
return nullptr;
//插入节点-非递归版本
bool Insert(const T& val_key)
if (_root == nullptr)//空树
_root = new Node(val_key);
_size++;
return true;
Node* parent = nullptr;
Node* cur = _root;
while (cur)
parent = cur;
if (cur->_key < val_key)
cur = cur->_right;
else //if (cur->_key > val_key)
cur = cur->_left;
//else//有一样的,不用插入,插入失败
//return false;
cur = new Node(val_key);
_size++;
if (parent->_key > val_key)
parent->_left = cur;
else
parent->_right = cur;
return true;
//删除节点-非递归版本
/*节点有三种情况
* 1,叶子节点:左右都是空
* 2,左右只有一边有一个子树
* 3,左右子树都有
*
* 实际上删除时,
* 1和2都是一样的操作,将其原本指向其的指针指向本节点的下一个(左或者右)
*
* 3需要找到其左子树的最大节点(左子树的最右节点)或 右子树的最小节点(右子树的最左边节点)
*/
/*
* 对于情况3,最重要的是删除替换节点,处理好替换节点的父节点与替换节点的关系
*/
bool Erase(const T& val_key)
//先要找到删除节点的位置
if (_root == nullptr)
return false;
Node* parent = nullptr;
Node* cur = _root;
while (cur)
if (cur->_key > val_key)
parent = cur;
cur = cur->_left;
else if (cur->_key < val_key)
parent = cur;
cur = cur->_right;
else//找到了
break;
Node* root = cur;
if (cur == nullptr)//没找到
return false;
if (cur->_left != nullptr && cur->_right != nullptr)//情况3
//方案一:右子树的最小值,右子树的最左边
Node* p = cur->_right;
Node* pt = cur;
while (p->_left)
pt = p;
p = p->_left;
//p就是右子树的最小值,pt是其父节点
cur->_key = p->_key;//替换
//删掉右子树的最小节点
Node* tmp;
if (p->_left)
tmp = p->_left;
else
tmp = p->_right;
if (pt->_left == p)
pt->_left = tmp;
else
pt->_right = tmp;
//
delete p;
p = nullptr;
_size--;
return true;
Node* tmp;
if (cur->_left)
tmp = cur->_left;
else
tmp = cur->_right;
if (cur == _root)//防止删除根节点,且情况一二的样子
_root = tmp;
else
if (parent->_left == cur)
parent->_left = tmp;
else
parent->_right = tmp;
_size--;
delete cur;
cur = nullptr;
return true;
size_t size(void)
return _size;
void _InOderR(Node* root)
if (root == nullptr)
return;
_InOderR(root->_left);
cout << root->_key << " ";
_InOderR(root->_right);
void InOderR()
_InOderR(_root);
private:
Node* _root;
size_t _size = 0;
;
AVL树
前面已经介绍了,AVL树是建立在二叉搜索树的基础上,全称叫平衡搜索二叉树。
二叉搜索树的结构思想是一个非常优秀的结构。可以用于快速查找。但是对于某些情况,光靠一个二叉搜索树是完全不够用的,就比如上面举的一个极端的情况。
这两种虽然也是搜索二叉树,但这种结构并不适合快速搜索。
所以,平衡搜索二叉树提供了一个解决方案来处理这个不能支持快速搜索的问题。也就是说AVL树提供了解决这种极端结构的方法。
AVL树建立的逻辑
如果搜索树的高度总时满足O(logN),我们就能保证查找、插入和删除的时间为O(logN)。则最坏的情况下高度依旧是O(logN)的树称为平衡树。
定义:一颗空的二叉树是AVL树
如果有一颗非空的二叉树,那么L与R分别是其左子树与右子树,当改非空二叉树满足以下条件时,是一颗AVL树
- L与R都是AVL树
- | HR-HL |小于等于1,左右子树的高度差的绝对值小于等于1
AVL树具有以下特征
- 一颗n元AVL树,其高度是O(logN)
- 对于AVL树而言,任意结点数都满足AVL树的条件
- 对于一颗n元的AVL树,其增删查改的时间复杂度都是O(高度)=O(logN)
AVL树增加了一个平衡因子的概念.
平衡因子用于计算右子树高度与左子树的高度差,保存在根节点。
对于一个正常的AVL树而言,其平衡因子的取值为-1、0、1
。
如果平衡因子不满足这些取值,那么就意味着,二叉树已经不是AVL树了,则就需要对其进行修正。而AVL树的修正被称为 “旋转”。
AVL树的查找就很搜索二叉树的查找是一样的,我们只需要研究AVL树的插入和删除即可。
AVL树有一个指向根节点的指针
虽然这个指向根节点的指针可以用其他方法来替代,这个是为了更方便的寻找路径,从当前节点到根节点的路径,也可以考虑使用栈来代替,不过我觉得不好用。这个后面会介绍作用。
维护AVL节点的类
template <class K,class V>
struct AVLTreeNode
typedef AVLTreeNode<K, V> Node;
AVLTreeNode<K, V>* _left;//指向左子树
AVLTreeNode<K, V>* _right;//指向右子树
AVLTreeNode<K, V>* _parent;//指向父节点
pair<K, V> _val;//节点终端索引值K以及value值V
int _bf;//平衡因子
AVLTreeNode(pair<K,V>& val)//构造
:_val(val)
,_bf(0)
,_left(nullptr)
,_right(nullptr)
,_parent(nullptr)
AVLTreeNode(const AVLTreeNode& node)//拷贝构造
_val = node._val;
_bf = node._bf;
_left = node._left;
_right = node._right;
_parent = node._parent;
~AVLTreeNode()
_left = _parent = _right = nullptr;
;
AVL树的插入
右单旋
沿着节点去修改节点平衡因子的值,如果某个节点的平衡因子>=|2|,那么就说明,以此节点作为根节点的搜索树不符合AVL树的结构了,就需要以此为节点进行旋转,我们称为右单旋。
讲解一下实现原理。
我们通过搜索树的结构完成对AVL树的插入,然后由于新的节点的插入,所以这个AVL树的高度结构可能会被破坏,所以我们要去检查。
从插入到节点处开始,这个节点的平衡因子是0,因为是新插入的节点,左右子树为空。然后利用指向父节点的指针,去遍历父节点,如果新插入的节点是根节点的左子树,那么就说明根节点的左子树高度要加1,然后其平衡因子_bf=H_ r-H_ l-1,则平衡因子的值减1,
- 如果现在的平衡因子的数是0,说明其左右子树高度差为0,也就没必要继续向上遍历了,因为从其根节点看这棵子树的高度没有变换。
- 如果现在的平衡因子是-1/1的话,那说明这棵子树的高度发生了变化,则从当前节点的根节点来看这个子树的高度发生了变化,则要继续向上遍历。
- 如果现在的平衡因子是-2/2的话,则说明以当前节点作为根节点的搜索树不在符合AVL树的结构了,则此说明需要对此树的结构进行修正。
则修正就有“旋转”:分为左单旋、右单旋、左右双旋以及右左双旋
实际上双选是由两
以上是关于二叉搜索树的理解以及AVL树的模拟实现的主要内容,如果未能解决你的问题,请参考以下文章