Choose lists, inverted indexes, heaps, queues, and caches by the operations an AI system must serve.
Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Data structures decide what an AI system can find, update, rank, and reuse quickly. If the structure matches the operation, a retriever can jump to the right model note, a background worker can process embedding jobs in order, and a retrieval cache can reuse ranked document IDs without serving stale results.
An engineer searches an internal model-serving notebook and types:
token hit
Behind the scenes, the system has thousands of technical notes. It can't read each note from start to finish under a tight latency budget. It needs a way to jump straight to the handful of notes that mention those words.
That jump is the job of a data structure. Arrays and tensors are good when position is the whole story: if you know row 2, column 1, you can land on the value instantly. CUDA and MPS explain where those tensors execute. Many systems start somewhere else: a word like token, a repeated query like token hit, or a stream of jobs waiting to be processed.
Production AI products don't use one data structure for every job. A lexical retriever can index terms, an ingestion worker can queue jobs, and a retrieval cache can reuse ranked IDs only while their source documents remain current. Each structure exists because a particular operation repeats.
Ask one question before you pick a structure: what operation must it serve?
That's the real skill behind data structures. You aren't memorizing names. You're matching an operation to a shape.
Position-based access works when coordinates are known. Here the query starts with a term, a top-k request, or a work queue, so the shape has to change.
One tiny technical-note search story carries the rest of the lesson. The documents are small enough to trace by hand, but the ideas scale to the retrieval layer inside a retrieval-augmented generation (RAG) system, where a large language model (LLM) answers with retrieved evidence.
| doc id | text |
|---|---|
| 0 | token cache eviction |
| 1 | gradient checkpointing memory |
| 2 | token hit cache |
User query:
token hit
This story appears five times:
A list is the right starting point because the data is tiny and the order of documents is stable.
If you scan each document by hand, 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.
For beginners, a list scan isn't "wrong." It's the smallest working version. The mistake is staying with a full scan after the question has changed.
When the corpus grows from 3 documents to 3 million, a query like token hit shouldn't force the system to reread each document. A full scan touches every document, so its cost grows in proportion to the corpus size. A dictionary lookup, by contrast, is expected amortized O(1) for the term key: it hashes the key directly to its slot.[1] That doesn't make the whole search constant-time. After the O(1) term lookup, scanning the posting list is O(|postings[term]|), so common terms whose postings are Θ(corpus) still dominate end-to-end lexical retrieval. The retriever then ranks the resulting candidates. The next chapter formalizes this gap with Big-O notation; for now, focus on the entry point: the data structure must let the query enter the system in its natural form, by term, not by document position.
The query starts with a term such as token. That means the system should jump from a term to the matching documents.
This is what an inverted index does.
An inverted index maps each term to its posting list. A posting records a matching document ID; larger indexes can store additional information such as term positions or term frequency. In this first version, a Python set[int] is enough because our score asks only whether each query term appears in each document.[2]
Split each document into terms and store the matching document IDs:
| 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:
| query term | posting list |
|---|---|
token | {0, 2} |
hit | {2} |
Turn those posting lists into match counts:
| 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 dictionary solves exact term lookup. The set solves uniqueness.
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.
This is a good beginner habit:
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 you optimize storage or intersections.[2]
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 TrueOnce documents have scores, the system needs the best few results. It doesn't need a full sorted report about the corpus.
That's the moment for a heap.
For our tiny example, scores are:
| doc id | score |
|---|---|
| 2 | 2 |
| 0 | 1 |
A heap keeps the top k results easy to retrieve. In retrieval systems, that matters because you often want the best 5, 10, or 20 candidates, rather than a fully sorted candidate list.
For a stream of scored candidates, keep a min-heap capped at k items. The weakest retained score stays at the root. When a stronger candidate arrives, replace that weakest item instead of sorting every candidate seen so far.
Real rankers also need an explicit tie-break rule. The heap demo and the retriever lab below both prefer the lower document ID when scores tie so their output stays deterministic. The min-heap key stores score first and negative ID second, so higher scores win and lower IDs win ties when Python compares tuples.
1import heapq
2
3candidate_scores = [(0, 1), (2, 2), (3, 3)]
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 [(3, 3), (2, 2)]The pieces fit together in a single flow:
A query enters as plain text, splits into terms, and uses the index to turn each term into a posting list. Scores accumulate per document, and the heap returns the best k document IDs. This same pattern sits inside RAG systems, where a language model reads only the best supporting chunks instead of the whole corpus.
Start with the slow version so you can see what the index is replacing. Then add the index and heap.
Put this in data_structures_demo.py. This first 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
23index: dict[str, set[int]] = defaultdict(set)
24for doc_id, text in enumerate(docs):
25 for term in tokenize(text):
26 index[term].add(doc_id)
27
28def search(query: str, k: int = 2) -> list[int]:
29 if not query.strip():
30 return []
31
32 scores: dict[int, int] = defaultdict(int)
33 for term in tokenize(query):
34 for doc_id in index.get(term, set()):
35 scores[doc_id] += 1
36
37 best = heapq.nlargest(
38 k,
39 scores.items(),
40 key=lambda item: (item[1], -item[0]),
41 )
42 return [doc_id for doc_id, _score in best]
43
44print("slow scan", slow_search("token hit"))
45print("posting list", sorted(index["token"]))
46print("search", search("token hit"))1slow scan [(0, 1), (2, 2)]
2posting list [0, 2]
3search [2, 0]| 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.
That table is more important than the syntax.
Retrieval is one place where data structures show up in AI systems.
New documents arrive and need to be split into chunks, then encoded as embedding vectors before they can enter a semantic index. For one class of equal-priority jobs, arrival order is a clean baseline. Production queues may add priorities, retries, and dead-letter handling, but they still need an explicit ordering contract.
That's a queue.
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:
That's first-in, first-out behavior. If you accidentally process newest work first, users may see random delays or starvation in old jobs.
For a durable worker, FIFO order is only one promise. A stateful request lane should record each accepted event in a durable transaction, then preserve this invariant: every accepted job is pending, running, completed, or explicitly failed, never silently lost between memory and storage. The in-memory deque demonstrates ordering but provides no durability by itself.
This 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.
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-bIf users ask the same question repeatedly, recomputing retrieval results per request wastes work.
A retrieval cache stores prior ranked document IDs so the system can reuse them.
1cache: dict[tuple[int, str], list[int]] = {}
2corpus_version = 1
3query = "token hit"
4
5cache_key = (corpus_version, query)
6if cache_key not in cache:
7 cache[cache_key] = search(query)
8
9print("cached", cache[cache_key])1cached [2, 0]| cache key part | why it exists |
|---|---|
query | separates one repeated request from another |
corpus_version | forces refresh after the document collection changes |
If document 2 stops mentioning token, the cached ranking [2, 0] becomes fast and wrong. A version number, timestamp, or explicit invalidation rule tells the system when old results must be dropped.
Run the failure before relying on the fix. A text-only key finds yesterday's ranking after an update; a versioned key misses and forces recomputation.
1query = "token hit"
2old_version = 1
3new_version = 2
4
5text_only_cache = {query: [2, 0]}
6versioned_cache = {(old_version, query): [2, 0]}
7
8print("text-only stale hit", query in text_only_cache)
9print("versioned stale hit", (new_version, query) in versioned_cache)1text-only stale hit True
2versioned stale hit FalseBeginners often don't choose the wrong structure because they lack vocabulary. They choose it because the first working code feels good enough.
| 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 |
| 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 |
| 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.
These tests protect the structures, rather than the final answer alone:
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_token_hit_ranks_doc_2_first():
10 assert search("token hit")[0] == 2
11
12def test_search_normalizes_case():
13 assert search("Token HIT") == [2, 0]
14
15def test_queue_is_fifo():
16 jobs = deque(["doc-3", "doc-4"])
17 assert jobs.popleft() == "doc-3"
18
19test_token_lookup_has_two_docs()
20test_empty_query_returns_empty_list()
21test_token_hit_ranks_doc_2_first()
22test_search_normalizes_case()
23test_queue_is_fifo()
24print("five structural tests passed")1five structural tests passedEach test checks one promise:
Try these before you look at the solution sketches.
hit latency token.sorted(index["token"]).search("token hit").search("attention") and explain why it shouldn't crash.token, then explain what must happen to the cache.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 drop cache entries tied to the old corpus version. |
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.
Most beginner confusion disappears once you ask one concrete question:
What operation must stay fast and predictable?
Use that question to pick the structure:
| If the job is... | Reach for... |
|---|---|
| keep things in their original order | list |
| jump from key to value | dictionary |
| guarantee uniqueness or fast membership | set |
| keep the best few results | heap |
| process work in arrival order | queue |
| reuse prior results safely | cache |
Later retrieval systems combine exact term postings with approximate nearest-neighbor indexes over embedding vectors. The selection rule stays the same: choose a structure that matches the lookup and update operations you need.
Data structures decide which candidates enter the model's field of view.
That's why data structures come before databases. First you need to know which structure makes a lookup fast. Then you're ready to put those structures behind durable tables, joins, transactions, and vector search.
Answer every question, then check your score. Score 75% or higher to mark this lesson complete.
8 questions remaining.
Questions and insights from fellow learners.