Course 3 · Foundations

Data Structures & Algorithms for Data

You don't need to be a competitive programmer. But you do need to know why some operations stay cheap as your data grows and others explode. This chapter teaches the handful of ideas — Big-O, hash tables, sorting, and join algorithms — that explain almost every "why is this slow?" and "why did this run out of memory?" you'll ever hit with data.

Big-O notation in plain language

Big-O is not about how fast something is in seconds. It's about how the amount of work grows as your data grows. That distinction is the whole game. A clever algorithm on a slow laptop will crush a sloppy one on a supercomputer the moment the data gets big enough — and in data work, the data always gets big enough.

Say you have n rows. We ask: if I double n, what happens to the work?

  • O(n) — double the rows, double the work. Looking at every row once. Fine.
  • O(n²) — double the rows, quadruple the work. Comparing every row to every other row. Catastrophic.

That gap sounds abstract until you put real numbers on it. At a thousand rows, an O(n²) algorithm does a million operations — instant, you'd never notice. At a million rows, it does a trillion operations — your job hangs for hours or dies. The O(n) version, meanwhile, did a million operations: a blink. Same task, same machine; one scales and one doesn't.

The one rule to internalize

We only care about the dominant term and we drop constants. O(2n + 5) is just O(n). Why? Because as n grows, the shape of the curve is all that matters — a constant factor of 2 doesn't change "fine" into "catastrophic," but going from a straight line to a parabola does.

Big-ONameWork as n growsA data example
O(1)constantDoesn't grow at allLook up a user by key in a dict / hash index
O(log n)logarithmicBarely grows (add a row → maybe one more step)Binary search in a sorted column; a B-tree index seek
O(n)linearGrows in step with the dataA full table scan — read every row once
O(n log n)linearithmicA little worse than linearSorting rows; ORDER BY; a sort-merge join
O(n²)quadraticExplodes — double n, 4× workA nested-loop join with no index; comparing all pairs

Keep this table in your head. When a query is mysteriously slow, the first question a senior engineer asks is: "what's the Big-O of what it's actually doing?" Usually something accidentally became O(n²).

Arrays/lists and hash tables

Two structures cover most of your day. An array (Python list) is a row of boxes in order. Reading box #5 is instant — O(1) — but finding a value means checking boxes one by one until you hit it: O(n).

A hash table (Python dict and set) is the single most important structure in data engineering. It computes a value's "hash" — a number derived from the data itself — and uses that number to jump straight to where the value lives. No scanning. Membership tests and key lookups are O(1), no matter how big the table gets.

Why this matters more than anything else here

Dedup, lookups, joins, and GROUP BY are all built on this one trick. "Have I seen this before?" and "what's the value for this key?" answered in constant time is what makes processing millions of rows feasible. When you understand the hash table, half of database internals stops being magic.

Let's feel the difference. We'll search for a value the slow way (scanning a list) and the fast way (testing a set), on a big input, and time both.

python
import time

N = 2_000_000
data = list(range(N))          # a list 0 .. N-1
data_set = set(data)           # the same values in a hash table (set)

target = N - 1                 # worst case: the last value (or a missing one)

# --- Slow way: scan the list, checking each element (O(n)) ---
start = time.perf_counter()
found = target in data         # 'in' on a LIST scans element by element
list_time = time.perf_counter() - start

# --- Fast way: hash lookup in a set (O(1)) ---
start = time.perf_counter()
found = target in data_set     # 'in' on a SET hashes and jumps straight there
set_time = time.perf_counter() - start

print(f"list scan : {list_time*1000:8.3f} ms")
print(f"set lookup: {set_time*1000:8.3f} ms")
print(f"set is ~{list_time/set_time:,.0f}x faster")
✓ You should see

The list scan takes on the order of tens of milliseconds; the set lookup is a fraction of a microsecond — often thousands to millions of times faster (exact numbers vary by machine). The key insight: make N ten times bigger and the list time grows ~10×, while the set time barely moves. That flat line is O(1), and it's why we reach for sets and dicts constantly.

⚠ The catch: hash tables live in memory

That O(1) speed isn't free — the whole set/dict has to fit in RAM. Build a hash table on a column with a billion distinct values and your process runs out of memory. This is the exact reason joins and GROUP BY on huge data can OOM, as we'll see below. Speed and memory are a trade.

