◈ python mapVol 3 · Wild 3/3
Volume 3 Python, from the metal up · algorithms in the wild · 3

wild·3The algorithms that run the internet

Type a word into a search box and a ranking of the web comes back before your finger leaves the key. Visit a page and your browser has already checked it against millions of known-bad addresses without asking anyone. Commit a file and version control names the exact two lines you touched. Click a pixel and a whole region fills. This last room of the gallery opens those four lids: the census that decides which page matters by simulating a bored surfer clicking forever, the sixteen-bit bouncer that answers definitely-not or probably-yes, the grid that finds the skeleton two files share so the leftovers are exactly your edit, and the fill that is breadth-first search wearing a paint costume. Every number on this page came out of a real run on Python 3.12.7 — including the false positive staged in plain sight and the RecursionError that kills the naive paint bucket. Then the gallery closes, and the course takes you back to the thinking that picks the tool.

iolinked · in the wild · 3 — the checkpoints4 steps
$ algorithms covered in the algorithms that run the internet
01PageRank — the random surfer's census
02Bloom filter — definitely-not or probably-yes
03Diff / LCS — the skeleton both versions share
04Flood fill — BFS in a paint costume

01The random surfer who built a search empire

★ ON THE NET · pageranknobody decides which page matters — the links already voted
Early web search read the pages. Ask for “pizza” and it counted the word — so a page chanting pizza pizza pizza two hundred times outranked the actual pizzeria, and for a while the whole young web was a shouting contest anyone could win with a paragraph of repeated keywords. The 1998 idea that ended the shouting looked away from the text entirely: a page matters if pages that matter link to it. Circular? Deliberately — and solvable. Model a bored surfer who clicks a random link on whatever page they are on, forever, except that 15% of the time they get bored and teleport to any page at all. Run that forever and ask: what fraction of eternity does the surfer spend on each page? That fraction is the page's rank. Nobody assigns importance. It precipitates out of the link structure, one iteration at a time, and this section runs the census for real on a six-page web.
the bored surfer, clicking foreverpower iteration — repeat one flow step until nothing moves
a nod from a heavyweight beats three from nobodiesa link hands over a SHARE of the linker's own rank
the 15% “bored now” teleportdamping d = 0.85 — what rescues dead ends and settles the loop
“this page matters” with no editor anywherethe fixed point: rank = step(rank), reached at iteration 27 here
results before your finger leaves the keythe census runs offline; your query only reads the finished table
pin it: Rank is the fraction of eternity a bored random surfer spends on the page. Nobody decides it — it precipitates out of the links, and the iteration below watches it precipitate.

Start with the failure that made the idea necessary. Word-counting search ranks text, and text is authored by the one party with every incentive to lie: the page itself. Any signal the author controls, the author will saturate — that is not cynicism, it is just what happened, within months, everywhere. The 1998 reframe moves the signal to something no single author controls: the link graph. A link from page q to page p is q spending a little of its own credibility vouching for p. But that only pushes the question back — how much is q's vouching worth? As much as q's own rank, which depends on who links to q… and the definition has swallowed its own tail. The escape is to stop treating the circularity as a paradox and start treating it as an equation: rank is whatever assignment of numbers makes the whole circular story consistent at once. Such an assignment exists, it is essentially unique, and — this is the practical miracle — you find it by the dumbest imaginable method: guess evenly, then iterate the flow until it stops moving.

Here is the flow, and it is worth seeing as plumbing. Rank is a conserved fluid: the whole web holds exactly 1.0 of it, always. Each round, every page deals its current rank out along its outgoing links in equal shares — a page with one link hands its entire balance to one neighbour; a page with ten links splits itself ten ways. Then the damping: only 85% of each page's balance travels the links; the other 15% is sprinkled evenly over all pages — the surfer's teleport. And one honest complication the textbook diagram hides: a dead end, a page with no outgoing links, would simply swallow whatever reaches it, and the run below shows the entire web's rank draining to 0.0000 in four rounds when we let that happen. The fix is part of the model: a dead end's balance is spread over every page, as if the surfer, stranded, always teleports. With both fixes in place the total stays 1.0 forever, and one page can rise only by another falling.

The widget's six-page web is rigged to stage the algorithm's signature upset. Page E collects three incoming links. Page F collects one. Count links — the pre-1998 instinct — and E wins in a walk. Run the census and F finishes at .326, more than double E's .162, because F's single link comes from A, the page the whole web cites, and A has only that one link to give — F inherits A's entire fortune every round. E's three links come from B, C and D, paupers living on teleport money, each splitting their pittance in half. Watch the max delta meter as you step: the disagreement between successive rounds shrinks by roughly ×0.85 per round — the damping factor is literally the decay rate of the sloshing — until at round 27 no page moves by even a millionth and the census stands still.

