ICS 46 Spring 2022
Notes and Examples: AVL Trees


Why we must care about binary search tree balancing

We've seen previously that the performance characteristics of binary search trees can vary rather wildly, and that they're mainly dependent on the shape of the tree, with the height of the tree being the key determining factor. By definition, binary search trees restrict what keys are allowed to present in which nodes — smaller keys have to be in left subtrees and larger keys in right subtrees — but they specify no restriction on the tree's shape, meaning that both of these are perfectly legal binary search trees containing the keys 1, 2, 3, 4, 5, 6, and 7.

Perfect binary treeDegenerate tree

Yet, while both of these are legal, one is better than the other, because the height of the first tree (called a perfect binary tree) is smaller than the height of the second (called a degenerate tree). These two shapes represent the two extremes — the best and worst possible shapes for a binary search tree containing seven keys.

Of course, when all you have is a very small number of keys like this, any shape will do. But as the number of keys grows, the distinction between these two tree shapes becomes increasingly vital. What's more, the degenerate shape isn't even necessarily a rare edge case: It's what you get when you start with an empty tree and add keys that are already in order, which is a surprisingly common scenario in real-world programs. For example, one very obvious algorithm for generating unique integer keys — when all you care about is that they're unique — is to generate them sequentially.

What's so bad about a degenerate tree, anyway?

Just looking at a picture of a degenerate tree, your intuition should already be telling you that something is amiss. In particular, if you tilt your head 45 degrees to the right, they look just like linked lists; that perception is no accident, as they behave like them, too (except that they're more complicated, to boot!).

From a more analytical perspective, there are three results that should give us pause:

Overall, when n gets large, the tree would be hideously expensive to build, and then every subsequent search would be painful, as well. So this, in general, is a situation we need to be sure to avoid, or else we should probably consider a data structure other than a binary search tree; the worst case is simply too much of a burden to bear if n might get large. But if we can find a way to control the tree's shape more carefully, to force it to remain more balanced, we'll be fine. The question, of course, is how to do it, and, as importantly, whether we can do it while keeping the cost low enough that it doesn't outweigh the benefit.


Aiming for perfection

The best goal for us to shoot for would be to maintain perfection. In other words, every time we insert a key into our binary search tree, it would ideally still be a perfect binary tree, in which case we'd know that the height of the tree would always be Θ(log n), with a commensurate effect on performance.

However, when we consider this goal, a problem emerges almost immediately. The following are all perfect binary trees, by definition:

Perfect binary trees of different heights

The perfect binary trees pictured above have 1, 3, 7, and 15 nodes respectively, and are the only possible perfect shapes for binary trees with that number of nodes. The problem, though, lies in the fact that there is no valid perfect binary tree with 2 nodes, or with 4, 5, 6, 8, 9, 10, 11, 12, 13, or 14 nodes. So, generally, it's impossible for us to guarantee that a binary search tree will always be "perfect," by our definition, because there's simply no way to represent most numbers of keys.

So, first thing's first: We'll need to relax our definition of "perfection" to accommodate every possible number of keys we might want to store.

Complete binary trees

A somewhat more relaxed notion of "perfection" is something called a complete binary tree, which is defined as follows.

A complete binary tree of height h is a binary tree where:

That can be a bit of a mind-bending definition, but it actually leads to a conceptually simple result: On every level of a complete binary tree, every node that could possibly be present will be, except the last level might be missing nodes, but if it is missing nodes, the nodes that are there will be as far to the left as possible. The following are all complete binary trees:

Complete binary trees of different sizes

Furthermore, these are the only possible complete binary trees with these numbers of nodes in them; any other arrangement of, say, 6 keys besides the one shown above would violate the definition.

We've seen that the height of a perfect binary tree is Θ(log n). It's not a stretch to see that the height a complete binary tree will be Θ(log n), as well, and we'll accept that via our intuition for now and proceed. All in all, a complete binary tree would be a great goal for us to attain: If we could keep the shape of our binary search trees complete, we would always have binary search trees with height Θ(log n).

The cost of maintaining completeness

The trouble, of course, is that we need an algorithm for maintaining completeness. And before we go to the trouble of trying to figure one out, we should consider whether it's even worth our time. What can we deduce about the cost of maintaining completeness, even if we haven't figured out an algorithm yet?

One example demonstrates a very big problem. Suppose we had the binary search tree on the left — which is complete, by our definition — and we wanted to insert the key 1 into it. If so, we would need an algorithm that would transform the tree on the left into the tree on the right.

The cost of maintaining completeness

The tree on the right is certainly complete, so this would be the outcome we'd want. But consider what it would take to do it. Every key in the tree had to move! So, no matter what algorithm we used, we would still have to move every key. If there are n keys in the tree, that would take Ω(n) time — moving n keys takes at least linear time, even if you have the best possible algorithm for moving them; the work still has to get done.

So, in the worst case, maintaining completeness after a single insertion requires Ω(n) time. Unfortunately, this is more time than we ought to be spending on maintaining balance. This means we'll need to come up with a compromise; as is often the case when we learn or design algorithms, our willingness to tolerate an imperfect result that's still "good enough" for our uses will often lead to an algorithm that is much faster than one that achieves a perfect result. So what would a "good enough" result be?


What is a "good" balance condition

Our overall goal is for lookups, insertions, and removals from a binary search tree to require O(log n) time in every case, rather than letting them degrade to a worst-case behavior of O(n). To do that, we need to decide on a balance condition, which is to say that we need to understand what shape is considered well-enough balanced for our purposes, even if not perfect.

A "good" balance condition has two properties:

In other words, it guarantees that the height of the tree is still logarithmic, which will give us logarithmic-time lookups, and the time spent re-balancing won't exceed the logarithmic time we would otherwise spend on an insertion or removal when the tree has logarithmic height. The cost won't outweigh the benefit.

Coming up with a balance condition like this on our own is a tall task, but we can stand on the shoulders of the giants who came before us, with the definition above helping to guide us toward an understanding of whether we've found what we're looking for.


A compromise: AVL trees

There are a few well-known approaches for maintaining binary search trees in a state of near-balance that meets our notion of a "good" balance condition. One of them is called an AVL tree, which we'll explore here. Others, which are outside the scope of this course, include red-black trees (which meet our definition of "good") and splay trees (which don't always meet our definition of "good", but do meet it on an amortized basis), but we'll stick with the one solution to the problem for now.

