5. AVL Trees

The classic binary search tree can be unbalanced, reducing the efficiency of the operations on it.

../../_images/unbalanced_tree.drawio.png

An AVL (Adelson-Velskii and Landis) tree is a binary search tree with a balance condition.

We want to maintain a depth of \(O(\log N)\).

We can enforce some conditions:

  1. Requiring that the left subtree has the same depth as the right subtree.

  2. Or requiring that every node must have the left and right subtree of the same height.

With the first condition it is not enough, because we can obtain the following tree:

../../_images/FG_04_031.svg

The second condition would work only for trees of \(2^k -1\), otherwise some nodes would be unbalanced. So this condition is too restrictive.

So what are the conditions of the AVL trees?

AVL tree

In AVL trees, for every node in the tree, the height of the left and right subtrees can differ by at most 1.

Note

The height of an empty tree is defined to be -1.

The following figure illustrates the difference between an AVL tree and a binary search tree:

../../_images/FG_04_032.svg

The tree on left is an AVL tree, not the other one.

5.1. How does it work?

The height information is kept for each node (in the node structure).

All the operations can be performed in \(O(\log N)\), with the exception of insertion and deletion.

If we take the AVL tree in the previous figure and we try to insert 6, it would destroy the balance condition.

../../_images/avl_insert_6.drawio.png

In this case, the property has to be restored before the insertion step is considered over.

To restore the property, we just need to do a rotation.

After an insertion, only nodes that are on the path from the insertion point to the root might have their balance altered because only those nodes have their subtrees altered.

../../_images/avl_path_insert.drawio.png

Let us call the node that must be rebalanced \(\alpha\).

Since any node has at most two children, and a height imbalance requires that \(\alpha\)’s two subtrees’ height differs by two, a violation might occur in four cases:

  1. An insertion into the left subtree of the left child of \(\alpha\).

  2. An insertion into the right subtree of the left child of \(\alpha\).

  3. An insertion into the left subtree of the right child of \(\alpha\).

  4. An insertion into the right subtree of the right child of \(\alpha\).

Activity

Which node is \(\alpha\) in the previous example?

And in which case, are we?

Cases 1 and 4 are identical in theory, as are cases 2 and 3.

For cases 1 and 4, the insertion occurs on the “outside” (left-left, right-right) and can be fixed by a single rotation.

For cases 2 and 3, the insertion occurs on the “inside” (left-right, right-left) and need to be fixed using a double rotation.

Important

These are fundamental operations! They will be used regularly, so they need to be efficient.

5.2. Single Rotation

To illustrate the idea of a single rotation we will consider the following figure.

../../_images/FG_04_034.svg

On the left we have an unbalanced tree after an insertion, and on the right we have the tree after the single rotation.

In detail we have \(k_2\), for which the left subtree is two levels deeper than its right subtree.

The node on the path of the unbalance is \(k_1\).

The idea behind the rotation:

  • Imagine that the tree is flexible.

  • Grab the node on the path (\(k_1\) in the example).

  • Shake it, and let the gravity take hold.

The result will be that \(k_1\) is the new root.

The binary search tree property tells us that in the original tree \(k_2 > k_1\), so \(k_2\) becomes the right child of \(k_1\).

You can also see that \(X, Y\) and \(Z\) are still on the same side respectively to \(k_1\) and \(k_2\).

Note

We could have the symmetric case and it would not change the idea.

../../_images/FG_04_036.svg

Example

Taking back the example of inserting 6 into the AVL tree.

../../_images/avl_insert_6_rotation.drawio.png
  • The node containing 8 is unbalanced.

  • The node on the path is 7.

  • We rotate 7 and 8.

  • We obtain the new subtree with 7 as the root.

Activity

  • Consider an empty AVL tree.

  • Now, consider the following insertion order: 3,2,1,4,5,6,7.

  • Draw the tree after each insertion with the corresponding rotations when necessary.

5.3. Double rotation

Using a simple rotation fix cases 1 and 4.

However, it doesn’t work for cases 2 and 3, as shown in the following figure.

../../_images/FG_04_037.svg

The problem is that \(Y\) is too deep and a single rotation does not make it any less deep.

This is why we use a double rotation.

Before using it, we need to consider one more node.

We just inserted a new node in \(Y\), so it’s not empty.

We can consider it has a root and two subtrees.

Consequently, the entire tree may be viewed as four subtrees connected by three nodes.

On which we can apply the double rotation, as illustrated below.

../../_images/FG_04_038.svg

One of the subtree (\(B\) or \(C\)) is deeper than \(D\), but we cannot be sure which one.

In the end, it doesn’t matter, both subtrees are drawn at \(1.5\) levels below \(D\).

Ok, how do balance the tree?

Remember that a rotation between \(k_1\) and \(k_3\) doesn’t work.

So the only root possible is \(k_2\).

It means that \(k_1\) must be the left child of \(k_2\), and \(k_3\) the right child.