Now the same census with nothing hidden: the full trace, the convergence, and then the demolition — the same web with the teleport deleted, so you can watch the dead end drain the world. The two-line summary of the run: one link from a heavyweight outranks three links from paupers, and without the teleport there is no ranking at all.

RUN · the census, iterated to a standstillsix pages, 27 rounds, then the teleport deleted and the web drains
you type
$ python pagerank.py

LINKS = {                  # a six-page web: page -> the pages it links to
    "A": ["F"],            # the heavyweight hands its whole vote to F
    "B": ["A", "E"],
    "C": ["A", "E"],
    "D": ["A", "E"],
    "E": ["A"],
    "F": [],               # a dead end: everyone reads it, it links nowhere
}
PAGES = sorted(LINKS)
N = len(PAGES)


def step(rank, d):
    # the bored surfer: with probability d follow a random link on the
    # current page; otherwise teleport anywhere. A dead end teleports always.
    sink = sum(rank[p] for p in PAGES if not LINKS[p])
    new = {}
    for p in PAGES:
        flow = sum(rank[q] / len(LINKS[q]) for q in PAGES if p in LINKS[q])
        new[p] = (1 - d) / N + d * (flow + sink / N)
    return new


rank = {p: 1 / N for p in PAGES}
print("power iteration, d = 0.85 (the 1998 damping):")
print(" it  0:", "  ".join("%s %.4f" % (p, rank[p]) for p in PAGES))
for it in range(1, 100):
    new = step(rank, 0.85)
    delta = max(abs(new[p] - rank[p]) for p in PAGES)
    rank = new
    if it <= 4 or it % 10 == 0 or delta < 1e-6:
        print(" it %2d:" % it, "  ".join("%s %.4f" % (p, rank[p]) for p in PAGES),
              " delta %.7f" % delta)
    if delta < 1e-6:
        print("converged: no page moved by more than 1e-6 after %d rounds" % it)
        break

top = sorted(PAGES, key=rank.get, reverse=True)
inbound = {p: sum(p in LINKS[q] for q in PAGES) for p in PAGES}
print()
print("rank order:", "  ".join("%s %.4f" % (p, rank[p]) for p in top))
print("in-links  :", "  ".join("%s:%d" % (p, inbound[p]) for p in top))

print()
print("no teleport (d = 1.0), dead end left unrescued - watch the web drain:")
rank = {p: 1 / N for p in PAGES}
for it in range(1, 5):
    rank = {p: sum(rank[q] / len(LINKS[q]) for q in PAGES if p in LINKS[q])
            for p in PAGES}
    print(" it %d: total rank left in the web = %.4f" % (it, sum(rank.values())))
you see
power iteration, d = 0.85 (the 1998 damping):
 it  0: A 0.1667  B 0.1667  C 0.1667  D 0.1667  E 0.1667  F 0.1667
 it  1: A 0.4028  B 0.0486  C 0.0486  D 0.0486  E 0.2611  F 0.1903  delta 0.2361111
 it  2: A 0.3359  B 0.0520  C 0.0520  D 0.0520  E 0.1139  F 0.3943  delta 0.2040394
 it  3: A 0.2440  B 0.0809  C 0.0809  D 0.0809  E 0.1471  F 0.3664  delta 0.0919292
 it  4: A 0.3050  B 0.0769  C 0.0769  D 0.0769  E 0.1800  F 0.2843  delta 0.0821005
 it 10: A 0.2978  B 0.0715  C 0.0715  D 0.0715  E 0.1614  F 0.3264  delta 0.0020499
 it 20: A 0.2993  B 0.0711  C 0.0711  D 0.0711  E 0.1618  F 0.3255  delta 0.0000194
 it 27: A 0.2993  B 0.0711  C 0.0711  D 0.0711  E 0.1618  F 0.3255  delta 0.0000005
converged: no page moved by more than 1e-6 after 27 rounds

rank order: F 0.3255  A 0.2993  E 0.1618  B 0.0711  C 0.0711  D 0.0711
in-links  : F:1  A:4  E:3  B:0  C:0  D:0

no teleport (d = 1.0), dead end left unrescued - watch the web drain:
 it 1: total rank left in the web = 0.8333
 it 2: total rank left in the web = 0.6667
 it 3: total rank left in the web = 0.2500
 it 4: total rank left in the web = 0.0000
