Overview
AVL trees are self-balancing binary search trees named after inventors Georgy Adelson-Velsky and Evgenii Landis. The structure maintains the binary search tree property while ensuring the tree remains balanced after every insertion and deletion operation. Balance is maintained by requiring that for every node, the heights of its left and right subtrees differ by at most one.
The balance constraint guarantees that the tree height remains logarithmic relative to the number of nodes, ensuring operations like search, insertion, and deletion complete in O(log n) time. This predictable performance makes AVL trees suitable for applications requiring guaranteed worst-case performance rather than average-case optimization.
When an operation violates the balance property, the tree performs one or more rotations to restore balance. Four rotation types handle all possible imbalance scenarios: left rotation, right rotation, left-right rotation, and right-left rotation. Each rotation restructures a small portion of the tree while preserving the binary search tree ordering.
Balanced AVL Tree:
8
/ \
4 12
/ \ / \
2 6 10 14
Imbalanced after inserting 1:
8
/ \
4 12
/ / \
2 10 14
/
1
After right rotation on 4:
8
/ \
2 12
/ \ / \
1 4 10 14
AVL trees provide stronger balance guarantees than red-black trees, resulting in faster lookups at the cost of more frequent rebalancing during modifications. Applications prioritizing read operations over write operations benefit from this trade-off.
Key Principles
Every AVL tree node stores a balance factor calculated as the height of its right subtree minus the height of its left subtree. Valid balance factors are -1, 0, and 1. A balance factor of -1 indicates the left subtree is one level taller, 0 indicates equal heights, and 1 indicates the right subtree is one level taller. Any other value violates the AVL property.
The tree enforces the balance property through structural rotations. A rotation changes the tree structure while maintaining the binary search tree ordering property. Single rotations handle cases where an imbalance occurs in a straight line (left-left or right-right), while double rotations handle zigzag patterns (left-right or right-left).
Left Rotation: Applied when the right subtree is too tall. The right child becomes the new root, the original root becomes the left child of the new root, and the new root's former left child becomes the original root's right child.
Before left rotation:
A
\
B
\
C
After left rotation:
B
/ \
A C
Right Rotation: Applied when the left subtree is too tall. The left child becomes the new root, the original root becomes the right child of the new root, and the new root's former right child becomes the original root's left child.
Before right rotation:
C
/
B
/
A
After right rotation:
B
/ \
A C
Left-Right Rotation: Applied when a node's left child has a taller right subtree. First performs a left rotation on the left child, then a right rotation on the original node.
Before:
C
/
A
\
B
After left rotation on A:
C
/
B
/
A
After right rotation on C:
B
/ \
A C
Right-Left Rotation: Applied when a node's right child has a taller left subtree. First performs a right rotation on the right child, then a left rotation on the original node.
Height calculation forms the foundation for balance factor computation. A node's height equals one plus the maximum height of its children. Leaf nodes have height 0, and nil nodes have height -1. The tree recalculates heights and balance factors along the path from the modified node to the root after each insertion or deletion.
The insertion process follows standard binary search tree insertion, then walks back up the tree updating balance factors. If a node's balance factor becomes -2 or 2, the algorithm determines which rotation pattern to apply based on the balance factors of the node and its children. After rotation, the tree regains balance and no further rotations are needed for that insertion.
Deletion requires more complex handling. After removing a node using standard binary search tree deletion, the algorithm traces back to the root, potentially requiring multiple rotations. Each rotation may cause a reduction in subtree height, triggering additional imbalance higher in the tree. This cascading effect means deletion may require up to O(log n) rotations in the worst case, though typically fewer occur in practice.
Ruby Implementation
Ruby does not include an AVL tree in its standard library, requiring custom implementation. The implementation consists of a node class storing value, left and right children, and height, plus a tree class managing the root and tree operations.
class AVLNode
attr_accessor :value, :left, :right, :height
def initialize(value)
@value = value
@left = nil
@right = nil
@height = 0
end
def balance_factor
right_height = @right ? @right.height : -1
left_height = @left ? @left.height : -1
right_height - left_height
end
def update_height
right_height = @right ? @right.height : -1
left_height = @left ? @left.height : -1
@height = [right_height, left_height].max + 1
end
end
The tree class implements insertion with automatic rebalancing:
class AVLTree
attr_reader :root
def initialize
@root = nil
end
def insert(value)
@root = insert_recursive(@root, value)
end
private
def insert_recursive(node, value)
return AVLNode.new(value) if node.nil?
if value < node.value
node.left = insert_recursive(node.left, value)
elsif value > node.value
node.right = insert_recursive(node.right, value)
else
return node # Duplicate values not allowed
end
node.update_height
rebalance(node)
end
def rebalance(node)
balance = node.balance_factor
# Left heavy
if balance < -1
if node.left.balance_factor > 0
node.left = rotate_left(node.left)
end
return rotate_right(node)
end
# Right heavy
if balance > 1
if node.right.balance_factor < 0
node.right = rotate_right(node.right)
end
return rotate_left(node)
end
node
end
def rotate_left(node)
new_root = node.right
node.right = new_root.left
new_root.left = node
node.update_height
new_root.update_height
new_root
end
def rotate_right(node)
new_root = node.left
node.left = new_root.right
new_root.right = node
node.update_height
new_root.update_height
new_root
end
end
Search follows standard binary search tree logic without modification:
class AVLTree
def search(value)
search_recursive(@root, value)
end
private
def search_recursive(node, value)
return nil if node.nil?
return node if node.value == value
if value < node.value
search_recursive(node.left, value)
else
search_recursive(node.right, value)
end
end
end
Deletion requires finding the node, removing it, and rebalancing the path back to the root:
class AVLTree
def delete(value)
@root = delete_recursive(@root, value)
end
private
def delete_recursive(node, value)
return nil if node.nil?
if value < node.value
node.left = delete_recursive(node.left, value)
elsif value > node.value
node.right = delete_recursive(node.right, value)
else
# Node with one child or no child
return node.right if node.left.nil?
return node.left if node.right.nil?
# Node with two children
successor = find_min(node.right)
node.value = successor.value
node.right = delete_recursive(node.right, successor.value)
end
node.update_height
rebalance(node)
end
def find_min(node)
current = node
current = current.left while current.left
current
end
end
In-order traversal visits nodes in sorted order:
class AVLTree
def in_order_traversal
result = []
in_order_recursive(@root, result)
result
end
private
def in_order_recursive(node, result)
return if node.nil?
in_order_recursive(node.left, result)
result << node.value
in_order_recursive(node.right, result)
end
end
Practical Examples
A sorted set implementation demonstrates AVL tree usage for maintaining ordered unique elements:
class SortedSet
def initialize
@tree = AVLTree.new
@size = 0
end
def add(value)
previous_size = size
@tree.insert(value)
@size = @tree.in_order_traversal.length
@size > previous_size
end
def include?(value)
!@tree.search(value).nil?
end
def remove(value)
return false unless include?(value)
@tree.delete(value)
@size -= 1
true
end
def to_a
@tree.in_order_traversal
end
def size
@size
end
end
# Usage
sorted_set = SortedSet.new
sorted_set.add(5)
sorted_set.add(2)
sorted_set.add(8)
sorted_set.add(2) # => false, already exists
sorted_set.to_a # => [2, 5, 8]
sorted_set.include?(5) # => true
sorted_set.remove(2) # => true
sorted_set.to_a # => [5, 8]
Range query implementation finds all values within a specified range:
class AVLTree
def range_query(min, max)
result = []
range_query_recursive(@root, min, max, result)
result
end
private
def range_query_recursive(node, min, max, result)
return if node.nil?
range_query_recursive(node.left, min, max, result) if node.value > min
result << node.value if node.value >= min && node.value <= max
range_query_recursive(node.right, min, max, result) if node.value < max
end
end
# Usage
tree = AVLTree.new
[10, 5, 15, 3, 7, 12, 20].each { |val| tree.insert(val) }
tree.range_query(6, 15) # => [7, 10, 12, 15]
tree.range_query(1, 8) # => [3, 5, 7]
Priority queue implementation with efficient access to minimum and maximum elements:
class PriorityQueue
def initialize
@tree = AVLTree.new
end
def enqueue(priority, item)
@tree.insert(priority)
end
def dequeue_min
return nil if @tree.root.nil?
min_node = find_min(@tree.root)
min_priority = min_node.value
@tree.delete(min_priority)
min_priority
end
def peek_min
min_node = find_min(@tree.root)
min_node&.value
end
def empty?
@tree.root.nil?
end
private
def find_min(node)
current = node
current = current.left while current&.left
current
end
end
# Usage
pq = PriorityQueue.new
pq.enqueue(5, "task1")
pq.enqueue(2, "task2")
pq.enqueue(8, "task3")
pq.dequeue_min # => 2
pq.peek_min # => 5
Database index simulation using AVL trees for efficient lookups:
class DatabaseIndex
def initialize
@indices = Hash.new { |h, k| h[k] = AVLTree.new }
end
def add_record(id, attributes)
attributes.each do |column, value|
@indices[column].insert(value)
end
end
def find_by(column, value)
node = @indices[column].search(value)
!node.nil?
end
def find_range(column, min, max)
@indices[column].range_query(min, max)
end
end
# Usage
index = DatabaseIndex.new
index.add_record(1, { age: 25, salary: 50000 })
index.add_record(2, { age: 30, salary: 60000 })
index.add_record(3, { age: 28, salary: 55000 })
index.find_by(:age, 30) # => true
index.find_range(:salary, 50000, 58000) # => [50000, 55000]
Common Patterns
The lazy height update pattern recalculates heights only when needed during rebalancing, avoiding unnecessary computation:
class AVLNode
def lazy_update_height
return if @left.nil? && @right.nil? && @height == 0
right_height = @right ? @right.height : -1
left_height = @left ? @left.height : -1
@height = [right_height, left_height].max + 1
end
end
The parent pointer pattern maintains references to parent nodes, simplifying traversal back to the root:
class AVLNodeWithParent
attr_accessor :value, :left, :right, :parent, :height
def initialize(value)
@value = value
@left = nil
@right = nil
@parent = nil
@height = 0
end
def update_ancestors
current = self
while current
current.update_height
current = current.parent
end
end
end
The iterative insertion pattern avoids recursion for environments with limited stack space:
def insert_iterative(value)
return @root = AVLNode.new(value) if @root.nil?
path = []
current = @root
parent = nil
while current
path << current
parent = current
if value < current.value
current = current.left
elsif value > current.value
current = current.right
else
return # Duplicate
end
end
new_node = AVLNode.new(value)
if value < parent.value
parent.left = new_node
else
parent.right = new_node
end
path.reverse.each do |node|
node.update_height
rebalance_at(node)
end
end
The bulk loading pattern builds balanced trees efficiently from sorted input:
def build_from_sorted(values)
build_recursive(values, 0, values.length - 1)
end
def build_recursive(values, start, finish)
return nil if start > finish
mid = (start + finish) / 2
node = AVLNode.new(values[mid])
node.left = build_recursive(values, start, mid - 1)
node.right = build_recursive(values, mid + 1, finish)
node.update_height
node
end
The split pattern divides a tree into two trees based on a threshold value:
def split(threshold)
smaller_tree = AVLTree.new
larger_tree = AVLTree.new
in_order_traversal.each do |value|
if value < threshold
smaller_tree.insert(value)
else
larger_tree.insert(value)
end
end
[smaller_tree, larger_tree]
end
Performance Considerations
AVL trees guarantee O(log n) time complexity for search, insertion, and deletion operations in all cases, including worst case. This contrasts with unbalanced binary search trees that degrade to O(n) when insertions create a linear chain. The strict balance requirement keeps tree height bounded by 1.44 * log n.
Space complexity is O(n) for storing n nodes. Each node stores constant additional data: two child pointers and a height value. Some implementations store the balance factor directly instead of height, saving computation at the cost of additional storage.
Rebalancing overhead affects write-heavy workloads. Insertion requires at most one rotation, plus O(log n) height updates along the path to the root. Deletion may require multiple rotations in the worst case, though empirical studies show an average of 0.21 rotations per deletion. Applications with frequent modifications and infrequent lookups may prefer red-black trees, which guarantee at most three rotations per operation.
Cache performance suffers compared to array-based structures because pointer chasing causes cache misses. Tree traversal accesses memory locations scattered throughout the heap rather than contiguous locations. For datasets fitting in cache, sorted arrays with binary search often outperform AVL trees despite worse theoretical complexity.
require 'benchmark'
# Compare AVL tree vs sorted array for different operations
avl = AVLTree.new
sorted_array = []
n = 10000
Benchmark.bm do |x|
x.report("AVL insert:") do
n.times { |i| avl.insert(rand(100000)) }
end
x.report("Array insert:") do
n.times do |i|
value = rand(100000)
index = sorted_array.bsearch_index { |x| x >= value } || sorted_array.length
sorted_array.insert(index, value)
end
end
x.report("AVL search:") do
1000.times { avl.search(rand(100000)) }
end
x.report("Array search:") do
1000.times { sorted_array.bsearch { |x| x >= rand(100000) } }
end
end
The height calculation overhead accumulates during operations. Each insertion or deletion triggers O(log n) height updates. Caching heights at nodes reduces this to constant time per update. Without caching, height calculation requires traversing both subtrees, increasing complexity to O(n) per operation.
Memory allocation patterns affect performance in garbage-collected languages like Ruby. Frequent insertions and deletions create allocation pressure, triggering garbage collection pauses. Preallocating a node pool or using object pooling reduces allocation overhead:
class NodePool
def initialize(capacity)
@pool = Array.new(capacity) { AVLNode.new(nil) }
@index = 0
end
def allocate(value)
return AVLNode.new(value) if @index >= @pool.length
node = @pool[@index]
node.value = value
node.left = nil
node.right = nil
node.height = 0
@index += 1
node
end
def reset
@index = 0
end
end
Common Pitfalls
Forgetting to update heights after rotations causes incorrect balance factor calculations. Every rotation changes the heights of the involved nodes. The rotated node and the new root both need height updates:
# INCORRECT - missing height updates
def rotate_left(node)
new_root = node.right
node.right = new_root.left
new_root.left = node
new_root # Heights not updated
end
# CORRECT
def rotate_left(node)
new_root = node.right
node.right = new_root.left
new_root.left = node
node.update_height # Update old root first
new_root.update_height # Then new root
new_root
end
Calculating balance factor as left height minus right height instead of right minus left creates inverted logic. This reverses which rotations apply:
# INCORRECT
def balance_factor
left_height = @left ? @left.height : -1
right_height = @right ? @right.height : -1
left_height - right_height # Wrong direction
end
# CORRECT
def balance_factor
left_height = @left ? @left.height : -1
right_height = @right ? @right.height : -1
right_height - left_height
end
Applying single rotations to zigzag patterns fails to restore balance. Left-right and right-left imbalances require double rotations:
# INCORRECT - only checks top-level balance
def rebalance(node)
balance = node.balance_factor
return rotate_left(node) if balance > 1 # Missing child check
return rotate_right(node) if balance < -1 # Missing child check
node
end
# CORRECT
def rebalance(node)
balance = node.balance_factor
if balance > 1
node.right = rotate_right(node.right) if node.right.balance_factor < 0
return rotate_left(node)
end
if balance < -1
node.left = rotate_left(node.left) if node.left.balance_factor > 0
return rotate_right(node)
end
node
end
Not rebalancing after deletion allows the tree to become unbalanced. Deletion requires rebalancing along the entire path from the deleted node to the root:
# INCORRECT - no rebalancing
def delete_recursive(node, value)
return nil if node.nil?
if value < node.value
node.left = delete_recursive(node.left, value)
elsif value > node.value
node.right = delete_recursive(node.right, value)
else
return node.right if node.left.nil?
return node.left if node.right.nil?
successor = find_min(node.right)
node.value = successor.value
node.right = delete_recursive(node.right, successor.value)
end
node # Missing rebalance call
end
# CORRECT
def delete_recursive(node, value)
return nil if node.nil?
if value < node.value
node.left = delete_recursive(node.left, value)
elsif value > node.value
node.right = delete_recursive(node.right, value)
else
return node.right if node.left.nil?
return node.left if node.right.nil?
successor = find_min(node.right)
node.value = successor.value
node.right = delete_recursive(node.right, successor.value)
end
node.update_height
rebalance(node) # Essential step
end
Using nil height as 0 instead of -1 breaks height calculations for leaf nodes. Leaf nodes should have height 0, which requires treating nil children as height -1:
# INCORRECT
def update_height
right_height = @right ? @right.height : 0 # Wrong
left_height = @left ? @left.height : 0 # Wrong
@height = [right_height, left_height].max + 1
end
# CORRECT
def update_height
right_height = @right ? @right.height : -1
left_height = @left ? @left.height : -1
@height = [right_height, left_height].max + 1
end
Modifying node values directly after insertion violates the binary search tree property. Values determine node position and cannot change after insertion without reconstruction:
# INCORRECT
node = tree.search(10)
node.value = 20 # Breaks BST ordering
# CORRECT
tree.delete(10)
tree.insert(20)
Reference
Balance Factor Values
| Balance Factor | Interpretation | Action Required |
|---|---|---|
| -1 | Left subtree one level taller | None |
| 0 | Subtrees equal height | None |
| 1 | Right subtree one level taller | None |
| -2 | Left subtree too tall | Rebalance required |
| 2 | Right subtree too tall | Rebalance required |
Rotation Selection
| Node Balance | Child Balance | Rotation Required |
|---|---|---|
| -2 | -1 or 0 | Right rotation |
| -2 | 1 | Left-right rotation |
| 2 | 1 or 0 | Left rotation |
| 2 | -1 | Right-left rotation |
Time Complexity
| Operation | Average Case | Worst Case | Notes |
|---|---|---|---|
| Search | O(log n) | O(log n) | Guaranteed logarithmic |
| Insert | O(log n) | O(log n) | At most one rotation |
| Delete | O(log n) | O(log n) | Multiple rotations possible |
| Find Min | O(log n) | O(log n) | Traverse left spine |
| Find Max | O(log n) | O(log n) | Traverse right spine |
| In-order Traversal | O(n) | O(n) | Visits all nodes |
| Range Query | O(k + log n) | O(k + log n) | k is result size |
Space Complexity
| Aspect | Complexity | Description |
|---|---|---|
| Tree Storage | O(n) | One node per element |
| Height Storage | O(1) per node | Integer at each node |
| Recursion Depth | O(log n) | Stack space for operations |
| Total | O(n) | Linear in number of nodes |
Operation Characteristics
| Property | AVL Tree | Red-Black Tree | Unbalanced BST |
|---|---|---|---|
| Max Height | 1.44 log n | 2 log n | n |
| Insert Rotations | At most 1 | At most 2 | 0 |
| Delete Rotations | O(log n) | At most 3 | 0 |
| Lookup Speed | Faster | Slower | Variable |
| Write Speed | Slower | Faster | Fastest |
| Balance Guarantee | Strict | Relaxed | None |
Node Structure
| Field | Type | Purpose |
|---|---|---|
| value | Comparable | Stored data element |
| left | AVLNode or nil | Left child reference |
| right | AVLNode or nil | Right child reference |
| height | Integer | Distance to deepest leaf |
| parent | AVLNode or nil | Parent reference (optional) |
Implementation Checklist
| Step | Required Action | Verification |
|---|---|---|
| 1 | Define node class with value, children, height | Node stores all required fields |
| 2 | Implement height calculation method | Nil nodes return -1 |
| 3 | Implement balance factor calculation | Right height minus left height |
| 4 | Implement left rotation | Preserves BST ordering |
| 5 | Implement right rotation | Preserves BST ordering |
| 6 | Implement rebalance logic | Checks child balance factors |
| 7 | Update heights after rotations | Both rotated nodes updated |
| 8 | Insert with rebalancing | Single rotation per insert |
| 9 | Delete with rebalancing | Multiple rotations possible |
| 10 | Test all rotation patterns | LL, RR, LR, RL cases work |