【数据结构与算法基础】代码仓库:https://github.com/jinrunheng/datastructure-and-algorithm

一:什么是 AVL 树?

在我的上一篇文章《二分搜索树与二分查找法》中,详细介绍了二分搜索树这种数据结构。二分搜索树最大的问题就是它并非是一棵平衡二叉树。

在给定的数据比较极端的情况下,譬如将一个递增数组中的元素依次添加到二分搜索树中,此时的二分搜素树就会退化为一个链表。

为了解决二分搜索树的这个问题,两位前苏联的计算机科学家 Georgy Adelson-Velsky 与 Evgenii Landis 发明了一种可以自平衡的二叉查找树,这种数据结构以两位科学家的名字的首字母命名,即:AVL 树。

13、AVL 树 - 图1

我们再来回顾一下什么是平衡二叉树?

首先,平衡二叉树必须是一棵二分搜索树。它或者是一颗空树,或它的左子树与右子树的高度之差(左子树的高度 - 右子树的高度,其别名叫平衡因子:balance factor)的绝对值不超过 1,且一棵平衡二叉树的左右子树均是一棵平衡二叉树。

13、AVL 树 - 图2

接下来,我们就一起探究一下 AVL 树这种数据结构是如何在二分搜索树的基础上做到可以自平衡的。

二:AVL 树的实现

1. 节点高度与平衡因子

AVL 树是如何实现自平衡的?

AVL 想要满足平衡二叉树的这个特性,就要保证所有节点的平衡因子的绝对值不超过 1。所以,AVL 树能够做到维持自平衡,实际上就是在树的结构发生变化时(向 AVL 中添加节点或删除节点),通过检测节点的平衡因子来调整树的结构。

AVL 树的节点除了存储左孩子与右孩子的引用以及自身的 value 值以外,还需要能够表示出当前节点的高度。

所以,AVL 树的节点类 Node 设计如下:

  1. public class Node<E extends Comparable<E>> {
  2. public E e;
  3. public Node left;
  4. public Node right;
  5. public int height;
  6. public Node(E e) {
  7. this.e = e;
  8. left = null;
  9. right = null;
  10. height = 1;
  11. }
  12. }

那么,在我们的 AVLTree 这个类中,就可以设计出两个方法。

第一个是获取当前节点的高度的方法:getHeight:

  1. /**
  2. * 返回 node 节点的高度
  3. *
  4. * @param node
  5. * @return
  6. */
  7. private int getHeight(Node node) {
  8. if (node == null)
  9. return 0;
  10. return node.height;
  11. }

第二个是获取当前节点的平衡因子:getBalanceFactor:

  1. /**
  2. * 获得节点 node 的平衡因子
  3. *
  4. * @param node
  5. * @return
  6. */
  7. private int getBalanceFactor(Node node) {
  8. if (node == null)
  9. return 0;
  10. return getHeight(node.left) - getHeight(node.right);
  11. }

2. 向 AVL 树中添加元素

我们先来回顾一下向二分搜索树中添加元素的逻辑:

如果当前二分搜索树的根节点为空,那么新插入的节点就会成为根节点。

如果当前二分搜索树的根节点不为空,就让根作为当前比较的节点:新插入的节点与当前节点进行比较;如果值比当前节点小就要“向左走”,如果值比当前节点大就要“向右走”,然后让下一层的节点继续作为当前比较的节点,直至走到应该插入的位置。

下图为在向当前的二分搜索树中添加节点“28”的流程:

13、AVL 树 - 图3

Java 代码如下:

  1. /**
  2. * @param e 向二分搜索树中添加新的元素
  3. */
  4. public void add(E e) {
  5. root = add(e, root);
  6. }
  7. /**
  8. * @param e 向二分搜索树中新插入的节点
  9. * @param node 当前比较的节点
  10. * @return 返回二分搜索树的根节点
  11. */
  12. private Node add(E e, Node node) {
  13. if (node == null) {
  14. size++;
  15. return new Node(e);
  16. }
  17. if (e.compareTo((E) node.e) < 0) {
  18. node.left = add(e, node.left);
  19. } else if (e.compareTo((E) node.e) > 0) {
  20. node.right = add(e, node.right);
  21. }
  22. return node;
  23. }

而向 AVL 树中添加元素就不是这么简单了。

对于向 AVL 树中添加一个节点,我们除了要保证二分搜索树的特性(二分搜索树的每个节点的值都要大于其左子树的所有节点的值,且小于其右子树的所有节点的值)外,还要保证维持平衡二叉树的特性。