what the census is telling you
  • Read the delta column: 0.2361, 0.2040, 0.0919… 0.0020 by round 10, below a millionth at round 27. Each round's disagreement is roughly 0.85 of the last — the damping factor is the convergence rate. That is why d = 0.85 and not 0.99: the census must actually finish.
  • The headline: F 0.3255 with ONE in-link beats E 0.1618 with three. A link is a share of the linker's own rank — A's undivided .30 vote lands on F whole, while E's three citers are paupers each splitting in half. The vote-counting instinct is not refined by this algorithm; it is replaced.
  • B, C and D finish at 0.0711 with zero in-links — not zero rank. That number is exactly teleport money plus their share of the dead end's redistributed balance: 0.15/6 + 0.85 × (0.3255/6). Every page is reachable by boredom; no page starves to nothing. That floor is what makes the equation solvable at all.
  • The second act is the honest demolition: d = 1.0, dead end unrescued, and the web's total rank goes 0.83 → 0.67 → 0.25 → 0.0000 in four rounds — everything funnels through A into F and vanishes. The teleport is not a tuning nicety. It is load-bearing: it is the difference between a census and a drain.
Where the simple story ends
This is the 1998 core, run honestly — and it is fair to say this loop seeded a search empire. It is not fair to say modern ranking IS this loop: today's search stacks hundreds of signals, and the pure census was gamed within years by link farms — webs of pages citing each other into artificial weight, the same arms race keyword stuffing started, moved one level up. What survives at every scale is the reframe: importance read from the structure others build, not the text you write about yourself — and the conserved-fluid iteration that turns a circular definition into a number.
reach for it when
You need “which node matters” on any graph where importance is circular — citation networks, follower graphs, module dependency graphs (“which package does everything ultimately lean on?”) — one number per node, from structure alone.

The census needed the whole graph in hand before your query arrived. The next algorithm answers membership questions while refusing to store the members at all →

02The bouncer who never forgets a face

★ ON THE NET · bloom filterdefinitely-not or probably-yes — and that asymmetry is the whole design
Your browser checks the address of every page you visit against a list of millions of known-bad URLs. Think about what that cannot be. Shipping you the list: megabytes, updated constantly. Asking a server about every page you open: a privacy disaster with a latency bill. What ships instead is a bit array and a few hash functions — a bouncer with a strange kind of memory. Show the bouncer a name and he hashes it k ways, looks at k pegs on his board, and answers one of exactly two things: “definitely never seen them” or “looks familiar — check the real list.” He is infallible in one direction only. A name he has seen, he will never deny — false negatives are impossible. A stranger he may occasionally wave through to the slow check — false positives happen at a rate you can compute in advance and buy down with bits. Browsers have shipped Bloom-style filters for exactly this job, because the answer that matters — NO, this page is fine, carry on — is 99.99% of the traffic and costs a few bit-reads.
the bouncer's whole memorym bits — here 16; no names, no hashes, no list
hashing the face k waysbits_for(word) — k probe positions from one sha256
“never seen them” — flat certaintyany probed bit is 0 → proof of absence
“looks familiar” — a hedgeall probed bits are 1 → go do the real, slow lookup
the invented facea stranger whose probes land on bits others set — priced by (1 − e−kn/m)k
pin it: A probed 0 is a proof; all-1s is a hunch. The filter buys instant, certain NO — the answer that is nearly all the traffic — by paying a false-positive rate it can compute in advance.

Be precise about what is being refused here, because the refusal is the invention. A set — the hash table you built in Volume 4 — answers membership exactly, and pays for exactness by storing the keys: memory grows with the names, and a million URLs cost megabytes however you squeeze. The Bloom filter's opening move is to refuse to store the names at all. Sixteen bits, in this section's toy — two bytes — will remember three hostnames well enough to answer for them forever. Not perfectly. Well enough, with the imperfection all pushed into one direction, and that direction chosen on purpose: the filter may occasionally say “probably” about a stranger, but it can never, structurally, deny a name it was given. One accepted lie bought total certainty in the direction that carries the traffic.

The machinery fits in a breath. Add a word: hash it into k positions — we use two, derived from one sha256 split into halves, the double-hashing idiom h1 + i·h2 — and set those k bits to 1. Check a word: compute the same k positions and read them. Any 0 → DEFINITELY NOT: had the word ever been added, that exact bit could not still be 0, because bits are never cleared. All 1s → probably — and the hedge is honest, because bits carry no signature of who set them. A stranger's probes can land, by luck, on positions other words lit; the filter sees k honest 1s and cannot know they were written by someone else. The widget stages exactly that: after three bad hostnames go in, hotel.example — never added — probes bits 13 and 8 and finds both lit, because phish.example lit that very pair.

What lifts this from trick to engineering is that the lie has a price tag you can read in advance. Load n words into m bits with k probes each and the odds a stranger finds all their probes lit is about (1 − e−kn/m)k — a formula with knobs, not a hope. Want a 1% false-positive rate for a million URLs? The formula tells you the bits to buy (about 9.6 per name — 1.2 bytes, versus the tens of bytes the name itself would cost) and the k to use. The run below puts the formula on trial at two sizes: 1000 names loaded, 10,000 strangers probed — measured 13.77% against a predicted 14.00%, then 1.92% against a predicted 1.96%. Theory and measurement agreeing to a third of a percent, from a structure that never stored a single character of any name it vouches for.

