Hash indexes
An index helps the database find rows without scanning the whole table. A B+ tree keeps keys sorted and supports range queries. A hash index instead sends each key straight to a bucket (a disk page) computed from its hash, like a hash table on disk. Equality lookups (WHERE id = 42) take about one disk read. Range queries get no help.
The hard part is growth. What happens when buckets fill up?
Static hashing
Fix the number of buckets N, and put key k in bucket h(k) mod N. When a bucket is full, chain an overflow page behind it. As the data grows, chains get longer and every lookup in that bucket reads more pages. Rebuilding with a bigger N means rehashing every record, which is far too expensive for a busy database.
Extendible hashing
Add one level of indirection: a directory of 2^g pointers to buckets.
- Global depth g: the directory uses the last g bits of the hash to pick an entry. Some books use the first bits instead, and the idea is the same.
- Local depth d (one per bucket): the bucket’s keys agree on their last d bits. Several directory entries, 2^(g − d) of them, may share one bucket.
Inserting key k:
- Follow entry (last g bits of h(k)) to its bucket. If there’s room, store k. Done.
- If the bucket is full and d < g: split the bucket. Make a new bucket, give both local depth d + 1, and move the keys whose bit d + 1 is 1. Repoint half of the entries that pointed to the old bucket.
- If the bucket is full and d = g: first double the directory (g + 1, each entry copied, no keys moved), then split as in step 2.
- Try k again. A very uneven split may need another round.
Worked example
Each bucket holds 2 keys. We start with g = 1 and two buckets, then insert 4, 9, 6, 13, 2, 11, 10, 20.
| Insert | What happens |
|---|---|
| 4, 9, 6, 13 | last bit 0 → 4, 6; last bit 1 → 9, 13. Both buckets are now full. |
| 2 = 10₂ | entry 0 is full with d = 1 = g, so double to g = 2, then split on bit 2: [4] and [6]. Store 2 next to 6. |
| 11 = 1011₂ | entry 11 shares the full bucket [9, 13] (d = 1 < g = 2), so split only. 9 and 13 both end in 01 and stay put. The new bucket for 11 starts empty, so store 11 there. |
| 10 = 1010₂ | entry 10 → [6, 2] is full with d = 2 = g, so double to g = 3. Split on bit 3: 2 = 010 stays, 6 = 110 moves. Store 10 in [2, 10]. |
| 20 = 10100₂ | last 3 bits 100 → the bucket [4] (d = 2, shared by 000 and 100). Store 20. |
Final: 8 directory entries, 5 buckets, and every lookup takes one directory step plus one bucket read.
Strengths and weaknesses
- Graceful growth. Only the full bucket splits, and the directory doubles rarely. Doubling copies only pointers, never records.
- Fast lookups. The directory usually fits in memory, so a lookup costs one disk read.
- Skewed keys hurt. Keys that share many low-order bits, such as 0, 8, 16 and 24, force repeated doublings, and the directory can grow huge.
- Linear hashing, a related scheme, avoids the directory altogether by splitting buckets in a fixed round-robin order.
Code
CAP = 2 # keys per bucket
class Bucket:
def __init__(self, depth):
self.depth, self.keys = depth, []
def insert(directory, g, k):
"""Insert key k; return the (possibly doubled) directory and global depth."""
while True:
b = directory[k & ((1 << g) - 1)] # use the last g bits
if len(b.keys) < CAP:
b.keys.append(k)
return directory, g
if b.depth == g: # no spare bit: double the directory
directory, g = directory + directory, g + 1
b.depth += 1 # split on one more bit
new, bit = Bucket(b.depth), 1 << (b.depth - 1)
for j, x in enumerate(directory):
if x is b and j & bit:
directory[j] = new
b.keys, new.keys = [x for x in b.keys if not x & bit], [x for x in b.keys if x & bit]
directory, g = [Bucket(1), Bucket(1)], 1
for k in [4, 9, 6, 13, 2, 11, 10, 20]:
directory, g = insert(directory, g, k)
for i, b in enumerate(directory):
print(f'{i:0{g}b} -> depth {b.depth}: {b.keys}')
# 000 -> depth 2: [4, 20]
# 001 -> depth 2: [9, 13]
# 010 -> depth 3: [2, 10]
# 011 -> depth 2: [11]
# 100 -> depth 2: [4, 20]
# 101 -> depth 2: [9, 13]
# 110 -> depth 3: [6]
# 111 -> depth 2: [11]
Common mistakes
- Doubling the directory on every split. Only when the full bucket’s local depth equals g.
- Mixing up global and local depth. g belongs to the directory, d to each bucket, and d ≤ g always.
- Forgetting shared buckets. After a doubling, pairs of entries point to the same bucket until it splits.
- Switching between first and last bits. Pick one convention and use it everywhere.
- Expecting range queries. A hash index scatters neighbouring keys. Use a B+ tree for ranges.
Complexity at a glance
| Case / operation | Time | Why |
|---|---|---|
| Lookup | 1 directory step + 1 bucket read | The directory is small and usually stays in memory. |
| Insert without a split | O(1) | |
| Bucket split | O(bucket size) | Plus updating the directory entries that pointed to the old bucket. |
| Directory doubling | O(2^g) pointers copied | Rare. It happens only when a full bucket's local depth equals g. |
Quick check
Test yourself — pick an answer to see if you got it.
1. A full bucket has local depth 2 and the global depth is 3. What happens on the next insert into it?
Two directory entries point to it (2^(3−2)), so there is a spare bit to split it without growing the directory.
2. How many directory entries point to a bucket with local depth d when the global depth is g?
The bucket fixes d bits. The other g − d bits can be anything.
3. The global depth is 3 and the index uses the last bits. Which directory entry does the key 20 = 10100₂ use?
The last three bits of 10100 are 100.
4. What is the main weakness of static hashing as the table grows?
The number of buckets is fixed. The only cure is rebuilding the whole table with more buckets.
5. When does the directory double?
Then no spare bit is left to split the bucket on, so g must grow first.