我们思考一下,如果当前的 AVL 树是平衡的,那么哪些情况会使得 AVL 树变得不平衡呢?其实无非就是以下这四种:

第一种情况:插入的元素在不平衡节点的左子树的左侧

13、AVL 树 - 图4

我们向当前的 AVL 树中插入元素“4”,导致节点“12”的平衡因子变为了 2,出现了不平衡。

这种情况发生的条件为当前节点(“12”)的平衡因子大于 1,且当前节点的左子树的平衡因子大于等于 0,表达式表示为:

  1. getBalanceFactor(node) > 1 && getBalanceFactor(node.left) >= 0

第二种情况:插入的元素在不平衡节点的右子树的右侧

13、AVL 树 - 图5

我们向当前的 AVL 树中插入元素“18”,导致节点“12”的平衡因子变为了 -2,出现了不平衡。

这种情况发生的条件为当前节点(“12”)的平衡因子小于 -1,且当前节点的右子树的平衡因子小于等于 0,表达式表示为:

  1. getBalanceFactor(node) < -1 && getBalanceFactor(node.right) <= 0

第三种情况:插入的元素在不平衡节点的左子树的右侧

13、AVL 树 - 图6

我们向当前的 AVL 树中插入元素“10”,导致节点“12”的平衡因子变为了 2,出现了不平衡。

这种情况发生的条件为当前节点(“12”)的平衡因子大于 1,且当前节点的左子树的平衡因子小于 0,表达式表示为:

  1. getBalanceFactor(node) > 1 && getBalanceFactor(node.left) < 0

第四种情况:插入的元素在不平衡节点的右子树的左侧

13、AVL 树 - 图7

我们向当前的 AVL 树中插入元素“14”,导致节点“12”的平衡因子变为了 -2,出现了不平衡。

这种情况发生的条件为当前节点(“12”)的平衡因子小于 -1,且当前节点的右子树的平衡因子大于 0,表达式表示为:

  1. getBalanceFactor(node) < -1 && getBalanceFactor(node.right) > 0

左旋与右旋

我们已经知道,向 AVL 树中添加元素时,以上的四种情况会导致出现不平衡,那么如何让当前的树的结构变得平衡呢?

对于第一种情况,我们需要用到右旋的操作:

13、AVL 树 - 图8

右旋操作如上图所示。所谓的右旋指的是,将不平衡的节点 “y” 顺时针旋转,使得上图中左侧的结构变为右侧的结构,此时,右侧的这棵树既满足二分搜索树的特性,也满足平衡二叉树的特性。

证明如下:

左侧的结构是一棵二分搜索树,满足:13、AVL 树 - 图9,变为右侧的结构之后,仍然有:13、AVL 树 - 图10,所以右侧的结构也是一棵二分搜索树。

13、AVL 树 - 图1113、AVL 树 - 图12 中,最大高度为 13、AVL 树 - 图13,所以节点 13、AVL 树 - 图14 的高度为 13、AVL 树 - 图15;节点 13、AVL 树 - 图16 也是平衡的,其平衡因子大于等于 0 且小于等于 1;所以,13、AVL 树 - 图17 的高度不是 13、AVL 树 - 图18 就是 13、AVL 树 - 图1913、AVL 树 - 图20 的高度表示为 13、AVL 树 - 图2113、AVL 树 - 图22 节点是不平衡的,它的平衡因子一定是 2,所以,13、AVL 树 - 图23 的高度为 13、AVL 树 - 图24

13、AVL 树 - 图25

那么,左侧的结构通过右旋操作变为了右侧的结构之后,13、AVL 树 - 图26 的高度为 13、AVL 树 - 图2713、AVL 树 - 图28 的高度为 13、AVL 树 - 图2913、AVL 树 - 图30 的左右子树的高度差为 1,所以可证明,通过右旋,使得右侧的结构不仅是一棵二分搜索树,也是一棵平衡二叉树。

右旋操作的 Java 代码如下:

  1. /**
  2. * 右旋:
  3. *
  4. * y x
  5. * / \ / \
  6. * x T4 z y
  7. * / \ ========> / \ / \
  8. * z T3 T1 T2 T3 T4
  9. * / \
  10. * T1 T2
  11. */
  12. private Node rightRotate(Node y){
  13. Node x = y.left;
  14. Node T3 = x.right;
  15. x.right = y;
  16. y.left = T3;
  17. // 更新 height
  18. y.height = Math.max(getHeight(y.left),getHeight(y.right)) + 1;
  19. x.height = Math.max(getHeight(x.left),getHeight(x.right)) + 1;
  20. return x;
  21. }