One more honest wrinkle before the run: step to the last beat of the widget and try to delete a word. Clearing scam.example's bits also un-remembers malware.example — the two words share bit 0, and a bit has no idea how many words are leaning on it. The attempted delete manufactures the one answer the filter promised never to give: a false negative. That is not a fixable bug; it is the compression itself. The variants that support deletion — counting filters — pay for it in bits, replacing each bit with a counter.

RUN · the bouncer, measuredthree names in 16 bits, a staged false positive, the formula on trial, and the delete that lies
you type
$ python bloom.py

import hashlib
import math


def bits_for(word, m, k):
    # double hashing: one sha256, split into two 64-bit halves, k probes
    dg = hashlib.sha256(word.encode()).digest()
    h1 = int.from_bytes(dg[:8], "big")
    h2 = int.from_bytes(dg[8:16], "big") | 1     # odd, so all 16 slots reachable
    return [(h1 + i * h2) % m for i in range(k)]


M, K = 16, 2
bloom = [0] * M
for bad in ["scam.example", "phish.example", "malware.example"]:
    ps = bits_for(bad, M, K)
    for p in ps:
        bloom[p] = 1
    print("add   %-16s -> set bits %s" % (bad, sorted(ps)))
print("the filter:", "".join(map(str, bloom)), "  (bit 0 on the left)")

print()
for host in ["quebec.example", "phish.example", "hotel.example"]:
    ps = bits_for(host, M, K)
    verdict = "PROBABLY IN" if all(bloom[p] for p in ps) else "DEFINITELY NOT"
    print("check %-16s -> bits %-8s -> %s" % (host, sorted(ps), verdict))
print("hotel.example was never added; its two probes landed on phish's two")
print("bits. a false positive - the price of storing 3 names in 16 bits.")

print()
print("the real rate: add 1000 hostnames, probe 10000 strangers, twice")
for m, k in ((4096, 3), (8192, 6)):
    filt = bytearray(m)
    for i in range(1000):
        for p in bits_for("word%04d" % i, m, k):
            filt[p] = 1
    fp = sum(all(filt[p] for p in bits_for("probe%05d" % i, m, k))
             for i in range(10000))
    theory = (1 - math.exp(-k * 1000 / m)) ** k
    print("  m=%4d k=%d: measured fp %4d/10000 = %.4f   theory %.4f"
          % (m, k, fp, fp / 10000, theory))

print()
print("why you cannot delete: scam and malware SHARE bit 0")
for p in bits_for("scam.example", M, K):
    bloom[p] = 0                       # try to remove scam.example
ps = bits_for("malware.example", M, K)
verdict = "PROBABLY IN" if all(bloom[p] for p in ps) else "DEFINITELY NOT"
print("clear scam's bits %s, then check malware.example -> %s"
      % (sorted(bits_for("scam.example", M, K)), verdict))
print("a false negative: the one answer a plain bloom filter must never give")
you see
add   scam.example     -> set bits [0, 15]
add   phish.example    -> set bits [8, 13]
add   malware.example  -> set bits [0, 7]
the filter: 1000000110000101   (bit 0 on the left)

check quebec.example   -> bits [5, 10]  -> DEFINITELY NOT
check phish.example    -> bits [8, 13]  -> PROBABLY IN
check hotel.example    -> bits [8, 13]  -> PROBABLY IN
hotel.example was never added; its two probes landed on phish's two
bits. a false positive - the price of storing 3 names in 16 bits.

the real rate: add 1000 hostnames, probe 10000 strangers, twice
  m=4096 k=3: measured fp 1377/10000 = 0.1377   theory 0.1400
  m=8192 k=6: measured fp  192/10000 = 0.0192   theory 0.0196

why you cannot delete: scam and malware SHARE bit 0
clear scam's bits [0, 15], then check malware.example -> DEFINITELY NOT
a false negative: the one answer a plain bloom filter must never give
what the bouncer is telling you
  • The false positive was not planted — it was found. hotel.example hashes to bits [8, 13], exactly the pair phish.example set. With 5 of 16 bits lit, a two-probe stranger has roughly a (5/16)² ≈ 10% chance of this; hotel.example was simply the first name on an alphabetical list to get unlucky. At production sizes you choose that rate instead of stumbling on it.
  • The measurement is the trust anchor: m=4096, k=3 → 13.77% measured vs 14.00% predicted; m=8192, k=6 → 1.92% vs 1.96%. Doubling the bits and tuning k cut the lie rate seven-fold, exactly on the formula's schedule. This is what “engineered probability” means: the error is a dial, not a surprise.
  • The asymmetry in one line of code: all(bloom[p] for p in bits_for(word)). One 0 short-circuits to False — proof — because adds only ever turn bits ON. Every YES is a maybe; every NO is a theorem. Design systems so the certain answer is the common one.
  • The delete demolition, run for real: clearing scam's bits [0, 15] flips malware.example's answer to DEFINITELY NOT — a false negative, the exact promise the structure exists to keep. Bit 0 was doing double duty for two words. A plain Bloom filter grows monotonically or not at all.
