Lesson - Part 1/2
A compact guided lesson through the first half of Jay Wengrow's data structures and algorithms sequence: how to count steps, choose structures, reason about recursion, and use trees and tries for the operations they actually serve.
Topic: Lesson - Part 1/2
Participants
- Maya (host)
- Eli (guest)
Sections Covered
This podcast will cover 6 sections about:
-
Arrays, Ordered Data, and the First Big O Map
Chapters 1-3: operational analysis, arrays and sets, ordered arrays, binary search, and foundational Big O notation
Established step counting and time complexity, traced array and array-based set operations, compared ordinary and ordered arrays, walked through linear and binary search, and introduced O(1), O(log N), and O(N) with their relevant trade-offs.
-
Sorting, Big O Rules, and the Shape of Real Code
Chapters 4-7: quadratic work, simple sorting, constants and dominant terms, scenario analysis, and practical Big O diagnosis
Extended the initial Big O framework through quadratic and exponential patterns. Traced Bubble, Selection, and Insertion Sort invariants; compared scenario-sensitive behavior; explained constants and dominant terms; and practiced diagnosing nested, sequential, fixed-bound, grouped, and multi-dataset loops using examples from duplicate ratings, intersections, labels, arrays of arrays, and password generation.
-
Fast Lookup, Constrained Containers, and Recursive Thinking
Chapters 8-11: hash tables, stacks and queues, recursion, recursive patterns, and top-down subproblems
Explained hash tables as key-to-value structures with average O(1) lookup, collisions, load-factor trade-offs, and membership indexing through the subset example. Then introduced stacks and queues as LIFO/FIFO abstract data types using brace linting and print jobs, before developing recursion through base cases, the call stack, filesystem traversal, top-down subproblems, extra parameters, and the staircase recurrence.
-
Avoiding Repeated Work: Dynamic Programming, Quicksort, and Quickselect
Chapters 12-13: overlapping subproblems, memoization versus bottom-up work, partitioning, Quicksort, Quickselect, and sorting as an optimization component
Explained how naive recursive code can repeat work, using recursive maximum and Fibonacci to introduce overlapping subproblems, memoization, and bottom-up dynamic programming. Then developed partitioning, Quicksort’s average and worst-case behavior, Quickselect’s targeted O(N) average-case selection, and sorting as a component in duplicate detection, while noting unavoidable factorial output for anagrams.
-
Node-Based Trade-Offs: Linked Lists, Search Trees, and Heaps
Chapters 14-16: linked lists and doubly linked lists, binary search trees, traversal, priority queues, and heaps
Compared linked lists, doubly linked lists, binary search trees, and heaps through their invariants and operation costs. Traced front-list rewiring, BST search and insertion, conceptual successor-based deletion and inorder traversal, plus heap trickle-up and trickle-down operations; emphasized balance, completeness, and choosing structures by required operations.
-
Tries for Prefixes, Then a Disciplined Choice of Structure
Chapter 17: trie search and insertion, autocomplete foundations, and cumulative application through one implementation exercise
Explained tries as prefix-oriented trees whose nodes hold character-keyed child dictionaries, traced shared paths, end-of-word markers, prefix search, and insertion with O(K) costs and memory trade-offs. The section then synthesized operation-driven structure choice and assigned a Trie implementation exercise with behavioral, invariant, and complexity checks.
Transcript
This episode is entirely AI-generated, including the voices you’re hearing. Our entirely fictional sponsor is the Pocket Indexer, a little desk gadget that pretends to sort your notes; some information here may be hallucinated or mistaken, so please double-check anything important.
This is Part 1 of a dependency-ordered lesson from Jay Wengrow’s Python guide. We’ll start with the practical question behind all of it: not whether code runs, but how much work it makes the computer do.
We’ll measure work in computational steps rather than seconds, because hardware changes while the structure of the work does not. From there, arrays give us our first useful contrast: instant access by index, but potentially expensive shifting when you insert or delete.
Then we’ll make the trade-offs less decorative. Ordered arrays buy faster searching but make insertion harder, and Big O gives us a compact way to describe how those costs change as the amount of data grows.
After that, we’ll test common code shapes: nested loops, simple sorts, best and worst cases, and the difference between an algorithm that looks quadratic and one that actually is. I’ll keep asking you to predict the next move before we unpack it, because passive agreement is not understanding.
The middle of the lesson moves into hash tables, stacks, queues, and recursion. Those are not interchangeable gadgets: one is about key-based lookup, some impose processing order, and recursion needs a base case or it keeps digging until the call stack objects.
We’ll then follow the same logic into dynamic programming, partitioning, linked lists, binary search trees, and heaps. The recurring question is less about which structure is generally best, more about which invariant gives you the operation you actually need.
We’ll finish this part with tries, the prefix-oriented trees behind the beginnings of autocomplete, and one small implementation exercise with checks. Keep a scratchpad nearby: for these chapters, tracing a tiny example by hand is usually more honest than nodding at a Big O label.
We begin with the habit that supports everything else in this lesson: separate what code accomplishes from how much work it takes. Two programs can produce the same answer, yet ask the computer to do very different amounts of work.
So working code is necessary, but it is not the whole quality test. If one version survives a large input and the other quietly bogs down, that difference matters.
Exactly. A data structure is a way of organizing data, while an algorithm is a set of instructions for completing a task. They interact constantly: the same algorithm can behave differently with differently organized data.
Give us the non-grand version. What counts as data here?
Strings, numbers, and collections of them. Three separate variables holding pieces of a sentence organize data one way; one array holding those three strings organizes the same underlying information another way.
And an algorithm is not necessarily some ceremonial computer-science beast. A loop that prints values is already an algorithm, because it is instructions for a task.
Right. The source begins with two ways to print even numbers through 100. One checks every number from 2 through 100, while the other starts at 2 and adds 2 each time.
Same output, but the second loop runs 50 times rather than 100. So its advantage is not mystical; it skips work that the task never required.
That brings us to the source's measurement rule: count computational steps, not seconds on a clock. Hardware changes, but an approach needing 5 steps versus 500 steps preserves the important comparison.
This is what the book calls time complexity, and it also uses speed, efficiency, performance, and runtime in that practical sense. We are counting work relative to the input, not promising a fixed number of milliseconds.
For arrays, the four operations to keep in view are reading, searching, insertion, and deletion. Do not blur reading and searching; they sound similar but have very different costs.
Let’s anchor it with the grocery list: apples, bananas, cucumbers, dates, elderberries. Reading asks, "What is at index 2?" Searching asks, "At what index is dates?"
Before we trace those, an array is a list of elements, and its size is its number of elements. Its indexes normally begin at zero, so apples is index 0 and elderberries is index 4.
Predict this: if the list begins at a conceptual memory address of 1010, what address holds index 3? Take a second before I say it.
It is 1013: start address plus offset 3. The source uses a simplified contiguous-memory model, where the array occupies adjacent cells and the computer remembers where the first cell is.
That is why reading dates at index 3 can be treated as one main step. The machine computes the location and jumps to it, rather than inspecting apples, bananas, and cucumbers first.
So array reading is O(1), pronounced "oh of one." Its work stays constant even if the array grows from five elements to five million.
A useful caveat: this is the conceptual array model used by the book. We should not turn that into a claim about every implementation detail of Python lists; the performance reasoning is the point.
Now reverse the question. To find dates by value, the computer knows memory addresses but does not already know which value sits at each address, so it must inspect cells one at a time.
Trace it: inspect apples at index 0, then bananas, then cucumbers, then dates. We found it after four inspections.
That procedure is linear search. If the target is elderberries, or is absent altogether, all five cells must be checked before the computer can be certain.
For N elements, that worst case is N steps: O(N), or linear time. The input doubles, and the maximum search work doubles with it.
Notice the invariant behind a completed linear search: every earlier candidate has been inspected and rejected. There is no shortcut because an ordinary, unordered array provides no information about where a value should be.
And linear search can get lucky. If apples is the target, it takes one step, but the book normally uses the worst case when it names Big O unless it says otherwise.
Insertion depends heavily on location. Appending figs after elderberries is treated as one insertion step, although the source notes that a language may need to allocate more underlying space behind the scenes.
Insertion into the middle is the less friendly case. If figs goes at index 2, predict what must move before it can fit.
Cucumbers, dates, and elderberries must shift right, beginning at the far end so nothing is overwritten. Then figs enters the opened slot: three shifts plus the insertion itself.
At the beginning, every existing element shifts. For N elements, worst-case array insertion takes N shifts plus one insertion, or N plus 1 steps.
Deletion has the mirror-image issue. Delete cucumbers from the middle, and the deletion itself is one step, but dates and elderberries must shift left to close the gap.
Deleting the first element is worst case, because all N minus 1 remaining values shift left. Counting deletion as well, that is N steps for an N-element array.
So arrays offer fast indexed reads, but maintaining a gapless sequence makes front and middle edits expensive. That is not an indictment of arrays; it is the price of the feature that made indexed reading easy.
Now the set looks almost suspiciously similar. In the source's array-based version, it is an array with one rule: duplicate values are not allowed.
That no-duplicates rule is its invariant. It is valuable whenever repeated data would be a problem, such as a phone-book entry that should not list the same number twice.
Reading, searching, and deletion work as they did in the array-based model: O(1) reading, up to O(N) searching, and up to O(N) deletion because deletion may shift values.
Insertion is where the rule changes the cost. Before adding figs to a set, the computer must establish that figs is not already present.
So even appending a new value requires a search first. With five existing values, it may inspect all five, then perform the insertion: up to six steps.
At the beginning of an array-based set, the worst case combines N search steps, N shifts, and one insertion: 2N plus 1. The set is slower here because it is doing extra work to enforce uniqueness.
Which does not mean, "avoid sets." If duplicate prevention is a real requirement, an ordinary array is cheaper only because it is failing to guarantee the thing you need.
That is the first design discipline: name the operations and rules your application actually needs. Then compare structures; do not crown one structure as generally superior.
Next, we add another rule: keep values in ascending order. An ordered array has the same elements as an array, but every insertion must preserve sorted order.
Suppose it holds 3, 17, 80, 202 and we insert 75. The computer compares 75 with 3, then 17, then 80, learns that 75 belongs immediately before 80, shifts 202 and 80 right, and inserts 75.
The invariant after every insertion is simple: every value remains in order. But the insertion now needs both a search for position and any necessary shifts, unlike a plain append.
In the source's walkthrough, an ordered array with N elements takes roughly N plus 2 steps for such an insertion. Fewer comparisons can mean more shifts, or vice versa, so the total stays in that neighborhood.
Why accept that cost? Predict this: in the ordered list 3, 17, 75, 80, 202, if we seek 22 and reach 75, can we stop?
Yes. Since every later value is at least 75, 22 cannot be to the right. Ordered linear search can reject some missing values early, a small but useful benefit of the ordering invariant.
But its worst case remains O(N). Search for 202, or for a missing value larger than the final value, and you still inspect every cell.
Ordering enables the much larger improvement: binary search. Instead of stepping through candidates, inspect the middle and discard the half that cannot contain the target.
Let’s trace a search for 7 in an ordered array of nine values. We begin with lower bound 0 and upper bound 8; predict the first index inspected.
The midpoint is 4, the central index. In the source's example, that cell holds 9, so 7 cannot be there or anywhere to its right; the upper bound becomes 3.
Now the remaining candidate indexes are 0 through 3. Predict the next midpoint before we reveal it.
Using integer division, the midpoint is 1. That cell holds 4, and because 7 is greater than 4, the lower bound becomes 2.
The remaining indexes are 2 and 3. Inspect index 2 first in this walkthrough; its value is still too small, so move the lower bound to 3, then inspect the final candidate.
The algorithm stops when it finds the target, or when lower bound passes upper bound. That empty range is the edge case proving the value is absent.
The invariant is that if the target exists, it lies within the current lower-to-upper range. Each comparison preserves that claim while throwing away an entire impossible half.
And that explains the precondition. On an unordered array, seeing 9 tells you nothing justified about whether a 7 lies left or right, so binary search has no reliable half to discard.
With 100 values, linear search can require 100 inspections, while binary search requires at most 7 in the source's example. The gap becomes absurdly important as the collection grows.
Binary search is O(log N). Its defining pattern is that doubling the data adds only one more step, because each inspection halves the remaining possibilities.
For perspective, the source gives an ordered array of one million values: linear search may take one million steps, while binary search takes about 20. This is why preserving order can be worth paying for.
But it is still a trade-off, not a free upgrade. Ordered arrays make insertion slower than ordinary arrays, while buying the option of binary search for workloads that search often.
Now we can formalize the labels we have been using. Big O answers one practical question: if there are N data elements, how many steps will this algorithm take?
For array reading, the answer is one main step regardless of N, so O(1). For worst-case linear search, the answer is N steps, so O(N).
For binary search, the answer is log base 2 of N steps, usually written O(log N). The base is omitted here because we are repeatedly splitting the candidate set in half.
A logarithm can sound more ceremonial than it is. Ask how many times you can halve 8 until you reach 1: 8 to 4 to 2 to 1, which is three halvings.
Likewise, 64 halves to 1 in six rounds. Binary search inherits that shape because each comparison cuts the remaining search interval roughly in half.
The deeper point is how performance changes as data grows. O(1) stays flat, O(log N) rises slowly, and O(N) rises in direct proportion to the number of elements.
That is why Big O groups algorithms by growth behavior rather than by a fixed stopwatch reading. A constant-time operation might have a larger fixed cost than another operation, but it does not keep getting worse as N grows.
For this part of the source, use worst case by default when someone says an algorithm is O(N). Linear search can certainly succeed in one step, but its dependable upper-bound behavior requires planning for N inspections.
A clean answer on an exam or in a design discussion should say which operation, which structure, and which scenario. Saying merely, "arrays are fast," is not analysis.
Say instead: array reads by index are O(1); searching an unordered array is O(N); insertion and deletion can require shifts; ordered arrays permit binary search at O(log N) but make insertion more costly.
And for an array-based set, add the invariant: duplicates are forbidden, so insertion includes checking whether the value already exists. The extra cost is not accidental; it is the enforcement mechanism.
Hold onto one final prediction. If a program mostly appends new records and rarely searches them, would you pay to keep every record ordered?
Usually not, because you would repeatedly pay the ordered-insertion cost without collecting much benefit from binary search. If searching dominates, the answer may reverse.
That is the core map: organize data around the work you will repeatedly do, then count the steps that organization forces or saves. Next, we can use this map to examine sorting and the more deceptive shapes of everyday loops.
We now have the first useful map: constant-time reads, logarithmic binary search on ordered data, and linear scans. The next question is what happens when an algorithm starts comparing many values against many other values.
Before you explain it, predict this: if I see one loop inside another, should I immediately write O(N squared) in the margin?
Often, yes; automatically, no. You have to ask what each loop ranges over, because two loops over the same N-sized collection multiply, while other nested loops may have a fixed inner count or may collectively visit each item only once.
Bubble Sort is our first clean case of quadratic work. It sorts by repeatedly comparing adjacent values and swapping them whenever the left value is greater than the right value.
Let's use a small array. Say we start with [4, 2, 7, 1, 3]; after the first full pass, what do you think is guaranteed?
The largest value, 7, is guaranteed to have reached its final position at the right end. That is Bubble Sort's pass-through invariant: each pass bubbles the largest still-unsorted value into place.
Trace the first pair: compare 4 and 2, find them out of order, and swap them, giving [2, 4, 7, 1, 3]. Then compare 4 and 7; they are already in order, so leave them alone.
Next compare 7 and 1, swap, and get [2, 4, 1, 7, 3]. Compare 7 and 3, swap again, and get [2, 4, 1, 3, 7].
So we do not know that the first four values are sorted. We only know the 7 is done and can be excluded from later passes.
Exactly. The next pass only needs to inspect the unsorted region, and it eventually places 4; later passes place 3, then confirm the remainder.
For five elements, the comparison counts are four, then three, then two, then one. That is 4 plus 3 plus 2 plus 1, or 10 comparisons before we even count swaps.
And reverse-sorted data is the nasty version because nearly every comparison also causes a swap?
Right. For N values, the comparisons follow roughly N squared over 2, and in the reverse-order case there can be roughly that many swaps too, so the work grows on the order of N squared.
Big O calls Bubble Sort O(N squared). It is not claiming the exact count equals N squared; it is describing the growth category, which becomes unpleasant quickly as the array grows.
This sounds familiar from duplicate checking. If I compare every rating to every other rating, that is also the same multiply-the-work shape.
Yes. The source uses ratings from 0 through 10 and a straightforward nested-loop function that checks each array position against every position; in the no-duplicate case, it completes about N times N comparisons.
There is a sharper approach for that bounded ratings situation. Create an auxiliary array of eleven slots, one per possible rating, and use each rating as an index into it.
Let me predict it: when we encounter a 3, we mark slot 3; if we encounter another 3 and slot 3 is already marked, we return True.
Precisely. One pass through the ratings performs one lookup per rating, so the source analyzes it as O(N), rather than O(N squared).
Do not turn that into a slogan that extra storage is free, though. This method uses an additional array, and the book deliberately postpones the time-versus-memory trade-off to its later space-complexity discussion.
So the improvement depends on the fact that ratings have only eleven possible values. We are not quietly claiming an arbitrary value can always become a convenient array index.
Correct. The point is to notice repeated comparisons and ask whether the problem gives you a direct way to remember what has already appeared.
Now, Bubble Sort is not the only simple O(N squared) sort. Selection Sort makes nearly the same number of comparisons, but it changes the swapping behavior.
Its invariant is different: after each pass, the lowest value in the unsorted portion is placed into the next open sorted position on the left. It selects the minimum rather than bubbling the maximum.
Trace the first pass on [4, 2, 7, 1, 3]. We begin by tentatively treating 4 as the lowest, then inspect the rest?
Yes. We see 2, so 2 becomes the lowest seen; 7 changes nothing; 1 becomes the new lowest; and 3 changes nothing. At the end, swap 1 with the value at index zero, producing [1, 2, 7, 4, 3].
The key is that Selection Sort waits until the end of a pass to make at most one swap. Bubble Sort may swap repeatedly while moving a large value rightward.
So with five values, both sorts make ten comparisons, but Selection Sort can make at most four swaps, one for each pass.
That is the source's comparison. In its worst-case accounting, Selection Sort takes about half as many steps as Bubble Sort, even though both are classified as O(N squared).
Which seems mildly insulting to the faster one. Why does Big O throw away that difference?
Because Big O is a category system concerned with long-run growth, not a stopwatch. N squared over 2 and N squared both grow quadratically, so the constant factor is omitted when comparing them against fundamentally different categories such as O(N).
But exact step counts still matter when two options share a category. If Selection Sort performs materially fewer swaps than Bubble Sort, that is a real practical difference; Big O simply does not capture every difference.
The same rule drops additive constants. An algorithm with 2N plus 10 steps is O(N), and one with N squared plus 2N minus 2 is O(N squared).
Because as N gets large, the squared part overwhelms the linear cleanup work. The smaller term is not literally absent; it is just no longer the growth driver.
Exactly. This becomes clear with Insertion Sort, which has removals and insertions once per pass, but can also perform many comparisons and shifts. Its total worst-case work includes quadratic and linear pieces, and Big O retains the quadratic one.
Insertion Sort grows a sorted region on the left. On each pass, it removes the next value, compares it leftward, shifts larger values right, and inserts the removed value into the resulting gap.
Let's trace just enough of [4, 2, 7, 1, 3]. We remove 2, compare it with 4, shift 4 right, then put 2 into the gap: [2, 4, 7, 1, 3].
Good. The sorted-left-portion invariant now says [2, 4] is sorted. Next, remove 7; compare it with 4, stop because 4 is smaller, and put 7 back, so the sorted portion becomes [2, 4, 7].
When we remove 1, it must pass 7, then 4, then 2; each larger value shifts right, and 1 lands at the front. The work depends heavily on how far each removed value has to travel.
Prediction time: if the input is already [1, 2, 3, 4, 5], does Insertion Sort still do quadratic shifting?
No. Each pass makes one comparison with the value on its left, finds the order already correct, makes no shifts, and reinserts in place. Its best case is about O(N).
In reverse order, each new value must move all the way through the sorted region, so comparisons and shifts build to O(N squared). For randomly arranged data, the source estimates roughly half that quadratic work, still O(N squared) in Big O terms.
Selection Sort does not get this pleasant best case because it keeps scanning the remaining unsorted portion even when everything is already ordered.
Right. Selection Sort remains around N squared over 2 steps across best, average, and worst scenarios. Insertion Sort is especially attractive when you have reason to expect data that is already mostly sorted; Selection Sort is better under the source's reverse-sorted worst-case comparison.
This is a useful correction to simplistic analysis. Worst case matters, but average and best cases can matter too when you know something about the data your program usually receives.
Can an early exit improve normal behavior without changing the official worst-case Big O? That sounds suspiciously like a loophole.
It is not a loophole; it is scenario analysis. Consider finding the intersection of two arrays by checking each value of the first against values of the second.
Once a value from the first array matches a value in the second, a break ends that inner search. There is no reason to keep checking for that same match after it has been found.
But if the arrays have no shared values, every inner loop still runs to the end. So worst case remains O(N squared), assuming equal-sized arrays.
Exactly, while cases with matches can use fewer comparisons. The break removes needless work in best and average situations without pretending it has changed the no-match worst case.
Let us now sharpen loop-reading instincts. Two loops written one after another are addition, not multiplication.
The temperature example first loops through Fahrenheit readings to create Celsius values, then loops through those Celsius values to sum them. That is N plus N work, or 2N, which Big O reduces to O(N).
So sequential loops are not secretly nested just because they happen in the same function. The second loop starts after the first has finished.
Yes. Contrast that with a word builder that, for every character, loops across every character to form two-character combinations; there, the inner loop occurs N times for each of N outer iterations, yielding O(N squared).
If it builds three-character combinations using three nested loops over the same array, the shape becomes O(N cubed). Each added full-N nesting multiplies the work again.
But nested syntax still is not proof. Give us one of the traps where it looks quadratic and is not.
A clothing-label function loops over N clothing items and, for each item, loops through only five fixed sizes. It does 5N work, and since five is a constant independent of N, it is O(N).
The inner loop is nested, but it does not range over the growing dataset. This is why you identify the changing quantity before doing algebra with loops.
And the array-of-arrays example is the other trap? It has an outer loop over inner arrays and an inner loop over numbers.
Right. If the task counts every 1 across all inner arrays, the inner loop collectively processes each number once. Let N mean the total count of numbers, and the algorithm is O(N), not O(N squared).
The outer structure groups the data, but it does not cause every number to be compared with every other number. Again, count actual visits rather than reacting to indentation.
What if two genuinely separate arrays interact, though? Say we multiply every number in array one by every number in array two.
Then we need two variables. If the first has N values and the second has M, the work is O(N times M), because each member of one dataset is paired with each member of the other.
Calling the combined total N would hide important differences. Two arrays of five values require 25 pairings, while arrays of nine and one values also total ten elements but require only nine pairings.
So Big O notation is only honest when its variables preserve the independent quantities that affect the work.
Exactly. There is one more growth pattern worth keeping distinct from all of this: exponential work.
The password-generation example creates every lowercase string of a given length. With length one, there are 26 possibilities; with length two, 26 times 26; with length three, 26 times 26 times 26.
So for a length N, it is O(26 to the N). Adding one character position multiplies the output count by 26, rather than adding a fixed amount of work.
Yes, and that is nearly the opposite story from binary search. Binary search takes one additional step when the data doubles, while this password generator multiplies its work when the input length increases by one.
It is slow because the task requires producing every combination; sometimes an ugly complexity reflects unavoidable output size, not merely clumsy code. Still, recognizing that growth curve keeps you from launching such a process casually and wondering why your machine has become contemplative.
Let me try the compact diagnosis. First identify N, or N and M; then see whether work is constant, sequential, nested, bounded by a fixed inner loop, or branching into combinations.
That is the right start. Then state the scenario you are analyzing, count the significant operations, simplify to the dominant growth term for Big O, and retain exact counts when choosing between algorithms in the same category.
For sorting, remember three invariants: Bubble Sort places the largest remaining value at the right after each pass, Selection Sort places the smallest remaining value at the left, and Insertion Sort maintains a sorted left portion.
And for design, the issue is not which sort has the nicest name. It is whether you need fewer swaps, good behavior on mostly sorted input, or a different approach because quadratic work is already the warning sign.
Well put. Carry that discipline forward: do not diagnose complexity from surface syntax alone, and do not let one Big O label erase meaningful trade-offs in data shape, scenario, steps, or memory.
We now have enough Big O vocabulary to ask a more useful question than, “What loop can I write?” The question is: can the data itself be arranged so the operation I need becomes cheap?
That sounds suspiciously like a hash table. We stop inspecting every item and somehow jump to the answer.
Often, yes, but only when the question has the right shape. A hash table stores paired data: a key and its associated value, like a menu item paired with its price.
In Python, the book calls this a dictionary. For a menu, "french fries" might be the key and 0.75 the value; asking for the price starts with the key.
So if I know the food, I can get its price quickly. If I know the price and ask which food costs it, that is not the same operation.
Exactly. Hash-table lookup is one-directional in this sense: key to value. Searching by value for its key can still require examining the pairs one by one, which is O(N).
The mechanism is hashing. A hash function deterministically converts a key into a number, and that number identifies where the associated value belongs.
Deterministically is doing real work there. The same key must produce the same number every time, or retrieval becomes a scavenger hunt designed by a spiteful pigeon.
Right. In the book’s simplified thesaurus example, letters become numbers and the letters in a word are multiplied; the key "bad" maps to 2 times 1 times 4, or 8.
To store "bad" paired with "evil," the table puts that pair at location 8. To retrieve it later, it hashes "bad" again, gets 8 again, and jumps to that location.
Predict this before we name the cost: if hashing the key and reading its computed location are each constant work, what is the usual lookup cost?
O(1) on average. It is not magic and not a promise for every individual table state, but it is why hash tables are so valuable for key-based lookup.
There is an immediate complication: two distinct keys can hash to the same location. In the book’s toy function, both "bad" and "dab" produce 8.
Then cell 8 has a collision. Predict the consequence if nearly every key lands in that one cell.
The supposed direct lookup degrades into a linear search through the items stored there. With separate chaining, the cell holds a small array of key-value pairs, but if that array holds everything, lookup becomes O(N).
So the hash table’s practical performance depends on three things: how much data it holds, how many cells it has, and whether its hash function spreads keys across those cells.
More cells mean fewer collisions, but allocating a thousand cells for five entries is not exactly an act of memory discipline.
That is the trade-off. The source gives a rule of thumb: about seven stored elements for ten cells, a load factor of 0.7, balancing collision avoidance against wasted memory.
Languages usually resize and manage these details for you. Still, understanding the trade-off prevents the lazy claim that hash tables are simply O(1), full stop.
They also organize naturally paired information: a word and synonym, an inventory item and count, or a status code and its meaning.
Yes, and they can simplify conditional logic when the logic is really just a table of pairs. But their more flexible use is as an index for membership.
Take an array of numbers: 61, 30, 91, 11. Searching it for 91 takes linear search if it is unordered, but we can construct a hash table with each number as a key and, say, True as an arbitrary value.
The values are basically placeholders. If looking up 91 returns something, 91 was present; if it returns nothing, it was absent.
Precisely. The important fact is key membership, not the paired value. The hash table acts like an index at the back of a book: it tells you where—or here, whether—the thing exists without rereading everything.
Now apply that to the subset problem. We want to know whether every value in one array appears in another.
The obvious version compares every smaller-array item against every larger-array item. If their sizes are N and M, that is O(N times M).
The indexed version first walks through the larger array once, storing every value as a hash-table key. Then it walks through the smaller array and performs one lookup per item.
For larger array a, b, c, d, e, f and smaller array b, d, f, we build keys for all six letters, then each of b, d, and f succeeds immediately. If the smaller array contains h, that failed lookup lets us return False.
And in the book’s combined-size framing, we touched each input item once, so it is O(N), where N is the total number of items across both arrays.
Correct. This is a recurring pattern: if an algorithm repeatedly searches a collection, spending one pass to create an index can be much cheaper than repeating linear searches.
Hash tables optimize lookup, but some structures solve a different problem entirely. Sometimes the important thing is not finding any item, but enforcing the order in which temporary data may be processed.
That brings us to stacks and queues: arrays with rules, which sounds restrictive until the rules stop someone from quietly breaking the algorithm.
A stack is last in, first out, or LIFO. You can push onto its top, pop from its top, and read only its top item.
A queue is first in, first out, or FIFO. You add at its back, then read and remove from its front.
Why not just use an unrestricted array and promise to behave?
You could, but the stack or queue interface makes incorrect operations unavailable. More importantly, it tells the next reader what ordering the algorithm depends on.
These are abstract data types: the essential thing is the behavioral contract, not necessarily the underlying storage. A stack can use an array underneath while exposing only push, pop, and read.
Let’s anchor that in the brace linter. We scan code left to right; what does the stack represent?
Each opening parenthesis, bracket, or brace is pushed onto the stack. The invariant is that the stack holds exactly the opening braces that have been seen but not yet matched, with the most recent one on top.
Suppose we read an opening parenthesis, then an opening square bracket. The stack, from bottom to top, is parenthesis, square bracket.
Then we encounter a closing square bracket. Predict what must happen.
We pop the top opening brace and compare types. It is a square bracket, so the pair matches and scanning continues; nesting works because the newest opening brace must close first.
Now consider a closing parenthesis when the stack is empty. That is an unmatched closing brace: there was no preceding opening brace to pop.
And if the stack top is an opening square bracket but the current character is a closing parenthesis, it is a mismatch, not merely an absence.
Exactly. One final edge case remains: if the scan ends while the stack still holds an opening brace, that brace has no closing partner.
The stack is not faster merely because it has a fancy name. It encodes the LIFO invariant that brace matching needs, and it prevents an accidental deletion from the middle.
Queues encode the opposite order. A printer should process First Document, then Second Document, then Third Document in the order jobs arrive.
Right. Enqueue each document at the back, then repeatedly dequeue from the front. The invariant is that the front is always the oldest unprocessed job.
One implementation detail matters: a queue can be built on an array, but removing from the front of an array can involve shifting remaining values. That cost belongs to the underlying implementation, not to the FIFO idea itself.
So abstract behavior first, then inspect the concrete structure if performance matters. Reasonable, if mildly less romantic than calling every line a queue.
Now for recursion, another tool that is often treated as either mystical or automatically elegant. It is simply a function calling itself.
Predict the failure mode of a function that does nothing except call itself again.
It recurses forever until the system stops it with stack overflow. Every recursive function needs at least one base case, a case in which it returns without making another recursive call.
Take a countdown from 3. The recursive case prints the current number and calls countdown on one less: 3 calls 2, 2 calls 1, and 1 calls 0.
At 0, the base case prints 0 and returns. That return is not decorative; it is the boundary that prevents a call on negative 1, then negative 2, indefinitely.
That is repeated-execution recursion. The same action, printing, repeats while the argument moves toward a stopping condition.
Good distinction. A second pattern is recursion that computes an answer from a smaller version of the same problem, a subproblem.
Factorial is compact here. factorial of 3 means 3 times factorial of 2; factorial of 2 means 2 times factorial of 1; and factorial of 1 is the base case, returning 1.
So the calls go downward first, but the multiplication resolves while the calls return: factorial 1 returns 1, then factorial 2 returns 2, then factorial 3 returns 6.
Yes. The computer tracks unfinished work with the call stack. When factorial 3 calls factorial 2, it must remember that factorial 3 is waiting; then factorial 2 waits while factorial 1 finishes.
This bookkeeping is LIFO: the most recently paused call resumes first. That is why a stack, which we just learned, is such a natural model for recursive execution.
And the call stack itself consumes memory. Infinite recursion keeps pushing calls until there is no room, which is the literal meaning behind stack overflow.
Recursion is particularly useful when the input has an unknown number of levels. A filesystem can contain directories, which contain directories, which contain more directories; you do not know in advance how deeply it goes.
For each entry in a directory, if that entry is itself a directory, print it and recursively run the same function on it. The base case is simply reaching a place with no subdirectories to process.
A fixed number of nested loops would only search a fixed depth. Recursion says, “Handle this directory the same way,” until the structure runs out.
Exactly. But do not infer that recursion should replace loops by default. The book’s countdown can be written cleanly with a loop too; recursion earns its place when its structure clarifies the problem.
Here is the top-down method for a calculation. Imagine the function already works for a smaller input, identify that subproblem, then combine its answer with one piece of the current input.
Use array sum. For 1, 2, 3, 4, what smaller problem should we trust the imaginary working function to solve?
The remainder: 2, 3, 4. If sum of that remainder returns 9, then the original answer is 1 plus 9, so the recursive rule is first element plus sum of the rest.
The base case can be an empty array returning 0. That makes the final call well-defined: eventually, sum of an empty array contributes nothing, and the answers unwind upward.
That is calculation-by-subproblem, unlike countdown. One returns a result assembled from a smaller result; the other repeatedly performs an action.
Well put. Another useful recursive technique is passing extra parameters to carry state that a loop would normally track.
To double an array in place, a recursive function can receive both the array and an index. It doubles array at that index, then calls itself with index plus one; when the index reaches the array length, it stops.
The index is not part of the public problem, but it is the function’s bookmark. In Python, a default of zero can keep the initial call tidy.
Finally, consider the staircase problem: with N stairs and jumps of one, two, or three, the number of paths to stair N comes from the paths to N minus 1, N minus 2, and N minus 3.
Its recurrence is paths of N minus 1 plus paths of N minus 2 plus paths of N minus 3, with careful base cases for the small and negative inputs. The important move is defining the large problem through smaller instances of itself.
We are deliberately not claiming that this recursive form is automatically efficient. It can repeat subproblems, and that repair is the next major step.
Exactly. For now, retain three selection rules: use a hash table when known keys or repeated membership checks dominate; use a stack or queue when processing order is the rule; and use recursion when base cases and smaller self-similar problems make the structure clear.
Then ask the uncomfortable follow-up: what gets stored, what can collide or overflow, and what work may be repeated? That is where a plausible solution becomes an engineered one.
We now have recursion as a way to express a problem, but that does not make the resulting code efficient. The issue is not recursion itself; the issue is whether the same subproblem gets solved again and again.
So before we admire a compact recursive function, we should ask a mildly unpleasant question: what work is it repeating? That is usually where the bill is hiding.
Exactly. Start with a recursive function that finds the greatest number in an array. It compares the first value with the maximum of the remaining array.
Conceptually, on [1, 2, 3, 4], it asks whether 1 is greater than max([2, 3, 4]). If not, it returns max([2, 3, 4]).
Pause and predict: in that version, how many times might max([2, 3, 4]) be calculated? Once, because it is the same answer, or more than once because the code mentions it twice?
More than once. The first comparison computes max([2, 3, 4]), and when the comparison fails, the return expression computes max([2, 3, 4]) again.
That duplication cascades. To calculate max([3, 4]), each call can calculate max([4]) once for the comparison and once again for the return.
So this is not merely a redundant lookup. Each repeated expression launches an entire smaller recursive process beneath it.
Right. For an array of four elements, the flawed version triggers fifteen calls to max. As the array gains one element, the number of calls roughly doubles, which is the source's O(2 to the N) pattern.
The repair is almost embarrassingly small: compute max of the remainder once, save it in a variable, then compare the first value with that variable. If the first value loses, return the saved result instead of calling max again.
And now there is one recursive chain through the array, not a branching thicket. For N values, max gets called N times, so this version is O(N).
Yes. This is an important distinction: a recursive function is not automatically exponential. One self-call per level can be linear; repeated branching calls are where we need to inspect the structure carefully.
Fibonacci gives us the more famous repeated-work problem. The sequence begins 0, 1, 1, 2, 3, 5, and each later number is the sum of the prior two.
The neat recursive rule is fib(n) equals fib(n minus 2) plus fib(n minus 1). Neat, but I can already hear the call stack acquiring structural damage.
Take fib(5). It calls fib(3) and fib(4), and fib(4) in turn calls fib(3) again.
That repeated fib(3) is an overlapping subproblem: a smaller instance of the same problem that separate branches both need. Naive recursion recomputes it rather than recognizing it has already paid for it.
Predict the practical fix. If a calculation like fib(3) has already returned 2, should the program recurse into fib(3) again, or save that answer somewhere it can retrieve?
Save it. Dynamic programming, in the practical sense used here, means optimizing recursive problems with overlapping subproblems by preventing duplicate calculations.
The first technique is memoization, with an e in the middle. It means remembering results that have already been computed.
For Fibonacci, use a hash table called memo. When fib(3) returns 2, store the pairing 3 mapped to 2.
Then, before calculating fib(3), the function checks whether 3 is already a key in memo. If it is, it returns 2 directly rather than opening another recursive branch.
Precisely. The sequence is: handle the base cases 0 and 1, check memo for n, recursively compute only if n is absent, store the new result, and return the stored result.
The memo table must be shared across the recursive calls. That is why the source passes it as an additional parameter: each call receives the same growing record of prior answers.
Otherwise every call gets its own private notebook, which is very organized and completely useless. The overlapping work would still happen.
With memoization, Fibonacci no longer explodes through repeated subproblems. In the source's count, it makes about 2N minus 1 calls, which Big O reduces to O(N).
There is a cost, though. Recursion uses a call stack, and memoization keeps a hash table, so this is not a free speed upgrade; it trades additional space for avoiding repeated time.
There is also the less glamorous second technique: stop recursing. Build the sequence from the bottom up with a loop.
For bottom-up Fibonacci, begin with 0 and 1. Repeatedly replace the pair with the current second value and the sum of the pair, until reaching the requested position.
Its invariant is simple: before each loop step, the two variables hold consecutive Fibonacci values; after the update, they hold the next consecutive pair. One pass to N gives O(N) time.
Predict which approach you would prefer for Fibonacci: memoized recursion or bottom-up iteration? The answer is not a moral test.
Usually, if an iterative solution is equally clear, bottom-up avoids recursive call-stack overhead and avoids the memo table. But if recursion is the more intuitive way to express the problem, memoization can preserve that clarity without repeating subproblems.
Now let’s turn from avoiding repeated work to using recursion for speed. The core move behind Quicksort and Quickselect is partitioning.
Partitioning does not fully sort an array. It does something narrower but more useful: it puts one selected value, the pivot, into its final position relative to the current range.
The invariant after a partition is that every value less than the pivot lies to its left, and every value greater than the pivot lies to its right. The values on either side may still be unsorted among themselves.
Use the array [0, 5, 2, 1, 6, 3], and choose the rightmost value, 3, as the pivot. Put a left pointer at 0 and a right pointer at 6, just left of the pivot.
Before we trace it, predict where the left pointer stops. It moves right while it sees values less than 3, so does it stop on 5, 2, or 1?
It stops on 5, because 0 is less than 3 but 5 is not. Then the right pointer moves left while it sees values greater than 3: it passes 6 and stops on 1.
The pointers have not met, so swap their values. The array becomes [0, 1, 2, 5, 6, 3].
Resume the left pointer: it passes 2 and stops on 5. The right pointer is also at that position, so the scanning phase ends.
And the final move is to swap the pivot, 3, with the left pointer's value, 5. We get [0, 1, 2, 3, 6, 5].
Exactly. Notice what has been proved, not guessed: 3 is now permanently correct. Everything left of it is smaller, and everything right of it is larger.
A single partition does linear work in the range it handles. Its pointers inspect values across that range, and swaps do not change that overall O(N) classification for one partition.
Quicksort then takes the parts [0, 1, 2] and [6, 5] as separate subarrays, partitions them recursively, and stops when a subarray has zero or one element. That is its base case.
Yes. Quicksort is not magically sorting all values at once. It repeatedly establishes one pivot’s final position, then delegates the two smaller ordering problems to recursive calls.
In a favorable or typical case, pivots land roughly near the middle. That produces about log N levels of subdivision, and across each level the partitions collectively process about N elements.
N work per level times about log N levels gives O(N log N). That is why Quicksort is far faster on average than the simple quadratic sorts we met earlier.
But the pivot choice matters. With the source's convention of always choosing the rightmost value, what happens if the input is already perfectly ordered?
The pivot can keep landing at an edge. Instead of splitting into two reasonably sized subarrays, we get one large subarray and one empty one, then repeat that failure.
The work then resembles N plus N minus 1 plus N minus 2, continuing down to 1. That is O(N squared), the same worst-case category as the simple sorts.
So Quicksort is not a guaranteed O(N log N) promise in this presentation. Its average behavior is its strength, while a run of badly placed pivots is the friction point.
Correct. It is also worth separating time from memory here: the source’s Quicksort works by rearranging the array through partitioning, but recursion still creates call-stack activity. We should not say efficient time means no space cost.
Now suppose you do not need the whole array sorted. Suppose you only need the second-lowest value, or the value at some chosen rank.
Sorting everything would work, but it does extra work. Once a partition has placed a pivot at its final index, we know whether the target rank lies left, right, or exactly at the pivot.
That is Quickselect. Its invariant is target-focused: after partitioning, discard the side that cannot contain the kth-lowest value, and recurse only into the side that can.
Imagine eight values and we seek the second-lowest, meaning index 1. After the first partition, say the pivot settles at index 4.
Predict the next move. Does Quickselect partition both sides as Quicksort does, or only the left side?
Only the left side. Since index 1 is less than index 4, the target must be among the values left of the pivot; the entire right side is irrelevant to this question.
If the next pivot settles at index 2, the target is still to its left. Partition that smaller left range, and once a pivot lands at index 1, return that value.
The important difference is that Quicksort must finish both subarrays because it promises a fully sorted array. Quickselect follows just one route because it promises one ranked value.
On average, the work is N plus N over 2 plus N over 4 and so on, which is about 2N. Big O ignores the 2, so Quickselect is O(N) on average.
That makes it the right tool when you need, say, a particular low-ranked value but do not need every surrounding value arranged. Sorting first at O(N log N) is valid, just not as focused.
There is another useful pattern: sometimes sorting is not the final goal, but a component that makes the remaining work easy. Duplicate detection is the clean example.
Take [5, 9, 3, 2, 4, 5, 6]. Sorting produces [2, 3, 4, 5, 5, 6, 9], which places equal values next to each other.
Then scan once, comparing each value with its immediate neighbor. The invariant is that if any duplicate exists in the sorted array, some adjacent pair must reveal it.
Sorting costs O(N log N), and the scan costs O(N). The higher-order term dominates, so the complete algorithm is O(N log N), a substantial improvement over checking every pair with nested loops.
The trade-off is worth stating plainly. Earlier, a hash-table-based duplicate check reached O(N), but used extra memory; sorting offers another route when that other approach is unsuitable, though it changes the array’s order in the source example.
One final boundary: sometimes slow growth is unavoidable because the required output itself is enormous. Generating every anagram of an N-character string produces N factorial arrangements.
So anagram generation is O(N factorial) in the source because there are N factorial answers to emit. No clever control flow can print fewer answers while still claiming to return all of them.
That is a useful corrective to optimization reflexes. First ask whether work is duplicated unnecessarily; then ask whether the task itself demands a large amount of output.
Here is the compact map to retain. Memoization removes repeated recursive subproblems, bottom-up iteration can remove recursion overhead, partition places a pivot correctly, Quicksort orders every region, and Quickselect pursues one rank.
And the questions to ask are concrete: have I already computed this subproblem, do I need the entire array sorted, and does my pivot split the remaining work well? Those questions are less glamorous than algorithm names, but they select the algorithm.
Next, we will return to data structures rather than algorithms alone. Linked lists, search trees, and heaps all organize nodes differently, and each makes a particular operation cheap while making another one less convenient.
We now have three node-based answers to three different problems. Linked lists favor local rewiring, binary search trees favor changing data that must stay ordered, and heaps favor repeatedly taking the highest-priority item.
First, nodes. An array stores elements so an index can be read directly, while a linked list is a chain where each node holds data and a link to the next node.
Right. With a linked list, your program immediately knows only the head, the first node; to reach later data, it follows links one at a time.
Predict this before we answer: at the front of a list, which is cheaper, inserting into an array or inserting into a linked list?
The linked list wins at the front. To put "yellow" before a head node containing "blue," create a yellow node, point yellow's next link at blue, and update the list's head to yellow.
That is a constant number of updates, so O(1). An array insertion at index zero instead shifts every existing element right, which is O(N) in the worst case.
Deletion has the same asymmetry. To remove the head of a linked list, update the list's head to the second node; no remaining values move.
But this is not a general victory lap for linked lists. Read the item at index four in an array and you jump to it; read index four in a linked list and you walk head, next, next, next, next.
So array reading is O(1), while linked-list reading is O(N) in the worst case. Searching is O(N) for both: neither structure knows where an arbitrary value lives.
Insertion and deletion in the middle are more nuanced than people make them sound. The link change itself is O(1), but getting to the relevant predecessor node can cost O(N).
Exactly. For a classic linked list, insertion and deletion are O(1) at the beginning but O(N) in the worst case overall because access is sequential; arrays reverse that convenience at the ends.
Give us the case where that local rewiring matters in a real algorithm, not just in a diagram full of cheerful arrows.
Consider scanning a list of email addresses and removing invalid ones. With an array, each removal can force many later addresses to shift left before you continue scanning.
With a linked list, once your traversal has already reached the node before an invalid address, you redirect its next link around the invalid node. That deletion is one local change, so repeated deletions during one pass are where the structure earns its keep.
The important qualifier is "once you've reached it." A linked list does not teleport you to a suspicious email address; it simply avoids shifting the rest after you get there.
That distinction is the whole subject, really. Data structures don't make work disappear; they move the cost toward operations you expect to perform.
A doubly linked list adds a previous link to every node, yes? More pointers, but now movement can go in both directions.
Yes, and the structure keeps both the head and tail. That makes appending at the tail O(1), and removing the head O(1), because both boundary nodes are directly known.
Which makes it a better underlying structure for a queue than an array-backed queue in this simplified model. Enqueue at the tail and dequeue at the head are both constant-time operations.
The trade-off is extra links and bookkeeping. Again, not more sophisticated for its own sake, but useful when the processing rule is first in, first out and both ends matter.
Now we widen the chain into a tree. A tree has a root at the top, children below a parent, descendants farther below, and levels that form the rows.
Balance matters because a balanced tree stays shallow. If one side becomes a long chain, the supposed tree starts behaving like a linked list, which is not the plot twist we wanted.
A binary search tree, or BST, has at most one left child and one right child. Its invariant is stricter: every left descendant is less than its node, and every right descendant is greater.
Take a root of 50, with 25 on the left and 75 on the right; suppose 75 has left child 56, and 56 has right child 61. Predict the path when searching for 61.
At 50, 61 is greater, so go right to 75. At 75, 61 is smaller, so go left to 56; at 56, 61 is greater, so go right, and there it is.
Each comparison discards an entire subtree. In a well-balanced BST, that repeated elimination gives search O(log N), much like binary search in an ordered array.
Insertion follows the same comparisons until an empty child position is found. Insert 45 into a tree with 50, then 25, then 33, then 40 on that rightward path: go left from 50, then right from 25, right from 33, right from 40, and attach 45.
So balanced BST insertion is also O(log N). That is its advantage over an ordered array, where finding a location may be quick but opening a gap still requires shifts and yields O(N) insertion.
But ordinary BSTs are not magically balanced. Insert 1, 2, 3, 4, 5 in that order and each value becomes the right child of the previous value.
Then the tree is effectively a chain, and searching or inserting can degrade to O(N). The source's practical warning is that insertion order matters; randomly ordered insertions tend to produce a more balanced tree than already sorted input.
Deletion is the operation that stops looking cute. Start with the easy cases.
If the node is a leaf, delete it. If it has one child, plug that child into the deleted node's former position.
If it has two children, replace it with its successor: the smallest value greater than the deleted value. Find that successor by visiting the deleted node's right child, then repeatedly following left children until no left child remains.
So if deleting 50, you look into the right subtree and keep going left. That replacement preserves the left-less-than and right-greater-than invariant rather than just shoving in a nearby-looking value.
Correct, and if that successor had a right child, that child must be reattached where the successor used to sit. The details are fiddly because deletion must preserve every ordering relationship, not merely remove a node.
We also want ordered output. How does the tree print book titles alphabetically without first collecting and sorting them?
Use inorder traversal: traverse the left subtree, visit the current node, then traverse the right subtree. Because every left value is lower and every right value higher, that order emits ascending values.
For a root titled "Moby Dick," the traversal finishes all earlier titles in the left subtree before printing Moby Dick, then visits later titles in the right subtree. It visits every node once, so traversal is O(N).
Notice the different promises. A BST can search, insert, and delete efficiently when reasonably balanced, while traversal deliberately touches everything because printing the whole collection requires seeing everything.
Next problem: not ordered output, but the next most urgent item. That is a priority queue.
A priority queue removes and reads only its front item, like a normal queue, but insertion keeps items ordered by priority. In the emergency-room example, a more severe case moves ahead of less urgent arrivals.
If we implement that with an ordered array and treat the array's end as the front, deletion from the end is O(1). Insertion remains O(N), because we must find the position and shift data to preserve order.
A heap improves that trade-off. We'll use a max-heap, whose root is always the greatest value, so the root is the highest-priority item.
Its first invariant is the heap condition: every node is greater than all of its descendants. Its second is completeness: each level is filled left to right, except possible empty positions at the far right of the bottom level.
This is not a BST. In a heap, knowing a target is smaller than the root does not tell you whether to search left or right, so arbitrary search is not efficiently directed.
That weak ordering is intentional. The heap guarantees the one thing a priority queue repeatedly needs: the maximum is at the root.
Predict where a newly inserted heap value goes before any comparisons. Does it start wherever it fits by value, or at one specific structural position?
It goes in the next available rightmost spot of the bottom level, becoming the last node. Completeness comes first; its final value position is repaired afterward.
Suppose a max-heap has root 100, with 88 and 25 beneath it, and the next open position is beneath an 8. Insert 40 there, compare it with 8, and swap because 40 is greater.
Now 40's parent is 25, so swap again. Its new parent is 100; since 40 is smaller, stop, and the heap condition holds.
That upward repair is called trickling up. The invariant during it is that the new value moves upward until its parent is at least as large, while the rest of the heap remains valid.
A complete binary tree has about log N levels, so a new node can rise through at most that many levels. Heap insertion is O(log N).
Deletion removes only the root, because that is the priority item. Predict the first repair step after removing a root of 100 from a heap whose last node is 3.
Move the last node, 3, into the root position. This preserves completeness, but it probably breaks the heap condition because 3 may be smaller than its children.
Then trickle it down. Compare its two children, swap it with the larger child if that child is greater, and repeat until it is no smaller than either child or has no children.
Choosing the larger child is necessary. If you swapped 3 with a smaller child while a larger sibling stayed above it, that larger sibling would immediately violate the heap condition.
So root deletion is O(log N) too. It is slower than deleting the end of an ordered array, but vastly better than that array's O(N) insertion.
That is why heaps suit priority queues: they are consistently very fast for both primary operations. An ordered array is extremely fast for deletion but slow for insertion, and queues generally do both repeatedly.
There is one practical snag with a linked-node heap: finding the last node efficiently. The algorithm needs it for both insertion and deletion, but a tree doesn't visually announce its bottom-right node to a program.
The common solution in the source is to implement the heap with an array. Store nodes level by level, from left to right, so the root is index zero and the last node is simply the final array element.
This array is still representing a tree, not abandoning the tree idea. For a node at index i, its left child is at 2i plus 1, its right child at 2i plus 2, and its parent at floor of i minus 1 divided by 2.
Those formulas let insertion find a parent while trickling up and let deletion find children while trickling down. More importantly, putting new values at the array end gives the next last-node location immediately.
Let's pressure-test the choices. You need many direct reads by numeric position: array, not linked list. You scan a list once and repeatedly remove the current invalid item: linked list becomes attractive.
You need changing values kept in alphabetical order, plus search and full ordered output: a reasonably balanced BST is the relevant structure. You need only the most urgent task next: use a heap-backed priority queue, not a fully sorted collection.
And if someone says, "I'll use a heap because trees are logarithmic," stop them. Heap lookup for an arbitrary value is not logarithmic here; its ordering does not tell you which branch contains the target.
Likewise, don't choose a linked list because pointer updates are constant-time if your workload mainly asks for the hundred-thousandth item. The traversal cost is the bill that eventually arrives.
One compact comparison: linked lists optimize nearby edits, BSTs optimize ordered mutation when balanced, and heaps optimize repeated maximum extraction. Similar-looking nodes, very different invariants.
Hold onto the invariant before the Big O label. For a linked list, preserve the chain; for a BST, preserve left-less and right-greater relationships; for a heap, preserve completeness and parent dominance.
That also gives you a debugging method. When an operation fails, ask which invariant your pointer update, successor replacement, or trickle step damaged.
Next, we narrow from general tree values to text itself. A trie will organize character paths so that a prefix such as "cat" can be followed directly, which is a different kind of lookup problem again.
We’ve reached a structure built for a very particular question: not “is this exact value here?”, but “what words could begin this way?” Before we define it, predict this: why can’t a hash table keyed by complete words directly answer a prefix query like “catn”?
Because the table might contain keys like “catnap” and “catnip,” but not the key “catn.” Hashing the whole prefix doesn’t reveal where all longer words with that prefix live.
Exactly. A hash table is excellent when you know the complete key and want its associated value; it is not a magic detector for partial keys. That one-directional, exact-key strength is the reason we need a different shape of data here.
Suppose a dictionary is an unsorted array. To find words beginning with “catn,” you may need to inspect every word, so the search is O(N), where N is the number of dictionary words.
If the array is alphabetized, binary search improves the first lookup to O(log N). So why not just use that and avoid another tree?
You can, and the source is careful about that. But a trie is specialized for prefix-oriented text: it follows the characters you typed, rather than searching among all words as whole units.
Trie is usually pronounced “try,” despite coming from retrieval. It is a tree, but not a binary tree: a node can have many children, because a prefix might continue with many different characters.
So what does one node actually contain? A character, or a list of children, or both?
In this implementation, each trie node contains a hash table called its children. The keys are characters, and each key’s value is another trie node.
The Trie object itself mainly keeps a reference to the root node. Think of the root as the starting point before any character has been consumed.
Let’s make it concrete. If the stored words are “ace,” “act,” “cat,” “catnap,” and “catnip,” what does the root know?
The root has keys “a” and “c.” Following “a” begins the shared path for “ace” and “act,” while following “c” begins the shared path for the three words starting with “cat.”
For “ace,” the path is root, then “a,” then “c,” then “e.” For “act,” the first two character steps are shared, and the path branches at that “c” node into an “e” child and a “t” child.
So shared prefixes are stored once rather than copied into every word. That is the central structural trick, not merely a decorative tree.
Right. The same happens with “cat,” “catnap,” and “catnip”: root to “c,” then “a,” then “t” is shared. Only after “cat” do the longer words continue along their separate character paths.
Now we need one small but important invariant. A path through characters is not enough to tell us whether we reached a complete word or merely the beginning of one.
Because “cat” can be a word while also beginning “catnap.” If the path keeps going, the structure needs to remember both facts.
Exactly. The source marks a completed word by adding a child key of “*” at its terminal node, with an unimportant value such as None. The asterisk says, “a word ends here.”
Consider “bat” and “batter.” At the node reached after b, a, t, there must be both a “*” child to mark “bat” complete and a “t” child to continue toward “batter.”
That gives us a useful invariant: every stored word has a root-to-asterisk path. And a node may have both the end marker and ordinary character children.
Yes. If you forget the marker, your code may recognize “bat” only as a prefix of “batter,” which is a subtle bug rather than a dramatic one. Those are often the annoying bugs.
Let’s trace prefix search. Predict the result in our trie: is “cat” a full word, only a prefix, or absent?
It is a full word, and also a prefix because “catnap” and “catnip” continue from it. The terminal node should contain “*” and still have further children.
Good. Now predict “ca.”
Only a prefix. The path exists, but there should not yet be an asterisk at the node after “ca.”
And “cab”?
Absent, assuming no word beginning c-a-b was inserted. We would fail when the current node has no “b” child.
That is the whole prefix-search algorithm. Start with current_node at the root, then process the query one character at a time.
For each character, look in current_node.children for that character. If it exists, move current_node to that child; if it does not, return None immediately.
For “cat,” follow “c” from root, then “a,” then “t.” Once all three query characters have been consumed, return the current node, because finding the node is more useful than merely returning True.
More useful because that node tells us whether “cat” is complete by checking for “*.” And it is the place from which an autocomplete feature could continue, even though we are not implementing that continuation here.
Precisely. This chapter establishes search and insertion, not a full routine that lists all completions. It gives us the foundation without quietly smuggling in a larger feature.
For “cab,” we follow “c,” then “a,” and fail when there is no “b” child. The failure is early and justified by the trie’s path invariant: no child means no stored word has that prefix.
What is the time cost? It sounds constant-ish because each child lookup uses a hash table.
Close, but don’t call it O(1). Each individual child lookup is O(1) on average, yet we perform one lookup per query character, so trie search is O(K), where K is the query length.
That notation matters. K is not the number of nodes or the number of words in the dictionary; it is the number of characters in the search string.
So a three-character query takes about three path steps even if the trie has grown to hold many more words. The cost changes when the query gets longer, not merely when the dictionary gets larger.
That is the practical attraction. O(K) is not literally constant time, but a fixed query such as “cat” follows a fixed-length path regardless of how many other words were inserted.
There is a trade-off, of course. The trie creates nodes and child hash tables to represent prefixes, so it spends memory to make these prefix paths directly navigable.
And an ordinary hash table can still be the right choice if the typing assistant needs exact lookup of a complete word, rather than prefix navigation. The question determines the structure.
Good. Let’s now insert a word. Assume the trie already contains “cat,” “catnap,” and “catnip,” and we want to insert “can.” Predict which character nodes must be newly created.
The “c” and “a” nodes already exist, so they are reused. At the node for “ca,” there is likely a “t” child but no “n” child, so only the “n” node is new, followed by its end marker.
Exactly. Insertion begins like search: set current_node to root and inspect each character of the incoming word.
For “can,” the root already has “c,” so move there. That node already has “a,” so move there as well.
Next comes “n.” The current node lacks an “n” child, so create a new trie node, store it as the value for key “n,” and make that new node current.
After the final character is handled, add the “*” key to the terminal node. That restores the invariant that “can” has a full root-to-marker path.
And if we insert “cat” again, the existing c-a-t path is reused and the asterisk is simply present already. Nothing should erase the n branch for “catnap.”
Correct. Reinsertion must not break existing paths, and marking a terminal node must not replace its child dictionary. A word can be complete and still be a prefix, so those facts must coexist.
Insertion is also O(K), because we inspect one character at a time and either follow or create one child node per character. The final marker adds a constant extra action, which Big O drops.
What about an empty search prefix? The supplied implementation starts at the root, so it would return the root node after consuming no characters.
That follows naturally from the search logic, although the chapter’s examples focus on nonempty words and prefixes. What matters is that your code define its behavior deliberately rather than treating it as an accidental surprise.
Another edge case is a query that reaches a node but not an asterisk. That is a valid prefix search success, yet it is not evidence that the query itself is a complete stored word.
So we need two questions when necessary: does search_prefix return a node, and does that returned node contain “*”? Mixing those questions is how “ca” gets incorrectly called a word.
Exactly. Now let’s step back, not to recite every structure we covered, but to make the selection habit explicit. Start with the required operation and its invariant, then choose the representation that makes that operation cheap.
If you need direct reading by numeric index, arrays are compelling because indexed reads are O(1). But insertion or deletion near the front can force shifts, which is the price of contiguous positional order.
If I need duplicate prevention in the source’s array-based set, the invariant is no repeated values. The cost is that insertion first searches to verify the value is absent.
Right. If ordered data enables binary search, an ordered array trades more expensive insertion for O(log N) searching. It is not generally superior; it is better when searching matters enough to justify maintaining order.
If you know a key and need its associated value, a hash table offers average O(1) lookup. But searching backward from a value to a key is not the same operation and may still require examining entries.
If temporary data must be undone in reverse arrival order, use a stack and its LIFO rule. If tasks must be processed in arrival order, use a queue and its FIFO rule.
Those constraints are useful because they prevent an algorithm from casually taking data from the wrong place. The structure is not less capable by accident; its restrictions preserve the intended processing order.
When a problem descends through unknown levels, recursion can express the structure neatly, as with a filesystem. But if recursive subproblems overlap, memoization or a bottom-up approach avoids computing the same result repeatedly.
For local edits while already walking through a list, a linked list can relink nodes without array shifting. But arbitrary reads still require following links from the head, so it is not a free upgrade.
For data that must stay ordered while changing often, a reasonably balanced binary search tree offers O(log N) search, insertion, and deletion in the source’s typical case. Its key invariant is left descendants smaller, right descendants greater.
For repeatedly extracting the current highest priority, use a heap. Its weak ordering deliberately keeps the maximum at the root while allowing O(log N) insertion and root deletion, rather than fully sorting every item.
And for prefix lookup, use a trie: characters guide us down a shared path. It is not a replacement for every dictionary-like problem, but it matches autocomplete-style prefixes unusually well.
Let’s apply that to one compact system. A typing assistant stores a word dictionary and also receives incoming background tasks, some more urgent than others; would you put everything in a trie?
No. The dictionary belongs in a trie if we need prefix navigation. The urgent task stream belongs in a heap-backed priority queue if the next task should be the highest-priority one.
And a hash table might still hold exact paired data, such as a complete word and some associated value, when exact-key lookup is needed. Naming one favorite structure first is less useful than naming the operation first.
Here is your implementation exercise. Build a Trie class with insert(word) and search_prefix(prefix), using nodes whose children are dictionaries and using the “*” marker for completed words.
Before coding, make a prediction. After inserting “cat” and then “can,” which nodes must be created during the second insertion, and what must the node after “can” record?
Your answer should be: reuse c and a, create n only if it is missing, then put “*” in n’s children. The t path for “cat” must remain intact; that is the shared-prefix test.
Use these behavioral checks after inserting “cat,” “can,” and “catnap.” search_prefix("ca") should succeed, search_prefix("cat") should succeed, and search_prefix("cab") should fail.
There is one deeper check. The node returned for “cat” must show that “cat” is complete while still retaining the “n” branch used by “catnap.”
Also insert “cat” a second time. Your trie should still find “can” and “catnap,” because reinsertion should reuse structure rather than overwrite it.
When you assess your own solution, state three things aloud. Search and insertion are O(K), the structure spends memory on nodes and their child dictionaries, and every stored word must follow a root-to-asterisk path.
If one of those claims is not true in your code, stop there. A working demo that lost the end marker, overwrote a child dictionary, or cannot explain its path invariant is not really finished.
That exercise is deliberately small, but it captures the larger lesson. Good data-structure work is less about collecting exotic containers and more about preserving the right invariant for the operation you actually need.
Let’s close by compressing the whole lesson into one discipline: start with the operations your program must perform, then choose a structure whose invariant makes those operations cheap enough. Not the fanciest structure, just the one that buys the capability you actually need.
So first question: can you explain why we counted steps rather than seconds? Because hardware changes, but an algorithm that needs to inspect N items versus one that jumps directly to an index has a stable structural difference.
Exactly. Arrays gave us that first contrast: reading by known index is O(1), while searching an unsorted array can take O(N). Ordered arrays make insertion more burdensome, but they earn the right to use binary search at O(log N).
And Big O is not an exact stopwatch. It tells the growth story as data grows, usually using the worst case unless we say otherwise; constants can still matter when two choices land in the same Big O category.
That is why Bubble Sort, Selection Sort, and Insertion Sort were useful beyond their practical limitations. They showed us invariants: Bubble Sort moves the greatest unsorted value to the right, Selection Sort fixes the smallest remaining value on the left, and Insertion Sort preserves a sorted left portion.
Then the warning sign was not simply, "I see nested loops, therefore quadratic." We had to ask what each loop ranges over, whether the loops are sequential, fixed-size, or traversing distinct datasets such as N and M.
Right. Hash tables changed the conversation by making key-to-value lookup O(1) on average, provided collisions stay under control. They are excellent for paired data and membership indexes, but they do not give you a fast reverse lookup from value to key.
Stacks and queues made a different point: constraints can be useful. A stack says last in, first out, which fits matching braces and undo; a queue says first in, first out, which fits print jobs and other work processed in arrival order.
With recursion, keep three things visible: the base case, the smaller recursive case, and what must be remembered on the call stack. Recursion is particularly natural for unknown depth and top-down subproblems, but repeated overlapping calls can turn a neat idea into O(2 to the N) waste.
The repair was dynamic programming: memoize a result so the same Fibonacci subproblem is not recalculated, or go bottom-up with iteration. Both source versions reach O(N), though memoization also uses a hash table and recursion carries call-stack overhead.
For node-based structures, the question became what kind of order matters. Linked lists make already-local insertions and deletions cheap, balanced binary search trees keep ordered search and mutation around O(log N), and heaps preserve only enough order to expose the highest-priority item quickly.
And tries narrow the job further. A trie follows characters along a prefix path, so prefix search and insertion cost O(K), where K is the query or word length, not the total number of stored words; the price is all those nodes and child dictionaries.
For your implementation exercise, build a Trie with insert(word) and search_prefix(prefix), using child dictionaries and an end-of-word marker. After inserting "cat", "can", and "catnap", predict the result before running it: "ca" and "cat" should succeed, "cab" should fail, and the "cat" node must mark a completed word while still retaining its "n" branch.
Before coding, trace those paths on paper: ask which nodes "can" reuses after "cat", which one it creates, and where the marker belongs. Then check your solution by reinserting "cat" without breaking "catnap", state the root-to-marker invariant, and justify O(K) time plus the memory trade-off; that habit of tracing before optimizing is the part worth keeping.