类似地,对于第二种情况,我们需要用到左旋的操作:

13、AVL 树 - 图31

左旋操作如上图所示。所谓的左旋指的是,将不平衡的节点 “y” 逆时针旋转,使得上图中左侧的结构变为右侧的结构,此时,右侧的这棵树既满足二分搜索树的特性,也满足平衡二叉树的特性。

左旋的正确性证明和右旋是一样的,我就不再赘述了。

左旋的 Java 代码如下:

  1. /**
  2. * 左旋:
  3. *
  4. * y x
  5. * / \ / \
  6. * T4 x y z
  7. * / \ ========> / \ / \
  8. * T3 z T4 T3 T1 T2
  9. * / \
  10. * T1 T2
  11. */
  12. private Node leftRotate(Node y){
  13. Node x = y.right;
  14. Node T3 = x.left;
  15. x.left = y;
  16. y.right = T3;
  17. // 更新 height
  18. y.height = Math.max(getHeight(y.left),getHeight(y.right)) + 1;
  19. x.height = Math.max(getHeight(x.left),getHeight(x.right)) + 1;
  20. return x;
  21. }

对于第三种情况,我们需要将不平衡节点 node 的左孩子先进行一次左旋,然后再对不平衡节点 node 进行右旋,这样就使得整棵树的结构既满足二分搜索树的特性,也满足平衡二叉树的特性了:

13、AVL 树 - 图32

同理,对于第四种情况,我们需要将不平衡节点 node 的右孩子先进行一次右旋,然后再对不平衡节点 node 进行左旋,示意图如下:
13、AVL 树 - 图33

向 AVL 树中添加元素的完整代码

通过上文的分析,我们已经知道了向 AVL 树中添加元素时,哪些情况会出现使得 AVL 树出现不平衡,以及这些不平衡的情况该如何解决。

向 AVL 树中添加元素的 Java 代码如下:

  1. /**
  2. * @param e 向 AVL 中添加新的元素
  3. */
  4. public void add(E e) {
  5. root = add(e, root);
  6. }
  7. /**
  8. * @param e 向 AVL 中新插入的节点
  9. * @param node 当前比较的节点
  10. * @return 返回 AVL 的根节点
  11. */
  12. private Node add(E e, Node node) {
  13. if (node == null) {
  14. size++;
  15. return new Node(e);
  16. }
  17. if (e.compareTo((E) node.e) < 0) {
  18. node.left = add(e, node.left);
  19. } else if (e.compareTo((E) node.e) > 0) {
  20. node.right = add(e, node.right);
  21. }
  22. // 更新 height
  23. node.height = 1 + Math.max(getHeight(node.left), getHeight(node.right));
  24. // 计算平衡因子
  25. int balanceFactor = getBalanceFactor(node);
  26. // 平衡的维护
  27. // LL
  28. if (balanceFactor > 1 && getBalanceFactor(node.left) >= 0)
  29. return rightRotate(node);
  30. // RR
  31. if(balanceFactor < -1 && getBalanceFactor(node.right) <= 0)
  32. return leftRotate(node);
  33. // LR
  34. if(balanceFactor > 1 && getBalanceFactor(node.left) < 0) {
  35. node.left = leftRotate(node.left);
  36. return rightRotate(node);
  37. }
  38. // RL
  39. if(balanceFactor < -1 && getBalanceFactor(node.right) > 0) {
  40. node.right = rightRotate(node.right);
  41. return leftRotate(node);
  42. }
  43. return node;
  44. }

3. 向 AVL 树中删除元素

除了向 AVL 树中添加元素会导致树的结构发生变化以外,删除元素也会使得 AVL 树的结构发生变化,只要是树的结构发生变化,就有可能出现不平衡的节点,所以在二分搜索树中删除节点的代码的基础上,我们也要做可以实现自平衡性的修改。

我们先来回顾一下,向二分搜索树中删除节点的操作:

在二分搜索树中删除元素分为以下几种情况:

第一种情况为,我们删除的节点只有左子树没有右子树。

13、AVL 树 - 图34

那么我们只需要让删除的节点的左孩子取代删除节点的位置即可。

第二种情况为,我们删除的节点只有右子树没有左子树。

13、AVL 树 - 图35

我们只需要让删除节点的右孩子取代删除节点的位置即可。

第三种情况为,我们删除的节点既有左子树也有右子树。

对于这种情况,我们使用 Hibbard Deletion 算法。如果我们要删除一个既有左子树也有右子树的节点,首先需要找到待删除节点的后继节点(successor)。