Then, we adjust the subtrees:

  • \(B\) must be on the left of \(k_2\), so a child of \(k_1\).

  • \(C\) must be on the right of \(k_2\), so a child of \(k_3\).

Is is called a double rotation, because it is the same effect of rotating between \(\alpha\)’s child and grandchild, and then between \(\alpha\) and its new child.

Activity

  • Take the tree the previous activity (the state after all the insertion).

  • Insert the following nodes: 16, 15, 14, 13, 12, 11, 10, 8, 9.

  • Draw each step with the corresponding single and double rotations.

The theory should be clear by now, so we can move to the implementation details.

5.4. Implementation of the AVL ADT

The implementation details are straightforward except that there are several cases.

5.4.1. Classes

AVL node

The class representing a node is similar to the one for the binary search tree, but we had the balancing in it.

 1    /**
 2     * Class to represent an AVL Node.
 3     * It extends the BinaryTree.Node by adding the balance field.
 4     * @param <E> Type of the element in the tree.
 5     */
 6    private static class AVLNode<E> extends Node<E>{
 7
 8        /** Constant to indicate left‐heavy */
 9        public static final int LEFT_HEAVY = -1;
10        /** Constant to indicate balanced */
11        public static final int BALANCED = 0;
12        /** Constant to indicate right‐heavy */
13        public static final int RIGHT_HEAVY = 1;
14        /** balance is right subtree height – left subtree height */
15        private int balance;
16
17        /**
18         * Construct a node with the given item as the data field.
19         * @param item The data field
20         */
21        public AVLNode(E item){
22            super(item);
23            balance = BALANCED;
24        }
25
26        /**
27         * Return a string representation of this object.
28         * The balance value is appended to the contents.
29         * @return String representation of this object.
30         */
31        @Override
32        public String toString(){
33            return balance + ": " + super.toString();
34        }
35
36    }

As you can see, we inherit from Node, so we don’t need to redefine everything.

One more thing, we optimize the type of height and just maintain if one subtree is unbalanced with -1, +1 or 0 if it’s balanced.

However, it complicates the code.

AVL Tree

For the tree the public interface is the same. In the end what changes is the balancing of the binary tree search.

Something that the user will not need to use directly, so it will be in the private part of the class.

 1/**
 2 * Self-balancing binary search tree using algorithm define by
 3 * Adelson-Velskii and Landis.
 4 * @param <E> Type contained in the tree.
 5 */
 6public class AVLTree<E extends Comparable<E>> extends BinarySearchTreeWithRotate<E> {
 7    // Data Fields
 8    /** Flag to indicate that height of tree has increased. */
 9    private boolean increase;
10}

5.4.2. Insertion

To insert a new node containing \(X\) into the tree \(T\):

../../_images/insertion_algorithm.png

5.4.2.1. Insert

We will optimize the code of the AVl tree, but we will try to make it readable.

We will consider the following functions:

  • The add function

  • The rebalanceLeft function

  • The rebalanceRight function

The add one is identical to the one in the binary tree search, except for the private one in which we need to launch the rebalanceLeft or rebalanceRight after the insertion.

 1    /**
 2     * Recursive add method. Insert the given item into the tree.
 3     * @param localRoot The local root of the subtree
 4     * @param item The item to insert
 5     * @return The new local root of the subtree with the item inserted
 6     */
 7    private AVLNode<E> add(AVLNode<E> localRoot, E item){
 8        if (localRoot == null){
 9            addReturn= true;
10            increase = true;
11            return new AVLNode<E>(item);
12        }
13        if (item.compareTo(localRoot.data) == 0){
14            // Item is already in the tree.
15            increase = false;
16            addReturn = false;
17            return localRoot;
18        } else if (item.compareTo(localRoot.data) < 0) {
19            // item < data
20            localRoot.left = add((AVLNode<E>) localRoot.left, item);
21            if (increase){
22                decrementBalance(localRoot);
23                if (localRoot.balance < AVLNode.LEFT_HEAVY) {
24                    increase = false;
25                    return rebalanceLeft(localRoot);
26                }
27            }
28        } else if (item.compareTo(localRoot.data) > 0) {
29            // item > data
30            localRoot.right = add((AVLNode<E>) localRoot.right, item);
31            if (increase){
32                incrementBalance(localRoot);
33                if (localRoot.balance > AVLNode.RIGHT_HEAVY) {
34                    increase = false;
35                    return rebalanceRight(localRoot);
36                }
37            }
38        }
39        return localRoot;
40    }

This way the balancing will be done at each level, if necessary.

Note that we are using two functions decrementBalance(localRoot); and incrementBalance(localRoot);.