Trees and sorted structures

Hash tables are unbeatable for "is this exact key present?" But they're useless for ranges — "give me all users who signed up between March and June" — because hashing scatters values randomly, destroying any order. For ranges and sorting you want data kept sorted.

The payoff of sorted data is binary search. To find a value in a sorted array, look at the middle: too high? Throw away the top half. Too low? Throw away the bottom half. Each step halves what's left, so finding anything among a million rows takes about 20 steps — that's O(log n).

Find 37 in a sorted array of 16 values — binary search: [ 2 5 9 14 21 28 33 37 41 50 ... ] ▲ check middle (28) → 37 is higher, keep the RIGHT half [ 33 37 41 50 ... ] ▲ check middle (41) → 37 is lower, keep the LEFT [ 33 37 ] ▲ found 37 — in ~log₂(n) steps, not n

A tree generalizes this idea so the structure can be updated cheaply as rows are inserted and deleted, while staying balanced enough that lookups remain O(log n). The version databases actually use is the B-tree — a wide, shallow tree tuned for reading from disk in big chunks. It's how a database index turns a WHERE user_id = 42 from an O(n) full scan into an O(log n) seek, and how it answers range queries (WHERE date BETWEEN …) by walking the sorted leaves. We unpack B-trees in Chapter 03; for now, just hold onto this: "kept sorted" is what buys you fast range queries and O(log n) lookups.

Sorting — and what happens when data won't fit

