π Your DSA Lab
A living notebook β 22 topics, each with plain-English concepts, key terms, real code, and an interactive animation you can drive.
How to use this
Work top to bottom down the left sidebar β the topics are ordered the way a DSA course teaches them, easiest to hardest. On each page: read the concepts, skim the key terms, then drive the animation (hit Step and predict what happens next) while reading the code. The goal isn't to memorize β it's to see the data move. Stuck on anything? Ask the Study Buddy on the right; it knows which page you're on.
The learning path
Seven phases, ordered by difficulty β each one builds on the last. Click any topic to jump there.
1 Β· Foundations
2 Β· Linear structures
3 Β· Recursion & sorting
4 Β· Non-linear structures
5 Β· Graphs
6 Β· Algorithm paradigms
7 Β· Extras & rigor
π Stack
LIFO β Last In, First Out. Think of a stack of plates.
The idea
You can only add to or remove from the top. The last thing you pushed is the first thing you pop. Undo buttons, browser back, and function call stacks all work this way.
Key terms
- push
- Add an element to the top of the stack.
- pop
- Remove and return the top element.
- peek / top
- Look at the top element without removing it.
- LIFO
- Last In, First Out β the ordering guarantee.
- overflow / underflow
- Pushing to a full stack / popping from an empty one.
Sample code
# A Python list already works as a stack
stack = []
stack.append(1) # push
stack.append(2)
stack.append(3)
top = stack[-1] # peek -> 3
x = stack.pop() # pop -> 3
print(stack) # [1, 2]const stack = [];
stack.push(1); // push
stack.push(2);
stack.push(3);
const top = stack[stack.length - 1]; // peek -> 3
const x = stack.pop(); // pop -> 3
console.log(stack); // [1, 2]O(1).ποΈ Queue
FIFO β First In, First Out. Think of a line at a ticket counter.
The idea
You add at the back (enqueue) and remove from the front (dequeue). The first one in line is the first one served. This is the engine of BFS β keep it fresh.
Key terms
- enqueue
- Add an element to the back of the queue.
- dequeue
- Remove and return the front element.
- front / rear
- The two ends: you read from the front, write to the rear.
- FIFO
- First In, First Out β the ordering guarantee.
Sample code
from collections import deque
q = deque()
q.append("a") # enqueue (add to rear)
q.append("b")
first = q.popleft() # dequeue -> "a" (remove from front)
# deque gives O(1) at BOTH ends β always use it, not a list// A simple queue with an array (fine for learning)
const q = [];
q.push("a"); // enqueue
q.push("b");
const first = q.shift(); // dequeue -> "a"list and pop(0) for a queue β that's O(n). Use collections.deque for O(1) at both ends.π Breadth-First Search NOW
Explore a graph level by level, using a queue to remember who's next.
Lesson 1 β What BFS actually does
Imagine dropping a stone in a pond. Ripples spread out in rings: first everything 1 step away, then everything 2 steps away, and so on. BFS is that ripple. Starting from one node, it visits all its neighbors first, then their neighbors, expanding outward one "ring" (level) at a time.
Lesson 2 β Why a queue?
To go level by level, you must handle nodes in the order you discovered them. That's exactly FIFO. When you visit a node, you enqueue all its undiscovered neighbors at the back. Because the queue is first-in-first-out, you always finish the current ring before starting the next one. Swap the queue for a stack and you'd get depth-first search instead β same skeleton, different container.
Lesson 3 β Watch it run π
Press Step to advance one action at a time (best for learning), or Play to auto-run. Watch how the current node's neighbors get pushed into the queue, and how the queue empties from the front. Hit New example β€³ to try BFS on a different graph.
Queue (front β back)
Visited (in order)
What just happened
Lesson 4 β The algorithm, step by step
Every BFS is the same five moves:
- Put the start node in the queue and mark it visited.
- Dequeue a node from the front β call it current.
- Look at each neighbor of current.
- If a neighbor is not visited yet, mark it visited and enqueue it.
- Repeat from step 2 until the queue is empty.
Key terms
- graph
- A set of nodes (vertices) connected by edges. BFS runs on graphs (a tree is just a special graph).
- node / vertex
- A single point in the graph (A, B, Cβ¦).
- edge
- A connection between two nodes.
- neighbor / adjacent
- A node directly connected by one edge.
- adjacency list
- How we store the graph: a map from each node to its list of neighbors.
{A:[B,C], ...} - visited set
- The nodes we've already discovered β prevents infinite loops on cycles.
- frontier
- The nodes currently in the queue: discovered but not yet explored.
- level / depth
- Distance (number of edges) from the start node. BFS finishes one level before the next.
Sample code
from collections import deque
def bfs(graph, start):
visited = {start} # mark start visited
queue = deque([start]) # frontier starts with the source
order = [] # the visiting order (for us to see)
while queue: # until the frontier is empty...
node = queue.popleft() # dequeue the FRONT (FIFO)
order.append(node)
for nb in graph[node]: # each neighbor
if nb not in visited:
visited.add(nb) # mark on ENQUEUE, not dequeue
queue.append(nb) # add to the back
return order
graph = {
"A": ["B", "C"], "B": ["A", "D", "E"], "C": ["A", "F"],
"D": ["B"], "E": ["B", "F"], "F": ["C", "E", "G"], "G": ["F"],
}
print(bfs(graph, "A")) # ['A', 'B', 'C', 'D', 'E', 'F', 'G']function bfs(graph, start) {
const visited = new Set([start]); // mark start visited
const queue = [start]; // frontier
const order = [];
while (queue.length) { // until frontier empty
const node = queue.shift(); // dequeue front (FIFO)
order.push(node);
for (const nb of graph[node]) { // each neighbor
if (!visited.has(nb)) {
visited.add(nb); // mark on ENQUEUE
queue.push(nb); // add to back
}
}
}
return order;
}Why it matters
parent pointer when you enqueue and you can rebuild that path. This is the #1 reason BFS shows up in interviews and real systems.Complexity
| Aspect | Cost | Why |
|---|---|---|
| Time | O(V + E) | Each vertex dequeued once, each edge examined once. |
| Space | O(V) | Visited set + queue can hold up to all vertices. |
BFS vs DFS β the one-line difference
π BFS β uses a Queue
Explores level by level (widest first). Finds shortest paths in unweighted graphs. Container: FIFO queue.
π³οΈ DFS β uses a Stack
Explores as deep as possible before backtracking. Great for cycles, topological sort. Container: LIFO stack (or recursion).
β±οΈ Complexity Analysis
How we measure how fast (time) and how memory-hungry (space) an algorithm is β as the input grows.
The idea
We don't measure algorithms in seconds β hardware varies. Instead we count how the number of operations grows as the input size n grows. That growth rate is written in Big-O notation. An O(n) algorithm does twice the work when the input doubles; an O(nΒ²) one does four times the work.
O(2n + 5) is just O(n).O(nΒ²) and O(2βΏ) explode while O(log n) barely moves.The common classes (fastest β slowest)
| Big-O | Name | Example |
|---|---|---|
O(1) | Constant | Array index, hash lookup, stack push |
O(log n) | Logarithmic | Binary search, balanced-tree / heap op |
O(n) | Linear | Scanning a list, BFS/DFS over V+E |
O(n log n) | Linearithmic | Good sorts (merge, heap, quick-avg) |
O(nΒ²) | Quadratic | Nested loops, bubble sort |
O(2βΏ) | Exponential | Brute-force subsets, naive recursion |
Key terms
- Big-O
- Upper bound β the worst-case growth rate. The one you'll quote most.
- Big-Ξ (theta)
- Tight bound β growth that's both an upper and lower bound.
- Big-Ξ© (omega)
- Lower bound β the best case.
- time vs space
- Time = operations performed. Space = extra memory used. You often trade one for the other.
- amortized
- Average cost per op over a sequence. A dynamic array's
pushisO(1)amortized even though occasional resizes costO(n). - best / avg / worst
- The same algorithm can behave differently on lucky vs unlucky inputs (e.g. quicksort).
O(n). A loop inside a loop β O(nΒ²). Halving the problem each step β O(log n). Then drop constants and lower-order terms.π’ Arrays & Strings
Contiguous, index-addressed memory β the most fundamental structure. Strings are just arrays of characters.
The idea
An array stores elements back-to-back in memory, each reachable by an index (0-based). Because the computer can jump straight to start + index, reading any element is O(1). The catch: inserting or removing in the middle or front means shifting everything after it β O(n).
Key terms
- index
- The position of an element, starting at 0.
a[0]is the first. - contiguous
- Stored in one unbroken block of memory β what makes
O(1)access possible. - dynamic array
- Auto-resizing array (Python
list, JSArray).pushisO(1)amortized. - in-place
- Modifying the array without allocating a copy β
O(1)extra space. - two pointers
- A common technique: walk two indices toward each other (or at different speeds) to solve problems in one pass.
Sample code
a = [10, 20, 30, 40]
a[2] # O(1) access -> 30
a.append(50) # O(1) amortized push
a.insert(0, 5) # O(n) β shifts everything right
20 in a # O(n) search (unsorted)
# two-pointer: reverse in place
i, j = 0, len(a) - 1
while i < j:
a[i], a[j] = a[j], a[i]
i += 1; j -= 1const a = [10, 20, 30, 40];
a[2]; // O(1) -> 30
a.push(50); // O(1) amortized
a.unshift(5); // O(n) β shifts everything
a.includes(20); // O(n) searchComplexity
| Operation | Cost |
|---|---|
| Access by index | O(1) |
| Search (unsorted) | O(n) |
| Push / pop at end | O(1) amortized |
| Insert / delete at front or middle | O(n) β shift the rest |
O(nΒ²); build a list and join instead.π Linked Lists
Nodes scattered in memory, each pointing to the next. No indices β you follow the chain.
The idea
A linked list is a chain of nodes. Each node holds a value and a pointer to the next node. There's no contiguous block, so no O(1) index jump β to reach the 5th node you walk 5 links (O(n)). The upside: inserting or deleting at a known spot is O(1) β just rewire pointers, no shifting.
Key terms
- node
- One link in the chain β holds a value plus a pointer.
- pointer / next
- Reference from one node to the following node.
- head / tail
- The first node / the last node (whose
nextis null). - null
- Marks the end of the list β the last node points to nothing.
- singly vs doubly
- Singly = points forward only. Doubly = each node also points back, so you can walk both ways.
Sample code
class Node:
def __init__(self, val):
self.val = val
self.next = None
# build 1 -> 2 -> 3
head = Node(1)
head.next = Node(2)
head.next.next = Node(3)
# traverse β O(n)
node = head
while node:
print(node.val)
node = node.next
# insert at head β O(1)
new = Node(0)
new.next = head
head = newclass Node {
constructor(val) { this.val = val; this.next = null; }
}
let head = new Node(1);
head.next = new Node(2);
// traverse β O(n)
let node = head;
while (node) { console.log(node.val); node = node.next; }Complexity
| Operation | Cost |
|---|---|
| Access / search the k-th node | O(n) β walk the chain |
| Insert / delete at head | O(1) |
| Insert / delete at a known node | O(1) β rewire pointers |
O(1) index) and cache friendliness; linked lists win on cheap insert/delete without shifting. Pick based on which operation dominates.ποΈ Hash Tables & Sets
Near-instant lookup by key. A hash function turns a key into a bucket index.
The idea
A hash table stores keyβvalue pairs in an array of buckets. A hash function converts a key into a bucket index in O(1), so lookups, inserts, and deletes are O(1) on average. When two keys land in the same bucket (a collision), we store both there and scan the short list. A set is the same thing minus the values β it just remembers which keys are present.
Key terms
- hash function
- Turns any key into a bucket index, fast and deterministically.
- bucket
- One slot in the backing array where entries land.
- collision
- Two keys hashing to the same bucket. Handled by chaining (a list per bucket).
- load factor
- entries Γ· buckets. When it gets high, the table resizes to keep lookups fast.
- set vs map
- A set stores keys only (membership). A map/dict stores keyβvalue pairs.
Sample code
# dict = hash map, set = hash set
prices = {"apple": 3, "pear": 5}
prices["apple"] # O(1) avg lookup -> 3
prices["kiwi"] = 2 # O(1) insert
"pear" in prices # O(1) membership
seen = set()
seen.add("cat") # O(1)
"cat" in seen # O(1)const prices = new Map([["apple", 3]]);
prices.get("apple"); // O(1) -> 3
prices.set("kiwi", 2);
prices.has("apple"); // O(1)
const seen = new Set();
seen.add("cat");
seen.has("cat"); // O(1)Complexity
| Operation | Average | Worst |
|---|---|---|
| Insert / lookup / delete | O(1) | O(n) (everything collides) |
O(1) is average β a bad hash or adversarial keys can degrade to O(n). This is exactly the visited-set that makes BFS efficient!π³ Trees
Hierarchical data β a root branching into children, with no cycles.
The idea
A tree has a root at the top; every node has one parent and any number of children. A binary tree allows at most two children (left/right). A binary search tree (BST) keeps them ordered β everything left of a node is smaller, everything right is larger β which makes search O(log n) when the tree is balanced.
Key terms
- root / leaf
- Top node with no parent / bottom node with no children.
- parent / child
- Direct up/down relationships between connected nodes.
- depth / height
- Depth = distance from root. Height = longest path down to a leaf.
- binary tree
- Each node has at most two children (left, right).
- BST
- Binary search tree: left < node < right, giving
O(log n)search when balanced. - balanced
- Height stays ~
log n. Unbalanced, a BST degrades into a linked list (O(n)).
Sample code
class TreeNode:
def __init__(self, val):
self.val = val
self.left = None
self.right = None
def inorder(node):
if not node: return
inorder(node.left) # go left
print(node.val) # visit
inorder(node.right) # go right
# On a BST, in-order prints values sorted.class TreeNode {
constructor(val) { this.val = val; this.left = this.right = null; }
}
function inorder(node) {
if (!node) return;
inorder(node.left); // go left
console.log(node.val); // visit
inorder(node.right); // go right
}Complexity (balanced BST)
| Operation | Balanced | Worst (degenerate) |
|---|---|---|
| Search / insert / delete | O(log n) | O(n) |
O(log n). Heaps and tries are specialized trees too.π² Tries (Prefix Trees)
A tree of characters for storing strings β built for lightning-fast prefix search (autocomplete).
The idea
A trie stores words character-by-character down a tree. Words that share a prefix share the same path β "car" and "cat" share c β a. Looking up a word or checking a prefix takes O(L) where L is the word's length β independent of how many words are stored. That's why it powers autocomplete and spell-check.
Key terms
- prefix tree
- Another name for a trie β the path from root spells out a prefix.
- node per character
- Each edge is one character; each node has a map of child characters.
- end-of-word marker
- A flag on a node saying "a complete word ends here" (β in the demo).
- prefix search
- The trie's superpower: check if any word starts with a prefix in
O(L).
Sample code
class Trie:
def __init__(self):
self.root = {} # nested dicts of chars
def insert(self, word): # O(L)
node = self.root
for ch in word:
node = node.setdefault(ch, {})
node["$"] = True # end-of-word
def starts_with(self, prefix): # O(L)
node = self.root
for ch in prefix:
if ch not in node: return False
node = node[ch]
return Trueclass Trie {
constructor() { this.root = {}; }
insert(word) { // O(L)
let node = this.root;
for (const ch of word)
node = node[ch] ??= {};
node["$"] = true; // end-of-word
}
startsWith(prefix) { // O(L)
let node = this.root;
for (const ch of prefix) {
if (!(ch in node)) return false;
node = node[ch];
}
return true;
}
}Complexity
| Operation | Cost |
|---|---|
| Insert / search word / prefix check | O(L) β L = word length |
| Space | can be large β up to one node per character stored |
πΈοΈ Graphs
Nodes connected by edges β the most general structure. Trees and linked lists are special cases.
The idea
A graph is a set of vertices (nodes) joined by edges. Edges can be directed (one-way, like Twitter follows) or undirected (mutual, like Facebook friends), and can carry weights (distance, cost). We usually store a graph as an adjacency list: a map from each node to its neighbors. BFS and DFS are how you explore one.
Adjacency list
Key terms
- vertex / edge
- A node / a connection between two nodes.
- directed vs undirected
- Edges one-way (AβB) vs mutual (AβB).
- weighted
- Edges carry a number (distance, cost). Dijkstra handles these.
- adjacency list
- Map of node β list of neighbors. Compact for sparse graphs.
- adjacency matrix
- A VΓV grid; edge lookup is
O(1)but space isO(VΒ²). - degree
- How many edges touch a node.
- path / cycle
- A route through edges / a path that returns to its start.
Sample code
# adjacency list (undirected)
graph = {
"A": ["B", "C"],
"B": ["A", "D"],
"C": ["A", "D"],
"D": ["B", "C"],
}
graph["A"] # neighbors of A -> ["B", "C"]
# weighted: store (neighbor, weight) pairs
wgraph = {"A": [("B", 5), ("C", 2)]}// adjacency list (undirected)
const graph = {
A: ["B", "C"],
B: ["A", "D"],
C: ["A", "D"],
D: ["B", "C"],
};
graph.A; // -> ["B", "C"]Complexity
| Representation | Space | Edge lookup |
|---|---|---|
| Adjacency list | O(V + E) | O(degree) |
| Adjacency matrix | O(VΒ²) | O(1) |
O(V + E). Head to the π BFS page to watch a traversal ripple out level by level.β°οΈ Heaps & Priority Queues
A tree that always keeps the smallest (or largest) item at the top β O(log n) to add or remove it.
The idea
A min-heap is a complete binary tree where every parent is β€ its children β so the minimum is always at the root. It's stored compactly in an array: for index i, children sit at 2i+1 and 2i+2. Adding an item (sift-up) or removing the top (sift-down) is O(log n) because you only travel one path up or down the tree's height. A priority queue is just a heap with a friendly name.
Key terms
- heap property
- Every parent β€ its children (min-heap) β so the min is always the root.
- min-heap / max-heap
- Smallest on top / largest on top.
- sift-up / sift-down
- Restore the heap property after an insert (bubble up) or removal (bubble down).
- complete binary tree
- Every level full except possibly the last, filled left-to-right β lets us use a flat array.
- priority queue
- The abstract "always give me the highest-priority item" idea; a heap is how it's built.
Sample code
import heapq
h = []
heapq.heappush(h, 5) # O(log n)
heapq.heappush(h, 2)
heapq.heappush(h, 8)
heapq.heappush(h, 1)
h[0] # peek min -> 1 (O(1))
heapq.heappop(h) # remove min -> 1 (O(log n))
# max-heap trick: push negated values (-v)// JS has no built-in heap β hand-roll or use a library.
// Concept: array where parent(i) = (i-1)>>1,
// children of i = 2i+1 and 2i+2.
class MinHeap {
constructor() { this.a = []; }
push(v) { this.a.push(v); this._up(this.a.length - 1); }
peek() { return this.a[0]; } // O(1)
// _up sifts up; pop() swaps root & last, then sifts down
}Complexity
| Operation | Cost |
|---|---|
| Peek min/max | O(1) |
| Insert (push) | O(log n) |
| Extract min/max (pop) | O(log n) |
| Build heap from n items | O(n) |
π Mathematical Foundations
The handful of math ideas that show up in almost every algorithm.
Big-O β measuring growth, not seconds
Big-O describes how an algorithm's work grows as the input n gets large. We ignore constants and small terms and keep the dominant one β O(nΒ²+3n) becomes O(nΒ²). It answers "if I double the input, what happens to the work?"
Logarithms β the "how many times can I halve it" number
logβ(n) is how many times you divide n by 2 before reaching 1. For a million items that's only ~20. Every time an algorithm halves its problem each step, you get an O(log n) β that's why binary search and balanced trees are so fast.
Modular arithmetic & GCD
Modulo (%) is remainder after division β "clock math" that wraps around (you used it in the circular queue!). GCD (greatest common divisor) is found fast with Euclid's algorithm: repeatedly replace the pair with (smaller, remainder).
Key terms
- Big-O
- Upper bound on growth as n β β. The vocabulary for comparing algorithms.
- logarithm
- Inverse of exponentiation. logβ(n) = number of halvings to reach 1.
- modulo (%)
- Remainder after division. Wraps values into a fixed range [0, m).
- GCD
- Greatest common divisor of two integers, via Euclid's algorithm.
- factorial / nCr
- Counting arrangements (n!) and combinations (n choose r) β the basis of combinatorics.
Sample code
def gcd(a, b): # Euclid's algorithm β O(log(min(a,b)))
while b:
a, b = b, a % b # replace with (b, remainder)
return a
print(gcd(48, 36)) # 12
# log intuition: how many halvings to reach 1?
n, steps = 1000, 0
while n > 1:
n //= 2; steps += 1
print(steps) # 10 (β log2(1000))log n in binary search, heaps, and balanced trees all come straight from this halving idea.π Bit Manipulation
Working directly with the binary bits of a number β fast and memory-cheap.
The idea
Integers are stored as binary (13 = 1101). Bitwise operators act on those bits in parallel, in a single CPU instruction. That makes them extremely fast for flags, sets, and low-level tricks.
The operators
| Op | Name | Does |
|---|---|---|
& | AND | 1 only where both bits are 1 |
| | OR | 1 where either bit is 1 |
^ | XOR | 1 where bits differ |
~ | NOT | flips every bit |
<< / >> | shift | slide bits left/right (Γ2 or Γ·2) |
^) lights up exactly where A and B differ β flip bits until the two rows match and watch it go all zeros.Key terms
- bit / mask
- A single 0/1; a mask is a number used to isolate or set specific bits.
- set / clear / toggle
- Turn a bit on (
x | 1<<i), off (x & ~(1<<i)), or flip it (x ^ 1<<i). - x & (x-1)
- Clears the lowest set bit β the classic trick for counting 1-bits.
- XOR trick
- a ^ a = 0, so XOR-ing everything cancels pairs and leaves the unique value.
Sample code
x = 0b1010 # 10
is_set = (x >> 1) & 1 # check bit 1 -> 1
x |= (1 << 2) # set bit 2 -> 1110 (14)
x &= ~(1 << 1) # clear bit 1 -> 1100 (12)
# find the element that appears once (all others twice)
def unique(nums):
r = 0
for v in nums: r ^= v # pairs cancel to 0
return r
print(unique([4,1,4,6,1])) # 6O(1) memory and one pass β no hash map needed.π Searching Algorithms
Finding an element β from a brute-force scan to O(log n) binary search.
Linear search β check everything
Walk the collection one item at a time until you find the target (or run out). Works on any data, sorted or not. Simple, but O(n).
Binary search β halve the haystack each step
If the data is sorted, compare the target to the middle element and throw away half the range every step. That halving is the log from the math page β O(log n). A million items in ~20 comparisons.
Key terms
- linear search
- Scan front-to-back. O(n). No ordering required.
- binary search
- Repeatedly halve a sorted range. O(log n).
- lo / mid / hi
- The three pointers bounding the current search window.
- invariant
- "If the target exists, it's within [lo, hi]" β stays true every iteration.
Sample code
def binary_search(a, target): # a must be SORTED
lo, hi = 0, len(a) - 1
while lo <= hi:
mid = (lo + hi) // 2
if a[mid] == target: return mid
elif a[mid] < target: lo = mid + 1 # target is right half
else: hi = mid - 1 # target is left half
return -1 # not found
print(binary_search([1,3,5,7,9], 7)) # 3Complexity
| Algorithm | Time | Needs sorted? |
|---|---|---|
| Linear search | O(n) | no |
| Binary search | O(log n) | yes |
ποΈ Sorting Algorithms
Putting things in order β the workhorse behind search, dedup, grouping, and more.
The bar to beat: O(n log n)
Simple sorts (bubble, insertion, selection) compare every pair and cost O(nΒ²). The efficient ones (merge, quick, heap) hit O(n log n) β which is the best any comparison-based sort can do. That log n comes from repeatedly halving, just like binary search.
The three you should know
| Sort | Idea | Time | Notes |
|---|---|---|---|
| Insertion | Build a sorted prefix one item at a time | O(nΒ²) | Great on tiny / nearly-sorted data |
| Merge | Split in half, sort each, merge | O(n log n) | Stable; needs extra memory |
| Quick | Partition around a pivot, recurse | O(n log n) avg | In-place; O(nΒ²) worst case |
MergeSort.java is exactly the divide-and-conquer merge sort below β split, recurse, merge.Key terms
- comparison sort
- Orders by comparing pairs. Lower bound is O(n log n).
- stable
- Equal elements keep their original relative order (merge sort is; quicksort isn't).
- in-place
- Sorts using O(1) extra memory (quicksort ~is; merge sort isn't).
- pivot / partition
- Quicksort's split point and the rearrange step around it.
Sample code
def merge_sort(a):
if len(a) <= 1: return a # base case
mid = len(a) // 2
left = merge_sort(a[:mid]) # divide + conquer
right = merge_sort(a[mid:])
return merge(left, right) # combine
def merge(l, r):
out, i, j = [], 0, 0
while i < len(l) and j < len(r):
if l[i] <= r[j]: out.append(l[i]); i += 1
else: out.append(r[j]); j += 1
return out + l[i:] + r[j:] # leftovers
print(merge_sort([5,2,9,1,6])) # [1, 2, 5, 6, 9]π Recursion & Divide-and-Conquer
A function that calls itself β and the strategy of splitting a problem into smaller copies of itself.
Recursion = base case + recursive case
A recursive function solves a big problem by calling itself on a smaller version, until it hits a base case simple enough to answer directly. Every call must move toward the base case, or it never stops.
Divide and conquer
A recursion pattern in three moves: divide the problem into parts, conquer each part by recursing, then combine the results. Merge sort and binary search are the classic examples β and it's why they're O(n log n) and O(log n).
Key terms
- base case
- The stopping condition β solved directly, no further recursion.
- recursive case
- Where the function calls itself on smaller input.
- call stack
- The stack (!) of paused function calls waiting to finish. Deep recursion can overflow it.
- divide & conquer
- Divide β recurse β combine.
Sample code
def factorial(n):
if n <= 1: # base case
return 1
return n * factorial(n - 1) # recursive case β shrinks toward base
print(factorial(5)) # 120 (5*4*3*2*1)
# the call stack, top to bottom:
# factorial(5) -> 5 * factorial(4) -> 5*4 * factorial(3) -> ...π΄ Backtracking
Try a choice, recurse, and undo it if it leads to a dead end.
The idea: explore a tree of choices
Many problems are "pick options one at a time" β arrange these items, fill this board, choose this subset. Backtracking walks that tree of choices with one rhythm: choose β explore (recurse) β un-choose. If a branch can't lead to a solution, you back up (backtrack) and try the next option.
Pruning β skip doomed branches early
The power move is checking constraints as you go: the moment a partial choice is already invalid (two queens attack, a sum exceeds the target), abandon that branch instead of building it out. Good pruning is the difference between fast and hopeless.
Key terms
- choice
- One decision at the current step (place a queen, add an item).
- constraint
- A rule a valid solution must satisfy.
- prune
- Abandon a branch early once it can't possibly work.
- un-choose
- Undo the last choice before trying the next β restores state.
Sample code
# all subsets of [1,2,3] via choose / explore / un-choose
def subsets(nums):
out, path = [], []
def dfs(i):
if i == len(nums):
out.append(path[:]) # a complete choice
return
path.append(nums[i]) # CHOOSE to include
dfs(i + 1) # EXPLORE
path.pop() # UN-CHOOSE
dfs(i + 1) # explore excluding it
dfs(0)
return out
print(subsets([1,2,3])) # [], [3], [2], [2,3], [1], ...πͺ Greedy Algorithms
Take the best-looking option right now, and never look back.
The idea
At each step, make the choice that looks best locally β and commit to it. No undoing, no exploring alternatives. It's fast and simple. The catch: a sequence of locally-best choices isn't always globally best, so greedy only works on problems with the right structure.
When greedy is actually correct
You need two properties: greedy-choice property (a locally optimal choice is part of some global optimum) and optimal substructure (an optimal solution contains optimal solutions to subproblems). If you can't argue those, greedy may silently give a wrong answer.
Key terms
- greedy choice
- The locally-best option taken at each step, never revisited.
- optimal substructure
- Optimal solutions are built from optimal sub-solutions.
- exchange argument
- The usual proof: show any optimal solution can be swapped toward the greedy one without getting worse.
Sample code
# interval scheduling: most non-overlapping meetings
def max_meetings(intervals):
intervals.sort(key=lambda x: x[1]) # greedy: earliest FINISH first
count, end = 0, float('-inf')
for s, e in intervals:
if s >= end: # doesn't overlap the last pick
count += 1; end = e
return count
print(max_meetings([(1,3),(2,5),(4,6)])) # 2π§© Dynamic Programming
Break a problem into overlapping subproblems, solve each once, and remember the answer.
DP = recursion + memory
When a recursion solves the same subproblem over and over, you're wasting work. DP fixes that: compute each subproblem once and store its answer, so later calls just look it up. That turns exponential blowup into linear.
Two styles
Top-down (memoization): write the natural recursion, add a cache. Bottom-up (tabulation): fill a table from the smallest subproblems up. Same answers, same complexity β pick whichever reads clearer.
Key terms
- subproblem / state
- A smaller instance, identified by the inputs that matter (the "state").
- overlapping subproblems
- The same subproblem is needed many times β the signal for DP.
- memoization
- Top-down: cache results of the recursion.
- tabulation
- Bottom-up: fill a table from base cases upward.
Sample code
from functools import lru_cache
# naive fib recomputes the same values -> O(2^n)
# memoized: each n computed once -> O(n)
@lru_cache(None)
def fib(n):
if n < 2: return n # base cases
return fib(n-1) + fib(n-2) # cached after first call
print(fib(50)) # instant: 12586269025Complexity
| Approach | Time | Why |
|---|---|---|
| Naive recursion | O(2βΏ) | Recomputes the same subproblems exponentially |
| DP (memo/table) | O(n) | Each subproblem solved exactly once |
π§ Common Problem-Solving Patterns
The reusable "shapes" that crack the majority of coding problems.
Why patterns?
Most problems aren't new β they're a familiar pattern in disguise. Half the battle is recognizing which one. Learn to spot the signal, and the solution structure comes for free.
The pattern toolbox
| Pattern | Use when⦠| Signal |
|---|---|---|
| Two pointers | Sorted array / pair-finding | "find a pair", "in-place", sorted input |
| Sliding window | Contiguous subarray/substring | "longest/shortest subarray withβ¦" |
| Fast & slow pointers | Cycle detection in a list | linked list, "does it loop?" |
| Hash map counting | Frequencies / seen-before | "count", "duplicate", "anagram" |
| Prefix sums | Many range-sum queries | "sum between i and j" |
| Binary search on answer | Monotonic feasibility | "minimize the max", "smallest valid" |
| BFS / DFS | Graphs, grids, trees | "shortest path", "connected", "reachable" |
| Heap / top-K | K largest/smallest, streaming | "top K", "K closest", "median" |
| Backtracking | Enumerate all valid configs | "all permutations/combinations" |
| Dynamic programming | Overlapping subproblems | "count ways", "min cost", "can youβ¦" |
β Algorithm Correctness & Proofs
How you know an algorithm works β not just that it passed a few tests.
Correctness = terminates + right answer
Two things to establish: the algorithm always stops (termination), and when it stops the output is correct. Tests can only show bugs exist; a proof shows they don't.
Loop invariants β the main tool
A loop invariant is a statement that stays true before and after every iteration. Prove three things and correctness follows:
- Initialization β it's true before the loop starts.
- Maintenance β if it's true before an iteration, it's still true after.
- Termination β when the loop ends, the invariant gives you the result you wanted.
Invariant β true at the top of every iteration
best. The invariant never breaks β that's the proof.Key terms
- loop invariant
- A property true at the top of every iteration; the backbone of loop proofs.
- induction
- Prove a base case, then that each case implies the next.
- termination
- Argue the algorithm always halts (e.g., a value strictly decreases toward a bound).
- pre/postcondition
- What must hold before running / what's guaranteed after.
- counterexample
- A single input where the algorithm fails β disproves correctness instantly.
Sample: an invariant in action
def max_of(a):
best = a[0]
# INVARIANT: `best` == the maximum of a[0..i-1]
for i in range(1, len(a)):
if a[i] > best:
best = a[i]
# invariant restored: best == max of a[0..i]
return best # at exit i==len(a): best == max of whole array