Where the simple story ends
Real deployments are this, scaled: millions of bits, k tuned from the formula, and the filter as a prefilter in front of a real store — the certain NO answers locally, the rare “probably” pays for one authoritative lookup. Two honest edges: at scale the double-hashing idiom and the formula are approximations — excellent ones, as the run just measured — and the field has moved past the plain Bloom for some jobs (counting variants for deletion, other compact structures where different trade-offs win). The asymmetry — absence provable, presence only probable — is the part that transfers everywhere.
reach for it when
Membership at scale where NO is the common answer and a rare wasted YES is cheap — “is this URL on the bad list?”, “is this key even in the store?” before a disk read, “have we already seen this item?” in a crawler — and you would rather spend bits than bytes.

The bouncer answers for one name at a time. The next algorithm holds two whole files side by side — and names the two lines you touched →

03How git sees what you changed

★ ON THE NET · diff / lcsversion control never compares line 5 to line 5
You change two lines in a 400-line file, commit, and the diff shows exactly those two lines. Pause on how strange that is. The obvious method — compare line 1 to line 1, line 2 to line 2 — is worthless: insert a single line at the top and every subsequent line “differs”, and the tool would report you rewrote the file. Whatever the diff is doing, it is not comparing positions. It is doing something closer to archaeology: it finds the longest common subsequence — the largest set of lines, in order, that both versions still share. That skeleton is the part of the file you never touched, wherever it drifted to. And once the skeleton is known, the diff writes itself: old lines off the skeleton were removed; new lines off the skeleton were added. The tool never found your edit. It found everything that was NOT your edit, and showed you the complement.
the 398 lines you did not touchthe LCS — the longest in-order skeleton both versions share
the two lines the tool showsthe complement: off-skeleton old = “−”, off-skeleton new = “+”
one inserted line fools nothingsubsequence: order preserved, position free to drift
how the skeleton is founddp[i][j] = skeleton length of the two PREFIXES old[:i], new[:j]
the stdlib doing it for realdifflib.unified_diff — run below, same four marked lines
pin it: A diff is not a comparison — it is a subtraction. Find the largest part that never changed; what is left over is, exactly and provably, what you did.

The grid that finds the skeleton is wild 1's edit-distance table asked a different question — same family, same shape, opposite direction: edit distance wants the cheapest way to turn one sequence into the other, LCS wants the largest part that survives unchanged, and each is computable from the other. The object itself: rows for the old file's lines, columns for the new file's, and dp[i][j] holding one exact fact — the length of the longest skeleton shared by the first i old lines and the first j new lines. Row zero and column zero are free: an empty file shares nothing with anything. Every other cell is settled by looking at three already-settled neighbours, which is the entire discipline of dynamic programming: no cell is ever guessed, each is proven from proven smaller answers.

The recurrence is two cases and both are common sense. If old line i and new line j are the same text, that line extends whatever skeleton the two shorter prefixes share: diagonal + 1 — the only move in the whole table that ever grows anything. If they differ, the cell can invent nothing; it inherits the better of its two neighbours, max(up, left) — old news, carried forward. Watch the widget fill: through the four identical lines the diagonal climbs a staircase 1, 2, 3, 4; the moment it hits the changed lines, row 5 flatlinestotal += int(n) matches nothing anywhere in the new file, so the row adds nothing, and the corner settles at 4. That single corner number already prices your edit before naming it: 6 − 4 = 2 removals, 6 − 4 = 2 additions.

Naming the edit is the traceback — walk from the corner back to the origin, reading each cell's story. A diagonal step is a line both files share: kept. An upward step spends an old line no skeleton wanted: print it with a “−”. A leftward step spends a new line the same way: “+”. The walk below leaves the corner on a genuine fork — up and left both read 4 — and the widget stops there to ask you what the tie means, because it is the honest subtlety of the whole method: the grid fixes the diff's size absolutely, but not its presentation. Ties are why two correct tools can print the same edit differently. This walker prefers left on ties, which groups removals above additions — the shape you have read a thousand times in a unified diff.

The run fills the same grid, walks the same traceback, and then hands the two files to difflib.unified_diff — the standard library's real tool — to check our homemade diff against the shipped one. Same four marked lines, same order.

RUN · the skeleton, then the difftwo 6-line versions, the 7×7 grid, the walk back, and difflib agreeing
you type
$ python diff.py