Indeed we need to update the balance of each node on the path to the root or until a rebalance is done.

 1    /**
 2     * Decrement the balance of the given node
 3     * @param node The node to decrement the balance of
 4     */
 5    private void decrementBalance(AVLNode<E> node) {
 6        // Decrement the balance.
 7        node.balance--;
 8        if (node.balance == AVLNode.BALANCED) {
 9            /* If now balanced, overall height has not increased. */
10            increase = false;
11        }
12    }
13
14    /**
15     * Increment the balance of the given node
16     * @param node The node to increment the balance of
17     */
18    private void incrementBalance(AVLNode<E> node){
19        node.balance++;
20        if(node.balance == AVLNode.BALANCED){
21            //If now balanced, overall height has not increased
22            increase = false;
23        }
24    }

5.4.2.2. Balance

We need to consider different cases:

  • The difference between the left subtree and the right subtree is greater than 1.

    • Check if the depth is greater on the exterior (left-left)

      • Simple rotation on the left.

    • Otherwise, double rotation on the left.

  • Otherwise, check the difference between the right subtree and the left subtree is greater than 1.

    • Check if the depth is greater on the exterior (right-right).

      • Simple rotation on the right.

    • Otherwise double rotation on the right.

We will use 2 private methods for the rotations:

 1    /**
 2     * Method to rebalance left.
 3     * @param localRoot Root of the AVL subtree that needs balancing.
 4     * @return A new localRoot
 5     */
 6    private AVLNode<E> rebalanceLeft(AVLNode<E> localRoot){
 7    }
 8    /**
 9     * Method to rebalance right.
10     * @param localRoot Root of the AVL subtree that needs balancing.
11     * @return A new localRoot
12     */
13    private AVLNode<E> rebalanceRight(AVLNode<E> localRoot){
14    }

The first one rebalanceLeft can be written as:

 1    /**
 2     * Method to rebalance left.
 3     * @param localRoot Root of the AVL subtree that needs balancing.
 4     * @return A new localRoot
 5     */
 6    private AVLNode<E> rebalanceLeft(AVLNode<E> localRoot){
 7        // Obtain reference to left child.
 8        AVLNode<E> leftChild = (AVLNode<E>) localRoot.left;
 9        // Check if left-right heavy
10        if (leftChild.balance > AVLNode.BALANCED){
11            // Obtain reference to left-right child
12            AVLNode<E> leftRightChild = (AVLNode<E>) leftChild.right;
13            if(leftRightChild.balance < AVLNode.BALANCED){
14                leftChild.balance = AVLNode.BALANCED;
15                leftRightChild.balance = AVLNode.BALANCED;
16                localRoot.balance = AVLNode.RIGHT_HEAVY;
17            } else if (leftRightChild.balance > AVLNode.BALANCED){
18                leftChild.balance = AVLNode.LEFT_HEAVY;
19                leftRightChild.balance = AVLNode.BALANCED;
20                localRoot.balance = AVLNode.BALANCED;
21            } else {
22                //Left-Right balanced case
23                leftChild.balance = AVLNode.BALANCED;
24                localRoot.balance = AVLNode.BALANCED;
25            }
26            localRoot.left = rotateLeft(leftChild);
27        }
28        else{
29            // Left-left case
30            leftChild.balance = AVLNode.BALANCED;
31            localRoot.balance = AVLNode.BALANCED;
32        }
33        // Now rotate on the right.
34        return (AVLNode<E>) rotateRight(localRoot);
35    }
36    /**
37     * Method to rebalance right.
38     * @param localRoot Root of the AVL subtree that needs balancing.
39     * @return A new localRoot
40     */
41    private AVLNode<E> rebalanceRight(AVLNode<E> localRoot){
42    }

Activity

  • Implement the rebalanceRight method.

1    /**
2     * Method to rebalance right.
3     * @param localRoot Root of the AVL subtree that needs balancing.
4     * @return A new localRoot
5     */
6    private AVLNode<E> rebalanceRight(AVLNode<E> localRoot){
7    }

Rotations

As you can see we need to define the two rotation functions:

  • rotateLeft

  • rotateRight

Starting with rotateLeft.

The following figure illustrates the idea.

../../_images/implementation_rotate_left.drawio.png

Which is very simple to implement:

 1    /**
 2     * Method to perform a left rotation.
 3     * @post root.left is the root of a binary search tree,
 4     *       root.left.left is raised one level,
 5     *       root.left.right does not change levels,
 6     *       root.right is lowered one level,
 7     *       the new root is returned.
 8     * @param root is the root of a binary search tree
 9     * @return The new root of the rotated tree
10     */
11    protected Node<E> rotateLeft(Node<E> root) {
12        Node<E> temp = root.right;
13        root.right = temp.left;
14        temp.left = root;
15        return temp;
16    }

Activity

  • Implement rotateRight

  • It’s the symmetric function:

../../_images/implementation_rotate_right.drawio.png

5.4.3. Deletion

We didn’t talk about removing a node.

Again it is the same as for the binary tree search, but we need to balance the tree after each deletion.

5.4.4. Other operations

Everything else is exactly the same as in the binary tree search ADT.

And it should not surprise you.

The AVL Tree is just a self-balancing BST, nothing more, nothing less.