◈ python mapVol 3 · Ch 46/47
Volume 3 Python, from the metal up · chapter 46

46Randomization & the probabilistic mind

In Chapter 45 we bent strings and number theory into the machinery of crypto and quant. And every algorithm we've built, all volume long, has made its choices deterministically: the same pivot, the same hash, the same next node, every single time. In this chapter we do the one thing that sounds like surrender and turns out to be a superpower. We flip a coin. Here's the plan. We'll armor a sort so no attacker can find its worst case. We'll compute a number like π by throwing darts at a wall. We'll price a financial option by simulating a million possible futures. And we'll draw a perfectly fair sample from a stream far too big to hold. The whole way through we keep asking the one question that actually matters: how can deliberately adding randomness make an algorithm more reliable, not less? By the end you'll reach for a coin on reflex. You'll reach for it when being predictable is the weakness, when the exact answer is out of reach, and when you need fairness over data you can't even count.

iolinked · chapter 46 — the checkpoints6 steps
$ sections covered in Randomization & the probabilistic mind
01Every fixed plan has a nemesis
02Randomness is armor — the randomized pivot
03Compute by throwing darts — Monte Carlo
04The √N law — and pricing the future
05Two ways to gamble — Las Vegas vs Monte Carlo
06A fair sample from an endless stream — reservoir sampling

01Every fixed plan has a nemesis

Let's start with a word we've leaned on all volume without ever saying it out loud: deterministic. A deterministic algorithm always makes the same choice on the same input. Feed it the same list twice and it does the exact same thing, step for step. Usually that's a virtue, because it's what makes code testable and predictable. But watch what it costs us. A deterministic algorithm has a fixed worst case — one specific arrangement of input that drives it to its slowest possible behavior, every time. And suppose someone can read your code, because it's open-source, or a public API, or a well-known library. Then they can construct that exact arrangement and hand it to you on purpose.

Quicksort is the classic victim of exactly this attack. Tell it "always pick the first element as the pivot," then hand it an already-sorted list, and watch it unravel. Every partition peels off just one element, so the recursion sinks n levels deep. That drops the tidy O(n log n) straight into O(n²). You stepped quicksort's partition in trace T28, and here we attack that same code. This is not a thought experiment, either — it's a working denial-of-service. Put a naive quicksort behind a web endpoint, and an attacker who sends sorted data can pin your CPU flat with a tiny request.

The escape is not a cleverer fixed rule, because the adversary just studies the new rule and defeats that one too. The escape is to stop being predictable altogether. Once the algorithm flips a coin to make its choices, no single input is reliably its worst case. The worst case now depends on coins the attacker cannot see.

Before we lean on coin flips, let's be honest about where they come from. Python's random module doesn't flip physical coins, because it runs a pseudorandom number generator. That's a deterministic formula that starts from a seed and churns out numbers that pass every statistical test for randomness. For sorting and simulation this is plenty, and it has one lovely bonus: reuse the seed and you can replay a run exactly. But there is a real caveat for the armor story. If an attacker can learn or guess your seed, they can predict every "coin" you'll flip, and the defense evaporates. When randomness must survive a determined adversary, use the secrets module, which draws from the operating system's unpredictable entropy instead.

★ YOU ALREADY RUN THIS · fisher-yatesthe three riffles you thought were enough
You have shuffled a deck. You bridge it, riffle the halves together with that satisfying brrrp, square it up, do it twice more, and deal — and it feels random, because your hands did something you could not have predicted. It isn't. Three riffles later the deck still remembers the box it came out of: the cards are lying in a handful of long ascending runs, and anyone who knows that has an edge on you. Now picture how you would deal a genuinely fair hand instead. You would fan the deck face down, pull one card blind from the whole fan, set it aside, and never touch it again — then pull blind from what is left, and again, until the fan is gone.
the fan of cards you have not placed yeta[0 : i+1] — the shrinking range you are allowed to roll in
pull one card blind from the whole fanj = randint(0, i) — every survivor equally likely, including the one already there
set it down and never touch it againa[i], a[j] = a[j], a[i], then i shrinks by one
riffling three times and calling it shuffledthe naive swap — same O(n), provably unfair
what your hands were reaching for all alongrandom.shuffle — CPython's is exactly this loop
pin it: A fair shuffle is not vigorous, it is disciplined — pull one card blind from what is left, set it down, never touch it again. Everything else is a deck that still remembers.
Deterministic — predictable pivot = first element adversary reads the rule… feeds a sorted list O(n²) — every time Randomized — unaimable pivot = 🎲 random adversary reads the rule… and has nothing to aim at O(n log n) — expected
Fig — Determinism is a fixed target. The attacker who knows your rule can always find your worst input — unless your choices depend on coins they can't predict.
Algorithmic-complexity attacks are real
Sorted-input quicksort, hash flooding (the hashing chapter), and catastrophic regex backtracking (the backtracking chapter) are all the same exploit: force a deterministic algorithm into its worst case on purpose. Randomization is a standard defense against every one of them.

So how much does a single coin flip actually buy? Let's stop asserting and start counting →

02Randomness is armor — the randomized pivot

