• 数据结构与算法(九):AVL树详细讲解



    数据结构与算法(一):基础简介

    数据结构与算法(二):基于数组的实现ArrayList源码彻底分析

    数据结构与算法(三):基于链表的实现LinkedList源码彻底分析

    数据结构与算法(四):基于哈希表实现HashMap核心源码彻底分析

    数据结构与算法(五):LinkedHashMap核心源码彻底分析

    数据结构与算法(六):树与二叉树

    数据结构与算法(七):赫夫曼树

    数据结构与算法(八):二叉排序树

    本文目录

    一、二叉排序树性能问题

    在上一篇中我们提到过二叉排序树构造可能出现的性能问题,比如我们将数据:2,4,6,8构造一颗二叉排序树,构造出来如下: 

    这肯定不是我们所希望构造出来的,因为这样一棵树查找的时候效率是及其低下的,说白了就相当于数组一样挨个遍历比较。

    那我们该怎么解决这个问题呢?这时候就需要我们学习一下二叉平衡树的概念了,本系列设计的二叉平衡树主要包含AVL树以及红黑树,本篇主要讲解AVL树。

    下面我们了解一下AVL树。

    二、AVL树定义以及相关概念

    AVL树定义

    一棵AVL树是其每个结点的平衡因子绝对值最多相差1的二叉查找树。

    平衡因子?这是什么鸟,别急,继续向下看。

    平衡因子

    平衡因子就是二叉排序树中每个结点的左子树和右子树的高度差。

    这里需要注意有的博客或者书籍会将平衡因子定义为右子树与左子树的高度差,本篇我们定义为左子树与右子树的高度差,不要搞混。

    比如下图中: 
    根结点45的平衡因子为-1 (左子树高度2,右子树高度3) 
    50结点的平衡因子为-2 (左子树高度0,右子树高度2) 
    40结点的平衡因子为0 (左子树高度1,右子树高度1)

    根据定义这颗二叉排序树中有结点的平衡因子超过1,所以不是一颗AVL树。

    所以AVL树可以表述如下:一棵AVL树是其每个结点的左右子树高度差绝对值最多相差1的二叉查找树。

    最小不平衡二叉排序树

    最小不平衡二叉树定义为:距离插入结点最近,且平衡因子绝对值大于2的结点为根结点的子树,称为最小不平衡二叉排序树。

    比如下图:

    在插入80结点之前是一颗标准的AVL树,在插入80结点之后就不是了,我们查找一下最小不平衡二叉排序树,从距离80结点最近的结点开始,67结点平衡因子为-1,50结点平衡因子为-2,到这里就找到了,所以以50为根结点的子树就是最小不平衡二叉排序树。

    明白了以上概念后我们就需要再了解一下左旋与右旋的概念了,这里左旋右旋对于刚接触的同学来说有点难度,但是对于理解AVL树,红黑树是必须掌握的概念,十分重要,不要怕,跟着我的思路我就不信讲不明白。

    三、左旋与右旋的概念

    左旋与右旋就是为了解决不平衡问题而产生的,我们构建一颗AVL树的过程会出现结点平衡因子绝对值大于1的情况,这时就可以通过左旋或者右旋操作来达到平衡的目的。

    接下来我们了解一下左旋右旋的具体操作。

    左旋操作

    上图就是一个标准的X结点的左旋流程。 
    在第一步图示仅仅将X结点进行左旋,成为Y结点的一个子节点。

    但是此时出现一个问题,就是Y结点有了三个子节点,这连最基础的二叉树都不是了,所以需要进行第二部操作。

    在第二部操作的时候,我们将B结点设置为X结点的右孩子,这里可以仔细想一下,B结点一开始为X结点的右子树Y的左孩子,那么其肯定比X结点大,比Y结点小,所以这里设置为X结点的右孩子是没有问题的。

    上图中Y结点有左子树B,如果没有左子树B,那么第二部也就不需要操作了,这里很容易理解,都没有还操作什么鬼。

    到这里一个标准的左旋流程就完成了。

    左旋操作具体应用

    在构建AVL树的过程中我们到底怎么使用左旋操作呢?这里我们先举一个例子,如下图: 

    在上图中我们插入结点5的时候就出现不平衡了,3结点的平衡因子为-2,这时候我们可以将结点3进行左旋,如右图,这样就重新达到平衡状态了。

    左旋操作代码实现
     1    /**
     2     * 左旋操作
     3     * @param t
     4     */
     5    private void left_rotate(AVL<E>.Node<E> t) {
     6        if (t != null) {
     7            Node tr = t.right;
     8            //将t结点的右孩子的左结点设置为t结点的右孩子
     9            t.right = tr.left;
    10            if (tr.left != null) {
    11                //重置其父节点
    12                tr.left.parent = t;
    13            }
    14            //t结点旋转下来,其右孩子相当于替换t结点的位置
    15            //所以这里同样需要调整其右孩子的父节点为t结点的父节点
    16            tr.parent = t.parent;
    17            //整棵树只有根结点没有父节点,这里检测我们旋转的是否为根结点
    18            //如果是则需要重置root结点
    19            if (t.parent == null) {
    20                root = tr;
    21            } else {
    22                //如果t结点位于其父节点的左子树,则旋转上去的右结点则
    23                //位于父节点的左子树,反之一样
    24                if (t.parent.left == t) {
    25                    t.parent.left = tr;
    26                } else if (t.parent.right == t) {
    27                    t.parent.right = tr;
    28                }
    29            }
    30            //将t结点设置为其右子树的左结点
    31            tr.left = t;
    32            //重置t结点的父节点
    33            t.parent = tr;
    34        }
    35    }

    代码基本上都加上了备注,对比左旋流程仔细分析一下,这里需要注意一下,旋转完后结点的父节点都需要重置。

    好了,对于左旋操作,相信你已经有一定了解了,如果还有不明白的地方可以自己仔细想一下,实在想不明白可以关注我公众号联系本人单独交流。

    接下来我们看看右旋是怎么回事。

    右旋操作

    上图就是对Y结点进行右旋操作的流程,有了左旋操作的基础这里应该很好理解了。

    第一步同样仅仅将Y结点右旋,成为X的一个结点,同样这里会出现问题X有了三个结点。

    第二步,如果一开始Y左子树存在右结点,上图中也就是B结点,则将其设置为Y的右孩子。

    到这里一个标准的右旋流程就完成了。

    右旋操作具体应用

    我们看一个右旋的例子,如图:

    在我们插入结点1的时候就会出现不平衡现象,结点5的平衡因子变为2,这里我们将结点5进行右旋,变为右图就又变为一颗AVL树了。

    右旋操作代码实现
     1    /**
     2     * 右旋操作
     3     * @param t
     4     */
     5    private void right_rotate(AVL<E>.Node<E> t) {
     6        if (t != null) {
     7            Node<E> tl = t.left;
     8            t.left =tl.right;
     9            if (tl.right != null) {
    10                tl.right.parent = t;
    11            }
    12
    13            tl.parent = t.parent;
    14            if (t.parent == null) {
    15                root = tl;
    16            } else {
    17                if (t.parent.left == t) {
    18                    t.parent.left = tl;
    19                } else if (t.parent.right == t) {
    20                    t.parent.right = tl;
    21                }
    22            }
    23            tl.right = t;
    24            t.parent = tl;
    25        }
    26    }

    对于右旋操作代码实现,没有加任何注释,希望你自己沉下心来逐行分析一下,有了左旋代码基础,这里并不难。

    好了,以上就是左旋与右旋的操作,这部分一定要搞明白,AVL树与红黑树的构建过程出现不平衡情况主要通过左旋与右旋来使其重新达到平衡状态。

    四、分治思想,左平衡操作与右平衡操作

    上面我们了解了左旋与右旋的概念,也通过具体案例明白到底怎么通过左旋或者右旋来使二叉排序树重新达到AVL树的要求,但是这里要明白有些情况并不是仅仅靠一次左旋或者右旋就能实现平衡的目的,这是就需要左旋右旋一起使用来使其达到平衡的目的。

    那么到底怎么区分是使用左旋或者右旋或者左旋右旋一起使用才能使树重新达到平衡呢?

    这里我们就需要仔细分情况来处理了,我们在构建AVL树插入某一个元素候如果出现不平衡现象肯定是左子树或者右子树出现了不平衡现象,这里有点绕,不过也很好理解,某一结点平衡因子绝对值超过1了,肯定是左子树过高或者右子树过高产生的,这里,我们采用分治的思想来解决,分治思想是算法思想的一种,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。

    这里我们怎么使用分治的思想呢?首先出现不平衡只有两种可能,某一结左子树或者右子树过高导致的,我们可以先考虑左子树过高该怎么处理,然后考虑右子树过高怎么处理,当然这里只是粗略的分为两大解决问题的方向,往下还会继续分析不同情况,接下来我们将会仔细分析。

    左平衡操作

    左平衡操作,即结点t的不平衡是因为左子树过深造成的,这时我们需要对t左子树分情况进行解决。

    左平衡操作情况分类

    1、如果新的结点插入后t的左孩子的平衡因子为1,也就是插入到t左孩子的左侧,则直接对结点t进行右旋操作即可

    2、如果新的结点插入后t的左孩子的平衡因子为-1,也就是插入到t左孩子的右侧,则需要进行分情况讨论

    • 情况a:当t的左孩子的右子树根节点的平衡因子为-1,这时需要进行两步操作,先以tl进行左旋,在以t进行右旋。

    经过上述过程,最终又达到了平衡状态。

    • 情况b:当p的左孩子的右子树根节点的平衡因子为1,这时需要进行两步操作,先以tl进行左旋,在以t进行右旋。

    • 情况c:当p的左孩子的右子树根节点的平衡因子为0,这时需要进行两步操作,先以tl进行左旋,在以t进行右旋。

    到这里细心的同学肯定有一个疑问,情况a,b,c不都是先以tl左旋,再以t右旋吗?为什么还要拆分出来?

    首先观察a,b,c三种情况,旋转之前是叶子结点的,在两次旋转之后依然是叶子结点,也就是说其平衡因子旋转前后无变化,均是0。

    但是再观察一下t,tl,tlr这三个节点旋转前后的平衡因子,不同情况下前后是不一样的,所以这里需要区分一下,具体旋转后t,tl,tlr的平衡因子如下:

    情况a: 
    t.balance = 0; 
    tlr.balance = 0; 
    tl.balance = 1;

    情况b: 
    t.balance = -1; 
    tl.balance =0; 
    tlr.balance = 0;

    情况c: 
    t.balance = 0; 
    tl.balance = 0; 
    tlr.balance = 0;

    以上就是左平衡操作的所有情况,接下来看下左平衡具体代码:

     1    /**
     2     * 左平衡操作
     3     * @param t
     4     */
     5    private void leftBalance(AVL<E>.Node<E> t) {
     6        Node<E> tl = t.left;
     7        switch (tl.balance) {
     8            case LH: 
     9                right_rotate(t);
    10                tl.balance = EH;
    11                t.balance = EH;
    12                break;
    13            case RH:
    14                Node<E> tlr = tl.right;
    15                switch (tlr.balance) {
    16                    case RH:
    17                        t.balance = EH;
    18                        tlr.balance = EH;
    19                        tl.balance = LH;
    20                        break;
    21                    case LH:
    22                        t.balance = RH;
    23                        tl.balance =EH;
    24                        tlr.balance = EH;
    25                        break;
    26                    case EH:
    27                        t.balance = EH;
    28                        tl.balance = EH;
    29                        tlr.balance =EH;
    30                        break;
    31                    //统一旋转
    32                    default:
    33                        break;
    34                }
    35                //统一先以tl左旋,在以t右旋
    36                left_rotate(t.left);
    37                right_rotate(t);
    38                break;
    39            default:
    40                break;
    41        }
    42    }

    好了,左平衡操作所有情况讲解以及具体代码实现,主要就是分治思想,加以细分然后逐个情况逐个解决的套路。

    右平衡操作

    右平衡操作,即结点t的不平衡是因为右子树过深造成的,这时我们需要对t右子树分情况进行解决。

    右平衡操作情况分类

    1、如果新的结点插入后t的右孩子的平衡因子为1,也就是插入到t左孩子的右侧,则直接对结点t进行左旋操作即可

    2、如果新的结点插入后t的右孩子的平衡因子为-1,也就是插入到t右孩子的左侧,则需要进行分情况讨论

    • 情况a:当t的右孩子的左子树根节点的平衡因子为1,这时需要进行两步操作,先以tr进行右旋,在以t进行左旋。

    • 情况b:当p的右孩子的左子树根节点的平衡因子为-1,这时需要进行两步操作,先以tr进行右旋,在以t进行左旋。

    • 情况c:当p的右孩子的左子树根节点的平衡因子为0,这时需要进行两步操作,先以tr进行右旋,在以t进行左旋。

    同样,a,b,c三种情况旋转前后叶子结点依然是叶子结点,变化的
    只是t,tr,trl结点的平衡因子,并且三种情况trl最后平衡因子均为0.

    右平衡代码实现:

    1    /**
     2     * 右平衡操作
     3     * @param t
     4     */
     5    private void rightBalance(AVL<E>.Node<E> t) {
     6        Node<E> tr = t.right;
     7        switch (tr.balance) {
     8            case RH:
     9                left_rotate(t);
    10                t.balance = EH;
    11                tr.balance = EH;
    12                break;
    13            case LH:
    14                Node<E> trl = tr.left;
    15                switch (trl.balance) {
    16                    case LH:
    17                        t.balance = EH;
    18                        tr.balance = RH;
    19                        break;
    20                    case RH:
    21                        t.balance = LH;
    22                        tr.balance = EH;
    23                        break;
    24                    case EH:
    25                        t.balance = EH;
    26                        tr.balance = EH;
    27                        break;
    28
    29                }
    30                trl.balance = EH;
    31                right_rotate(t.right);
    32                left_rotate(t);
    33                break;
    34            default:
    35                break;
    36        }
    37    }

    到此,左平衡与右平衡操作也就讲解完了,主要思想是采用的分治思想,大问题化为小问题,然后逐个解决,到这里,如果能全部理解,那么AVL树的最核心部分就完全理解了,对于红黑树来说上面也是很核心的部分。

    五、AVL树的创建过程

    这部分我们主要了解下怎么创建AVL树,也就是添加元素方法的整体逻辑。

    先看下每个结点类所包含的信息:

     1public class Node<E extends Comparable<E>>{
     2        E element; // data
     3        int balance = 0; // 每个结点的平衡因子
     4        Node<E> left;
     5        Node<E> right;
     6        Node<E> parent;
     7        public Node(E element, Node<E> parent) {
     8            this.element = element;
     9            this.parent = parent;
    10        }
    11
    12        @Override
    13        public String toString() {
    14            // TODO Auto-generated method stub
    15            return element + "BF: " + balance;
    16        }
    17
    18        public E getElement() {
    19            return element;
    20        }
    21
    22        public void setElement(E element) {
    23            this.element = element;
    24        }
    25
    26        public int getBalance() {
    27            return balance;
    28        }
    29
    30        public void setBalance(int balance) {
    31            this.balance = balance;
    32        }
    33
    34        public Node<E> getLeft() {
    35            return left;
    36        }
    37
    38        public void setLeft(Node<E> left) {
    39            this.left = left;
    40        }
    41
    42        public Node<E> getRight() {
    43            return right;
    44        }
    45
    46        public void setRight(Node<E> right) {
    47            this.right = right;
    48        }
    49
    50        public Node<E> getParent() {
    51            return parent;
    52        }
    53
    54        public void setParent(Node<E> parent) {
    55            this.parent = parent;
    56        }
    57    }

    最主要的是每个结点类添加了一个balance属性,也就是记录自己的平衡因子,在插入元素的时候需要动态的调整。

    我们看下插入元素方法的Java实现:

     1    /**
     2     * 添加元素方法
     3     * @param
     4     */
     5    public boolean addElement(E element) {
     6        Node<E> t = root;
     7        //t检查root是否为空,如果为空则表示AVL树还没有创建,
     8        //则需要创建根结点即可
     9        if (t == null) {
    10            root = new Node<E>(element, null);
    11            size = 1;
    12            root.balance = 0;
    13            return true;
    14        } else {
    15            int cmp = 0;
    16            Node<E> parent;
    17            Comparable<? super E> e = (Comparable<? super E>)element;
    18            //查找父类的过程,逻辑和讲解二叉排序树时查找父类是一样的
    19            do {
    20                parent = t;
    21                cmp = e.compareTo(t.element);
    22                if (cmp < 0) {
    23                    t= t.left;
    24                } else if (cmp > 0) {
    25                    t= t.right;
    26                } else {
    27                    return false;
    28                }
    29            } while (t != null);
    30            //创建结点,并挂载到父节点上
    31            Node<E> child = new Node<E>(element, parent);
    32            if (cmp < 0) {
    33                parent.left = child;
    34            } else {
    35                parent.right = child;
    36            }
    37            //节点已经插入,
    38            // 插入元素后 检查平衡性,回溯查找
    39            while (parent != null) {
    40                cmp = e.compareTo(parent.element);
    41                //元素在左边插入
    42                if (cmp < 0) {
    43                    parent.balance++;
    44                } else{ //元素在右边插入
    45                    parent.balance --;
    46                }
    47                //插入之后父节点balance正好完全平衡,则不会出现平衡问题
    48                if (parent.balance == 0) {
    49                    break;
    50                }
    51                //查找最小不平衡二叉树
    52                if (Math.abs(parent.balance) == 2) {
    53                    //出现平衡问题
    54                    fix(parent);
    55                    break;
    56                } else {
    57                    parent = parent.parent;
    58                }
    59            }
    60            size++;
    61            return true;
    62        }
    63    }

    其大体流程主要分为两大部分,前半部分和二叉排序树插入元素的逻辑一样,主要是查找父节点,将其挂载到父节点上,而后半部分就是AVL树特有的了,也就是查找最小不平衡二叉树然后对其修复,修复也就是通过左旋右旋操作使其达到平衡状态,我们看下fix方法主要逻辑:

     1    /**
     2     * 发现最小不平衡树,对其进行修复
     3     * @param parent
     4     */
     5    private void fix(AVL<E>.Node<E> parent) {
     6        if (parent.balance == 2) {
     7            leftBalance(parent);
     8        }
     9        if (parent.balance == -2) {
    10            rightBalance(parent);
    11        }
    12    }

    很简单,就是判断左边与右边哪边不平衡,进而进行左平衡或者右平衡操作,至于左平衡右平衡上面已经详细讲解过,不在过多说明。

    好了,以上就是构建一颗AVL树的过程讲解,如果有不懂得地方可以静下心来自己好好分析一下。

    六、AVL树总结

    本篇主要讲解了AVL的概念以及通过最基础的左旋,右旋使其保持树中每一个结点的平衡因子值保证在「-1,0,1」中,这样构建出来的树具有很好的查找特性。

    AVL树相对于红黑树来说是一颗严格的平衡二叉树,平衡条件非常严格(树高差只有1),只要插入或删除不满足上面的条件就要通过旋转来保持平衡。由于旋转是非常耗费时间的。AVL树适合用于插入删除次数比较少,但查找多的情况。

    在平衡二叉树中应用比较多的是红黑树,红黑树对高度差要求没有AVL那么严格,用以保持平衡的左旋右旋操作次数比较少,用于搜索时,插入删除次数多的情况下通常用红黑树来取代AVL树。TreeMap的实现以及JDK1.8以后的HashMap中都有红黑树的具体应用。

    下一篇我可能先写图的概念以及图一些经典算法,放心红黑树我肯定会写的,关于AVL树与红黑树的差异在写完红黑树在做详细比较,以上简单提一下。

    好了,本篇就到此为止了,希望对你有用。

  • 相关阅读:
    查询某段时间内过生日的员工名单的SQL语句
    Winform中如何读取局域网路由的IP地址
    什么是组件,组件有何特点?
    SQL 存储过程优化总结
    MVC文件结构作用概述(1)
    SQL常用总结
    IEnumerable与IEnumerator区别
    转谈工程师的价值和发展
    新建项目,是否勾选“为解决方案创建目录”的文件结构的区别
    [转]c# asp.net 新建项目与新建网站区别
  • 原文地址:https://www.cnblogs.com/leipDao/p/10097001.html
Copyright © 2020-2023  润新知