对于二分搜索树而言,待删除节点的后继节点就是该节点右子树“最左的”那个节点,将后继节点替换待删除的节点就完成了删除操作。

13、AVL 树 - 图36

13、AVL 树 - 图37

在二分搜索树中删除一个元素的完整 Java 代码如下:

  1. public void remove(E e) {
  2. root = remove(e, root);
  3. }
  4. private Node remove(E e, Node node) {
  5. if (node == null) {
  6. return null;
  7. }
  8. if (e.compareTo((E) node.e) < 0) {
  9. node.left = remove(e, node.left);
  10. return node;
  11. } else if (e.compareTo((E) node.e) > 0) {
  12. node.right = remove(e, node.right);
  13. return node;
  14. } else {
  15. if (node.left == null) {
  16. Node rightNode = node.right;
  17. node.right = null;
  18. size--;
  19. return rightNode;
  20. }
  21. if (node.right == null) {
  22. Node leftNode = node.left;
  23. node.left = null;
  24. size--;
  25. return leftNode;
  26. }
  27. // Hibbard Deletion
  28. Node successor = minimum(node.right);
  29. Node right = removeMin(node.right);
  30. Node left = node.left;
  31. successor.left = left;
  32. successor.right = right;
  33. node.left = null;
  34. node.right = null;
  35. return successor;
  36. }
  37. }

对于 AVL 树的删除节点的方法,我们只需要在二分搜索树中删除节点的方法之上,加入维护自平衡的部分即可。这个部分实际上和向 AVL 树中添加元素维护自平衡的原理是一样的。

向 AVL 树中删除节点的 Java 代码如下:

  1. public void remove(E e) {
  2. root = remove(e, root);
  3. }
  4. private Node remove(E e, Node node) {
  5. if (node == null)
  6. return null;
  7. Node retNode;
  8. if (e.compareTo((E) node.e) < 0) {
  9. node.left = remove(e, node.left);
  10. retNode = node;
  11. } else if (e.compareTo((E) node.e) > 0) {
  12. node.right = remove(e, node.right);
  13. retNode = node;
  14. } else {
  15. // 待删除节点的左子树为空
  16. if (node.left == null) {
  17. Node rightNode = node.right;
  18. node.right = null;
  19. size--;
  20. retNode = rightNode;
  21. }
  22. // 待删除的节点右子树为空时
  23. else if (node.right == null) {
  24. Node leftNode = node.left;
  25. node.left = null;
  26. size--;
  27. retNode = leftNode;
  28. }
  29. // 待删除的节点左右子树均不为空
  30. else {// Hibbard Deletion
  31. Node successor = minimum(node.right);
  32. Node right = remove((E) node.right, successor);
  33. Node left = node.left;
  34. successor.left = left;
  35. successor.right = right;
  36. node.left = null;
  37. node.right = null;
  38. retNode = successor;
  39. }
  40. }
  41. if(retNode == null)
  42. return null;
  43. // 更新 height
  44. retNode.height = 1 + Math.max(getHeight(retNode.left), getHeight(retNode.right));
  45. // 计算平衡因子
  46. int balanceFactor = getBalanceFactor(retNode);
  47. // 平衡的维护
  48. // LL
  49. if (balanceFactor > 1 && getBalanceFactor(retNode.left) >= 0)
  50. return rightRotate(retNode);
  51. // RR
  52. if(balanceFactor < -1 && getBalanceFactor(retNode.right) <= 0)
  53. return leftRotate(retNode);
  54. // LR
  55. if(balanceFactor > 1 && getBalanceFactor(retNode.left) < 0) {
  56. retNode.left = leftRotate(retNode.left);
  57. return rightRotate(retNode);
  58. }
  59. // RL
  60. if(balanceFactor < -1 && getBalanceFactor(retNode.right) > 0) {
  61. retNode.right = rightRotate(retNode.right);
  62. return leftRotate(retNode);
  63. }
  64. return retNode;
  65. }

三:AVL 树与二分搜索树的性能测试

在完成 AVL 树的性能测试前,我们需要两个验证方法,第一个是验证当前的 AVL 树满足二分搜索树的特性,第二个是验证当前的 AVL 树是一棵平衡二叉树。