AVL trees

AVL trees are what you might called "nearly balanced" binary search trees. While they certainly aren't as perfectly-balanced as possible, they nonetheless achieve the goals we've decided on: maintaining logarithmic height at no more than logarithmic cost.

So, what makes a binary search tree "nearly balanced" enough to be considered an AVL tree? The core concept is embodied by something called the AVL property.

We say that a node in a binary search tree has the AVL property if the heights of its left and right subtrees differ by no more than 1.

In other words, we tolerate a certain amount of imbalance — heights of subtrees can be slightly different, but no more than that — in hopes that we can more efficiently maintain it.

Since we're going to be comparing heights of subtrees, there's one piece of background we need to consider. Recall that the height of a tree is the length of its longest path. By definition, the height of a tree with just a root node (and empty subtrees) would then be zero. But what about a tree that's totally empty? To maintain a clear pattern, relative to other tree heights, we'll say that the height of an empty tree is -1. This means that a node with, say, a childless left child and no right child would still be considered balanced.

This leads us, finally, to the definition of an AVL tree:

An AVL tree is a binary search tree in which all nodes have the AVL property.

Below are a few binary trees, two of which are AVL and two of which are not.

AVL and non-AVL trees

The thing to keep in mind about AVL is that it's not a matter of squinting at a tree and deciding whether it "looks" balanced. There's a precise definition, and the two trees above that don't meet that definition fail to meet it because they each have at least one node (marked in the diagrams by a dashed square) that doesn't have the AVL property.

AVL trees, by definition, are required to meet the balance condition after every operation; every time you insert or remove a key, every node in the tree should have the AVL property. To meet that requirement, we need to restructure the tree periodically, essentially detecting and correcting imbalance whenever and wherever it happens. To do that, we need to rearrange the tree in ways that improve its shape without losing the essential ordering property of a binary search tree: smaller keys toward the left, larger ones toward the right.

Rotations

Re-balancing of AVL trees is achieved using what are called rotations, which, when used at the proper times, efficiently improve the shape of the tree by altering a handful of pointers. There are a few kinds of rotations; we should first understand how they work, then focus our attention on when to use them.

The first kind of rotation is called an LL rotation, which takes the tree on the left and turns it into the tree on the right. The circle with A and B written in them are each a single node containing a single key; the triangles with T1, T2, and T3 written in them are arbitrary subtrees, which may be empty or may contain any number of nodes (but which are, themselves, binary search trees).

LL rotation in an AVL tree

It's important to remember that both of these trees — before and after — are binary search trees; the rotation doesn't harm the ordering of the keys in nodes, because the subtrees T1, T2, and T3 maintain the appropriate positions relative to the keys A and B:

Performing this rotation would be a simple matter of adjusting a few pointers — notably, a constant number of pointers, no matter how many nodes are in the tree, which means that this rotation would run in Θ(1) time:

A second kind of rotation is an RR rotation, which makes a similar adjustment.

RR rotation in an AVL tree

Note that an RR rotation is the mirror image of an LL rotation.

A third kind of rotation is an LR rotation, which makes an adjustment that's slightly more complicated.

LR rotation in an AVL tree

An LR rotation requires five pointer updates instead of three, but this is still a constant number of changes and runs in Θ(1) time.

Finally, there is an RL rotation, which is the mirror image of an LR rotation.

RL rotation in an AVL tree

Once we understand the mechanics of how rotations work, we're one step closer to understanding AVL trees. But these rotations aren't arbitrary; they're used specifically to correct imbalances that are detected after insertions or removals.

An insertion algorithm

Inserting a key into an AVL tree starts out the same way as insertion into a binary search tree:

The problem is that adding the new node introduced the possibility of an imbalance. For example, suppose we started with this AVL tree:

An AVL tree in jeopardy of becoming imbalanced

and then we inserted the key 35 into it. A binary search tree insertion would give us this as a result:

An AVL tree after becoming imbalanced

But this resulting tree is not an AVL tree, because the node containing the key 40 does not have the AVL property, because the difference in the heights of its subtrees is 2. (Its left subtree has height 1, its right subtree — which is empty — has height -1.) What can we do about it?

The answer lies in the following algorithm, which we perform after the normal insertion process:

It can be shown that any one of these rotations — LL, RR, LR, or RL — will correct any imbalance brought on by inserting a key.

In this case, we'd perform an LR rotation — the first two links leading from 40 down toward 35 are a Left and a Right — rooted at 40, which would correct the imbalance, and the tree would be rearranged to look like this:

An AVL tree after fixing the imbalance

Compare this to the diagram describing an LR rotation:

After the rotation, we see what we'd expect:

Note, too, that the tree is more balanced after the rotation than it was before. This is no accident; a single rotation (LL, RR, LR, or RL) is all that's necessary to correct an imbalance introduced by the insertion algorithm.

A removal algorithm

Removals are somewhat similar to insertions, in the sense that you would start with the usual binary search tree removal algorithm, then find and correct imbalances while the recursion unwinds. The key difference is that removals can require more than one rotation to correct imbalances, but will still only require rotations on the path back up to the root from where the removal occurred — so, generally, O(log n) rotations.

Asymptotic analysis

The key question here is What is the height of an AVL tree with n nodes? If the answer is Θ(log n), then we can be certain that lookups, insertions, and removals will take O(log n) time. How can we be so sure?

Lookups would be O(log n) because they're the same as they are in a binary search tree that doesn't have the AVL property. If the height of the tree is Θ(log n), lookups will run in O(log n) time. Insertions and removals, despite being slightly more complicated in an AVL tree, do their work by traversing a single path in the tree — potentially all the way down to a leaf position, then all the way back up. If the length of the longest path — that's what the height of a tree is! — is Θ(log n), then we know that none of these paths is longer than that, so insertions and removals will take O(log n) time.

So we're left with that key question. What is the height of an AVL tree with n nodes? (If you're not curious, you can feel free to just assume this; if you want to know more, keep reading.)

What is the height of an AVL tree with n nodes? (Optional)

The answer revolves around noting how many nodes, at minimum, could be in a binary search tree of height n and still have it be an AVL tree. It turns out AVL trees of height n ≥ 2 that have the minimum number of nodes in them all share a similar property:

The AVL tree with height h ≥ 2 with the minimum number of nodes consists of a root node with two subtrees, one of which is an AVL tree with height h − 1 with the minimum number of nodes, the other of which is an AVL tree with height h − 2 with the minimum number of nodes.

Given that observation, we can write a recurrence that describes the number of nodes, at minimum, in an AVL tree of height h.

      M(0) = 1       When height is 0, minimum number of nodes is 1 (a root node with no children)
      M(1) = 2       When height is 1, minimum number of nodes is 2 (a root node with one child and not the other)
      M(h) = 1 + M(h - 1) + M(h - 2)

While the repeated substitution technique we learned previously isn't a good way to try to solve this particular recurrence, we can prove something interesting quite easily. We know for sure that AVL trees with larger heights have a bigger minimum number of nodes than AVL trees with smaller heights — that's fairly self-explanatory — which means that we can be sure that 1 + M(h − 1) ≥ M(h − 2). Given that, we can conclude the following:

      M(h) ≥ 2M(h - 2)

We can then use the repeated substitution technique to determine a lower bound for this recurrence:

      M(h) ≥ 2M(h -  2)
           ≥ 2(2M(h - 4))
           ≥ 4M(h - 4)
           ≥ 4(2M(h - 6))
           ≥ 8M(h - 6)
           ...
           ≥ 2jM(h - 2j)         We could prove this by induction on j, but we'll accept it on faith

        let j = h/2

           ≥ 2h/2M(h - h)
           ≥ 2h/2M(0)
      M(h) ≥ 2h/2

So, we've shown that the minimum number of nodes that can be present in an AVL tree of height h is at least 2h/2. In reality, it's actually more than that, but this gives us something useful to work with; we can use this result to figure out what we're really interested in, which is the opposite: what is the height of an AVL tree with n nodes?

      M(h) ≥ 2h/2
  log2M(h) ≥ h/2
2 log2M(h) ≥ h

Finally, we see that, for AVL trees of height h with the minimum number of nodes, the height is no more than 2 log2n, where n is the number of nodes in the tree. For AVL trees with more than the minimum number of nodes, the relationship between the number of nodes and the height is even better, though, for reasons we've seen previously, we know that the relationship between the number of nodes and the height of a binary tree can never be better than logarithmic. So, ultimately, we see that the height of an AVL tree with n nodes is Θ(log n).

(In reality, it turns out that the bound is lower than 2 log2n; it's something more akin to about 1.44 log2n, even for AVL trees with the minimum number of nodes, though the proof of that is more involved and doesn't change the asymptotic result.)