Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Search three model-serving notes for token hit. Document 2 contains both words, document 0 contains one, and document 1 contains neither:
| doc id | text |
|---|---|
| 0 | token cache eviction |
| 1 | gradient checkpointing memory |
| 2 | token hit cache |
A loop over a Python list can find those matches. You used lists and dictionaries in Python for AI Engineering: lists keep items in order, while dictionaries retrieve values by key. Here the choice changes how much work a search repeats. A retriever finds documents relevant to a query; ours will rank them by the number of distinct query words they contain.
With three notes, scanning every word is cheap. With many notes and repeated queries, we'd rather build a lookup once and reuse it. The same records will help us compare lists, indexes, heaps, and caches without changing the search problem.
Let the operation choose the shape
Before naming a structure, ask which operation must stay cheap and predictable:
- "Which item is at position 2?" points to a sequence.
- "Which documents contain
token?" points to a key-value mapping. - "Have I already seen document 2?" points to a set.
- "Which waiting job is oldest?" points to a queue.
- "Which retained result is weakest?" points to a heap.
- "Which notes share a parent topic, link to each other, or begin with this prefix?" points to a tree, graph, or trie.
These aren't interchangeable labels. Each structure preserves a different promise about position, identity, order, or connection. The useful choice follows from the operation you need to repeat.[1]
Lists and array-shaped sequences
A Python list is an ordered, mutable sequence. CPython stores it as a resizable array of object references, so an integer index reaches a known position directly. Our strings fit a list because they're ordinary Python objects.[2]
That shape differs from array.array, which packs a restricted basic value type, and from a NumPy array or tensor, which usually holds one numeric type in a rectangular shape and supports vectorized math. The distinction matters here because the corpus needs flexible Python objects, not a rectangular block of one numeric type.
Start with three operations and predict what each one has to move:
| operation | before | after | what had to move |
|---|---|---|---|
read docs[2] | [doc 0, doc 1, doc 2] | doc 2 | nothing |
append doc 3 | [doc 0, doc 1, doc 2] | [doc 0, doc 1, doc 2, doc 3] | usually no existing item |
insert doc 3 at front | [doc 0, doc 1, doc 2] | [doc 3, doc 0, doc 1, doc 2] | every existing reference shifts right |
An append occasionally needs a larger backing array and copies existing references. Spread across many appends, that occasional resize still gives constant work per append on average. The complexity table calls this amortized constant time.
That last row explains why a list is a poor first-in, first-out queue when code repeatedly uses pop(0). The output is correct, but removing the first item still moves every later reference. When arrival order becomes the operation to protect, a deque fits better. We'll use it later.
The list is still the right starting point here because document order is stable and a complete scan is easy to verify by hand.
Now run the query against the list. The match counts look like this:
| doc id | contains token | contains hit | total matches |
|---|---|---|---|
| 0 | 1 | 0 | 1 |
| 1 | 0 | 0 | 0 |
| 2 | 1 | 1 | 2 |
Document 2 should rank above document 0 because it matches both terms. Document 1 contributed nothing, yet the scan still opened it. That unused read is the cost we want the next structure to remove.
To skip irrelevant documents, we'll need extra information prepared before the query arrives. That saves repeated scanning, at the cost of storing and updating an index.
Building an inverted index
The query starts with a term such as token, so let the lookup start there instead of at document 0. The system should jump from the term to matching documents.
An inverted index answers that direction of lookup. It reverses the document-to-terms view into a term-to-documents map. The key changes from a document ID to the thing the query already has: a term.
| view | key | value |
|---|---|---|
| forward scan | document ID | terms in one document |
| inverted index | term | document IDs containing that term |
Python calls its built-in hash map a dict. A hash function narrows down where a key could be stored; equality checks distinguish keys that collide. Keys must be hashable: their hash stays stable, and keys that compare equal must have equal hashes. Strings and integers work as keys; a mutable list doesn't.[3]
Python dictionaries preserve insertion order, but that order isn't what makes lookup fast. For this index, the important behavior is that a term leads to its posting collection, regardless of where that key was inserted.
The index maps each term to its posting list. A posting records a matching document ID; larger indexes can also store term positions or frequency. In this first version, a Python set[int] is enough because our score asks only whether each query term appears in each document.[4]
Build the index by hand
Split each document into terms and store the matching document IDs. For token, predict which rows should be reachable before checking the table:
| term | document IDs |
|---|---|
token | {0, 2} |
cache | {0, 2} |
eviction | {0} |
gradient | {1} |
checkpointing | {1} |
memory | {1} |
hit | {2} |
Now the query can enter through the index. It asks for two posting lists instead of reopening all three documents:
| query term | posting list |
|---|---|
token | {0, 2} |
hit | {2} |
Turn those posting lists into match counts by adding one for each query term that contains a document:
| doc id | matches from token | matches from hit | final score |
|---|---|---|---|
| 0 | 1 | 0 | 1 |
| 2 | 1 | 1 | 2 |
Document 2 wins because it matches both terms. Document 0 still matters because it matches one term. The index removed document 1 from scoring entirely, so lookup and scoring now have a visible boundary.