这两个方法的实现也非常简单:

  1. /**
  2. * 验证当前的 AVL 树是否满足二分搜索树的特性
  3. *
  4. * @return
  5. */
  6. public boolean isBST() {
  7. ArrayList<E> list = new ArrayList<>();
  8. inOrder(root, list);
  9. for (int i = 1; i < list.size(); i++)
  10. if (list.get(i - 1).compareTo(list.get(i)) > 0)
  11. return false;
  12. return true;
  13. }
  14. /**
  15. * @param node 中序遍历以 node 为 根的 AVL
  16. */
  17. private void inOrder(Node node, List<E> list) {
  18. if (node == null) {
  19. return;
  20. }
  21. inOrder(node.left, list);
  22. list.add((E) node.e);
  23. inOrder(node.right, list);
  24. }
  25. /**
  26. * 验证当前的二叉树是否是一棵平衡的二叉树
  27. *
  28. * @return
  29. */
  30. public boolean isBalanced() {
  31. return isBalanced(root);
  32. }
  33. /**
  34. * 验证当前的二叉树是否是一棵平衡的二叉树
  35. *
  36. * @param node
  37. * @return
  38. */
  39. private boolean isBalanced(Node node) {
  40. if (node == null)
  41. return true;
  42. int balanceFactor = getBalanceFactor(node);
  43. if (Math.abs(balanceFactor) > 1)
  44. return false;
  45. return isBalanced(node.left) && isBalanced(node.right);
  46. }

我们的性能测试代码如下:

  1. class AVLTreeTest {
  2. @Test
  3. void AVLAndBSTPerformanceTest() {
  4. int[] testArr = new int[20000];
  5. for (int i = 0; i < testArr.length; i++)
  6. testArr[i] = i;
  7. testBst(testArr);
  8. testAvl(testArr);
  9. }
  10. @Test
  11. void AVLTest(){
  12. AVLTree<Integer> avl = new AVLTree<>();
  13. for (int i = 0; i < 1000; i++){
  14. avl.add(i);
  15. if(!avl.isBalanced() || !avl.isBST())
  16. throw new RuntimeException("error!");
  17. }
  18. for(int i = 0; i < 1000; i++){
  19. avl.remove(i);
  20. if(!avl.isBalanced() || !avl.isBST())
  21. throw new RuntimeException("error!");
  22. }
  23. System.out.println("correct!");
  24. }
  25. private void testBst(int[] testArr) {
  26. BST<Integer> bst = new BST<>();
  27. long start = System.nanoTime();
  28. for (int i = 0; i < testArr.length; i++)
  29. bst.add(i);
  30. for (int i = 0; i < testArr.length; i++)
  31. if (!bst.contains(i)) throw new RuntimeException("error");
  32. long end = System.nanoTime();
  33. System.out.println("BST : " + (end - start) / 1000000000.0 + " s");
  34. }
  35. private void testAvl(int[] testArr) {
  36. AVLTree<Integer> avl = new AVLTree<>();
  37. long start = System.nanoTime();
  38. for (int i = 0; i < testArr.length; i++)
  39. avl.add(i);
  40. for (int i = 0; i < testArr.length; i++)
  41. if (!avl.contains(i)) throw new RuntimeException("error");
  42. long end = System.nanoTime();
  43. System.out.println("AVL : " + (end - start) / 1000000000.0 + " s");
  44. }
  45. }

我们的性能测试非常简单,首先初始化一个容量为 20000 的单调递增的数组,然后将数组所有的元素依次添加到二分搜索树与 AVL 树这两种数据结构中,并在所有元素添加完毕后,依次执行查询操作。

由于数组是单调递增的,我们的二分搜索树会退化为一个链表,添加和查询元素的时间复杂度均为13、AVL 树 - 图38#card=math&code=O%28N%5E2%29&id=nu5us);而 AVL 树则因为其自平衡的特性使得添加和查找元素的复杂度为 13、AVL 树 - 图39#card=math&code=O%28logN%29&id=gwfZR)。

在我的计算机上,AVLAndBSTPerformanceTest 这个单元测试的执行结果为:

  1. BST : 1.935773389 s
  2. AVL : 0.018965793 s

我们可以看到,二者的差异是巨大的。

四:总结

在这一篇文章中,我介绍了 AVL 树这种数据结构以及 AVL 树是如何通过左旋和右旋来实现自平衡的。并且,我们通过一个小的测试来验证在数据极端的情况下,AVL 树和二分搜索树的性能差异。

AVL 树是计算机科学中,最早被发明的自平衡二叉查找树,除了 AVL 外,还有很多优秀的平衡二叉查找树这种数据结构,期待在后面的文章中,我可以与你一同研究与学习。

本文中使用的代码均在:

https://github.com/jinrunheng/datastructure-and-algorithm/

好啦,至此为止,这一篇文章我就介绍完毕了~欢迎大家关注我的公众号【kim_talk】,在这里希望你可以收获更多的知识,我们下一期再见!

13、AVL 树 - 图40