import difflib

OLD = ["import sys",
       "def main():",
       "    total = 0",
       "    for n in sys.argv[1:]:",
       "        total += int(n)",
       "    print(total)"]
NEW = ["import sys",
       "def main():",
       "    total = 0",
       "    for n in sys.argv[1:]:",
       "        total += float(n)",
       "    print(round(total, 2))"]

m, n = len(OLD), len(NEW)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
    for j in range(1, n + 1):
        if OLD[i - 1] == NEW[j - 1]:
            dp[i][j] = dp[i - 1][j - 1] + 1
        else:
            dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])

print("the LCS grid - dp[i][j] = longest skeleton of old[:i] and new[:j]")
for row in dp:
    print("  ", row)
print("corner says: the two files share a %d-line skeleton (of 6 each)" % dp[m][n])

out = []
i, j = m, n
while i > 0 or j > 0:                # walk back from the corner
    if i > 0 and j > 0 and OLD[i - 1] == NEW[j - 1]:
        out.append("  " + OLD[i - 1]); i -= 1; j -= 1      # diagonal: kept
    elif j > 0 and (i == 0 or dp[i][j - 1] >= dp[i - 1][j]):
        out.append("+ " + NEW[j - 1]); j -= 1              # left: added
    else:
        out.append("- " + OLD[i - 1]); i -= 1              # up: removed
print()
print("the traceback, read forwards - everything off the skeleton is the edit:")
for line in reversed(out):
    print("   " + line)

print()
print("difflib.unified_diff - the stdlib's real tool, same verdict:")
for line in difflib.unified_diff(OLD, NEW, "old.py", "new.py", lineterm=""):
    print("   " + line)
you see
the LCS grid - dp[i][j] = longest skeleton of old[:i] and new[:j]
   [0, 0, 0, 0, 0, 0, 0]
   [0, 1, 1, 1, 1, 1, 1]
   [0, 1, 2, 2, 2, 2, 2]
   [0, 1, 2, 3, 3, 3, 3]
   [0, 1, 2, 3, 4, 4, 4]
   [0, 1, 2, 3, 4, 4, 4]
   [0, 1, 2, 3, 4, 4, 4]
corner says: the two files share a 4-line skeleton (of 6 each)

the traceback, read forwards - everything off the skeleton is the edit:
     import sys
     def main():
         total = 0
         for n in sys.argv[1:]:
   -         total += int(n)
   -     print(total)
   +         total += float(n)
   +     print(round(total, 2))

difflib.unified_diff - the stdlib's real tool, same verdict:
   --- old.py
   +++ new.py
   @@ -2,5 +2,5 @@
    def main():
        total = 0
        for n in sys.argv[1:]:
   -        total += int(n)
   -    print(total)
   +        total += float(n)
   +    print(round(total, 2))
what the grid is telling you
  • Read the table's shape like a graph: the diagonal staircase 1–2–3–4 through the untouched lines, then two flat rows where the changed lines match nothing. A flat row IS a changed line, visible before any traceback — the grid wears the diff on its face.
  • The homemade walk prints exactly − int(n), − print(total), + float(n), + print(round(total, 2)) — two removals, two additions, the counts the corner promised. Four kept lines, four marked lines, nothing else touched: the two-line edit in a six-line file, pinpointed by subtraction.
  • difflib.unified_diff agrees line for line — and shows one presentation habit worth noticing: its hunk header says @@ -2,5 +2,5 @@ because it trims context to three lines around the change, so import sys does not even appear. Same verdict, editorial cropping.
  • The tie at the corner — dp[5][6] = dp[6][5] = 4 — is why diff output is not unique. Every maximal traceback yields a minimal diff of the same size; which one you see is a tie-breaking convention. When two tools disagree about how to display your edit, neither is wrong — they broke ties differently.
Where the simple story ends
This 7×7 grid is the concept at full honesty; production tools engineer the same idea harder. Filling m×n cells for two 100,000-line files is too slow, so real version control uses LCS-family algorithms designed to find minimal diffs without materialising the whole table, plus presentation heuristics — context trimming, hunk merging, options that prefer more readable (not just minimal) diffs. What never changes: the diff is defined as the complement of a longest common subsequence — the tool finds what survived, and shows you the rest.
reach for it when
Two sequences, and the question is what changed / what survived — file revisions, config drift between environments, two exports of the same data — or you need the minimal patch that turns one into the other.

From comparing whole files to a single click on a pixel: the gallery closes with the algorithm you have been running since your first drawing app →

04The paint bucket you have clicked for years

