This week we learned about data structures that make common operations fast: balanced search trees and hashing for searching, and heaps for finding the largest (or smallest) value.
Balanced search trees
In a binary search tree, every key in a node’s left subtree is smaller than the node’s key, and every key in its right subtree is larger. Searching works like binary search: compare with the current node, then go left or right, which takes Θ(log n) time if the tree is balanced, but the shape depends on the order of insertion. If you insert keys in sorted order, every node only has a right child, the tree is basically a linked list, and search drops to Θ(n). Balanced search trees fix this by rebalancing as keys are inserted.
AVL trees
An AVL tree is a binary search tree where every node has a balance factor of −1, 0, or 1. The balance factor is the height of the node’s left subtree minus the height of its right subtree, with an empty tree having height −1. You have to check every node, not just the root, because a tree can look balanced at the root and still be lopsided further down.
When an insertion makes a node’s balance factor 2 or −2, you fix the unbalanced node closest to the new leaf with one of four rotations:
- R (right) rotation: the new key went into the left subtree of the left child
- L (left) rotation: the mirror image, the right subtree of the right child
- LR rotation: the new key went into the right subtree of the left child, fixed by a left rotation and then a right rotation
- RL rotation: the mirror image of LR
The trick is that the middle of the three keys involved always becomes the new subtree root.
2-3 trees
A 2-3 tree handles balancing differently. Instead of rotating, it lets nodes hold more than one key. A 2-node has one key and two children, a 3-node has two keys and three children, and all the leaves have to be on the same level. New keys always go into a leaf. If that leaf ends up with three keys, it splits: the middle key moves up into the parent, and the smallest and largest keys become their own nodes. If the parent overflows too, it splits the same way, and if the root splits, the tree gets a new root. Since the tree only ever grows at the top, all the leaves stay at the same depth, and search is Θ(log n) no matter what order the keys come in.
Heaps
A heap is a complete binary tree where every parent is greater than or equal to its children (for a max heap; a min heap is the reverse). Either way, the max (or min) is always at the root. Since the tree is complete, you can store it in an array without any pointers. With 1-based indexing, the children of index i are at 2i and 2i + 1, and its parent is at ⌊i/2⌋.
To insert a key, you put it at the end and sift it up, swapping it with its parent until the parent is bigger. To remove the max, you swap the root with the last element, shrink the heap by one, and sift the new root down, swapping it with its larger child each time. Both operations take Θ(log n).
The bottom-up heapify construction is a little tricky. Instead of inserting keys one at a time, you put them all in the array and start at the last parent, ⌊n/2⌋. Moving backward to the root, you sift each node down. By the time you reach a node, both of its subtrees are already heaps. It seems like it should be Θ(n log n), but most nodes are near the bottom and can only move down a level or two, so the whole thing is Θ(n). Heapsort builds a heap this way and then removes the max n times, putting each one into the spot that just opened up at the end of the array, sorting in place in Θ(n log n).
Hashing
Hashing uses a hash function, like h(k) = k mod m, to turn a key into an index in an array of size m. When two keys hash to the same index, that is called a collision. There are two main ways to handle collisions. Separate chaining (open hashing) keeps a linked list at each index. Linear probing (closed hashing) puts the key in the next open cell, wrapping around to the start if needed. The load factor α = n/m measures how full the table is. When it gets too high, collisions pile up, so you rehash everything into a bigger table. With a good hash function and a reasonable load factor, search, insert, and delete take Θ(1) on average, although the worst case is still Θ(n).