Count the fixed-pivot disaster first, because the arithmetic is small enough to check by hand. Partitioning a block of m elements costs m−1 comparisons, one for every element measured against the pivot. If every pivot is the extreme value, which is exactly what a sorted list guarantees, the block sizes march down n−1, n−2, …, 1. Add them up and the total is (n−1)+(n−2)+…+1 = n(n−1)/2, which is the textbook O(n²). And it is not approximate. I ran a comparison-counting quicksort on a sorted list of n = 2000, and the fixed-pivot version reported 1,999,000 comparisons — precisely n(n−1)/2, to the comparison.

Now make the pivot random. Each pivot is equally likely to be any rank, so the partitions come out balanced on average. The expected number of comparisons works out to about 2n ln n (≈ 1.39·n·log₂n), squarely in the O(n log n) class. I fed it the same sorted input, this time with a random pivot: about 24,600 comparisons, averaged over twenty runs. That's roughly 80× fewer comparisons, and it happened on the very input that murdered the deterministic version.

One phrase in that sentence deserves a hard look: on average. An expected value is just a long-run average, the number your results settle toward over many repeats. Roll a fair six-sided die many times and the average roll settles toward 3.5, even though no single roll can be 3.5. Check it: (1+2+3+4+5+6)/6 = 21/6 = 3.5. The randomized quicksort claim is the same kind of statement. Any single run might draw unlucky pivots and land above that 24,600 average, and that's fine. The promise is about the average over the coin flips, and that average holds for every input, sorted or not.

The deep move: we didn't change the input, we changed who chooses. The chance that a random shuffle happens to land on any one specific bad arrangement is 1/n!. For a mere 20 items that's 1 in 2.4×10¹⁸; for a shuffled deck of 52 cards it's 1 in 8×10⁶⁷ — more arrangements than there are atoms in the galaxy. The worst case didn't vanish. It became something you will not witness before the heat death of the universe.

armor.pypython
def quicksort_count(a, pivot="first"):
    a = list(a); comps = 0; stack = [(0, len(a)-1)]
    while stack:
        lo, hi = stack.pop()
        if lo >= hi: continue
        if pivot == "random":                 # ← the one line that armors it
            p = random.randint(lo, hi); a[p], a[hi] = a[hi], a[p]
        pv = a[hi]; i = lo
        for j in range(lo, hi):
            comps += 1                         # count every key comparison
            if a[j] < pv:
                a[i], a[j] = a[j], a[i]; i += 1
        a[i], a[hi] = a[hi], a[i]
        stack.append((lo, i-1)); stack.append((i+1, hi))
    return comps
# sorted input, n=2000:  fixed → 1,999,000   random → ~24,600  (≈80× fewer)

Line by line, here is what the code is doing. We sort a copy with an explicit stack instead of recursion, so the sorted worst case can't overflow Python's call stack. The if pivot == "random" line swaps a randomly chosen element into the pivot slot, and that one swap is the entire defense. The inner for loop is the partition. The comps += 1 line tallies each key comparison, so we measure the cost directly instead of trusting a formula. Everything else is ordinary Lomuto partitioning, the same scheme you stepped in T28.

NOW WRITE IT YOURSELFwrite the fair shuffle — then measure how badly your hands were cheating
The fair one first. Write fisher_yates(a): walk i from the last index down to 1, roll j = random.randint(0, i) — inclusive of i, that matters — and swap. Prove it is fair the only way that counts: shuffle "ABC" six hundred thousand times, tally the orderings, and check all six land near 100,000. Now model your hands. Write riffle(deck) that does what a person does: cut near the middle (a coin flip per card gives you a realistic cut), then drop cards one at a time from the two packets, where the fuller packet is likelier to drop next. Then find a ruler. Write rising_sequences(perm) — the number of increasing runs the deck breaks into. A boxed deck is 1. A truly uniform deck of n averages (n+1)/2, so 26.5 for 52 cards. Now measure: run 1, 2, 3, 5, 7 and 10 riffles on a fresh deck, thousands of trials each, and print the mean and the maximum you ever saw. Put your own fisher_yates and random.shuffle on the same scale. Before you look: how many riffles do you think it takes? Then read the maximum column next to 2**k and say what it is telling you.
show the solution
import random
from statistics import mean

random.seed(2024)
N = 52


def fisher_yates(a):
    """Deal from the fan: each round pulls ONE uniform card into a settled slot."""
    a = list(a)
    for i in range(len(a) - 1, 0, -1):
        j = random.randint(0, i)          # 0..i INCLUSIVE — i+1 choices
        a[i], a[j] = a[j], a[i]
    return a


def riffle(deck):
    """One human riffle: cut near the middle, then drop cards from the two
       packets — the fuller packet is likelier to drop next (Gilbert-Shannon-Reeds)."""
    n = len(deck)
    cut = sum(random.random() < 0.5 for _ in range(n))   # binomial cut
    left, right = deck[:cut], deck[cut:]
    out = []
    while left and right:
        if random.random() < len(left) / (len(left) + len(right)):
            out.append(left.pop(0))
        else:
            out.append(right.pop(0))
    return out + left + right


