Composite & Beyond
The first 12 chapters gave you single structures. This chapter combines them into working machines: LRU caches, segment trees, skip lists, and Bloom filters. Real systems almost never run on one structure alone. They run on a pair of structures that cover each other's weak operation.
Combining structures into machines
No new base structures in this chapter, only new ways to combine them
Look back at the course. An array (chapter 1) gives O(1) random access. A linked list (chapter 3) gives O(1) insert and remove once you hold the node. A hash map (chapter 6) gives O(1) lookup by key. A heap (chapter 9) gives O(log n) access to the minimum or maximum. Each structure is fast at one thing and slow at something else.
Real requirements rarely need only one of those. "A cache must read in O(1) and evict the least recently used entry in O(1)." "An array must support updates and fast range sums." Every single structure gets stuck on one of the two operations. The usual answer is not a new structure. It is to put two structures together so that each one only performs the operation it can finish quickly. All six machines in this chapter are built that way.
| Machine | Built from | What it buys you | Where it is used |
|---|---|---|---|
| LRU cache | hash map + doubly linked list | O(1) read and O(1) eviction of the least recently used entry | Redis, browser caches, OS page replacement |
| LFU cache | two hash maps + one list per frequency | O(1) eviction of the least frequently used entry | the Redis allkeys-lfu policy |
| Segment tree | array + binary tree over ranges | range queries with updates, both O(log n) | database statistics, time-series aggregation, contests |
| Fenwick tree | array + the lowbit bit operation | prefix sums that stay correct after updates | counting problems, inversion counts, contests |
| Skip list | sorted linked list + random index levels | expected O(log n) search, insert, and delete in sorted order | Redis zset, LevelDB MemTable |
| Bloom filter | bit array + k hash functions | a very small structure that can prove an element is absent | crawler deduplication, protecting a database from lookups of missing keys |
Each member only performs the operation it can finish in O(1) or O(log n), and never the one that would cost it O(n). In an LRU cache the hash map only looks up, and the list only maintains order.
The members store references to each other. The hash map value is a list node, and the list node stores the key back. Either side reaches the other in one step, with no search.
Every operation must update all members in the same step. Removing a list node also removes its hash map entry. Losing that synchronization is the most common source of bugs in composite structures.
A method for design questions
When you are asked to "design an X", work in three steps. First, list every operation (get, put, delete, random, and so on). Second, write down the complexity budget for each one: does the problem require O(1) or O(log n)? Third, check where a single structure goes over budget, and add a second structure that covers exactly that operation. Every section in this chapter is one run through this method.
LRU cache: hash map + doubly linked list
★ The most common design question in interviewsThe main section of this chapter. Start from the requirements, rule out the alternatives, and build it.
Start from the requirement. A cache is storage that is fast but small. Memory is far faster than disk but cannot hold everything, so a cache keeps only the part of the data most likely to be read again. Being small leads to one unavoidable question: when the cache is full, which entry leaves?
The classic answer is to evict the entry that has not been used for the longest time. That policy is called LRU (least recently used). The reason it works is temporal locality: programs tend to read again, very soon, the data they have just read, and data that has not been touched for a long time is usually not needed soon either.
So the requirement list is fixed, and it is exactly LC 146: get(key) reads, put(key, value) writes, and a full cache evicts the least recently used entry automatically. Both operations must be O(1). A cache exists to be fast, so if its own operations were O(n) it would defeat its purpose.
Ruling out the alternatives: why this pair?
Do not memorize the answer. Cross out the candidates one at a time, the way you would in an interview, and the answer appears on its own.
A hash map aloneget and put really are O(1). But which entry do you evict when it is full? A hash map spreads keys across buckets and has no notion of order, so it cannot tell which key was used least recently. To find out you would store a timestamp per key and scan the whole map for the smallest one: O(n). Ruled out.
An array or a list alone, kept in access orderKeep the entries in a line ordered by last use: whatever is accessed moves to the front, and the last one is the eviction candidate. The order works. But get(key) must find the entry first, and there is no index to compute and no hash to look up, so you scan from the front: O(n). Ruled out.
Array + hash map (the map stores the index)The map locates the array index in O(1), and the array keeps the order. But moving the accessed element to the front means deleting from the middle and inserting at the front of an array. As chapter 1 showed, every element after it shifts, which is O(n), and it also invalidates a whole range of indices stored in the map. Ruled out.
Singly linked list + hash map (the map stores the node)Very close. The map stores key to list node, so you reach the node in one step, and list insertion and removal are O(1) once you hold the right node. But unlinking a node from the middle means setting the predecessor's next pointer to this node's next, and a singly linked node cannot reach its predecessor. Finding it means scanning from the head: O(n). One pointer short.
Doubly linked list + hash mapGive every node a prev pointer. Now unlinking has both neighbors at hand, so removal is O(1) and inserting at the head is O(1), and the hash map still locates any node in O(1). The map answers "where is this key" and the list answers "how old is it". Each structure's O(n) operation is handled by the other one. This is the standard LRU design.
Common mistake: "list search is O(n), so LRU is O(n)"
That is wrong, because nothing in an LRU cache ever walks the list to find a node. Locating a node always goes through the hash map, which maps a key straight to a node reference. The list is used only after the node is already in hand, to unlink it and relink it at the head, and those are pointer updates in O(1). Each structure does only the operation it is fast at.
There are only three rules, and all of them are O(1). ① A get that hits moves the node to the head of the list, which records that it was just used. ② A put of a new key links a node at the head and adds a hash map entry. ③ When the cache is full, unlink the last real node (tail.prev, the least recently used one) and delete its hash map entry in the same step. Run a few operations yourself:
| Operation | What the hash map does | What the list does | Cost |
|---|---|---|---|
| get(key), hit | key → node reference, one step | unlink the node, insert it at the head | O(1) |
| get(key), miss | no such key, return -1 | not involved | O(1) |
| put, key exists | locate the node, overwrite the value | unlink, then insert at the head | O(1) |
| put, new key, not full | add key → new node | insert the new node at the head | O(1) |
| put, new key, full | delete the evicted key's entry, add the new one | unlink tail.prev, insert the new node at the head | O(1) |
Writing it out (LC 146)
Dummy head and tail nodes come from chapter 3: put one empty node at each end and never delete them. Every insertion and removal then happens between two existing nodes, which removes all the null checks.
collections.OrderedDict, which is itself a hash map plus a doubly linked list, or the functools.lru_cache decorator. Interviews ask for the hand-written version. A short version is in worked example A.Java already ships this machine
The name of LinkedHashMap in the JDK says what it is: Linked (a doubly linked list) plus HashMap (a hash table). Every Entry sits in a hash bucket and also carries before/after pointers that link all entries into one doubly linked list. That is the machine you just wrote by hand. The constructor argument accessOrder = true makes the list follow access order instead of insertion order, and one overridden hook method completes the cache.
Where LRU runs in practice
Redis: with maxmemory-policy allkeys-lru, Redis evicts by LRU when memory runs low. It uses an approximate LRU: keeping one global list of hundreds of millions of keys would cost too much memory for the pointers, so Redis samples a few keys at random and evicts the least recently used one among them. That trades a little accuracy for a large memory saving. Browsers: HTTP caches and decoded-image caches control their size with LRU variants. Operating systems: when physical memory runs out, the system must choose which page to write to disk. Common page replacement algorithms such as Clock are also approximations of LRU, because exact LRU would require updating a list on every single memory access.
LFU: adding one more dimension
Least frequently used — evict by use count, and how LC 460 (hard) reaches O(1)
LRU asks how long ago an entry was used. LFU asks how often it has been used: it evicts the entry with the smallest use count, and when several entries have the same count, it evicts the least recently used one among them. LFU suits workloads where the popular items stay popular. One very popular entry should not be pushed out by a burst of one-time reads just because nobody touched it for ten minutes.
Why is LFU harder than LRU? In an LRU cache the oldest entry is always sitting at the tail of the list, so it is ready without any work. In an LFU cache the frequency changes on every access: the count goes up by one, so the entry has to move to a different position in the frequency order. A heap (chapter 9) would give O(log n) per adjustment, and it still would not break ties by recency. The O(1) solution is one bucket per frequency.
Three pieces of state. ① A hash map key → (value, freq). ② A hash map freq → bucket, where each bucket holds every key with that frequency in time order, so a bucket is itself a small LRU. ③ One variable minFreq holding the smallest frequency currently in use. An access moves the key from its freq bucket into the freq+1 bucket, which is two O(1) list operations. An eviction removes the oldest key in the minFreq bucket, which is the least recently used key among the least frequently used ones. minFreq needs no search either, because only two events change it: it goes up by 1 when the old bucket becomes empty (the key that just left was the last one in the minFreq bucket), and it resets to 1 when a new key is inserted, since a new key has frequency 1 and nothing can be lower.
popitem(last=False) pops from the front of an OrderedDict, which is the oldest key in that bucket. There is no loop anywhere in this class, which is the direct evidence that every operation is O(1).LRU or LFU? Each has a failure mode
LFU's weakness is that a formerly popular entry takes a long time to leave. An entry that was read tens of thousands of times yesterday and is never read again still has a very high count, so new entries need a long time to push it out. In practice this is fixed by decaying the counts over time; the Redis LFU policy has a decay factor. LRU's weakness is that a single large scan destroys it: one full-table read touches every row once and flushes the genuinely hot data out of the cache. Redis offers both policies so you can pick the one that matches your access pattern.
Segment tree: a binary tree over ranges
Range query and point update, both O(log n)
A different kind of requirement. Given an array, you are asked many times for the sum of the range l to r. The prefix sum from chapter 1 already solves that: build the table once, then every query is O(1). Now add one condition: the elements can be modified. Changing a single a[i] invalidates every prefix sum after it, so the table has to be rebuilt in O(n). With 100,000 updates that is 100,000 rebuilds, and prefix sums stop being usable.
| Approach | Update one element | One range sum query | Verdict |
|---|---|---|---|
| Plain array | O(1) | O(n) | Fast to update, slow to query. Fails when queries are frequent. |
| Prefix sums | O(n) rebuild | O(1) | Fast to query, slow to update. Fails when updates are frequent. |
| Segment tree | O(log n) | O(log n) | Neither operation is the fastest possible, but neither one collapses. |
Between those two extremes you want something where neither operation is bad. Split the whole range in half, and keep splitting until every part is a single element. That gives a binary tree (chapter 7), and each node stores the sum of its own range. The knowledge about sums is now spread over O(n) nodes, and any single update or query only has to touch O(log n) of them.
To change a[i], descend from the root, halving the range each time, until you reach that leaf, and write the new value. On the way back up, recompute the sum of every ancestor on the path as left child + right child. The number of nodes touched equals the height of the tree, so it is O(log n). Every other node still holds a correct sum, and that is what makes it better than prefix sums here.
To get the sum of [l,r], every node is in one of three situations. ① No overlap with [l,r]: return 0. ② Fully inside [l,r]: return the stored sum and stop descending. ③ Partial overlap: split and ask both children. At most 2 nodes per level end up in case ③, so the total number of visited nodes is O(log n).
Try it yourself. In update mode, click a leaf and watch one path from the leaf to the root light up. In query mode, set a range and watch which nodes are fully covered: the green nodes hand over a stored sum directly, without ever descending to a leaf.
Writing it out (LC 307)
The tree is stored in a plain array: tree[1] is the root, and the children of node i are 2i and 2i+1, the same layout as the heap in chapter 9, so no node objects are created. The array is allocated with 4n entries. Here is why 4n and not 2n. If n is a power of two, the tree is perfect: it has n leaves and n−1 internal nodes, and the largest index used is below 2n. When n is not a power of two, the bottom level is incomplete, and the recursion can still place a leaf at an index in the level below the deepest full level. In the worst case the deepest index is under 4n, so 4n is always safe and needs no case analysis.
Two extensions worth knowing by name
Replace + in the code with Math.max and the same tree answers range maximum queries. Any operation that is associative works here: sum, minimum, maximum, GCD, matrix product. That generality comes from the tree structure itself. The second extension is lazy propagation. A bulk update such as "add 5 to every element in [l,r]" is written as a mark on the large node that covers the range, and the mark is only pushed down to the children when a later operation actually needs them. Without lazy propagation, a range update means one point update per element, which is O(n log n). With it, a range update is O(log n). It is standard in programming contests and rare in interviews.
Fenwick tree: prefix sums through the lowest set bit
Binary indexed tree — about 15 lines, smaller and faster than a segment tree, but it does less
A segment tree works, but it takes dozens of lines. If the only requirement is point update plus prefix sum query, there is a much smaller structure: the Fenwick tree, also called a binary indexed tree. It keeps a single array tree[1..n] and one rule: tree[i] holds the sum of the segment that ends at i and is lowbit(i) elements long.
lowbit(i) is the value of the lowest set bit in the binary form of i. You compute it with lowbit(x) = x & (-x). Here is why that works. In two's complement, -x = ~x + 1. Inverting turns the lowest 1 into a 0 and every bit below it into 1. Adding 1 then carries through those bits and stops exactly at the position of the original lowest 1. So x and -x agree on that one bit and differ everywhere else, and the AND keeps only that bit. Check it with 6: 6 = 0110, -6 = 1010, and 0110 & 1010 = 0010 = 2.
| i | binary | lowbit(i) | range covered by tree[i] | length |
|---|---|---|---|---|
| 1 | 0001 | 1 | [1, 1] | 1 |
| 2 | 0010 | 2 | [1, 2] | 2 |
| 3 | 0011 | 1 | [3, 3] | 1 |
| 4 | 0100 | 4 | [1, 4] | 4 |
| 5 | 0101 | 1 | [5, 5] | 1 |
| 6 | 0110 | 2 | [5, 6] | 2 |
| 7 | 0111 | 1 | [7, 7] | 1 |
| 8 | 1000 | 8 | [1, 8] | 8 |
Read the pattern in the table: the more trailing zeros i has in binary, the longer the segment it covers. To get the prefix sum a[1..7], note that 7 = 0111 = 4 + 2 + 1, and the sum splits into tree[7] (1 element) + tree[6] (2 elements) + tree[4] (4 elements). Those three ranges meet end to end and never overlap. i -= lowbit(i) strips one set bit at a time, which is exactly this binary decomposition, so it runs at most log n times. An update goes the other way: i += lowbit(i) jumps to every larger segment that contains position i, also at most log n times. Worked example B animates both walks.
x & -x works the same way. Python integers have unlimited precision, so there is no overflow to worry about.| Compare | Segment tree | Fenwick tree |
|---|---|---|
| Amount of code | about 50–60 lines | about 15 lines |
| Constant factor and space | recursive calls and a 4n array, larger constant factor | plain loops and an array of n+1, small constant factor, cache friendly |
| What it can answer | sum, minimum, maximum, GCD — any associative operation; with lazy propagation it also does range updates | only operations where a range answer can be recovered by subtracting two prefixes (sum, xor). A minimum cannot be recovered that way, so range minimum is out. |
| Index convention | 0-based or 1-based, either works | must be 1-based (lowbit(0) = 0 loops forever) |
| How to choose | If you need prefix sums that survive updates, use a Fenwick tree. If you need minimum or maximum, range updates, or any merge that is not reversible by subtraction, use a segment tree. | |
Skip list: express lanes over a sorted list
Expected O(log n) from coin flips — the structure behind the Redis sorted set
A limitation from chapter 3: searching a sorted linked list is O(n). The data is sorted, but you cannot binary search it, because binary search needs O(1) random access and a linked list does not provide it (chapter 1). You can only follow next one node at a time, and the sortedness is wasted.
A skip list fixes that by adding express lanes above the list. The bottom level L0 is the complete sorted list, and each level above it holds roughly half the nodes of the level below. A higher level has fewer nodes, so one step there covers more ground. A search starts at the top level and follows one rule: move right while the next node is still smaller than the target, otherwise drop down one level. By the time you reach L0 you are already next to the target.
Each level holds about half the nodes of the level below, so there are about log₂n levels, and on each level you take only a couple of steps before dropping down. If you could take many steps on one level, the level above would have carried you further. The expected search cost is therefore O(log n). For n = 1,000,000 a plain sorted list needs about 500,000 comparisons on average, while a skip list needs on the order of 40. That matches binary search and a balanced tree, while keeping the linked list property that insertion and deletion only rewrite a few pointers.
Promoting exactly every second node would be ideal, but a single insertion destroys it: the new node shifts the position of every node after it, so the index levels would have to be rebuilt in O(n). A skip list instead lets each new node flip a coin for its height: heads means one more level, tails means stop, so each extra level has probability 1/2. Nobody maintains the exact pattern, but in expectation each level still holds half the nodes of the one below. The expected height of one node is 1 + 1/2 + 1/4 + … = 2, and the expected height of the whole list of n nodes is about log₂n.
This is important: the O(log n) is expected over the coin flips, not over the input. There is no bad input for a skip list, because the structure does not depend on the data at all. A very unlucky run of coin flips could still make it slow, but the probability of that is negligible. This is the same trade as choosing a random pivot in quicksort, or spreading keys with a hash function (chapter 6): randomness replaces expensive deterministic maintenance.
Why the Redis sorted set uses a skip list instead of a red-black tree
Both give O(log n) search, insert, and delete. Salvatore Sanfilippo, the author of Redis, gave three reasons. ① The implementation is far simpler: red-black tree insertion has a dozen rotation and recolouring cases, while skip list insertion is an ordinary linked-list insertion repeated once per level. ② Range operations are natural: ZRANGE asks for a rank interval, and a skip list locates the start and then walks forward along L0, while a tree needs repeated in-order traversal steps. ③ It is easier to modify; Redis added a span field to skip list nodes to support rank queries. LevelDB and RocksDB also use a skip list for their in-memory write buffer (the MemTable), partly because it suits lock-free concurrency: updating a few pointers is easier to make atomic than rotating a tree.
How to implement it (LC 1206): each node carries an array next[], where entry i is its successor on level i. All three operations share the same navigation logic. Start at the top level, move right on each level while the next value is still smaller than the target, and record the last node visited on each level in update[]. Insertion and deletion are then ordinary linked-list pointer updates performed after those recorded nodes.
update = [self.head] * MAX_LEVEL stores the same head reference many times. That is safe here because the code only replaces list elements and never mutates one through the shared reference.The title of the paper says it all
Skip lists come from a 1990 paper by William Pugh: Skip Lists: A Probabilistic Alternative to Balanced Trees. The paper argues that balanced trees are hard to implement correctly, and that a simpler randomized structure gives the same expected performance. More than thirty years later, Redis and LevelDB both use skip lists, which supports that argument.
Bloom filter: trading accuracy for memory
A bit array plus k hash functions — it can prove absence, not presence
The last machine answers one very simple question: have I seen this before? A web crawler has to know whether a URL has already been fetched. With 10 billion URLs at about 60 bytes each, storing them in a hash set (chapter 6) would need roughly 600 GB, before counting any per-entry overhead. That does not fit.
A Bloom filter answers by not storing the data at all. It stores only the hash positions. It keeps one bit array of m bits and uses k different hash functions. Note what it cannot do: a Bloom filter cannot return the stored values, and it cannot tell you for certain that an element is present. It is not a fast set.
The k hash functions give k positions, and all of those bits are set to 1. x itself is not stored. One element costs at most k bits, and the bits are shared by all elements.
Recompute the k positions. If any of those bits is 0, x was definitely never inserted, because insertion would have set it. If all k bits are 1, x may have been inserted, but other elements could also have set exactly those bits.
Notice how one-sided this is. The only two answers are definitely not present and possibly present. The reason is that bits only go from 0 to 1 and are never cleared. Another element can set your bits as a side effect, which produces a false positive, but no operation ever clears a bit, so there is never a false negative. Try to produce a false positive yourself:
The false positive rate is not fixed. It depends on three things: the size of the bit array m, the number of hash functions k, and the number of elements actually inserted n. Two qualitative rules. A larger bit array relative to the number of elements (larger m/n) lowers the rate, because the bits fill up more slowly. The number of hash functions k has an optimum: too few and each element leaves too small a fingerprint, so collisions are likely; too many and each element sets too many bits, so the array fills up and the rate rises again. In practice you pick m and k from a target rate. For example, 10 bits per element with k = 7 gives about 1 percent. A hash set needs hundreds of bits per element, so the saving is one to two orders of magnitude.
Using it as a first filter
Crawler deduplication: 10 billion URLs at 10 bits each is about 12 GB, which fits on one machine. A 1 percent false positive rate only means skipping a very small number of new pages. Protecting a database from lookups of missing keys: a flood of requests for keys that do not exist in the database passes straight through the cache and reaches the database every time. Put all existing keys into a Bloom filter in front of the cache, and every request the filter reports as definitely absent is rejected immediately. Spam and malicious URL lists: the blocklist is too large to keep in memory, so the filter decides whether an expensive exact check is worth running. The pattern is always the same: nothing that was inserted is ever wrongly rejected, and the small number of wrongly accepted items are checked again by a slower exact lookup.
Two common mistakes
① A plain Bloom filter does not support deletion. One bit can be shared by several elements, so clearing it would also remove those other elements from the filter, which would create false negatives and break the one guarantee the structure provides. If you need deletion, use a counting Bloom filter, where each position is a small counter instead of a single bit, or a cuckoo filter. ② The false positive rate rises with the number of inserted elements. If you size the filter for 10 million entries and insert 100 million, almost every bit is 1, "possibly present" becomes the answer for everything, and the filter stops being useful. Estimating the number of elements in advance is a precondition for using one.
Proposed in 1970, used more today
Burton Bloom proposed this structure in 1970, when memory was measured in kilobytes and giving up a little accuracy for a large memory saving was a necessity. Memory has become far cheaper since then, yet the structure is used more than ever, because data volumes have grown faster than memory. Redis, HBase, Cassandra, and Chrome all contain one.
Three worked examples: take the machines apart again
★ Interview coreEach one: the requirement, why this combination, a frame-by-frame run, three implementations, complexity, follow-up questions
LC 146 · LRU Cache
MEDIUMProblem: implement a fixed-capacity LRU cache where get and put are O(1) and a full cache evicts the least recently used entry. Why this combination: §02 derived it in full. The hash map answers "where is this key" in O(1), and the doubly linked list answers "how old is it" while supporting O(1) unlink and insert. Each structure covers the other's O(n) operation, and the full hand-written implementation is also in §02. Here we first step through the official example, then add the short version that saves time in an interview.
move_to_end and popitem(last=False) are exactly the moveToHead and tail eviction you wrote by hand. CPython implements them in C, so they are faster.Complexity and follow-up questions
get and put are O(1) in time, and the space is O(capacity). Common follow-ups. ① "Why must the list be doubly linked?" Unlinking needs the predecessor; see option 4 in §02. ② "Why does the node store the key?" To delete the matching hash map entry when the tail node is evicted. ③ "How would you make it thread safe?" One lock around the whole cache is the simplest answer; a stronger answer mentions sharding the cache with one lock per shard, or avoiding the problem as Redis does by processing commands on a single thread. ④ "What if the capacity is very large?" Use approximate LRU with sampling and drop the list, which is what Redis does.
LC 307 · Range Sum Query - Mutable
MEDIUMProblem: update(i, val) changes one element and sumRange(l, r) asks for a range sum, and the two are interleaved. Why this combination: prefix sums answer in O(1) but need O(n) per update; a plain array updates in O(1) but answers in O(n). When both operations are frequent, either extreme fails, so you need a structure that is O(log n) for both. That is what §04 and §05 provide. Step by step: the segment tree paths were animated in the §04 lab, so here is the Fenwick tree version, showing how lowbit moves along the tree array.
Complexity and follow-up questions
In both versions update and query are O(log n). The Fenwick tree uses n+1 space and the segment tree uses 4n. Follow-ups. ① "When do you need a segment tree?" For range minimum or maximum, range updates, or any merge whose result cannot be recovered by subtracting two prefixes. ② "What about two dimensions?" LC 304 is immutable, so a 2D prefix sum works. If it were mutable, use a 2D Fenwick tree with nested lowbit loops. ③ "Why must update compute delta?" Because the tree's primitive operation is add, not assign: each cell holds the sum of a segment, so you cannot overwrite it with a single element's value.
LC 380 · Insert Delete GetRandom O(1)
MEDIUMProblem: design a set where insert, remove, and getRandom (return a uniformly random member) are all O(1). You may have seen it in the hashing chapter; here we derive it again with the method from §01: list the operations, find where a single structure goes over budget, add a second structure for that operation. Ruling out: a hash set alone gives O(1) insert and remove, but getRandom fails, because the bucket array is full of empty slots and probing random buckets can miss many times in a row, so uniform sampling is not O(1). An array alone gives a perfect getRandom (a random index), but remove has to find the value first, which is O(n), and then close the hole, which is another O(n). Combining: the array stores the values, which handles random selection, and the hash map stores value → index, which handles lookup. One problem is left: deleting from the middle of an array shifts elements. The fix is the technique from chapter 1, swapping with the last element before removing.
self.arr[i] = last assigns it to itself and idx[last] = i is deleted again by the following del, so that edge case needs no special handling.Complexity and follow-up questions
All three operations are O(1) (insert is amortized), and the space is O(n). Follow-ups. ① "What if duplicates are allowed?" That is LC 381: the hash map value becomes a set of indices, and the swap needs care when the two values are equal. It is considerably harder and worth doing. ② "Why can a hash table not do getRandom on its own?" Its bucket array contains empty slots, so uniform sampling either scans O(capacity) buckets or uses rejection sampling with no bound on the number of attempts. ③ "How do you prove getRandom is uniform?" The array has no gaps, so each index is chosen with probability exactly 1/size. Notice what this problem shares with LRU: each member of the pair contributes one O(1) operation, and together they cover everything the problem asks for.
Problem set: 8 composite structures
Final problem setEasiest first. Do 303 and 307 together as a pair, then work through the three hard ones.
Chapter quiz
✎ QuizAnswer all 7 correctly to complete the last chapter
In a hand-written LRU cache the hash map already reaches any list node in O(1). So why does the list still have to be doubly linked?
In Java, new LinkedHashMap<>(cap, 0.75f, true) plus an override of removeEldestEntry is already a working LRU cache. What does it maintain internally that makes this possible?
The data is loaded once and never modified afterwards, and then you answer a very large number of range sum queries. Which structure should you choose?
A Fenwick tree is built on one bit operation: it extracts the lowest set bit of x (the value usually called lowbit). Write that expression, using x as the variable.
When a skip list inserts a node, it flips a coin to decide how many index levels the node gets, instead of maintaining an exact "every second node is promoted" structure. Why?
A Bloom filter can be wrong in one direction only. Which kind of mistake can it make?
Which pair of structures does Redis use for a sorted set (zset) once the data is large?
- The method: list every operation and its complexity budget, then pick base structures that cover each other. Each member performs only the operation it can finish in O(1) or O(log n), and the two are joined by storing references to each other and updating all members in the same step.
- LRU = hash map (answers "where") + doubly linked list (answers "how old"), with get and put both O(1). The list must be doubly linked, because unlinking a node updates its predecessor's next pointer and a singly linked node cannot reach its predecessor. The node must store the key, so the matching hash map entry can be deleted on eviction. Java gives you the same machine as LinkedHashMap with accessOrder = true.
- LFU is harder than LRU because the frequency changes on every access. O(1) needs a frequency map, one time-ordered list per frequency, and a minFreq pointer. The eviction rule is: least frequently used, and among those, least recently used.
- Range queries in three cases: queries only means prefix sums with O(1) queries; updates mixed with queries means a segment tree or a Fenwick tree, both O(log n). A segment tree handles any associative merge and, with lazy propagation, range updates in O(log n) instead of O(n log n). A Fenwick tree is about 15 lines and uses
lowbit = x & (−x)to isolate the lowest set bit, but it only works for operations recoverable by subtracting two prefixes, and it must be 1-based. - Skip list = sorted linked list + index levels grown by coin flips: expected O(log n), where the expectation is over the coin flips and not over the input, so there is no bad input. Randomization replaces the rotation maintenance of a balanced tree. It is the structure behind the Redis sorted set (skip list + hash table) and the LevelDB MemTable.
- Bloom filter = bit array + k hash functions: "not present" is certain, "present" is only possible, because bits only go from 0 to 1. You cannot delete from a plain one, and the false positive rate depends on the bit array size, the number of hash functions, and the number of inserted elements. It is a first filter, not a faster set.
- None of these six machines uses a new base structure. Arrays, linked lists, hash maps, trees, and bit operations all came from the first 12 chapters. For a design question: restate the operations and the complexity target, then name the combination, then point out yourself what has to be kept in sync between the two structures.