Why a set lives inside the dictionary
The dictionary solves exact term lookup. The set solves uniqueness and membership. Keeping those jobs separate makes the index easier to reason about.
Suppose document 2 were token token hit. The posting list for token should still contain document 2 once, not twice. That's why the value is a set of document IDs instead of a list of document IDs.
Keep the two operations separate:
- Dictionary asks: "given this term, which collection is its value?"
- Set asks: "is this document ID already in that collection?"
Python sets don't promise a useful iteration order, so examples sort them before printing. Production lexical indexes commonly keep ordered, compressed posting lists and richer statistics. The Python set is a teaching choice: it makes membership and duplicate prevention visible before storage layout and fast posting intersections enter the design.[4]
A repeated term is the smallest proof. In the demo below, document 2 says token twice, but membership in the token posting set still contributes one document ID.
1from collections import defaultdict
2
3docs_with_repeat = [
4 "token cache eviction",
5 "gradient checkpointing memory",
6 "token token hit",
7]
8postings: dict[str, set[int]] = defaultdict(set)
9
10for doc_id, text in enumerate(docs_with_repeat):
11 for term in text.split():
12 postings[term].add(doc_id)
13
14print("token postings", sorted(postings["token"]))
15print("document 2 counted once", len(postings["token"]) == 2)1token postings [0, 2]
2document 2 counted once TrueWhy does the inverted index store document IDs in a set instead of a list in this binary-match scorer?
Answer
A set keeps each document ID unique. Repeating token inside one document must not add multiple posting entries or inflate its binary match score.
Keep only the winners
The index has narrowed the query to scored candidates. Now the system needs the best few results, not a fully sorted report of the corpus. That distinction determines the next structure.
A heap maintains a small group of winners while candidates arrive. If you need two results out of thousands, it can avoid fully sorting candidates you won't return. With only two matches in our current corpus, a sort is perfectly reasonable; add one more candidate to expose the selection decision.
For the three notes, the scored candidates are:
| doc id | score |
|---|---|
| 2 | 2 |
| 0 | 1 |
A binary heap stores a partial order rather than a fully sorted list. The heapq operations used here maintain a min-heap: the smallest key is always at index 0. The remaining list isn't globally sorted, which is fine because the root is the only position we need to inspect for eviction.[5][1]
That root guarantee fits top-k retrieval. Keep only k winners, with the weakest winner at the root. You often want the best 5, 10, or 20 candidates rather than a complete ranking of every candidate. The root tells you exactly which retained result can leave when a stronger candidate appears.
Suppose doc 3, hit latency token, arrives with score 2. Keep only two results. Doc 0 should leave because its score is 1, while docs 2 and 3 each score 2. Which tied result should come first?
Use keys (score, -doc_id). Python compares tuples from left to right, so score wins first. When scores tie, doc 2 has key (2, -2), which is larger than doc 3's (2, -3). The lower document ID wins the tie. Inside a min-heap, however, the smaller key goes at the root so it's ready for eviction:

Real rankers need an explicit tie-break rule. The heap demo and retriever lab both prefer the lower document ID when scores tie, which keeps output deterministic.
1import heapq
2
3candidate_scores = [(0, 1), (2, 2), (3, 2)]
4k = 2
5top_k: list[tuple[int, int]] = []
6
7for doc_id, score in candidate_scores:
8 # Same key rule as search(): higher score wins; lower doc_id wins ties.
9 candidate = (score, -doc_id)
10 if len(top_k) < k:
11 heapq.heappush(top_k, candidate)
12 elif candidate > top_k[0]:
13 evicted = heapq.heapreplace(top_k, candidate)
14 print("evicted", (evicted[0], -evicted[1]))
15
16print("top k", [(score, -doc_key) for score, doc_key in sorted(top_k, reverse=True)])1evicted (1, 0)
2top k [(2, 2), (2, 3)]The lab later calls heapq.nlargest, which returns results in descending key order. It's suited to selecting a small k from many candidates.[5] CPython can use a full sort when k is at least the number of candidates, so the tiny lab isn't a demonstration of a speedup. The explicit heap above shows what bounded selection does.
You need the best 10 results from a long stream of scored documents. What does a capped min-heap avoid doing?
Answer
It avoids fully sorting every candidate. The heap retains only 10 winners and exposes the weakest winner for replacement, while an explicit tie rule keeps output deterministic.
Building a tiny retriever
Return to the original three notes. First implement the scan, then build an index and accumulate scores from postings. This is the retrieval part of RAG (retrieval-augmented generation): a language model could later read the returned documents. There's no language model in this lab.
Put this in data_structures_demo.py. The tokenizer lowercases and splits on whitespace. Production tokenizers also handle punctuation and language-specific rules, but keeping normalization tiny makes the structure visible. Returning a set means this example scores binary term membership: each distinct query term contributes at most once.
1from collections import defaultdict
2import heapq
3
4docs = [
5 "token cache eviction",
6 "gradient checkpointing memory",
7 "token hit cache",
8]
9
10def tokenize(text: str) -> set[str]:
11 return set(text.lower().split())
12
13def slow_search(query: str) -> list[tuple[int, int]]:
14 terms = tokenize(query)
15 matches: list[tuple[int, int]] = []
16 for doc_id, text in enumerate(docs):
17 words = tokenize(text)
18 score = sum(term in words for term in terms)
19 if score:
20 matches.append((doc_id, score))
21 return matches
22
23def build_index(documents: list[str]) -> dict[str, set[int]]:
24 postings: dict[str, set[int]] = defaultdict(set)
25 for doc_id, text in enumerate(documents):
26 for term in tokenize(text):
27 postings[term].add(doc_id)
28 return postings
29
30index = build_index(docs)
31
32def search(query: str, k: int = 2) -> list[int]:
33 if k < 1 or not query.strip():
34 return []
35
36 scores: dict[int, int] = defaultdict(int)
37 for term in tokenize(query):
38 for doc_id in index.get(term, set()):
39 scores[doc_id] += 1
40
41 best = heapq.nlargest(
42 k,
43 scores.items(),
44 key=lambda item: (item[1], -item[0]),
45 )
46 return [doc_id for doc_id, _score in best]
47
48print("slow scan", slow_search("token hit"))
49print("posting list", sorted(index["token"]))
50print("search", search("token hit"))1slow scan [(0, 1), (2, 2)]
2posting list [0, 2]
3search [2, 0]Read the two outputs as two different contracts. slow_search returns score tuples in corpus order, so (2, 2) appears after (0, 1). Those tuples expose the score trace without ranking it. search uses the posting lists, applies top-k selection, and returns [2, 0].
defaultdict(set) creates an empty set when a missing key is accessed with brackets during indexing. During search, index.get(term, set()) returns an empty set for an unknown term without inserting it into the index. A query for attention therefore returns []. Repeated query words are removed by tokenize, so token token hit scores exactly like token hit.
Map each operation to one structure
| Job | Structure | Why it fits |
|---|---|---|
| keep the original corpus order | list | the documents start as an ordered sequence |
| jump from term to matching docs | dictionary of sets | the query starts with a term |
| count how many terms matched | dictionary of integers | each document needs a score |
| keep the best few results | heap | the system wants top-k rather than a full sorted list |
The heap key uses score first and negative document ID second. Higher scores win; lower IDs win ties. That small rule makes repeated runs predictable, so a later debugging trace can distinguish a ranking bug from a tie-break choice.
When order and reuse become the problem
The retriever chose structures by lookup and ranking. Ingestion asks a different question: which job arrived first? Repeated queries ask another: can this result be reused without becoming stale?
Process arrivals in order
New documents arrive and wait to be indexed. Later systems may also split them into chunks and encode each chunk as an embedding, a numerical representation used for similarity search. For one class of equal-priority jobs, arrival order is a clean baseline. The operation isn't "find by key" or "keep the best"; it removes the oldest item.
A queue is the behavior: first in, first out (FIFO). Python's collections.deque is the container used here. Its name means double-ended queue, and it supports appends and pops at either end in approximately constant time: more waiting jobs don't require shifting all existing references. Middle indexing becomes slower, so it isn't a replacement for a list when random position access is the main job.[6]
Why does list.pop(0) hurt? A standard Python list stores item references in a single contiguous array. Calling pop(0) removes the head element, which forces CPython to shift every subsequent reference left by one slot. If 50,000 document ingestion jobs or token chunks sit in a queue, draining them item by item incurs quadratic total work.
Under the hood, collections.deque avoids shifting by allocating a doubly linked chain of fixed-size blocks (each holding 64 element pointers). In streaming token processors, GPU scheduling pipelines, and LLM inference engines like vLLM[7], this FIFO contract is frequently implemented as a circular ring buffer: a fixed-size array where head and tail pointers advance modulo buffer capacity ((tail + 1) % capacity). Appends and pops update integer offsets in time without moving existing elements in memory.
Trace the state from left (oldest) to right (newest). Predict the next job after each operation:
| operation | deque state | next job |
|---|---|---|
| start | [doc-3, doc-4] | doc-3 |
append("doc-5") | [doc-3, doc-4, doc-5] | doc-3 |
popleft() | [doc-4, doc-5] | doc-4 |
1from collections import deque
2
3embedding_jobs = deque(["doc-3", "doc-4", "doc-5"])
4next_job = embedding_jobs.popleft()
5
6print("next job", next_job)
7print("remaining", list(embedding_jobs))1next job doc-3
2remaining ['doc-4', 'doc-5']The rule is simple:
- append new work on one side
- pop the oldest work from the other side
That's FIFO behavior. If you accidentally process newest work first, old jobs can wait indefinitely while new jobs keep arriving. The queue's order is part of the system's correctness, not just an implementation detail.
An in-memory deque shows ordering, not durability. If a worker writes an embedding and crashes before recording completion, a broker may retry the job. The downstream write needs a durable idempotency key so the retry can't create a duplicate vector. The next chapter puts that guarantee in a database transaction and a uniqueness constraint.
The failure case makes the ordering bug visible. New jobs belong at the tail; putting them at the front lets recent uploads jump ahead of old work. Run it before trusting the queue:
1from collections import deque
2
3fifo_jobs = deque(["oldest-doc"])
4fifo_jobs.extend(["new-doc-a", "new-doc-b"])
5
6newest_first_jobs = deque(["oldest-doc"])
7newest_first_jobs.appendleft("new-doc-a")
8newest_first_jobs.appendleft("new-doc-b")
9
10print("FIFO runs", fifo_jobs.popleft())
11print("wrong order runs", newest_first_jobs.popleft())1FIFO runs oldest-doc
2wrong order runs new-doc-bReuse results without serving stale answers
If users ask the same question repeatedly, recomputing retrieval results per request wastes work. A cache can remove that repeated computation, but only if its hit is still valid.
A retrieval cache stores prior ranked document IDs so the system can reuse them. A cache isn't one primitive structure. It combines a mapping from key to value with policies for freshness and eviction. The mapping finds a candidate result; policies decide whether keeping that result stays safe.
1cache: dict[tuple[int, str, int], list[int]] = {}
2corpus_version = 1
3query = "token hit"
4k = 2
5
6cache_key = (corpus_version, query, k)
7if cache_key not in cache:
8 cache[cache_key] = search(query, k)
9
10print("cached", cache[cache_key])1cached [2, 0]Make freshness part of the key
| cache key part | why it exists |
|---|---|
query | separates one repeated request from another |
corpus_version | forces refresh after the document collection changes |
k | separates a request for one result from a request for two |
If document 2 changes to hit cache, its score falls to 1. It now ties with doc 0, so the correct ranking becomes [0, 2], not the cached [2, 0]. Rebuild the affected index and advance corpus_version before serving queries against the new corpus. Changing the version alone can't repair an old index.
Every input that can change the result belongs in the key or in an invalidation rule. Our lab fixes the scoring rule and has no user-specific documents. A shared service must also separate permission scopes and invalidate results when scoring or normalization changes. Two people sending identical text aren't necessarily allowed to retrieve the same notes.
The demo dictionary can grow without limit. A long-running service also needs a bound such as a maximum entry count or byte budget, plus an eviction policy. Least recently used (LRU) discards the entry that hasn't been accessed for the longest time. With two cache slots, the order changes even on a hit:
| request | least recent to most recent | action |
|---|---|---|
token | [token] | compute and store |
hit | [token, hit] | compute and store |
token again | [hit, token] | reuse and move to most recent |
cache | [token, cache] | evict hit, then store cache |
Versioning prevents stale hits. Eviction bounds storage, even when all entries are still valid. A plain insertion-ordered dictionary doesn't implement LRU by itself: reading a key doesn't move it to the end. OrderedDict.move_to_end() and popitem(last=False) provide the operations needed for this recency order.[6]
Keep k=2 fixed for this failure example. A text-only key finds yesterday's ranking after an update; a versioned key misses and forces recomputation from the updated index.
1query = "token hit"
2old_version = 1
3new_version = 2
4
5text_only_cache = {query: [2, 0]}
6versioned_cache = {(old_version, query, 2): [2, 0]}
7
8print("text-only stale hit", query in text_only_cache)
9print("versioned stale hit", (new_version, query, 2) in versioned_cache)1text-only stale hit True
2versioned stale hit FalseWhy should a retrieval-cache key include corpus_version as well as the query text?
Answer
The same query can have a different correct ranking after documents change. A new version forces a cache miss instead of serving a fast but stale result.
Use paths, links, and prefixes
Lists, dictionaries, sets, deques, and heaps covered position, identity, arrival order, and priority. What if the query asks for a path instead? Three other shapes become useful when the question is about hierarchy, a relationship, or a shared prefix.[1]
A tree gives each node one parent path
A rooted tree has a chosen root, parent-child edges, and no cycles. Every node except the root has exactly one parent. In a topic taxonomy, each note follows one path from the root:
1notes
2โโโ serving
3โ โโโ doc 0: token cache eviction
4โ โโโ doc 2: token hit cache
5โโโ training
6 โโโ doc 1: gradient checkpointing memoryThat shape makes subtree questions natural: "show every note under serving." A binary search tree solves a different problem: it orders keys so smaller keys lie in a node's left subtree and larger keys in its right subtree. Each comparison chooses a branch. A balanced tree needs only a logarithmic number of such choices; a badly skewed tree becomes a chain. A topic hierarchy isn't automatically a search tree.
A heap is also drawn as a tree, but it isn't a binary search tree. A min-heap guarantees only that each parent is no larger than its children. It doesn't support arbitrary key search the way a search tree does. Its job is still priority eviction.
A graph allows many-to-many links
Suppose doc 2 belongs under both serving and caching, or doc 0 links directly to doc 2. One-parent taxonomy no longer captures the relationship cleanly. A graph stores vertices (nodes) and edges (links); edges may be directed or undirected, and graphs may contain cycles. Choose it when the links themselves are the data you need to traverse.
Python code commonly represents a sparse graph as a dictionary of neighbor sets:
1links = {
2 0: {2},
3 1: set(),
4 2: {0},
5}
6
7print("neighbors of doc 0", sorted(links[0]))1neighbors of doc 0 [2]The printout [2] makes the directed edge from doc 0 to doc 2 visible. The reverse edge from doc 2 to doc 0 is why traversal must track visited nodes. A neighbor lookup shows one step; it doesn't yet prove that a full traversal will terminate.
If a traversal can encounter 0 -> 2 -> 0, it needs a visited set. Without one, the search can loop forever. Graph cost depends on representation: an adjacency list stores outgoing neighbors compactly for sparse links, while a matrix makes every possible pair explicit. Representation follows the density and operation of the links.
A trie follows one symbol per edge
A trie, also called a prefix tree, organizes strings by shared prefixes. If the product asks "what completions start with to?", shared characters should be traversed once. Allowed terms token, tool, and top share the path t -> o, then branch:
1(root)
2โโโ t
3 โโโ o
4 โโโ k -> e -> n token [end]
5 โโโ o -> l tool [end]
6 โโโ p top [end]Looking up prefix to follows two edges from the empty root before enumerating matching suffixes. An end marker distinguishes a complete term from a prefix: if to were also an allowed term, its node would need an end marker even though it has children. Reaching the prefix depends on prefix length, not on how many unrelated words exist. Returning completions still costs time to traverse and produce those matches. Tries can use substantial memory for nodes and child links, so compressed variants merge unbranched paths.
Prefix trees answer "what strings start with this prefix?" Graphs model dependencies and knowledge links. Trees model module, configuration, and taxonomy hierarchies. The shape still follows the operation, even when several shapes can represent the same records.
Compare the work each operation does
Big-O describes how an upper bound on work grows with input size. O(1) stays bounded as the collection grows; O(n) grows linearly. O(log n) grows slowly: doubling the input adds only a constant number of steps for a balanced-tree lookup. These labels don't predict wall-clock time. The later complexity lesson develops the notation more carefully.
In this table, n is items in a structure, m is scored candidates, k is retained winners, V is graph vertices, E is graph edges, and p is prefix length. The bounds assume bounded-cost key comparisons and hashes; reading a long string key has its own cost.[1]
Use the table to compare operations, not to crown one structure fastest. A list index is cheap when the position is known. A list scan is cheap to write but grows with every item. The right row depends on the question that dominates your workload.
| structure and operation | typical cost | caveat |
|---|---|---|
| Python list index | O(1) | position must already be known |
| Python list append | amortized O(1) | occasional resize copies references |
| Python list scan or front insert/delete | O(n) | scan checks items; front mutation shifts references |
| Python dict lookup / set membership | expected O(1) | worst case is O(n); keys need stable hashes |
deque append / popleft() | O(1) | middle access is O(n) |
| heap root peek | O(1) | only root is guaranteed best or weakest |
| heap push or pop | O(log n) | heap list isn't globally sorted |
bounded top-k over m candidates | O(m log k) time, O(k) extra space for 2 <= k <= m | k=1 takes a linear scan; full sorting may be simpler when k is close to m |
| balanced search-tree lookup | O(log n) | an unbalanced tree may degrade to O(n) |
| breadth-first or depth-first graph traversal | O(V + E) with adjacency lists | traversal still needs a visited set when cycles are possible |
| trie prefix descent | O(p) before output | assumes constant or expected-constant child-edge lookup; completions add output cost |
The retriever has more than one cost. Expected dictionary lookup reaches each posting set quickly, but it still scans returned postings. A common term may appear in almost every document. If m candidates receive scores, bounded selection adds O(m log k) work for 2 <= k <= m. The scores dictionary also holds up to m entries, so the lab's total query memory isn't just the heap's O(k). Constant-time key lookup never makes the whole query constant-time.
Diagnose the wrong shape
The wrong structure usually isn't a vocabulary problem. The first working loop feels good enough, so it stays. Diagnose the operation that got slow, duplicated, stale, or impossible to finish:
| Symptom | Likely cause | Fix |
|---|---|---|
| query gets slower as documents grow | the code still scans the whole list per query | build an index keyed by the query field |
| same document appears more than once | a list was used where uniqueness matters | store posting lists as sets |
| FIFO worker slows as queue grows | code repeatedly calls list.pop(0) | use deque.append() with deque.popleft() |
| heap output looks partly out of order | code assumes every heap position is sorted | trust the root invariant or explicitly sort final winners |
| old jobs starve | work is being popped in the wrong order | use a FIFO queue and test the order explicitly |
| cached result looks right until documents change | cache has no version or invalidation rule | tie the cache key to corpus updates |
| cache memory keeps growing | dictionary has no size or byte bound | add an eviction budget and measure it |
| graph traversal never finishes | a cycle revisits the same nodes | record visited nodes in a set |
| prefix lookup is fast but returning results is slow | trie walk is cheap, but completion set is large | cap output or rank completions separately |
| ranking feels mysterious | scores are hidden inside loops | print one query's per-document score table |
The fastest way to debug is still the same: trace one tiny example by hand. Make the operation and its promise visible before changing the container.
Test the promises, not just the ranking
The final ranking can look right while an intermediate promise is broken. These tests check the structures as well as the result:
1from collections import deque
2
3def test_token_lookup_has_two_docs():
4 assert index["token"] == {0, 2}
5
6def test_empty_query_returns_empty_list():
7 assert search("") == []
8
9def test_indexed_search_matches_ranked_scan():
10 for query in ["token hit", "token", "Token HIT", "token token hit", "attention", ""]:
11 ranked = sorted(slow_search(query), key=lambda item: (-item[1], item[0]))
12 for k in [0, 1, 2, 5]:
13 assert search(query, k) == [doc_id for doc_id, _score in ranked[:k]]
14
15def test_search_normalizes_case():
16 assert search("Token HIT") == [2, 0]
17
18def test_queue_is_fifo():
19 jobs = deque(["doc-3", "doc-4"])
20 assert jobs.popleft() == "doc-3"
21
22def test_graph_cycle_is_bounded_by_visited_set():
23 links = {0: {2}, 2: {0}}
24 visited: set[int] = set()
25 pending = deque([0])
26 while pending:
27 doc_id = pending.popleft()
28 if doc_id in visited:
29 continue
30 visited.add(doc_id)
31 pending.extend(links.get(doc_id, set()))
32 assert visited == {0, 2}
33
34test_token_lookup_has_two_docs()
35test_empty_query_returns_empty_list()
36test_indexed_search_matches_ranked_scan()
37test_search_normalizes_case()
38test_queue_is_fifo()
39test_graph_cycle_is_bounded_by_visited_set()
40print("six structural tests passed")1six structural tests passedEach test checks one promise:
- the index maps terms to the right documents
- empty input is predictable
- indexed ranking matches the scan, including ties, unknown terms, repeated terms, and different result limits
- query normalization is consistent
- queue order stays first-in, first-out
- graph traversal terminates across a cycle
Predict the next operation on paper
Pause before looking at the solution sketches. Predict the next operation, then name the structure that makes it cheap or safe:
- Add a fourth document with
docs.append("hit latency token"), then rebuild withindex = build_index(docs). - Predict
sorted(index["token"]). - Predict
search("token hit"). - Run
search("attention")and explain why it shouldn't crash. - Change document 2 so it no longer contains
token, then explain what must happen to the cache. - Choose a structure for each new requirement: autocomplete the prefix
to; represent notes with several cross-links; process oldest upload first. - Explain why a min-heap isn't a sorted list and why a heap isn't a binary search tree.
Solution sketches
Use these to check your reasoning, not to skip the exercise.
| Practice item | What a good answer should show |
|---|---|
Add hit latency token | token and hit both gain the new document ID. |
Predict sorted(index["token"]) | [0, 2, 3] after the new document is added. |
Predict search("token hit") | [2, 3]. Documents 2 and 3 tie with score 2, so the lower document ID wins the deterministic tie-break. |
Query attention | index.get(term, set()) returns an empty set, so the function returns [] instead of crashing. |
| Change document 2 | Rebuild the affected posting lists and advance the corpus version used in cache keys. Remove obsolete entries through cleanup or eviction. |
| Prefix, cross-links, oldest upload | Use a trie, graph, and deque, respectively. Each requirement asks for a different path or order. |
| Heap distinctions | Only heap root satisfies the global minimum rule. A binary search tree instead orders left and right subtrees to support arbitrary key search. |
If your answer feels fuzzy, go back to the score table. Good data-structure reasoning should look inspectable on paper before it looks clever in code. Use the table to predict what work each operation forces before code hides it.
Hand the promises to durable storage
The inverted index, deque, and versioned cache you just built live in one process. Close the process and they're gone. Share them across two workers and you also need identity, uniqueness, and freshness that survive a restart. The in-memory structures made those promises concrete; durability makes them shared.
That's why data structures come before databases. First pick the shape that makes a lookup cheap and inspectable. Then put that promise behind durable tables, joins, transactions, and vector search.