def rising_sequences(perm):
    """How many increasing runs the deck decomposes into. A brand-new deck
       has 1. A truly random deck of n averages (n+1)/2."""
    pos = [0] * len(perm)
    for where, card in enumerate(perm):
        pos[card] = where
    runs = 1
    for c in range(1, len(perm)):
        if pos[c] < pos[c - 1]:
            runs += 1
    return runs


new = list(range(N))
print("brand-new deck        :", rising_sequences(new), "rising sequence")

TRIALS = 4000
for k in (1, 2, 3, 5, 7, 10):
    seqs = []
    for _ in range(TRIALS):
        d = list(new)
        for _ in range(k):
            d = riffle(d)
        seqs.append(rising_sequences(d))
    print(f"{k:2d} riffle(s)          : mean {mean(seqs):6.2f}   max seen {max(seqs):3d}"
          f"   theory caps it at 2**{k} = {2**k}")

fy = [rising_sequences(fisher_yates(new)) for _ in range(TRIALS)]
sh = []
for _ in range(TRIALS):
    d = list(new)
    random.shuffle(d)
    sh.append(rising_sequences(d))
print(f"your fisher_yates     : mean {mean(fy):6.2f}   max seen {max(fy):3d}")
print(f"random.shuffle        : mean {mean(sh):6.2f}   max seen {max(sh):3d}")
print(f"a uniform deck should : mean {(N + 1) / 2:6.2f}")

# fairness of your own shuffle, measured on a deck small enough to count
from collections import Counter
tally = Counter(tuple(fisher_yates("ABC")) for _ in range(600_000))
print(sorted((("".join(k)), v) for k, v in tally.items()))


# ---------- the actual run, python 3.12.7 (seed 2024) ----------
#
# brand-new deck        : 1 rising sequence
#  1 riffle(s)          : mean   2.00   max seen   2   theory caps it at 2**1 = 2
#  2 riffle(s)          : mean   4.00   max seen   4   theory caps it at 2**2 = 4
#  3 riffle(s)          : mean   7.96   max seen   8   theory caps it at 2**3 = 8
#  5 riffle(s)          : mean  19.60   max seen  26   theory caps it at 2**5 = 32
#  7 riffle(s)          : mean  24.70   max seen  32   theory caps it at 2**7 = 128
# 10 riffle(s)          : mean  26.29   max seen  34   theory caps it at 2**10 = 1024
# your fisher_yates     : mean  26.56   max seen  35
# random.shuffle        : mean  26.52   max seen  34
# a uniform deck should : mean  26.50
# [('ABC', 99844), ('ACB', 100036), ('BAC', 99861), ('BCA', 99776), ('CAB', 100285), ('CBA', 100198)]
#
#
# ---------- reading it ----------
#
# 1. Your fisher_yates measured 26.56 and random.shuffle measured 26.52,
#    against a theoretical 26.50. They are the same algorithm -- read
#    inspect.getsource(random.Random.shuffle) and you will find your own
#    loop, running downward with an inclusive roll.
#
# 2. Three riffles: 7.96 rising sequences where a fair deck has 26.5. The
#    deck is still about 70% ordered. It FELT random because your hands
#    were unpredictable; the deck was not, and those are different claims.
#
# 3. Look at the max-seen column against 2**k. One riffle can never produce
#    more than 2 rising sequences -- a riffle interleaves two packets, so
#    it can at most double the runs. Three riffles cap at 8, and the
#    measurement never exceeded 8. That ceiling is not a statistical
#    tendency, it is arithmetic: k riffles cannot manufacture more than
#    2**k rising sequences, however hard you shuffle. This is the Bayer-
#    Diaconis result, and it is why the usual advice is seven riffles.
#
# 4. The cap stops binding around k = 6 (2**6 = 64 is already past what a
#    52-card deck can show), which is why the means keep creeping upward
#    after that -- 24.70 at seven riffles, 26.29 at ten -- instead of
#    jumping. Randomness arrives gradually; the ceiling lifts all at once.
#
# 5. The ABC census: six orderings, 600,000 trials, every count within
#    0.3% of 100,000. That is what "each of the n! orderings with
#    probability 1/n!" looks like when you actually count it.
#
# 6. random.seed(2024) makes every number above reproducible on your
#    machine. Delete the seed and the third digit moves; the story does
#    not.
fixed pivot, sorted input depth = n n(n−1)/2 compares random pivot depth ≈ log₂ n ≈ 2n ln n compares Same elements. The pivot choice alone decides whether the tree is a stick or a bush.
Fig — The cost of quicksort is the shape of its recursion tree. A fixed pivot on sorted data grows a one-sided stick (n levels); a random pivot grows a balanced bush (log n levels).
InteractiveSlide n — watch the worst case pull away from the expected one
fixed pivot 0 random pivot 0 bar length is log-scaled; the numbers are the real comparison counts
800
The gap between the two bars is the adversary's power — and randomizing the pivot gives it away.
The deeper cut — where 2n ln n comes from

Here's why the average is what it is. Number the sorted values 1..n by rank. Take two values of rank i and j, with i<j. They are compared only if one of them becomes a pivot before any value ranked strictly between them does. Here's why: an in-between pivot would split i and j into different partitions, and they'd never meet again. Now look at the j−i+1 values in that rank window. Each is equally likely to be picked first, so the probability that it's i or j is 2/(j−i+1). Sum that expected indicator over all pairs and you get Σ 2/(j−i+1) ≈ 2(n+1)H_n − 4n ≈ 2n ln n, where H_n is the harmonic number. My measured 24,593 for n=2000 sits right on that curve — the leading 2n ln n ≈ 30,400 minus the O(n) correction. And notice what this argument never mentions: the input's arrangement. No worst-case input exists, because the cost is an average over the coins, not over the data.

↺ The thing people get backwards
Most people assume a random pivot makes quicksort faster on average than a good deterministic pivot. It doesn't — on random data, a plain median-of-three does just as well. What randomization buys is not speed, it's robustness: it strips the adversary of the power to choose your input. You trade a deterministic guarantee for a probabilistic one that no input can break. Randomness here is armor, not a turbocharger.
Where you meet this
Python's own sort is Timsort (the sorting chapter), not quicksort — but C-library qsort implementations, many database sort routines, and countless others randomize the pivot for exactly this reason. Python also randomizes string hashing per process (PYTHONHASHSEED) to defeat hash-flooding DoS, and "power of two random choices" load balancers spread traffic evenly by picking two servers at random and taking the emptier — randomness as armor, everywhere.

Armor is one use of a coin. The other is stranger: using randomness to compute a number you can't otherwise reach

03Compute by throwing darts — Monte Carlo

Now turn the whole idea inside out, because randomness has a second job. So far we used it to protect an exact algorithm from a hostile input. But we can also use it to estimate an answer that's too hard to compute directly. The technique is called Monte Carlo, after the casino, and its motto is worth memorizing: to learn about a system, sample random outcomes from it and count.

The cleanest demonstration computes π. Draw a 1×1 square, and inside it the quarter of a circle of radius 1 centered at one corner. The square's area is 1; the quarter-circle's area is (1/4)·π·r² = π/4. Throw darts uniformly at random into the square. The fraction that land inside the quarter circle must, on average, equal the ratio of the areas — π/4. So π ≈ 4 × (darts inside) / (total darts). A dart at (x, y) is inside exactly when x² + y² ≤ 1 — one multiply, one compare, no trigonometry, and the symbol π appears nowhere in the code. You measure it by counting.

Let's make the counting concrete before we run the code. Suppose you throw 1,000 darts and 787 of them land inside the quarter circle. Your estimate is 4 × 787 / 1000 = 3.148, which is about 0.006 above the true 3.14159…. Notice what did the work there: pure counting, one hit ratio, one multiply by 4. The uniformity of the darts is the load-bearing assumption, because darts that favored one corner would skew the hit ratio. That's why random.random() matters here: it spreads its draws evenly across [0,1), so every patch of the square gets hit in proportion to its area.

montecarlo.pypython
def estimate_pi(n):
    inside = 0
    for _ in range(n):
        x, y = random.random(), random.random()   # a dart in the unit square
        if x*x + y*y <= 1.0:                        # inside the quarter circle?
            inside += 1
    return 4 * inside / n                           # fraction inside ≈ π/4

#      n         estimate      error
#     100        3.120000     0.021593
#    1000        3.128000     0.013593
#   10000        3.126000     0.015593   ← note: WORSE than n=1000!
#  100000        3.137280     0.004313
# 1000000        3.140592     0.001001
# 10000000       3.142310     0.000717

Four lines do the whole job. random.random() draws a coordinate in [0,1), so the pair (x, y) is a uniform dart. The one comparison x*x + y*y <= 1.0 asks "is it inside?", inside tallies the hits, and 4 * inside / n turns the ratio into π. The table is a real run, and look closely at it: at n = 10,000 the estimate was actually worse than at n = 1,000. That's not a bug. A single random run can get lucky or unlucky, and no single run owes you accuracy. What improves reliably is never one run — it's the typical error across many, and pinning that down is the next section.

inside outside π ≈ 4 · (green / total) area ratio = π/4
Fig — No formula for π anywhere — just darts and a count. The geometry does the math: the fraction landing in the quarter circle is π/4.
InteractiveThrow more darts — watch π assemble itself out of noise
darts thrown 500 estimate of π 3.1416 error vs true π 0.0000
500
You just computed π with no formula — only counting. More darts, tighter answer (drag it back and forth: the noise is real).
Wait —
a million darts only bought me three correct digits. How many would I need for six?

04The √N law — and pricing the future

Here's the law that governs every Monte Carlo estimate, and it's worth burning into memory: the typical error shrinks like 1/√N, where N is the number of samples. I measured it directly. Run the π estimator 200 times at N = 100 and the estimates scatter with a standard deviation of about 0.165. Quadruple to N = 400 and the scatter halves to 0.081. Quadruple again to 1600 → 0.039; again to 6400 → 0.020. Every ×4 in samples halves the error — the fingerprint of 1/√N.

You can see the halving inside the formula itself. Quadruple the samples and the error goes from 1/√N to 1/√(4N), and √(4N) = 2√N. So the new error is exactly half the old one, straight from the square root. Check it against the measurements: half of 0.165 is 0.0825, and the measured scatter at N = 400 was 0.081. Half of 0.081 is 0.0405, and the next measurement came in at 0.039. The law isn't a slogan, because it's sitting right there in the run data.

Flip that around and it stings. To win one more correct decimal digit, meaning a 10× smaller error, you need 100× more samples. Monte Carlo hands you the first digits cheaply and then charges brutally for the last ones. So why 1/√N? Each sample is an independent draw, and averaging N independent draws is exactly the setup where the spread of the average narrows as 1/√N. It's the same statistics that make a poll of 1,000 people accurate to a few percent, not a few thousandths. None of this is special to π — it's the signature of averaging independent randomness, anywhere it appears.

10²1 digit 10⁴2 digits 10⁶3 digits 10⁸4 digits 10¹⁰5 digits 10¹²6 digits bar height is log-scaled — each step right is a 100× taller sample budget
Fig — The cruel arithmetic of 1/√N: every extra correct digit multiplies the required samples by 100. Three digits is a million darts; six digits is a trillion.
InteractiveSlide the sample budget — feel the diminishing returns
samples N (log scale) → typical error → N = 10,000 typical error ≈ 0.016 one more digit → ×100 samples
10,000
The curve is 1.6/√N (the constant measured from the real π run) — steep at first, then agonizingly flat.

And this is exactly why Wall Street runs on Monte Carlo. The fair price of a financial option is an expected value — the average payoff across every possible future path of the underlying stock, discounted back to today. For the simplest options there's a closed-form answer, the famous Black–Scholes formula. But for the messy real ones, path-dependent, many assets, exotic payoffs, the integral has no formula at all. So you don't solve it — you sample it: simulate thousands of random futures, compute the payoff in each, and average. I priced a European call both ways to see this land, and Black–Scholes says 10.4506. Monte Carlo, averaging simulated futures, gave 11.02 at 1,000 paths, then 10.49 at 100,000 paths, then 10.4458 at a million paths. That's the √N law crawling toward the true price. Risk desks simulate millions of paths every night to answer one question: "how much could we lose tomorrow?" That is expected-value thinking made computational: when you cannot reason about the average, sample it.

If you've never met an option, here's the whole contract in one breath. A European call gives you the right, on one fixed expiry date, to buy a stock at a pre-agreed strike price K. Say the strike is K = 100 and the stock finishes at 112. You exercise, buy at 100, and the option was worth 12. If the stock finishes at 93 instead, you simply walk away, and the option pays 0. That's why the payoff formula is max(ST − K, 0): the final price minus the strike, floored at zero. The hard part is that today nobody knows where the stock will finish, so the fair price has to be an average over all the possible futures.

ex3_out.pypython
def mc_call(n, S0, K, r, sigma, T):
    drift = (r - 0.5*sigma**2)*T
    vol   = sigma*(T**0.5)
    total = 0.0
    for _ in range(n):
        z  = random.gauss(0, 1)               # one random shock
        ST = S0*math.exp(drift + vol*z)        # one simulated future price
        total += max(ST - K, 0.0)              # the call's payoff on that path
    return math.exp(-r*T) * total/n            # discounted average payoff
# Black–Scholes = 10.4506;  mc_call(1_000_000) → 10.4458

The loop is the whole idea, so read it slowly. random.gauss(0, 1) draws one random "shock" from a bell curve. Then S0*exp(drift + vol·z) turns that shock into one possible price at expiry. And max(ST − K, 0) is what the call pays on that particular future. Average over a million futures, discount back to today, and you've evaluated an integral no one wrote down — by sampling it.

✗ The myth

"More samples always means a better answer, so if it's not accurate enough, just crank N higher."

✓ The reality

Not quite, and the arithmetic says why. Error falls only as 1/√N, so the tenth digit would cost 10¹⁸ samples — more darts than any machine will ever throw. Past a point you stop brute-forcing and reach for variance reduction instead. Techniques like antithetic variates, control variates, and low-discrepancy sequences shrink the constant in front of the 1/√N rather than fighting the law itself.

Where you meet this
Every path-traced movie frame and every game's global illumination is Monte Carlo integration over light rays — the grainy "noise" in a half-rendered image is the 1/√N error, and it clears as more rays are cast. The method was born at Los Alamos in the 1940s for neutron diffusion; today it powers physics simulation, A/B testing, Bayesian inference (MCMC), and — everywhere — finance.
Why the machine loves it
Monte Carlo is embarrassingly parallel: every sample is independent, so you split N draws across cores — or thousands of GPU lanes — with zero locking or coordination (recall the GIL tradeoffs from Volume 2). That's why render farms and risk grids throw enormous core counts at it, and the 1/√N error falls in wall-clock time roughly in proportion to the hardware you own.

Quicksort's coins and π's darts gamble in opposite directions. Naming that difference is the whole taxonomy of randomized algorithms →

05Two ways to gamble — Las Vegas vs Monte Carlo

Look at what the coins actually put at risk in each algorithm. Randomized quicksort always returns a correctly sorted list, because the coins only affect how long it takes — a bad streak makes it slower, never wrong. The π estimator is the mirror image: it always finishes in exactly N steps, but the answer is only approximate. So a bad streak there makes it wrong-ish, never slow. Those are the two families of randomized algorithm, and knowing which one you're holding tells you exactly what can go wrong.

The families have names, and fittingly, both names are casinos. The always-correct, sometimes-slow family is called Las Vegas, and randomized quicksort lives there. You always walk out with the right answer, and only the waiting time varies. The fixed-time, approximately-right family is called Monte Carlo, and the π estimator lives there. The runtime is locked, but the answer carries an error you must respect. So when you meet a new randomized algorithm, ask the one sorting question first: did the coins put the time at risk, or the answer?

Las VegasMonte Carlo
Answeralways correct ✓probably correct / approximate
Running timerandom (usually fast)fixed & bounded
You bet…timecorrectness
Examplesrandomized quicksort, quickselectπ by darts, option pricing, Miller–Rabin
Las Vegas answer: LOCKED ✓ time: 🎲 varies keep rolling until right — correctness is never at risk Monte Carlo answer: 🎲 varies time: LOCKED stop on a clock — accept a tiny chance of error
Fig — Each family locks one thing and lets the coin move the other. Las Vegas bets time to guarantee the answer; Monte Carlo bets the answer to guarantee the time.

There's a beautiful bridge between the two families. If you can check an answer cheaply, you can convert Monte Carlo into Las Vegas. Guess with a fast randomized method, then verify the guess, and if it's wrong, simply guess again. The verify step turns "probably right" into "definitely right, eventually" — you've traded a small error probability for a small amount of extra runtime.

The deeper cut — tunable doubt (Miller–Rabin)

The Miller–Rabin primality test from the number-theory chapter is Monte Carlo with a dial on it. For a composite number, a single random "witness" exposes it as composite with probability at least 3/4. So a prime-looking number could still be a fraud with probability at most 1/4 after one test. Now run k independent tests. The chance of being fooled every single time drops to at most (1/4)ᵏ = 4⁻ᵏ. At k = 30 that's under 10⁻¹⁸, and at that point you're likelier to be struck by a cosmic-ray bit-flip than to certify a composite as prime. You don't wait for certainty here. You buy as much of it as you want, one cheap random test at a time.

Notice the shape of what Miller–Rabin sells you: certainty as a commodity, priced per test. Each extra witness costs a few microseconds of work and multiplies your remaining doubt by at most 1/4. That trade, a tiny quantified risk in exchange for enormous speed, is the probabilistic mind in miniature. Our last stop pushes the same mind further: an algorithm that promises perfect fairness over data it never even gets to hold.

InteractiveBuy certainty by the flip — watch your doubt collapse ×¼ a test
each independent test multiplies your remaining doubt by ¼ k = 15 tests → a fake passes ≤ 1 in 1.07 × 10⁹ times rarer than winning a national lottery jackpot 10³ 10⁶ 10⁹ 10¹² 10¹⁵ 10¹⁸ · sand 10²¹ 10²⁴ meter length = orders of magnitude of certainty — each test buys ~0.6 more Certainty isn't found — it's bought, one cheap coin-flip at a time.
15
A single Miller–Rabin test lets a composite slip through with probability at most ¼; k independent tests fail together only with probability 4⁻ᵏexponential, not the crawling 1/√N two sections up. Slide to 30 and you're likelier to hit a cosmic-ray bit-flip in RAM than to certify a fraud.
Wait —
can I take a perfectly fair random sample from a stream when I don't even know how long it is — and can't store it?

06A fair sample from an endless stream — reservoir sampling

The problem sounds impossible when you first hear it. You're watching a firehose: every tweet, every log line, every sensor reading. You want to keep a uniformly random sample of, say, one item. The requirement: when the stream finally ends, every item that ever flowed past had an equal chance of being the one you kept. Now the catch, and it's a triple one. You don't know how long the stream is, because it could be billions of items or never end at all. You get exactly one pass over the data, and you have O(1) memory — room for the one item you're holding, nothing more.

The solution is one line of genius, and it's called Reservoir sampling: keep the first item. When the i-th item arrives, replace whatever you're holding with it, with probability exactly 1/i. That's the entire algorithm, and notice something strange: n, the stream's length, never appears anywhere in the rule.

reservoir.pypython
def reservoir_one(stream):
    kept = None
    for i, item in enumerate(stream, start=1):   # i = 1, 2, 3, ...
        if random.random() < 1.0 / i:            # newest barges in w.p. 1/i
            kept = item
    return kept
# length-10 stream, 1,000,000 trials — how often each item was the survivor:
#   item 0: 10.00%   item 1: 10.04%   item 2:  9.98%   item 3:  9.99%
#   item 4:  9.97%   item 5: 10.00%   item 6: 10.06%   item 7:  9.98%  ...
# every item ≈ 1/10, dead fair — with room for exactly one item in memory

The loop reads one item at a time, holding a single survivor. At position i, the test random.random() < 1.0/i comes up true with probability 1/i. So item 1 is kept for sure, item 2 has a 1-in-2 shot at bumping it, item 3 a 1-in-3 shot, and so on down the stream. The comment in the code is a real million-trial run. Every one of the ten items ended up the survivor almost exactly a tenth of the time, deviating by at most 0.06 percentage points. Perfectly fair, in one pass, in constant memory.

Before the general proof, run the smallest interesting case by hand: a stream of just three items. Item 3 is the survivor only if it's kept at its own step, which happens with probability 1/3. Item 2 must be kept at step 2 and then survive item 3, so its chance is 1/2 × 2/3 = 1/3. Item 1 is kept for sure, then must survive both later steps: 1 × 1/2 × 2/3 = 1/3. All three land on exactly 1/3, with nothing tuned and no stream length known in advance. The general proof below is this same multiplication, written once for every position.

the stream flows past → #11/1 #21/2 #31/3 #41/4 #i1/i reservoir holds ONE item (1/i)·(i/(i+1))·((i+1)/(i+2))···((n−1)/n) = 1/n the product telescopes — every numerator cancels the previous denominator
Fig — Why it's fair: an item kept at step i must then survive every later replacement. Multiply the survival chances and the whole product collapses to exactly 1/n — the same for the first item and the last.

Here's the magic, worked out. Consider item i. It gets kept at step i with probability 1/i. To still be the survivor at the end, it must then not be replaced by items i+1, i+2, …, n. Item j replaces with probability 1/j, so it fails to replace with probability (j−1)/j. Multiply the whole chain of survival:

(1/i) · (i/(i+1)) · ((i+1)/(i+2)) · … · ((n−1)/n) = 1/n

and it telescopes: every numerator cancels the denominator just before it, leaving exactly 1/n. Every item ends with probability 1/n, whether it arrived first or billionth. The math is fair by construction, and the length n is never needed to make it so.

SYNTAX · the random idiom — which of the four you actually wantshuffle reorders in place · sample draws k without replacement · choices draws k with replacement · secrets when someone is watching
import random random.shuffle(seq) -- IN PLACE. returns None. seq must be mutable random.sample(seq, k=k) -- k distinct items, new list, WITHOUT replacement random.choices(seq, k=k) -- k draws WITH replacement. duplicates allowed random.choices(seq, weights=w, k=k) -- ...and loaded dice random.choice(seq) -- exactly one item random.randrange(n) -- 0 .. n-1, like range() random.randint(a, b) -- a .. b INCLUSIVE. the odd one out; Fisher-Yates needs it random.random() -- a float in [0.0, 1.0) random.gauss(mu, sigma) -- one draw from a bell curve random.seed(n) -- same seed, same run. debugging, tests, reproducible papers import secrets -- when the coin must survive an ADVERSARY secrets.choice(seq) · secrets.token_hex(n) · random.SystemRandom()
shuffleMutates and returns None, like list.sort. d = random.shuffle(deck) is the classic way to throw a deck away. And in CPython this is Fisher–Yates — run 6 prints the source.
sampleWithout replacement, so no item appears twice, and k may not exceed the population. Returns a new list and leaves the original untouched.
choicesWith replacement — the same item can come up five times. This is the one that takes weights=, which makes it the tool for loaded dice and simulated events.
randint(a, b)Inclusive at both ends, unlike every other range in Python. That is precisely why Fisher–Yates writes randint(0, i): slot i must be allowed to keep its own card.
seedThe generator is a deterministic formula. Same seed, same sequence, byte for byte — which is a gift for tests and a liability for security.
secretsDraws from the OS entropy pool. Tokens, passwords, session ids, nonces, and the primes in a real key — anywhere a predicted coin is a breach rather than a nuisance.
SystemRandom()The whole random API, backed by os.urandom. Use it when you want sample/shuffle's convenience with secrets' unpredictability. It cannot be seeded, by design.
you type
$ python random_idiom.py

import random, secrets, inspect

random.seed(7)                                  # replayable coins

deck = ["A", "K", "Q", "J", "10"]

# ---------- 1. shuffle: in place, returns None ----------
d = list(deck)
print(random.shuffle(d), d)

# ---------- 2. sample: k WITHOUT replacement, a new list ----------
print(random.sample(deck, 3), deck)

# ---------- 3. choices: k WITH replacement, weights allowed ----------
print(random.choices(deck, k=5))
print(random.choices(["hit", "miss"], weights=[1, 9], k=10))

# ---------- 4. choice / randrange / randint / uniform / gauss ----------
print(random.choice(deck), random.randrange(5), random.randint(0, 4))
print(round(random.uniform(0, 1), 4), round(random.gauss(0, 1), 4))

# ---------- 5. the seed makes a run replayable ----------
random.seed(42); a = random.sample(range(100), 5)
random.seed(42); b = random.sample(range(100), 5)
print(a, b, a == b)

# ---------- 6. CPython's shuffle IS Fisher-Yates. read it, don't trust me ----------
print(inspect.getsource(random.Random.shuffle).strip())

# ---------- 7. the two traps ----------
try:
    random.sample({"A", "K", "Q"}, 2)
except TypeError as err:
    print("TypeError:", err)
try:
    random.sample(deck, 9)
except ValueError as err:
    print("ValueError:", err)

# ---------- 8. when an adversary is watching, random is not enough ----------
print(secrets.choice(deck) in deck, len(secrets.token_hex(8)))
sysrand = random.SystemRandom()
print(len(sysrand.sample(range(100), 3)))
you see
None ['10', 'A', 'J', 'K', 'Q']
['A', '10', 'K'] ['A', 'K', 'Q', 'J', '10']
['Q', '10', 'K', 'A', 'Q']
['miss', 'miss', 'hit', 'miss', 'miss', 'miss', 'miss', 'hit', 'miss', 'hit']
K 0 4
0.8585 -0.1375
[81, 14, 3, 94, 35] [81, 14, 3, 94, 35] True
def shuffle(self, x):
        """Shuffle list x in place, and return None."""

        randbelow = self._randbelow
        for i in reversed(range(1, len(x))):
            # pick an element in x[:i+1] with which to exchange x[i]
            j = randbelow(i + 1)
            x[i], x[j] = x[j], x[i]
TypeError: Population must be a sequence.  For dicts or sets, use sorted(d).
ValueError: Sample larger than population or is negative
True 16
3
where beginners trip
  • Run 1 printed None before the deck. shuffle mutates and returns nothing, so deck = random.shuffle(deck) replaces your deck with None — the same trap as xs = xs.sort().
  • Run 6 is the receipt, not a claim: randbelow(i + 1), walking i downward, swapping into a settled suffix. That is Fisher–Yates, in the standard library, on your machine. Read the source rather than believing anyone about it.
  • Run 3's choices returned Q twice. With replacement is the default meaning of that function — reach for sample the moment duplicates would be wrong (a lottery, a hand of cards, a test split).
  • You cannot sample a set: sets have no order, so there is no reproducible way to index one. The error tells you the fix — sorted(s) first.
  • randint(0, len(a)) is off the end of the list; randint(0, len(a) - 1) is right, and randrange(len(a)) says it more plainly. Inclusive-b is the single most common off-by-one in this module.
  • Seeding makes a run replayable, and that is exactly why random is not safe against an adversary. Its Mersenne Twister state is fully recoverable from enough observed output. For tokens, keys, password resets, and shuffles people bet money on, use secrets or SystemRandom.
  • Every number in this run is reproducible only because of random.seed(7) on line 3. Delete it and every line below changes — which is the whole point of seeding in a test.
InteractiveWalk down the stream — watch the newcomer's odds shrink
item #i arrives and barges into the reservoir with probability 1/i: 100% i = 1 → 1/1 = 100.0% …and after the algebra, every one of the i items seen so far is holding an equal 1/i share. each item so far: 1/1 = 100.0%
1
The newcomer's odds fall as 1/i — and that's exactly what keeps the whole sample fair.
Where you meet this
"A random sample of all tweets," Spark's and BigQuery's TABLESAMPLE, random log sampling for observability dashboards, and picking a random line from a file too big for RAM — all reservoir sampling. Any time a system shows you "a random selection" from something it can't hold all at once, this one-pass 1/i trick is very likely running underneath.
The deeper cut — keeping k items (Algorithm R)

The same trick scales to a sample of k items. Fill the reservoir with the first k items you see. Then for the i-th item (0-indexed, with i ≥ k), pick a random slot j in 0..i, and if j < k, overwrite res[j] with the new item. The same telescoping argument gives every item a final probability of exactly k/n. I ran it with k = 5 from a stream of 100, across 200,000 trials. Every item appeared as a chosen one about 10,000 times out of 200,000, which is k/n = 5% each, flat across all 100. One pass, O(k) memory, provably uniform.

Step back and see the shape of this whole chapter. Randomness bought us three different things, and the first was armor, because no adversary can aim at a random pivot. The second was computation: sample the darts, the futures, the light rays, and average your way to an answer no formula gives. The third was fairness under ignorance, a uniform sample drawn without ever knowing how much data there is. The transferable move, the one the 1% run on reflex, is this. When being predictable is your weakness, add a coin. When the exact answer is out of reach, estimate it by trying many random cases and averaging. And when you want a property like fairness, work backwards from the property to the rule that produces it. Stanislaw Ulam invented Monte Carlo not by out-computing the neutron equations but by asking a humbler question. Recovering from illness and playing solitaire, he wondered about his odds of winning at the cards. He realized that instead of grinding through the combinatorics, he could just deal many random hands and count. That question — "what if I just try it thousands of times and count?" — is available to you on every hard problem you'll ever meet.

Six chapters of patterns; one volume of thinking. Next we assemble them into a single operating system for the algorithmic mind — how to look at any new problem and know, in seconds, which technique it wants. Chapter 47 is the difference between the 99% and the 1%, and it closes the volume →

PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 46, in working code

Twelve tiny programs where a single coin flip changes everything — armor against the adversary who studies your code, a way to measure π by throwing darts, and fair samples pulled from a stream you only get to see once.

Randomness is armor — the randomized pivot
A deterministic algorithm has one fixed worst case an adversary can hand you on purpose. Flip a coin to choose, and there is nothing left to aim at — same input, no disaster.
Compute by throwing darts — Monte Carlo
Turn randomness inside out: sample random outcomes and count to MEASURE a number you can't easily compute. Cheap first digits, and a 1/√N law that charges brutally for the last ones.
Two ways to gamble — Las Vegas vs Monte Carlo
Las Vegas is always correct and gambles its running time; Monte Carlo is always fast and gambles its correctness. Same coin, opposite bargains.
Fair samples, fair shuffles, fair spreads
The everyday face of randomization: pick one item from an endless stream, prove it's uniform, shuffle a deck without bias, and flatten load across servers — all with a handful of coin flips.
end of chapter 46 · six sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked