前言
前面写过两篇关于二叉搜索树的博文,但是它不具有平衡性,最差情况时,会退化成链表,查找的效率会降至O(n)。为了避免这样的情况发生,有人就在原始二叉搜索树的基础上,设置了相应条件,使其变为平衡的二叉搜索树,保留它高效率搜索的特性。关于AVL树的文章很多,很多都是数据结构和算法书上的东西。在我刚开始学习AVL树时,我也是按部就班的根据书上的算法进行学习,照着写很流畅,可是过两天就又忘了,出现这种情况,只能怪我对它了解的太少,没有学透它。为了真正掌握它,我让自己彻底忘记书上关于AVL树的算法,只通过它的性质,从头推导它,亲身体验在实现过程中遇到的问题,这些问题应该怎样解决,最终得到属于自己的算法,因此这篇博文侧重点在AVL树算法的推理和实现。
AVL树的基本性质
AVL树的性质非常简单,就一句话:在插入和删除过程中,AVL树中任何节点的两个子树高度的最大差值为1。具体的细节可以参照维基百科:AVL tree。
AVL树节点设计
AVL树需要保证任何节点的两个子树高度的最大差值为1,相比于二叉搜索树的树节点,AVL树节点需要在它的基础增加一个表示节点高度的字段。因为添加和删除都发生在树的末端,因此树的高度应该从下向上递增,根节点表示的节点高度最大。我们把空节点的高度记作0,叶节点的高度记作1,因此每一个插入的新节点,其高度都为1,然后其父节点在子节点的基础上(取左右子树的最大高度)加一即可。树节点类的具体代码如下:
class Node<E extends Comparable<E>>{
public E value;
public Node<E> parent;
public Node<E> left;
public Node<E> right;
public int height;
public Node(E value, Node<E> parent){
this.value = value;
this.parent = parent;
this.height = 1;
}
public String toString(){
return value + "(" + height + ")";
}
}
获取节点高度的函数如下:
private int height(Node<E> node){
return node == null ? 0 : node.height;
}
插入时会出现什么问题?
AVL树的插入还是按照以前的二叉搜索树的插入方法实现,但是在插入过程中,会出现节点的两个子树高度的最大差值大于1的情况。如果AVL树规则被破坏,则两子树的高度差为2,具体的情况如下所示。
3节点的左子树高度为2,右子树高度为0,两子树高度差为2。因为2节点可以为3节点的左右孩子节点,2节点就有两种位置情况,1节点可以为2节点的左右孩子节点,1节点也有两种位置情况,所有这种插入异常一共就有四种情况,则AVL树在插入时候,有四种破坏平衡的情况需要解决。
2为3的左节点,1为2的左节点
这种情况的具体图示如下所示:
左边的情况,3节点的左右子树高度差为2,要想平衡左右子树,那必须让3的右子树的高度加1,可是从哪里借一个节点呢?从左子树借,因为左子树的值都小于3,不能放在3的右子树上,因此只能把父节点3借来,使AVL树达到平衡。
对于左边的形式,我们只需要将3设置为2的右节点即可,因为3是向右移动,所以这种解决方式一般叫右旋。上面的图像是这种形式的最简单表示,因为简单,所有很多东西都没考虑,第一、3节点是否有父节点,如果存在,则该父节点应该和2节点联系起来;第二、2节点是否存在右节点,如果存在,则2的右节点应该和3节点联系起来。
下面我们就将所有情况都加上去,其图示如下所示:
上面的图可能会有一些不严谨,比如7节点的高度不一定会变化等,但是我们的重点是如何理解那个右旋,对于左图,我们根据括号里面的高度,能够很快知道,节点5不平衡。按照我们前面说的,将5右移到3的右节点,把3的右节点移到5的左节点即可。总结起来就是:
1、5的左节点为3的右节点,如果4节点存在,则4的父节点为5节点。
2、3的右节点为5节点,5的父节点为3节点,3的父节点为7。
3、如果5的父节点7不存在,则根节点为3节点,若存在,则7节点的左节点为3节点。
4、将5节点的高度设置为左右子树最大高度加1,3节点高度也设置为其左右子树最大高度加1。
将上面的总结转换为代码如下:
private void rotateRight(Node<E> node){
assert node.left != null;
Node<E> left = node.left;
node.left = left.right;
if(left.right != null){
left.right.parent = node;
}
Node<E> parent = node.parent;
node.parent = left;
left.right = node;
left.parent = parent;
if(parent == null){
root = left;
}else if(node == parent.left){
parent.left = left;
}else{
parent.right = left;
}
node.height = Math.max(height(node.left), height(node.right)) + 1;
left.height = Math.max(height(left.left), height(left.right)) + 1;
}
2为1的右节点,3为2的右节点
这种情况的具体图示如下所示:
这种情况和前面的情况非常类似,只是换成了右节点罢了。我们仍然借的是父节点1来填充节点2的左节点,来实现树的平衡。对于左边的形式,我们只需要将1设置为2的左节点即可,因为1是向左移动,所以这种解决方式一般叫左旋。上面的图像是这种形式的最简单表示,因为简单,所有很多东西都没考虑,第一、1节点是否有父节点,如果存在,则该父节点应该和2节点联系起来;第二、2节点是否存在左节点,如果存在,则2的左节点应该和3节点联系起来。
下面我们就将所有情况都加上去,其图示如下所示:
上面的图可能会有一些不严谨,比如7节点的高度不一定会变化等,但是我们的重点是如何理解那个左旋,对于左图,我们根据括号里面的高度,能够很快知道,节点4不平衡。按照我们前面说的,将2右移到4的右节点,把4的右节点移到2的左节点即可。总结起来就是:
1、2的右节点为4的左节点,如果3节点存在,则3的父节点为2节点。
2、4的左节点为2节点,2的父节点为4节点,4的父节点为7。
3、如果2的父节点7不存在,则根节点为4节点,若存在,则7节点的左节点为4节点。
4、将2节点的高度设置为左右子树最大高度加1,5节点高度也设置为其左右子树最大高度加1。
将上面的总结转换为代码如下:
private void rotateLeft(Node<E> node){
assert node.right != null;
Node<E> right = node.right;
node.right = right.left;
if(right.left != null){
right.left.parent = node;
}
Node<E> parent = node.parent;
node.parent = right;
right.left = node;
right.parent = parent;
if(parent == null){
root = right;
}else if(node == parent.left){
parent.left = right;
}else{
parent.right = right;
}
node.height = Math.max(height(node.left), height(node.right)) + 1;
right.height = Math.max(height(right.left), height(right.right)) + 1;
}
1为3的左节点,2为1的右节点
这种情况的具体图示如下所示:
对于最左边的图,如果想使其达到平衡,那么我们需要使1成为2的左子节点,3成为2的右子节点,因此它的平衡需要分两步来实现,首先把节点1转换节点2的左节点,就如同中间图所示,如何实现呢?1向左运动变成2的左节点,这种操作不就是前面的左旋吗,因此我们对节点1进行左旋,即可。如何将节点3转换节点2的右节点呢?对于中间的图,将3向右运动变成2的右节点,这种操作不就是前面的右旋吗,因此我们对节点3进行右旋,即可。因为前面的左、右旋都把所有情况考虑了,这种情况的总结就是:
1、首先对节点1进行左旋。
2、最后对节点3进行右旋。
将上面的总结转换为代码如下:
public void rotateLeftRight(Node<E> node){
rotateLeft(node.left);
rotateRight(node);
}
3为1的右节点,2为3的左节点
这种情况的具体图示如下所示:
这种情况和上面几乎类似,其原理也差不多,就不多赘述。它的的总结就是:首先对节点3进行右旋,最后对节点1进行左旋。具体代码如下:
private void rotateRightLeft(Node<E> node){
rotateRight(node.right);
rotateLeft(node);
}
AVL树的插入
前面我们介绍了,在插入过程中,可能出现的一些问题,并通过相应的节点旋转来实现平衡。接下来我们具体说说,插入一个新的节点,如何平衡整个AVL树。
我们在平衡过程中,有两个问题需要解决,第一个是插入后会影响到父节点的高度,父节点的高度需要重新计算。第二插入后造成AVL树不平衡,需要节点的左右旋转保持树的平衡。
对于第一个问题,当我们通过二叉搜索树的插入方法,插入一个高度为1的新节点时,它的父节点的高度并没有因为它的到来,而直接加1,而是通过它的左右节点的最大高度加1来决定。如果通过计算,左右节点的最大高度加1的值和父节点的高度值相同,这说明此次插入并没有破坏AVL树的平衡,直接退出,如果不相等,则父节点的高度等于该值。
总结如下:1、如果父节点的左右子树高度的最大值加1的值h等于父节点的高度,AVL树平衡,直接退出程序。
2、如果父节点的左右子树高度的最大值加1的值h大于父节点的高度,父节点高度等于该值h,继续执行。
对于第二个问题,当父节点的左右子节点的高度差等于2,这说明此次插入破坏了AVL树的平衡。首先,我们应该判断是左子树的高度大,还是右子树的高度大。
如果是左子树高度比右子树的高度大,则说明被插入的节点在左子树,此时我们再判断被插入的节点在左子树的哪边,可以通过两种方式判断。一种是,利用新节点的值v1和左节点的值v2比较,如果v1>v2,则新节点被插入在左子树的右边,如果v1<v2,则新节点被插入在左子树的左边;另一种是比较左子树的左右子树高度,如果左边高,则新节点被插入在左子树的左边,如果右边高,则新节点被插入在左子树的右边。如果新节点被插入在左子树的左边,则利用右旋来平衡AVL树。如果新节点被插入在左子树的右边,则利用右左旋来平衡AVL树。
如果是右子树高度比左子树的高度大,其处理方式和上面一样,只是旋转方向刚好相反,具体就不赘述了。
这里我们需要考虑一件事情,经过旋转平衡后的AVL树,是否还需要继续平衡?当插入一个新的节点时,父节点的高度可能加1,也可能不变,如果不变,就直接退出了,如果变化,就可能造成AVL树的不平衡,出现不平衡,我们采用的是节点的左右旋转来维稳。如果我们仔细观察这四种旋转方式,会发现旋转完成后,旋转部分的子树的高度减1。插入节点造成的加1和旋转维稳的减一刚好抵消,使得被插入子树的高度保持不变,又因为AVL树左右子树都符合AVL树的规则,因此,在插入过程中,AVL树最多只需要旋转两次(左旋和右旋分别记作一次)即可实现平衡。
总结如下:1、判断左子树高度和右子树高度差是否等于2,等于则需要进行平衡,不等于,不处理。
2、如果树不平衡,判断左右子树高度大小,再使用compareTo方法或者高度判断方法决定旋转方式。
3、平衡处理后,直接退出程序,因为AVL一次维稳操作即可保证整个AVL树的平衡。
具体代码如下:
public boolean add(E e){
if(e == null){
return false;
}
Node<E> node = root;
if(node == null){
root = new Node<>(e, null);
size++;
return true;
}
Node<E> parent = null;
int cmp = 0;
do{
parent = node;
cmp = e.compareTo(node.value);
if(cmp > 0){
node = node.right;
}else if(cmp < 0){
node = node.left;
}else{
return false;
}
}while(node != null);
Node<E> newNode = new Node<>(e, parent);
if(cmp > 0){
parent.right = newNode;
}else{
parent.left = newNode;
}
fixAfterInsertion(parent);
size++;
return true;
}
private void fixAfterInsertion(Node<E> parent){
do{
int leftHeight = height(parent.left);
int rightHeight = height(parent.right);
if(Math.abs(leftHeight - rightHeight) == 2){
if(leftHeight > rightHeight){ // Add to the left subtree
Node<E> left = parent.left;
if(height(left.left) > height(left.right)){
rotateRight(parent);
}else{
rotateLeftRight(parent);
}
}else{ // Add to the right subtree
Node<E> right = parent.right;
if(height(right.right) > height(right.left)){
rotateLeft(parent);
}else{
rotateRightLeft(parent);
}
}
break;
}
int newHeight = Math.max(leftHeight, rightHeight) + 1;
if(parent.height == newHeight){
break;
}else{
parent.height = newHeight;
}
parent = parent.parent;
}while(parent != null);
}
其中的关于二叉搜索树插入部分的函数可以参考我的这篇博文:二叉搜索树的插入。
AVL树的判断
通过广度优先搜索遍历AVL节点,接着判断节点的左右高度差,若高度差大于等于2则不为AVL树。具体代码如下:
public boolean isValid(){
Node<E> node = root;
if(node == null){
return true;
}
ArrayDeque<Node<E>> nodeDeque = new ArrayDeque<>();
nodeDeque.addFirst(node);
while(!nodeDeque.isEmpty()){
node = nodeDeque.removeLast();
if(Math.abs(height(node.left) - height(node.right)) >= 2){
return false;
}
if(node.left != null){
nodeDeque.addFirst(node.left);
}
if(node.right != null){
nodeDeque.addFirst(node.right);
}
}
return true;
}
AVL树的删除
AVL树的删除部分和二叉搜索树的删除方法一模一样,但是删除节点后,可能使其子树节点的高度减一,进而造成左右子树节点的高度差为2,从而破坏AVL树的稳定性。和前面插入一样,我们仍然需要考虑删除节点后的两个问题,一个是父节点的高度如何改变,二是AVL树稳定性被破坏后如何维稳。
对于第一个问题,当我们通过二叉搜索树的删除方法,删除一个高度为1的新节点时,它的父节点的高度并没有因为它的删除,而直接减1,而是由它的左右节点的最大高度加1来决定。如果通过计算,左右节点的最大高度加1的值和父节点的高度值相同,这说明此次删除并没有破坏AVL树的平衡,直接退出,如果不相等,则父节点的高度等于该值。
总结如下:1、如果父节点的左右子树高度的最大值加1的值h等于父节点的高度,AVL树平衡,直接退出程序。
2、如果父节点的左右子树高度的最大值加1的值h大于父节点的高度,父节点高度等于该值h。
对于第二个问题,当父节点的左右子节点的高度差等于2,这说明此次插入破坏了AVL树的平衡。首先,我们应该判断是左子树的高度大,还是右子树的高度大。
如果是左子树高度比右子树的高度大,则说明被删除的节点在右子树,说明左子树需要维稳操作,接着我们再判断左子树的哪个子树高度过高。因为被删除的节点在右子树,所以这里不能像插入一般可以通过两种方式判断,只能比较左子树的左右子树高度来进行判断。如果左边高,则左边子树高度过高,需要左旋来降低高度,如果右边高,则说明右边子树高度过高,需要左右旋来降低高度,如果两个子树高度一样怎么办呢?
在前面插入时,失稳的左右子树高度不可能相同,所以,我们没有提到这个问题。但是在删除时,却可能出现这个问题。这里我们先假设使用左旋来实现维稳,具体图例如下:
由上可知,左旋能够实现这种情况的维稳。
接下来是左右旋操作的图例,如下所示:
由上可知,左右旋虽然将4节点维稳了,但是3节点确失稳了。为什么会这样呢?在前面分析左右旋时,我们知道,假设对节点6进行左右旋,就是把4提到6的位置,然后自断左右子树分给父节点(3)和祖父节点(6),但是却可能自己并没有左或者右节点,这样就会让原先的父节点或者祖父节点的某个子树高度为0,使得旋转后的祖父或者父节点的左右子树的高度差等于2。因为4节点没有左节点,所以一定会在旋转后,父节点3没有右节点,出现平衡破坏的情况。至于没有右节点的情况,就在下面进行介绍。为什么单纯的左旋没有造成这样的情况呢,只是因为左旋是将3换到6的位置,然后将右子树给6,因为3节点的左右节点都存在,所以绝对不会让6节点旋转后缺失左节点,不会出现平衡破坏的情况,故左边子树高度和右边子树高度相同时,使用左旋操作来实现维稳。
如果是右子树高度比左子树的高度大,则说明被删除的节点在左子树,说明右子树需要维稳操作,具体的操作和前面的情况相同,只是说明一下,右子树的左边子树高度和右边子树高度相同时,应该怎么处理。
首先采用右旋处理,具体图示如下所示:
由上可知,右旋能够实现这种情况的维稳。
接下来是右左旋操作的图例,如下所示:
这种情况就是前面说的,没有右节点的情况。使用右左旋,会使原先的父节点5缺少左节点,出现失稳现象。它的详细解释,可以参考前面的介绍,原理类似。
删除是不是和插入一样,只需要一次维稳,整棵AVL树就都平衡了呢?其实不是,因为删除一个节点,父节点的高度可能减1,而维稳操作,也可能使高度减一,因此,一次维稳可能并不能使AVL树完全平衡,需要继续判断,可能最终追溯到根节点。
总结如下:1、判断左子树高度和右子树高度差是否等于2,需要进行平衡,不等于,不处理,跳出操作。
2、如果树不平衡,判断左右子树高度大小,判断失衡的位置,选择合适的旋转方式。
3、平衡处理后,将下次操作节点指向祖父节点,再次判断平衡性。
具体代码如下:
public boolean remove(Object e){
Node<E> replaced = getNode(e);
if(replaced == null){
return false;
}else{
--size;
}
Node<E> removed = replaced;
if(replaced.left != null && replaced.right != null){
if(height(replaced.left) > height(replaced.right)){
removed = predecessor(replaced);
}else{
removed = successor(replaced);
}
}
Node<E> moved = null;
if(removed.left != null){
moved = removed.left;
}else{
moved = removed.right;
}
Node<E> parent = removed.parent;
if(moved != null){
moved.parent = parent;
}
if(parent == null){
root = moved;
return true;
}else if(removed == parent.left){
parent.left = moved;
}else{
parent.right = moved;
}
if(replaced != removed){
replaced.value = removed.value;
}
fixAfterDeletion(parent);
return true;
}
private void fixAfterDeletion(Node<E> parent){
do {
int leftHeight = height(parent.left);
int rightHeight = height(parent.right);
if(Math.abs(leftHeight - rightHeight) == 2){
if(leftHeight > rightHeight){
Node<E> left = parent.left;
if(height(left.right) > height(left.left)){
rotateLeftRight(parent);
}else {
rotateRight(parent);
}
}else {
Node<E> right = parent.right;
if(height(right.left) > height(right.right)){
rotateRightLeft(parent);
}else {
rotateLeft(parent);
}
}
parent = parent.parent.parent; // Grandparent node
continue;
}
int nextHeight = Math.max(leftHeight, rightHeight) + 1;
if(parent.height == nextHeight){
break;
}else {
parent.height = nextHeight;
}
parent = parent.parent;
} while (parent != null);
}
其中的关于二叉搜索树删除部分的函数可以参考我的这篇博文:二叉搜索树的删除。
后记
AVL树不管删除还是插入,其核心思想都是为了满足左右子树的高度差小于2的条件,我们的维稳处理也必须从这方面入手。其实可以通过平衡因子(-1、0、1)来处理平衡,但是我觉得高度更直观些,所以就使用高度属性来作为维稳基础。虽然算法书上很多介绍AVL树的代码,关于它的博文也纷繁复杂,但是还是希望自己能够真正掌握AVL树的核心,能通过自己的分析写出相应算法,不然你学的永远是别人咀嚼剩下的东西。