Sorting shows up everywhere: ORDER BY, deduplication, and one of the join algorithms below. The best general-purpose sorts (the kind behind Python's sorted()) run in O(n log n). The intuition: there are n items to place, and pinning down each one's position takes about log n comparisons (the same "halving" we just saw). You can't do better than O(n log n) with comparison-based sorting — it's a proven floor.

python
rows = [("eu", 95), ("us", 200), ("eu", 80), ("us", 120)]

# sort by amount (the 2nd field), descending — O(n log n)
by_amount = sorted(rows, key=lambda r: r[1], reverse=True)
print(by_amount)
# [('us', 200), ('us', 120), ('eu', 95), ('eu', 80)]

That works while everything fits in memory. But data engineering routinely sorts files bigger than RAM — a 500 GB dataset on a 16 GB machine. You can't hold it all at once, so you use an external sort:

  1. Split & sort chunks. Read as much as fits in memory, sort that chunk, write it back to disk as a sorted "run." Repeat until the whole file is a pile of sorted runs.
  2. Merge the runs. Open all the sorted runs at once and repeatedly take the smallest current value across them — like merging sorted decks of cards. This streams through the data using little memory and produces one fully sorted output.
Why you care

External sort is the template for "data bigger than memory" in general — sort what fits, spill the rest to disk, merge. When Spark or a database "spills to disk" during a big sort or join, this is what's happening under the hood. Knowing the pattern demystifies a lot of big-data behavior and a lot of slow jobs.

The payoff: how a JOIN actually executes

You write SELECT … FROM orders JOIN customers ON orders.customer_id = customers.id and rows come back matched up. But how? The database picks one of three algorithms, and the choice is the difference between milliseconds and a job that never finishes. Say orders has n rows and customers has m rows.

Nested-loop join is the naive way: for each of the n orders, scan all m customers looking for a match. That's n × m comparisons — O(n·m), the quadratic monster from the Big-O table. Fine for tiny tables; ruinous for big ones (unless one side has an index, which turns the inner scan into an O(log m) seek).

Hash join is the workhorse, and it's our hash table again. Build phase: load the smaller table into a hash table keyed on the join column. Probe phase: stream the bigger table through, and for each row do an O(1) lookup to find its matches. Total work is just O(n + m) — you touch each row about once. This is why hash joins are fast.

Sort-merge join sorts both tables on the join key (O(n log n + m log m)), then walks them together in one pass like the merge step of external sort. It shines when the data is already sorted (or indexed in order), or is too big for a hash table to fit in memory — because sorting can spill to disk.

AlgorithmHow it worksCostBest whenWatch out for
Nested-loopFor each left row, scan the right sideO(n·m)Tiny tables, or right side is indexedExplodes on two big un-indexed tables
Hash joinBuild a hash table on the small side, probe with the big sideO(n + m)Equality joins; one side fits in memoryHash table must fit in RAM → can OOM
Sort-mergeSort both sides, then merge in one passO(n log n + m log m)Inputs already sorted, or too big for a hash joinSorting cost if inputs aren't pre-sorted
⚠ Why a join blows up memory — and rows

Two ways a join hurts you. First, a hash join's build table must fit in RAM; join on a high-cardinality column and it can't — OOM. Second, fan-out: if the join key isn't unique, every left row matches many right rows, and the output can be far larger than either input. A join of two 1-million-row tables on a duplicated key can produce billions of rows. When a "simple join" eats all your memory, fan-out or a missing/duplicated key is usually the culprit. Check that your join keys are as unique as you think.

You rarely choose the algorithm directly — the query planner does (Chapter 04). But now, when EXPLAIN says Hash Join or Nested Loop, you'll know exactly what it means and whether to worry.

GROUP BY and DISTINCT — the same hash table, again

Once the hash table clicks, aggregation is almost obvious. GROUP BY region works by hashing on region: keep a dict whose keys are regions and whose values are running totals. Stream through the rows once, hashing each region to its bucket and updating the total. One pass, O(n), memory proportional to the number of groups (not rows).

DISTINCT and deduplication are even simpler: drop everything into a set. The set's O(1) membership test silently discards repeats, leaving only unique values.

python
rows = [("us", 120), ("eu", 80), ("us", 200), ("eu", 95)]

# GROUP BY region, SUM(amount) — a hash aggregation by hand
totals = {}
for region, amount in rows:
    totals[region] = totals.get(region, 0) + amount   # O(1) per row
print(totals)                 # {'us': 320, 'eu': 175}

# SELECT DISTINCT region — dedup via a set
print(set(r[0] for r in rows))   # {'us', 'eu'}
✓ You should see

{'us': 320, 'eu': 175} and {'us', 'eu'}. You just hand-built the engine behind GROUP BY and DISTINCT — and it ran in a single O(n) pass. The same memory caveat applies: a GROUP BY on a column with millions of distinct values needs a bucket for each one, which is how aggregations, too, can run out of memory.

✓ Check yourself

  • Can you explain why O(n²) is "fine" at a thousand rows but catastrophic at a million?
  • Why is a set lookup O(1) while a list scan is O(n) — what is the hash doing?
  • What does a hash join do in its build phase, and why can that phase run out of memory?
  • What is fan-out, and how can it make a join's output bigger than both inputs?
Exercise — Find common elements: the slow way vs. the fast way

Given two lists of IDs, find the values that appear in both. First write the obvious nested-loop version, then the hash-based version, and reason about the Big-O of each. (This is a tiny join — common elements on a key.)

solution
a = list(range(0, 100_000))
b = list(range(50_000, 150_000))   # overlap is 50_000 .. 99_999

# --- Slow way: nested loop — O(n·m) ---
def common_slow(xs, ys):
    result = []
    for x in xs:              # n iterations
        if x in ys:           # each 'in' on a LIST is O(m) → n*m total
            result.append(x)
    return result

# --- Fast way: build a set, then probe — O(n + m) ---
def common_fast(xs, ys):
    ys_set = set(ys)          # build: O(m)
    return [x for x in xs if x in ys_set]   # probe: n * O(1) = O(n)

# common_slow(a, b)  # works, but ~10 BILLION comparisons — don't wait up
print(len(common_fast(a, b)))   # 50000, returns almost instantly

The reasoning. common_slow does n outer iterations, and each x in ys scans the whole list — O(m) — so it's O(n·m), around 10 billion operations here. common_fast pays O(m) once to build the set, then does n constant-time lookups, for O(n + m) — a few hundred thousand operations. Same answer, but the fast version is the exact build/probe pattern of a hash join. The only cost: the set must fit in memory. You now understand joins from both ends — the SQL you write and the algorithm underneath.

Next

We've seen the algorithms that make data operations fast; now let's see where the data physically lives and how indexes turn those algorithms loose on disk. → Inside a Database — Storage & Indexes