Data Structures & Algorithms — Advanced
Dynamic Programming
Dynamic programming (DP) is recursion plus one addition: memoization — caching the result of each subproblem the first time it's computed, so if the same subproblem comes up again, you look up the cached answer instead of recomputing it. This matters because naive recursion often recomputes the exact same subproblem an exponential number of times.
Fibonacci is the clearest illustration. Naive recursive Fibonacci recalculates fib(2) dozens of times while computing fib(10), because fib(10) calls fib(9) and fib(8), both of which independently call down through fib(2) again. Memoization fixes this directly:
Without @lru_cache, this function is O(2ⁿ) — exponential, because of all the repeated recomputation. With it, each value of n is only ever actually computed once, making it O(n).
1D DP — Climbing Stairs: how many distinct ways to climb n stairs, taking 1 or 2 steps at a time? The recurrence: the number of ways to reach step n is the ways to reach step n-1 plus the ways to reach step n-2 (your last move was either a 1-step or a 2-step). This is structurally identical to Fibonacci.
2D DP — 0/1 Knapsack: given items with weights and values, and a weight capacity, maximize total value without exceeding capacity, where each item can be used at most once ("0/1" — you either take it or you don't). The state you're caching is "best value achievable with the first i items and capacity w":
2D DP — Longest Common Subsequence (LCS): the longest sequence of characters (not necessarily contiguous) that appears in the same relative order in both strings. dp[i][j] stores the LCS length using the first i characters of one string and the first j of the other:
Coin change (fewest coins to make an amount) — a classic "unbounded" DP problem (each coin denomination can be used any number of times, unlike knapsack's "use once"):
The general pattern for approaching any DP problem: define what state you're caching (usually "the answer for a smaller version of the input, parameterized by 1-2 variables"), write the recurrence relating a state to smaller states, identify the base case(s), then decide whether to implement it top-down (recursion + @lru_cache, as in Fibonacci above) or bottom-up (building up a table iteratively, as in the other three examples) — both compute the same thing, bottom-up usually avoids recursion's call-stack overhead.
Sorting Algorithms — Trade-offs
All of these sort correctly; they differ in speed, memory use, and one property worth defining explicitly: stability. A sort is stable if two elements with equal sort keys keep their original relative order after sorting — this matters when you're sorting by one field but want ties broken by "whichever came first in the original data," e.g., sorting a table of student records by grade, where students with the same grade should stay in their original (say, alphabetical) order.
Practical default: Python's built-in sorted() uses Timsort, a hybrid that's stable and fast in practice on real-world data. When asked in an interview which sort you'd choose: "merge sort if I specifically need a stability guarantee, quicksort if I just need fast general-purpose sorting and don't care about stability" is a reasonable, defensible default answer.
Heaps and Priority Queues
Union-Find (Disjoint Set)
Union-Find tracks a collection of elements split into groups ("sets"), efficiently answering two questions: "are these two elements in the same group?" and "merge these two groups into one." It's the standard tool for problems about connectivity/grouping — e.g., detecting cycles in a graph, or counting connected components without doing a full DFS/BFS.
Path compression and union by rank together are what make Union-Find's operations nearly O(1) in practice (technically O(α(n)), where α is the inverse Ackermann function — grows so slowly it's effectively constant for any input size you'd realistically encounter).