★ ON THE NET · flood fillthe bucket, the magic wand, and minesweeper's zero-reveal — one loop
Click a pixel with the bucket tool and the whole region fills — and stops, crisply, at the outline. Click an empty square in minesweeper and a whole territory of zeros opens by itself, stopping exactly at the numbered border. Drag the magic wand and “select contiguous region” picks precisely the patch you meant. Three features, three decades of software, one algorithm wearing a paint costume: start at the clicked pixel, spread to neighbouring pixels of the same colour, never touch a pixel twice. That is breadth-first search — the frontier machine from chapter 44 — with “visited” renamed “painted” and the visited set itself promoted from bookkeeping to the product. And hiding inside “neighbouring” is a genuine decision with visible consequences: do pixels that touch only at a corner count? Four neighbours or eight — the widget below builds a chamber whose wall has a diagonal crack, and runs your click under both answers.
the clickthe BFS source — ch44's ripple, one more time
“same region” means “same colour, touching”the edge rule: same-coloured neighbours only
paint that never re-wets a pixelvisited = painted — each pixel enters the deque once, ever
the outline that holds the fillboundary pixels fail the colour test; the frontier dies there
the fill that leaks through a diagonal gap4- vs 8-connectivity — whether corners are neighbours at all
pin it: Flood fill is BFS where the visited set IS the product. And “region” is not a fact of the image — it is a consequence of what you let “neighbour” mean.

There is a genuine unmasking here, so collect it in one sentence: the paint bucket — the first tool you ever clicked in a drawing program — is chapter 44's breadth-first search, unchanged. The queue is the same queue; the rings are the same rings; the never-revisit discipline is the same discipline. What changed is only the reading of the words. BFS says visit every node reachable from the source; the bucket says paint every pixel connected to the click — and “connected” just means reachable through edges, where an edge joins touching pixels of the same colour. The wall in your drawing is not special to the algorithm. It is simply pixels of a different colour, so the colour test fails there, no edge exists, and the frontier dies at the outline for the same reason ch44's ripple dies at the end of a graph: there is nowhere legal left to go.

The implementation below is chapter 52's deque doing its day job — popleft from the front, append to the back, the frontier advancing in rings — with one detail worth stealing for any BFS you ever write: pixels are painted when they join the queue, not when they leave it. Paint-on-arrival would let two frontier pixels both discover the same neighbour and queue it twice; paint-on-enqueue makes the once-ever rule structural. And one decision the loop cannot duck: the move list. N4 is up, down, left, right — edges only. N8 adds the four corner steps. Two extra tuples in a list, and they change the meaning of every region on the grid, because connectivity is a definition, not a discovery — the classic production bug is drawing a boundary that is only diagonally connected and filling with a rule that treats corners as passable: the wall is airtight under one rule and a sieve under the other.

The widget's field stages that bug in miniature. A four-pixel chamber sits sealed behind a wall — except at row 5, column 3, where two wall cells meet only at their corners, leaving a diagonal crack. Under N4 the crack is just more wall: the free cells on either side of it touch corner-to-corner, corners are not neighbours, no edge exists — the fill paints 33 pixels and the chamber survives. Under N8, the same click, the same walls: at ring 5 the paint steps corner-through-corner and the chamber floods — 37 pixels. Four pixels of difference, and they are the entire distance between “the tool selected what I meant” and “the paint went everywhere”. The run then adds the other classic failure, the one that ships to production: write the fill recursively and each deferred neighbour becomes a stack frame — on a 200×200 open field Python dies with RecursionError after 999 pixels, under 3% of the region. The deque holds the same frontier as plain data on the heap, and finishes anything that fits in memory.

The run, both rules, before and after — and then the recursive version walking into the wall Python builds at a thousand frames.

RUN · the bucket, under both rulesone field, one click — the chamber holds at 33, leaks at 37, and recursion dies at 999
you type
$ python flood.py

from collections import deque

GRID = ["##########",
        "#........#",
        "#..####..#",
        "#..#..#..#",
        "#..#..#..#",
        "#...###..#",
        "#........#",
        "##########"]
ROWS, COLS = len(GRID), len(GRID[0])
N4 = [(-1, 0), (1, 0), (0, -1), (0, 1)]
N8 = N4 + [(-1, -1), (-1, 1), (1, -1), (1, 1)]


def flood(start, moves):
    grid = [list(row) for row in GRID]
    q = deque([start])                 # ch52's deque: the frontier, in a queue
    grid[start[0]][start[1]] = "o"
    filled = 1
    while q:
        r, c = q.popleft()
        for dr, dc in moves:
            nr, nc = r + dr, c + dc
            if 0 <= nr < ROWS and 0 <= nc < COLS and grid[nr][nc] == ".":
                grid[nr][nc] = "o"     # paint it - and never visit it again
                filled += 1
                q.append((nr, nc))
    return grid, filled


print("before - the chamber's wall has a diagonal crack at row 5, col 3:")
for row in GRID:
    print("   " + row)

