AmouAI Hub/Courses/Data Structures & Algorithms/Chapter 29
Advanced Structures: Segment Trees, Fenwick, LRU, Bloom
Four structures that show up the moment scale gets serious: range queries with updates, an O(1) cache, and a filter that is allowed to be wrong in exactly one direction.
By the end of this chapter you can
- Explain why a prefix-sum array fails as soon as updates are allowed
- Build a segment tree and describe exactly which O(log n) nodes a query touches
- Implement a Fenwick tree and explain what
i & -iis doing - Build an LRU cache from a hash map plus a doubly linked list, with every operation O(1)
- Describe a Bloom filter's error model and compute its false-positive rate
- Choose the right structure for a given access pattern
1Range queries meet updates
Either of the two operations alone is easy. Wanting both at once is what forces you into a tree.
“What is the sum of elements 400 through 900?” is answered instantly by a prefix-sum
array (Chapter 4): precompute pre[i] = a[0] + ... + a[i-1] once, then any range is
pre[hi+1] - pre[lo] in O(1).
Now change one element. Every prefix sum after it is wrong, and repairing them is
O(n).
| Structure | Range query | Point update | Space |
|---|---|---|---|
| Plain array | O(n) | O(1) | n |
| Prefix-sum array | O(1) | O(n) | n |
| Segment tree | O(log n) | O(log n) | ~4n |
| Fenwick (BIT) | O(log n) | O(log n) | n |
| Sqrt decomposition | O(√n) | O(1) | n + √n |
A prefix-sum array is a ledger with a running balance written on every line. Reading any range is a subtraction. Correcting a single early entry means rewriting every line below it.
A segment tree is a set of subtotals arranged in a hierarchy: totals per day, per week, per month, per year. Changing one transaction updates its day, its week, its month and its year — four numbers, not four thousand. And any range you ask for can be assembled from a handful of those subtotals.
That is the trade the rest of this section makes concrete: give up O(1) queries to
get O(log n) updates, and be far better off overall.
The sqrt decomposition in that table is worth a sentence, because it is the
easiest of the four to write and is often enough: chop the array into blocks of
√n, keep a total per block, and answer a query by walking the partial blocks at
each end plus the whole blocks in between. Twenty lines, no recursion, and
√100000 ≈ 316 is fast in practice.
2Segment trees
A binary tree over ranges. The root covers everything, each node splits its range in half, and the leaves are the elements.
Each internal node stores the combination of its two children — the sum, the minimum, the
maximum, the GCD, whatever your operation is. Updating a leaf repairs the log n nodes on
the path to the root; a query assembles the answer from at most 2 log n nodes.
class SegmentTree:
"""Iterative, 1-indexed-internally, using the standard 2n array layout."""
def __init__(self, data):
self.n = len(data)
self.t = [0] * (2 * self.n)
self.t[self.n:] = data # leaves occupy the second half
for i in range(self.n - 1, 0, -1): # build parents bottom-up
self.t[i] = self.t[2 * i] + self.t[2 * i + 1]
def update(self, i, value):
i += self.n
self.t[i] = value
i //= 2
while i: # repair the path to the root
self.t[i] = self.t[2 * i] + self.t[2 * i + 1]
i //= 2
def query(self, lo, hi):
"""Sum of data[lo:hi] — half open."""
total = 0
lo += self.n
hi += self.n
while lo < hi:
if lo & 1: # lo is a right child: take it, move on
total += self.t[lo]
lo += 1
if hi & 1: # hi is a right child: take its left sibling
hi -= 1
total += self.t[hi]
lo //= 2
hi //= 2
return totalWalk up from both ends of the range simultaneously. At each level you can absorb at most one node from the left boundary and one from the right, because everything strictly inside is covered by an ancestor you will reach next.
There are log n levels and at most two nodes taken per level, so the answer is
always the combination of fewer than 2 log n stored values — about 34 nodes for a
million elements.
The operation only has to be associative. Sum, min, max, GCD, matrix product, “the longest run of 1s in this range” — all fine.
Fenwick trees (next section) additionally need the operation to be invertible, because they answer a range by subtracting one prefix from another. There is no “un-minimum”, so range-minimum needs a segment tree.
Segment trees also extend to lazy propagation, where an update applies to a whole
range: mark the node, defer the work to its children until someone actually looks. That gives
O(log n) range updates as well as range queries, and it is the standard tool for
“add 5 to everything between here and there”.
3Fenwick trees
The same asymptotics as a segment tree, in n words of memory and eight lines of code — at the price of one piece of bit arithmetic you have to take seriously for ten minutes.
A Fenwick tree, or binary indexed tree, stores partial sums at cleverly chosen
positions: index i holds the sum of the i & -i elements ending at
i.
class Fenwick:
def __init__(self, n):
self.n = n
self.t = [0] * (n + 1) # 1-indexed; t[0] is unused
def add(self, i, delta):
"""Add delta to element i (0-indexed)."""
i += 1
while i <= self.n:
self.t[i] += delta
i += i & -i # move to the next node that covers i
def prefix(self, i):
"""Sum of the first i elements."""
total = 0
while i > 0:
total += self.t[i]
i -= i & -i # strip the lowest set bit
return total
def range_sum(self, lo, hi):
"""Sum of data[lo:hi] — half open."""
return self.prefix(hi) - self.prefix(lo)i & -i is the lowest set bitIn two's complement, -i is ~i + 1, which flips every bit above the
lowest set bit and leaves that bit alone. AND-ing the two therefore isolates exactly that bit:
12 = 0b1100
-12 = 0b0100 (as far as the low bits are concerned)
12 & -12 = 4 <- the lowest set bit
So t[12] covers 4 elements: 9, 10, 11, 12. t[8] covers 8 elements,
t[7] covers 1. Stripping the lowest set bit walks you down through a prefix in at most
log n steps, because a number has at most log n set bits.
The prefix sum up to 13 is t[13] + t[12] + t[8], because 13 = 8 + 4 + 1 and each
term covers a block of that size.
It is the same move as making 13 pence from coins of 8, 4 and 1: write the number in binary and
each 1-bit is a coin you must hand over. The number of steps is the number of set bits, which is why
the whole thing is O(log n) without any tree ever being built.
| Segment tree | Fenwick tree | |
|---|---|---|
| Memory | 2n to 4n | n |
| Code length | ~30 lines | ~8 lines |
| Constant factor | Higher | Very low |
| Sum, XOR, count | Yes | Yes |
| Min, max, GCD | Yes | No — not invertible |
| Range update | Yes, with lazy propagation | Only with two trees and some algebra |
| Reach for it when | The operation is exotic, or updates hit ranges | You need prefix sums and speed |
Fenwick trees also solve the “count how many elements so far are smaller than
x” problem, which is how you count inversions in
O(n log n) without merge sort: sweep the array, and at each element ask the tree for the
number of already-seen values greater than it, then add the element.
4LRU caches
Two conflicting requirements: find any key instantly, and know which key was used longest ago. One structure cannot do both, so use two.
Papers you use often stay on the desk; the rest go in the drawer, which is slow. When the desk is full and you need something new, you put away whatever you have not touched in the longest time.
To make that work you need two things at once: to find any paper instantly (an index), and to know which paper has been untouched longest (an order). A pile gives you the order but not the lookup; an index gives you the lookup but not the order.
So keep both, and make sure that touching a paper updates both in constant time.
The classic implementation is a hash map plus a doubly linked list. The map takes
a key to its list node, so lookup is O(1). The list keeps the keys in recency order, and
because it is doubly linked, any node can be unlinked and moved to the front in O(1)
— which a singly linked list cannot do, since you would have to find the predecessor.
class Node:
__slots__ = ("key", "value", "prev", "next")
def __init__(self, key=None, value=None):
self.key, self.value = key, value
self.prev = self.next = None
class LRUCache:
def __init__(self, capacity):
self.cap = capacity
self.map = {}
self.head = Node() # sentinels remove every edge case from the
self.tail = Node() # unlink/insert code — no None checks at all
self.head.next = self.tail
self.tail.prev = self.head
def _unlink(self, node):
node.prev.next = node.next
node.next.prev = node.prev
def _push_front(self, node):
node.next = self.head.next
node.prev = self.head
self.head.next.prev = node
self.head.next = node
def get(self, key):
node = self.map.get(key)
if node is None:
return -1
self._unlink(node) # touching it makes it the most recent
self._push_front(node)
return node.value
def put(self, key, value):
node = self.map.get(key)
if node is not None:
node.value = value
self._unlink(node)
self._push_front(node)
return
if len(self.map) >= self.cap:
lru = self.tail.prev # the node just before the tail sentinel
self._unlink(lru)
del self.map[lru.key] # <- the eviction people forget
node = Node(key, value)
self.map[key] = node
self._push_front(node)- Forgetting
del self.map[lru.key]. The list shrinks, the map does not, and the cache silently grows without bound. This is why the evicted node must store its own key. - Not moving a node on
get. A read is a use. Skip the reorder and you have built an LFU-ish thing that evicts recently-read entries. - Skipping the sentinels. They cost two nodes and remove every “is this the head?” branch from the unlink and insert code. Almost every LRU bug lives in those branches.
OrderedDictcollections.OrderedDict is a hash map plus a doubly linked list, in C:
from collections import OrderedDict
class LRUCache(OrderedDict):
def __init__(self, cap): super().__init__(); self.cap = cap
def get(self, k):
if k not in self: return -1
self.move_to_end(k); return self[k]
def put(self, k, v):
if k in self: self.move_to_end(k)
self[k] = v
if len(self) > self.cap: self.popitem(last=False)
And functools.lru_cache — which you used in Chapter 25 for memoisation
— is exactly this structure with a function call as the key.
5Bloom filters
A set that answers 'definitely not present' or 'probably present', in a fraction of the memory. The asymmetry is the feature.
Instead of writing down every guest's name, you keep a long row of boxes. When a guest arrives you compute three numbers from their name and stamp those three boxes.
Later, to check whether someone is on the list, compute their three numbers. If any of those boxes is blank, they were definitely never stamped in — a certain no. If all three are stamped, they are probably on the list, but it is possible three other guests stamped those boxes between them.
You cannot un-stamp a box, which is why a Bloom filter cannot support deletion: unstamping might erase evidence of somebody else.
class BloomFilter:
def __init__(self, size, k):
self.size = size
self.k = k
self.bits = 0 # a Python int as an arbitrary-length bit vector
def _positions(self, item):
h = hash(item)
h1 = h & 0xFFFFFFFF
h2 = (h >> 32) & 0xFFFFFFFF or 0x9E3779B1
# Kirsch-Mitzenmacher: k hashes from two, with no loss of accuracy
return [(h1 + i * h2) % self.size for i in range(self.k)]
def add(self, item):
for p in self._positions(item):
self.bits |= 1 << p
def __contains__(self, item):
return all((self.bits >> p) & 1 for p in self._positions(item))- False negatives are impossible. If an item was added, all its bits are set, so the query returns True. Always.
- False positives are possible. Other items' bits can happen to cover all of yours.
With m bits, n items and k hash functions, the
false-positive rate is about (1 - e^(-kn/m))^k, minimised at
k = (m/n) · ln 2.
Concretely: about 10 bits per item and 7 hash functions gives roughly a 1% false-positive rate — and 10 bits is far less than storing a URL, an email address or a 32-byte hash. That ratio is the entire reason the structure exists.
| Use | Why the asymmetry is acceptable |
|---|---|
| Database: skip a disk read | A false positive costs one wasted read; a false negative would lose data |
| Web cache: “have we seen this URL?” | Occasionally re-fetching is harmless |
| Spell-checker dictionary | Occasionally accepting a non-word is tolerable |
| Blocklists / malware URL sets | A rare false positive escalates to a real lookup |
| Distributed systems: set reconciliation | A cheap summary to send over the network |
Where a Bloom filter would be wrong: any application in which a false positive is expensive or irreversible. “Have I already charged this customer?” must not be answered probabilistically.
Bloom filters are one of a set of probabilistic (sketch) structures that trade exactness for memory, and they all share the same pitch: an answer that is approximately right, in a fraction of the space, in one pass.
- Counting Bloom filter — counters instead of bits, which restores deletion at 4× the space.
- Count-Min sketch — approximate frequencies. “Roughly how many times has this IP hit us?”
- HyperLogLog — the number of distinct items, to within ~2%, in a couple of kilobytes regardless of the count.
- Cuckoo filter — Bloom-like, but supports deletion and is usually smaller at low error rates.
6Choosing under pressure
These four are what you reach for when the ordinary structures stop being enough. The trigger for each is specific.
| The situation | The structure |
|---|---|
| Range sums, no updates | Prefix-sum array (Ch. 4) |
| Range sums with point updates | Fenwick tree |
| Range min/max/GCD with updates | Segment tree |
| Updates that hit whole ranges | Segment tree with lazy propagation |
| Counting inversions, or “how many so far are smaller” | Fenwick over value ranks |
| A bounded cache with recency eviction | Hash map + doubly linked list |
| Membership over a huge set, memory-bound | Bloom filter |
| Approximate counts / cardinality | Count-Min sketch / HyperLogLog |
| Something simple that is fast enough | Sqrt decomposition |
Every structure in this chapter buys an improvement by giving something up, and naming the trade is more useful than memorising the code:
- Segment tree: gives up
O(1)queries to gain fast updates. - Fenwick: gives up general operations to gain memory and constant factor.
- LRU: gives up single-structure simplicity to gain
O(1)on both axes. - Bloom: gives up exactness — in one direction only — to gain an order of magnitude of memory.
When you meet a structure you have not seen, ask what it gave up. The answer usually tells you both how it works and when it is the wrong choice.
You will not implement a segment tree at work this year. You may well need to know that
“range query plus update” is a solved problem with a name, so that you can search for it
rather than write an O(n) loop and wonder why the service is slow.
That recognition — this shape of problem has a structure — is what this chapter is for. Chapter 30 makes it the whole subject.
What to carry forward
- A prefix-sum array gives O(1) range queries and O(n) updates. Wanting both fast is what forces a tree.
- A segment tree stores a combined value per range. Update repairs one root path; a query assembles fewer than 2 log n stored nodes.
- Segment trees need only an associative operation, so min, max and GCD all work. Lazy propagation adds O(log n) range updates.
- A Fenwick tree does prefix sums in n words and eight lines, but needs an invertible operation — so no range-minimum.
i & -iisolates the lowest set bit. Adding it walks up, subtracting it walks down a prefix, in at most log n steps.- Fenwick over value ranks counts inversions in O(n log n) without merge sort.
- An LRU cache is a hash map (for lookup) plus a doubly linked list (for recency). Both are needed; neither suffices alone.
- Use sentinel head and tail nodes, move a node on
getas well asput, and delete the evicted key from the map. - A Bloom filter never gives a false negative and sometimes gives a false positive. ~10 bits per item and 7 hashes gives about 1%.
- Bloom filters cannot delete, because clearing a bit might erase another item's evidence.
- Ask what a structure gave up. That tells you how it works and when not to use it.
>_Playground
A segment tree and a Fenwick tree racing a naive array, an LRU cache being exercised, and a Bloom filter's error rate measured against its formula.
✓Exercises
Checked automatically the moment you submit. Work top to bottom — each one assumes the last. Your answers are saved in this browser.
Chapter 30 — Patterns, Interviews and the Capstone
Fifteen recognisable patterns, a procedure for attacking a problem you have never seen, and a capstone that makes you use eight structures in one system.