g4, n4 = flood((1, 1), N4)
print()
print("click (1,1), 4-way fill: painted %d cells - the chamber held:" % n4)
for row in g4:
    print("   " + "".join(row))

g8, n8 = flood((1, 1), N8)
print()
print("same click, 8-way fill: painted %d cells - it leaked through:" % n8)
for row in g8:
    print("   " + "".join(row))

print()
print("the recursive version, on a 200x200 open grid:")
big = [["."] * 200 for _ in range(200)]


def rec(r, c):
    if 0 <= r < 200 and 0 <= c < 200 and big[r][c] == ".":
        big[r][c] = "o"
        rec(r + 1, c); rec(r - 1, c); rec(r, c + 1); rec(r, c - 1)


try:
    rec(0, 0)
except RecursionError:
    painted = sum(row.count("o") for row in big)
    print("  RecursionError after painting %d of 40000 cells" % painted)
    print("  every deferred neighbour became a stack frame; the deque holds")
    print("  the same frontier on the heap and never runs out of room")
you see
before - the chamber's wall has a diagonal crack at row 5, col 3:
   ##########
   #........#
   #..####..#
   #..#..#..#
   #..#..#..#
   #...###..#
   #........#
   ##########

click (1,1), 4-way fill: painted 33 cells - the chamber held:
   ##########
   #oooooooo#
   #oo####oo#
   #oo#..#oo#
   #oo#..#oo#
   #ooo###oo#
   #oooooooo#
   ##########

same click, 8-way fill: painted 37 cells - it leaked through:
   ##########
   #oooooooo#
   #oo####oo#
   #oo#oo#oo#
   #oo#oo#oo#
   #ooo###oo#
   #oooooooo#
   ##########

the recursive version, on a 200x200 open grid:
  RecursionError after painting 999 of 40000 cells
  every deferred neighbour became a stack frame; the deque holds
  the same frontier on the heap and never runs out of room
what the paint is telling you
  • Read the two after-grids against each other: identical except the chamber — 33 painted and four dots survive under N4, 37 and nothing survives under N8. The walls never changed; the click never changed; only the definition of “touching” did. When a bucket tool leaks through what looks like a solid line, this is usually the whole story.
  • The crack is surgical: wall cells at (4,3) and (5,4) meet corner-to-corner, so free cells (5,3) and (4,4) also meet corner-to-corner. N4 has no corner move — for it, the pair are strangers. N8's move list contains (−1, +1), and that single tuple is the door.
  • RecursionError after 999 of 40,000 cells — the recursive fill did not slow down, it hit Python's default recursion limit and died with 97.5% of the field unpainted. Every deferred neighbour is a stack frame; a long corridor of pixels is a thousand-deep call chain. The deque version is not a style preference — it is the difference between a fill that works on toys and one that works on images.
  • The paint-on-enqueue detail is doing quiet work in the count: 33 pops for 33 painted pixels, exactly one each. Mark on discovery, not on processing — the once-ever guarantee becomes structural, and the queue can never bloat with duplicates.
Where the simple story ends
A shipping bucket tool wraps this loop in engineering: a tolerance so “same colour” means “within a distance in colour space” rather than exactly equal, scanline variants that paint whole horizontal runs per queue entry instead of single pixels, and selection tools that keep the reachable SET (the magic wand's marching ants) rather than recolouring it. The wand's “contiguous” checkbox is literally this section's choice — ticked, it flood-fills from your click; unticked, it takes every matching pixel in the image, connected or not. The reachability core survives every variant: a region is what the neighbour rule can reach from where you clicked.
reach for it when
Any “everything connected to here” question on a grid or image — region select, counting islands of pixels, room detection in a tile map, minesweeper's zero-cascade — BFS with the visited set as the answer.

Close the gallery on the family resemblance, because after twelve algorithms it is impossible to miss. PageRank is a tiny loop plus an invariant — rank is conserved — iterated until a fixed point. The Bloom filter is two functions plus an invariant — bits never clear, so a probed zero is proof — with its one lie priced by a formula. The diff grid is a two-case recurrence plus an invariant — every cell exact before anything depends on it — and the answer read backwards along the arrows. Flood fill is ch44's loop plus an invariant — painted once, spread only through legal edges — with the visited set promoted to the product. None of the four is more than fifteen lines. What carries each one is the promise it can afford to keep on every step, and the honesty to know exactly where the promise ends: the census that link farms gamed, the bouncer's priced lie, the diff that is minimal but not unique, the region that depends on what “neighbour” means. That is what an algorithm is, seen from the metal up — and by now, you can read one cold.

That closes the gallery — twelve algorithms in the wild, from your pocket to the map to the internet. Back to the course: the thinking that picks the tool →

end of in the wild · 3 · the gallery closes
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked