The Art of the Algorithm
A complete tour — from formal definitions and asymptotic proofs to working code you can run today in GNU Octave, with interactive visualizations woven throughout.
SECTION 0What Is an Algorithm?
An algorithm is a finite, well-defined sequence of computational steps that transforms an input into an output. The word honors the 9th-century mathematician al-Khwārizmī, but the idea is older than the name and far older than the computer.
A procedure is an algorithm if it has:
- Finiteness — it terminates after finitely many steps.
- Definiteness — each step is precisely, unambiguously specified.
- Input — zero or more quantities supplied before it begins.
- Output — one or more quantities with a specified relation to the input.
- Effectiveness — every operation is basic enough to be done exactly, in principle, by a human with pencil and paper.
When we analyze an algorithm we ask two separate questions, and we must never conflate them:
- Correctness — does it always produce the right output? (Section 3.)
- Efficiency — how do its time and memory requirements grow as the input grows? (Sections 1–2.)
The model of computation
To count "steps" meaningfully we adopt the RAM (Random-Access Machine) model. We assume each basic operation — an arithmetic op, a comparison, a memory read or write of a single word — costs one unit of time. We then count operations as a function of input size n. This abstraction lets us compare algorithms independently of hardware, compiler, or programming language: a fact that will outlive any particular CPU.
GNU Octave is a free, open-source environment for numerical computation, largely compatible with MATLAB. Its 1-based array indexing and matrix-native syntax make algorithmic ideas read almost like pseudocode. Every code block in this guide is valid Octave you can paste straight into the interpreter. Save a function block as name.m (the filename must match the function name) and call it from the prompt.
% Your first runnable example: sum 1..n two ways and compare cost.
% The closed form is O(1); the loop is O(n). Same answer, different work.
function s = sum_loop(n) % O(n) time, O(1) space
s = 0;
for k = 1:n
s = s + k;
endfor
endfunction
function s = sum_formula(n) % O(1) time -- Gauss's trick
s = n * (n + 1) / 2;
endfunction
% Try it: sum_loop(100), sum_formula(100) -> both give 5050
SECTION 1Asymptotic Analysis
We don't care that one machine is twice as fast as another, nor about the cost of a single addition. We care about shape: how the running time scales as n → ∞. Asymptotic notation is the language for that shape.
For functions \(f,g:\mathbb{N}\to\mathbb{R}^{+}\), we write \(f(n)=O(g(n))\) if there exist positive constants \(c\) and \(n_0\) such that
\( 0 \le f(n) \le c\,g(n) \quad\text{for all } n \ge n_0. \)
Read: "beyond some point \(n_0\), \(f\) is no worse than a constant multiple of \(g\)." It is an upper bound, and a loose one is still valid.
\(f(n)=\Omega(g(n))\) if \(\exists\,c,n_0>0\) with \(0\le c\,g(n)\le f(n)\) for all \(n\ge n_0\) — a lower bound.
\(f(n)=\Theta(g(n))\) if \(f(n)=O(g(n))\) and \(f(n)=\Omega(g(n))\) — a tight bound. Equivalently, \(\exists\,c_1,c_2,n_0>0\) with \(c_1 g(n)\le f(n)\le c_2 g(n)\) for all \(n\ge n_0\).
The strict cousins: \(f=o(g)\) means the ratio \(f/g\to 0\) (strictly smaller); \(f=\omega(g)\) means \(f/g\to\infty\) (strictly larger).
Proof technique: produce the witnesses
To prove a Big-O claim you must exhibit a constant \(c\) and threshold \(n_0\) that work. Here is the canonical example.
\(3n^2 + 5n + 2 = O(n^2).\)
We seek \(c,n_0\) with \(3n^2+5n+2 \le c\,n^2\) for all \(n\ge n_0\). For every \(n\ge 1\) we have \(n \le n^2\) and \(1 \le n^2\). Therefore
\(3n^2 + 5n + 2 \;\le\; 3n^2 + 5n^2 + 2n^2 \;=\; 10n^2.\)
So the choice \(c = 10,\; n_0 = 1\) satisfies the definition. Hence \(3n^2+5n+2 = O(n^2)\).
The same function is also \(O(n^3)\) (true but loose) and \(\Omega(n^2)\) (since \(3n^2+5n+2 \ge 3n^2\) for all \(n\ge 1\), take \(c=3\)). Combining the two tight bounds gives \(\Theta(n^2)\) — the honest answer.
Proving a negative bound
\(n^2 \ne O(n).\)
Suppose, for contradiction, that \(n^2 = O(n)\). Then there exist \(c,n_0>0\) with \(n^2 \le c\,n\) for all \(n\ge n_0\). Dividing both sides by the positive quantity \(n\) gives \(n \le c\) for all \(n\ge n_0\). But \(n\) grows without bound, so choosing any \(n > \max(c, n_0)\) violates the inequality. This contradiction shows no such constants exist, hence \(n^2 \ne O(n)\).
When \(L=\lim_{n\to\infty} f(n)/g(n)\) exists: if \(L=0\) then \(f=o(g)\); if \(0
The hierarchy you must memorize
| Class | Name | n = 1,000,000 means… | Typical source |
|---|---|---|---|
O(1) | constant | 1 step | array index, hash lookup |
O(log n) | logarithmic | ~20 steps | binary search, balanced trees |
O(n) | linear | 10⁶ steps | one scan of the input |
O(n log n) | linearithmic | ~2×10⁷ steps | merge sort, heap sort, FFT |
O(n²) | quadratic | 10¹² steps | nested loops, naive sorts |
O(n³) | cubic | 10¹⁸ steps | naive matrix multiply |
O(2ⁿ) | exponential | astronomically large | brute-force subsets |
O(n!) | factorial | beyond astronomical | brute-force permutations |
The gap between \(n\log n\) and \(n^2\) is the difference between an algorithm finishing in a blink and one that never finishes at all. This is why analysis matters more than micro-optimization.
SECTION 2Recurrences & Divide-and-Conquer
Divide-and-conquer algorithms split a problem into smaller subproblems, solve those recursively, and combine the results. Their running time is naturally described by a recurrence relation — an equation that defines \(T(n)\) in terms of itself at smaller inputs.
A generic divide-and-conquer recurrence has the form
\( T(n) = a\,T(n/b) + f(n), \)
where \(a\ge 1\) subproblems are each of size \(n/b\) (with \(b>1\)), and \(f(n)\) is the cost to divide the problem and combine the answers. Three methods solve such recurrences.
Method 1 — Substitution (guess & verify by induction)
The recurrence \(T(n) = 2T(n/2) + n\), with \(T(1)=1\), satisfies \(T(n) = O(n\log n)\).
Guess \(T(n) \le c\,n\log_2 n + n\) for some constant \(c>0\) (the extra \(+n\) absorbs the base case). Base: for \(n=1\), \(\log_2 1 = 0\), so the bound reads \(T(1)\le 1\), which holds. Step: assume the bound for all sizes below \(n\). Then
\(T(n) = 2T(n/2)+n \le 2\!\left[c\tfrac{n}{2}\log_2\tfrac{n}{2}+\tfrac{n}{2}\right]+n\)
\(= c\,n(\log_2 n - 1) + n + n = c\,n\log_2 n - cn + 2n.\)
For any \(c\ge 2\) the term \(-cn+2n \le 0 \le n\), so \(T(n)\le c\,n\log_2 n + n\), completing the induction. Hence \(T(n)=O(n\log n)\). (This is exactly merge sort.)
Method 2 — The recursion tree
Unroll the recurrence as a tree: the root does \(f(n)\) work, its \(a\) children each do \(f(n/b)\), and so on. Summing work level by level often reveals the answer by inspection. For \(T(n)=2T(n/2)+n\): each of the \(\log_2 n + 1\) levels contributes exactly \(n\) total work (level \(i\) has \(2^i\) nodes each costing \(n/2^i\)), giving \(n\cdot(\log_2 n + 1) = \Theta(n\log n)\). The tree picture is also the fastest way to guess the bound the substitution method then proves.
Method 3 — The Master Theorem
For the common shape \(T(n)=aT(n/b)+f(n)\), one theorem dispatches most cases mechanically. The pivot is comparing \(f(n)\) against the "watershed" function \(n^{\log_b a}\).
Let \(a\ge 1\), \(b>1\) be constants, \(f(n)\) a function, and \(T(n)=a\,T(n/b)+f(n)\). Let \(c^\star=\log_b a\). Then:
Case 1. If \(f(n)=O\!\left(n^{c^\star-\varepsilon}\right)\) for some \(\varepsilon>0\), then \(T(n)=\Theta\!\left(n^{c^\star}\right)\). — the leaves dominate.
Case 2. If \(f(n)=\Theta\!\left(n^{c^\star}\right)\), then \(T(n)=\Theta\!\left(n^{c^\star}\log n\right)\). — work is balanced across levels.
Case 3. If \(f(n)=\Omega\!\left(n^{c^\star+\varepsilon}\right)\) for some \(\varepsilon>0\), and the regularity condition \(a\,f(n/b)\le k\,f(n)\) holds for some \(k<1\), then \(T(n)=\Theta\!\left(f(n)\right)\). — the root dominates.
The recursion tree has depth \(\log_b n\) and a branching factor \(a\), so it has \(a^{\log_b n} = n^{\log_b a}\) leaves. That leaf count is the work done at the bottom of the tree. The theorem simply asks: is the combine-cost \(f(n)\) smaller than (Case 1), equal to (Case 2), or larger than (Case 3) the work at the leaves? Whoever wins, wins the whole sum.
Worked example — merge sort. \(T(n)=2T(n/2)+\Theta(n)\). Here \(a=2,b=2\), so \(c^\star=\log_2 2 = 1\) and \(f(n)=\Theta(n)=\Theta(n^{1})=\Theta(n^{c^\star})\). That is Case 2, giving \(T(n)=\Theta(n\log n)\) — matching the substitution proof above.
Worked example — binary search. \(T(n)=T(n/2)+\Theta(1)\). Now \(a=1,b=2\), \(c^\star=\log_2 1 = 0\), and \(f(n)=\Theta(1)=\Theta(n^0)\): Case 2 again, \(T(n)=\Theta(\log n)\).
SECTION 3Correctness & Loop Invariants
A fast algorithm that returns wrong answers is worthless. The workhorse tool for proving an iterative algorithm correct is the loop invariant — a property that is true before the loop starts and stays true after every iteration, like an inductive hypothesis embedded in the code.
To prove correctness with a loop invariant, show three things:
- Initialization — the invariant holds before the first iteration.
- Maintenance — if it holds before an iteration, it holds before the next.
- Termination — when the loop ends, the invariant (plus the exit condition) implies the algorithm is correct.
This is mathematical induction wearing work clothes: initialization is the base case, maintenance is the inductive step.
Case study: Insertion Sort
Insertion sort builds a sorted prefix one element at a time, exactly the way most people sort a hand of playing cards. Here it is in Octave.
function A = insertion_sort(A)
% Sorts row vector A ascending, in place. Time: O(n^2) worst, O(n) best.
for j = 2:numel(A)
key = A(j); % the element to insert into the sorted prefix
i = j - 1;
% shift everything larger than key one slot to the right
while i >= 1 && A(i) > key
A(i+1) = A(i);
i = i - 1;
endwhile
A(i+1) = key; % drop key into the gap
endfor
endfunction
insertion_sort returns a permutation of its input arranged in non-decreasing order.
Invariant. At the start of each iteration of the for loop with index \(j\), the subarray \(A[1..j-1]\) contains the original elements of those positions, now in sorted order.
Initialization. When \(j=2\), the prefix \(A[1..1]\) is a single element, which is trivially sorted and unchanged. ✓
Maintenance. Assume \(A[1..j-1]\) is sorted. The while loop moves every element of \(A[1..j-1]\) strictly greater than key one position right, preserving their relative order, until it finds the slot where key is \(\ge\) its left neighbour and \(\le\) its right neighbour. Placing key there yields a sorted \(A[1..j]\). Only original elements were moved or inserted, so it remains a permutation. Thus the invariant holds for \(j+1\). ✓
Termination. The loop ends when \(j = n+1\). Substituting into the invariant, \(A[1..n]\) — the whole array — is sorted and is a permutation of the input. ∎ Therefore the algorithm is correct.
The outer loop runs \(n-1\) times. In the worst case (reverse-sorted input) the inner while shifts all \(j-1\) prior elements, giving \(\sum_{j=2}^{n}(j-1)=\frac{n(n-1)}{2}=\Theta(n^2)\). In the best case (already sorted) the while test fails immediately, so the work is \(\Theta(n)\). This sensitivity to input order is why insertion sort is the algorithm of choice for small or nearly-sorted arrays — and why hybrid sorts (e.g. Timsort) switch to it on small subranges.
SECTION 4Sorting
Sorting is the most studied problem in computing — partly because it is everywhere, and partly because it is the perfect arena for learning every analysis technique at once. We meet the quadratic family, then the \(\Theta(n\log n)\) family, then prove that no comparison sort can ever beat \(n\log n\).
The quadratic family
Three classic \(O(n^2)\) sorts. They are slow on large inputs but simple, in-place, and instructive.
function A = bubble_sort(A)
% Repeatedly swap adjacent out-of-order pairs; large items "bubble" up.
n = numel(A);
for i = 1:n-1
swapped = false;
for j = 1:n-i % last i items are already in place
if A(j) > A(j+1)
tmp = A(j); A(j) = A(j+1); A(j+1) = tmp;
swapped = true;
endif
endfor
if !swapped, break; endif % early exit if a full pass made no swap
endfor
endfunction
function A = selection_sort(A)
% Each pass selects the minimum of the unsorted tail and places it.
n = numel(A);
for i = 1:n-1
m = i;
for j = i+1:n
if A(j) < A(m), m = j; endif
endfor
tmp = A(i); A(i) = A(m); A(m) = tmp;
endfor
endfunction
Merge sort — divide and conquer, provably \(\Theta(n\log n)\)
Split the array in half, sort each half recursively, then merge the two sorted halves in linear time. The merge step is the whole trick: comparing the fronts of two sorted lists lets you emit the global minimum in \(O(1)\).
function A = merge_sort(A)
n = numel(A);
if n <= 1, return; endif % base case: 0 or 1 element is sorted
mid = floor(n/2);
L = merge_sort(A(1:mid));
R = merge_sort(A(mid+1:end));
A = merge(L, R);
endfunction
function M = merge(L, R)
% Combine two sorted vectors into one sorted vector in O(|L|+|R|).
M = zeros(1, numel(L) + numel(R));
i = 1; j = 1; k = 1;
while i <= numel(L) && j <= numel(R)
if L(i) <= R(j) % "<=" keeps the sort STABLE
M(k) = L(i); i = i + 1;
else
M(k) = R(j); j = j + 1;
endif
k = k + 1;
endwhile
while i <= numel(L), M(k) = L(i); i++; k++; endwhile % drain L
while j <= numel(R), M(k) = R(j); j++; k++; endwhile % drain R
endfunction
Merge sort runs in \(\Theta(n\log n)\) time in the worst case.
The merge of two lists of combined length \(m\) performs at most \(m-1\) comparisons and exactly \(m\) writes, so it costs \(\Theta(m)\). Splitting is \(\Theta(1)\) with index arithmetic. Hence the running time obeys
\(T(n) = 2\,T(n/2) + \Theta(n),\qquad T(1)=\Theta(1).\)
Apply the Master Theorem with \(a=2,\,b=2\): the watershed is \(n^{\log_2 2}=n^1\), and \(f(n)=\Theta(n)=\Theta(n^{1})\), so we are in Case 2. Therefore \(T(n)=\Theta(n^{1}\log n)=\Theta(n\log n)\). Because every input is split identically regardless of its values, this bound is the same in the best, average, and worst cases. ∎
Quicksort — fast in practice, fragile in the worst case
Pick a pivot, partition the array so smaller elements go left and larger go right, then recurse on each side. No merge needed — the work is all in partitioning.
function A = quick_sort(A, lo, hi)
if nargin < 2, lo = 1; hi = numel(A); endif % allow quick_sort(A)
if lo < hi
[A, p] = partition(A, lo, hi); % p is the pivot's final resting index
A = quick_sort(A, lo, p - 1);
A = quick_sort(A, p + 1, hi);
endif
endfunction
function [A, i] = partition(A, lo, hi)
pivot = A(hi); % Lomuto scheme: last element is the pivot
i = lo - 1;
for j = lo:hi-1
if A(j) <= pivot
i = i + 1;
tmp = A(i); A(i) = A(j); A(j) = tmp;
endif
endfor
i = i + 1;
tmp = A(i); A(i) = A(hi); A(hi) = tmp; % put pivot between the two sides
endfunction
If each partition splits the array into reasonably balanced pieces, the recurrence is \(T(n)=2T(n/2)+\Theta(n)=\Theta(n\log n)\) — and this is the expected behavior over random pivots. But if the pivot is always the smallest or largest element (e.g. an already-sorted array with last-element pivot), partitions are maximally unbalanced: \(T(n)=T(n-1)+\Theta(n)=\Theta(n^2)\). The standard defenses are randomized pivots or median-of-three selection, which make the bad case astronomically unlikely. Quicksort is usually the fastest comparison sort in practice because its inner loop is tight and cache-friendly, despite the worse worst case.
| Algorithm | Best | Average | Worst | Space | Stable? |
|---|---|---|---|---|---|
| Bubble sort | Θ(n) | Θ(n²) | Θ(n²) | Θ(1) | yes |
| Selection sort | Θ(n²) | Θ(n²) | Θ(n²) | Θ(1) | no |
| Insertion sort | Θ(n) | Θ(n²) | Θ(n²) | Θ(1) | yes |
| Merge sort | Θ(n log n) | Θ(n log n) | Θ(n log n) | Θ(n) | yes |
| Quicksort | Θ(n log n) | Θ(n log n) | Θ(n²) | Θ(log n) | no |
| Heap sort | Θ(n log n) | Θ(n log n) | Θ(n log n) | Θ(1) | no |
The fundamental limit: why \(n\log n\) is a wall
Every sort above that works only by comparing elements is a comparison sort. Remarkably, we can prove that no comparison sort — present or future, however clever — can beat \(n\log n\) in the worst case.
Any comparison sort makes \(\Omega(n\log n)\) comparisons in the worst case.
Model any comparison sort as a binary decision tree: each internal node is a comparison "is \(a_i \le a_j\)?", with the two answers as the two children; each leaf is a final permutation the algorithm outputs. To sort correctly, the tree must be able to produce every one of the \(n!\) possible orderings of the input — so it has at least \(n!\) reachable leaves.
A binary tree of height \(h\) has at most \(2^h\) leaves. Therefore
\(2^h \ge n! \;\Longrightarrow\; h \ge \log_2(n!).\)
By Stirling's approximation, \(\log_2(n!) = n\log_2 n - n\log_2 e + O(\log n) = \Theta(n\log n)\). The worst-case number of comparisons equals the longest root-to-leaf path — the height \(h\). Hence \(h = \Omega(n\log n)\). ∎
Merge sort and heap sort match this bound, so they are asymptotically optimal among comparison sorts. (Counting sort and radix sort can beat it — but only by abandoning comparisons and exploiting structure in the keys, so the theorem does not apply to them.)
SECTION 5Searching
Finding an element in a collection. The lesson here is sharp: a tiny structural assumption — sortedness — collapses a linear scan into a logarithmic leap.
Linear search — \(O(n)\), no assumptions
function idx = linear_search(A, target)
idx = -1; % sentinel: -1 means "not found"
for i = 1:numel(A)
if A(i) == target
idx = i; return; % first match wins
endif
endfor
endfunction
Binary search — \(O(\log n)\) on sorted data
Look at the middle. If it's the target, done. If the target is smaller, the answer can only be in the left half; if larger, the right half. Each step throws away half of what remains.
function idx = binary_search(A, target)
% PRECONDITION: A is sorted ascending. Returns an index of target, or -1.
lo = 1; hi = numel(A); idx = -1;
while lo <= hi
mid = floor((lo + hi) / 2); % integer midpoint, no overflow in Octave
if A(mid) == target
idx = mid; return;
elseif A(mid) < target
lo = mid + 1; % target must be to the right
else
hi = mid - 1; % target must be to the left
endif
endwhile
endfunction
Binary search is correct and runs in \(O(\log n)\) time.
Correctness (invariant). The invariant is: if target is in A, then it lies within the window A[lo..hi]. Initially the window is the whole array, so it holds. Each iteration inspects A[mid]: if A[mid] < target, then by sortedness every index \(\le\) mid holds a value \(<\) target, so target (if present) is in A[mid+1..hi] — exactly the new window. The symmetric argument handles A[mid] > target. The invariant is thus maintained. The loop exits either by returning a found index, or when lo > hi, i.e. the window is empty — and the invariant then says target is not present, so returning \(-1\) is correct.
Running time. Let the window size be \(s = hi-lo+1\). Each iteration replaces the window with one of size at most \(\lfloor s/2\rfloor\). Starting from \(s=n\), after \(k\) iterations the size is at most \(n/2^k\). The loop stops once the size drops below \(1\), which happens when \(2^k > n\), i.e. \(k > \log_2 n\). Hence at most \(\lfloor\log_2 n\rfloor + 1\) iterations, each \(O(1)\) work: \(O(\log n)\) total. ∎
SECTION 6Graph Algorithms
A graph \(G=(V,E)\) is a set of vertices joined by edges — the right model for maps, networks, dependencies, and relationships of every kind. We store one as an adjacency matrix adj(u,v)=1 when an edge \(u\to v\) exists, which is wonderfully natural in Octave's matrix world.
Breadth-First Search — shortest paths in unweighted graphs
BFS explores in concentric rings: all vertices at distance 1, then distance 2, and so on, using a FIFO queue. It visits every vertex and edge once, so it runs in \(O(V+E)\).
function order = bfs(adj, start)
% Returns vertices in the order BFS first reaches them from `start`.
n = rows(adj);
visited = false(1, n);
order = [];
queue = [start];
visited(start) = true;
while !isempty(queue)
u = queue(1); queue(1) = []; % dequeue front
order(end+1) = u;
for v = 1:n % enqueue unvisited neighbours
if adj(u, v) && !visited(v)
visited(v) = true;
queue(end+1) = v;
endif
endfor
endwhile
endfunction
Depth-First Search — go deep, then backtrack
DFS plunges as far as possible along each branch before backing up. It is the backbone of cycle detection, topological sorting, and connectivity. Also \(O(V+E)\).
function order = dfs(adj, start)
n = rows(adj);
visited = false(1, n);
order = [];
[order, ~] = dfs_visit(adj, start, visited, order);
endfunction
function [order, visited] = dfs_visit(adj, u, visited, order)
visited(u) = true;
order(end+1) = u;
for v = 1:rows(adj)
if adj(u, v) && !visited(v)
[order, visited] = dfs_visit(adj, v, visited, order); % recurse
endif
endfor
endfunction
Dijkstra's algorithm — shortest paths with weights
When edges carry non-negative weights, Dijkstra greedily finalizes the closest unfinished vertex, relaxing its outgoing edges. Here W(u,v) is the positive weight of edge \(u\to v\), and 0 means "no edge".
function dist = dijkstra(W, src)
% W: n-by-n weight matrix, W(u,v) > 0 is an edge, 0 = no edge. Weights >= 0.
n = rows(W);
dist = inf(1, n);
dist(src) = 0;
visited = false(1, n);
for iter = 1:n
% pick the unvisited vertex with the smallest tentative distance
u = -1; best = inf;
for v = 1:n
if !visited(v) && dist(v) < best
best = dist(v); u = v;
endif
endfor
if u == -1, break; endif % remaining vertices unreachable
visited(u) = true;
for v = 1:n % relax edges out of u
if W(u, v) > 0 && !visited(v) && dist(u) + W(u, v) < dist(v)
dist(v) = dist(u) + W(u, v);
endif
endfor
endfor
endfunction
With non-negative edge weights, when Dijkstra marks a vertex \(u\) visited, dist(u) equals the true shortest-path distance from the source to \(u\).
Suppose not, and let \(u\) be the first vertex finalized with an incorrect (too large) dist(u). Let \(P\) be a true shortest path from the source to \(u\). Walking \(P\) from the source, it begins among visited vertices and ends at the unvisited \(u\); let \((x,y)\) be the first edge of \(P\) crossing from a visited vertex \(x\) to an unvisited vertex \(y\). Because \(x\) was finalized correctly (it precedes \(u\)) and its edge was relaxed, \(\text{dist}(y) \le \text{dist}(x)+w(x,y)\), which equals the length of \(P\) up to \(y\). Since edge weights are non-negative, that prefix length is \(\le\) the full length of \(P\), i.e. \(\text{dist}(y) \le \text{dist}(u)_{\text{true}} \le \text{dist}(u)\). But the algorithm chose \(u\) as the unvisited vertex of minimum tentative distance, so \(\text{dist}(u) \le \text{dist}(y)\). The two inequalities force \(\text{dist}(u)=\text{dist}(u)_{\text{true}}\), contradicting the assumption. ∎
The proof uses non-negativity crucially (the prefix of \(P\) is no longer than all of \(P\)). With negative weights a later cheap edge could undercut an already-finalized vertex, and Dijkstra fails. Use the Bellman–Ford algorithm there: it relaxes all edges \(|V|-1\) times for \(O(VE)\), and also detects negative cycles. The simple \(O(V^2)\) selection loop above becomes \(O((V+E)\log V)\) with a binary-heap priority queue.
SECTION 7Dynamic Programming
Dynamic programming (DP) solves a problem by combining solutions to overlapping subproblems, each solved once and cached. It applies precisely when a problem has two features.
Optimal substructure — an optimal solution is built from optimal solutions to subproblems.
Overlapping subproblems — a naive recursion would solve the same subproblems again and again. DP stores each answer so it is computed only once.
The motivating example: Fibonacci
The naive recursion recomputes the same values exponentially many times — \(T(n)=T(n-1)+T(n-2)+\Theta(1)\), which is \(\Theta(\varphi^{\,n})\). Caching collapses it to \(\Theta(n)\).
function f = fib_naive(n) % EXPONENTIAL: O(phi^n) -- do not use for big n
if n <= 1
f = n;
else
f = fib_naive(n-1) + fib_naive(n-2);
endif
endfunction
function f = fib_dp(n) % BOTTOM-UP: O(n) time, O(n) space
memo = zeros(1, n+1);
memo(1) = 0; % fib(0); Octave is 1-indexed so memo(k+1)=fib(k)
if n >= 1, memo(2) = 1; endif % fib(1)
for i = 3:n+1
memo(i) = memo(i-1) + memo(i-2);
endfor
f = memo(n+1);
endfunction
0/1 Knapsack — the canonical DP table
Given items with integer weights and values and a capacity \(W\), choose a subset of maximum value that fits. The recurrence considers each item either left out or taken:
\(\;dp[i][w] = \max\big(\,dp[i\!-\!1][w],\; v_i + dp[i\!-\!1][w-w_i]\,\big)\;\) (the second term only if \(w_i \le w\)).
function best = knapsack(weights, values, W)
% 0/1 knapsack. Time O(n*W), space O(n*W). Column j stores capacity j-1.
n = numel(weights);
dp = zeros(n+1, W+1); % dp(i+1, w+1) = best value, first i items, cap w
for i = 1:n
for w = 0:W
if weights(i) <= w
take = dp(i, w - weights(i) + 1) + values(i);
leave = dp(i, w + 1);
dp(i+1, w+1) = max(leave, take);
else
dp(i+1, w+1) = dp(i, w+1); % item i too heavy; cannot take it
endif
endfor
endfor
best = dp(n+1, W+1);
endfunction
% Example: knapsack([1 3 4 5], [1 4 5 7], 7) -> 9
Longest Common Subsequence
The edit-distance cousin behind diff and DNA alignment. Match extends the diagonal; a mismatch takes the better of dropping one character from either string.
function len = lcs(X, Y)
% Length of the longest common subsequence of vectors/strings X and Y. O(mn).
m = numel(X); n = numel(Y);
dp = zeros(m+1, n+1);
for i = 1:m
for j = 1:n
if X(i) == Y(j)
dp(i+1, j+1) = dp(i, j) + 1; % extend the match
else
dp(i+1, j+1) = max(dp(i, j+1), dp(i+1, j)); % drop from X or from Y
endif
endfor
endfor
len = dp(m+1, n+1);
endfunction
% Example: lcs('ABCBDAB', 'BDCAB') -> 4 ("BCAB")
Both are DP. Top-down memoization keeps the natural recursion but caches results in a table, computing only the subproblems actually needed. Bottom-up tabulation (as above) fills the table in dependency order with simple loops, avoiding recursion overhead and making the \(\Theta(\text{number of subproblems})\) cost obvious. Choose top-down when the reachable subproblem set is sparse; bottom-up when you'll need them all.
SECTION 8Greedy Algorithms
A greedy algorithm makes the locally optimal choice at each step and never reconsiders. When it works, it is simpler and faster than DP — but it works only when the problem has a special structure, and proving it requires care.
A problem admits a greedy solution when a globally optimal solution can always be reached by a sequence of locally optimal (greedy) choices — i.e. there is always an optimal solution that agrees with the first greedy choice. Combined with optimal substructure, this licenses the greedy strategy.
Activity selection
Given activities with start and finish times, schedule the most that don't overlap. The greedy rule — always take the activity that finishes earliest among those still compatible — is optimal.
function chosen = activity_select(starts, finishes)
% Returns indices of a maximum set of mutually compatible activities.
[finishes, idx] = sort(finishes); % sort by finish time, keep original idx
starts = starts(idx);
chosen = [idx(1)]; % always take the earliest-finishing one
last_finish = finishes(1);
for i = 2:numel(starts)
if starts(i) >= last_finish % compatible: starts after last finishes
chosen(end+1) = idx(i);
last_finish = finishes(i);
endif
endfor
endfunction
Choosing the compatible activity with the earliest finish time yields a maximum-size set of non-overlapping activities.
Let \(g\) be the activity that finishes earliest overall, and let \(O\) be any optimal solution. If \(g\in O\) we are done for this step. Otherwise let \(o\) be the earliest-finishing activity in \(O\). Since \(g\) finishes no later than \(o\) and the activities of \(O\) are pairwise compatible, replacing \(o\) by \(g\) in \(O\) keeps every activity compatible (nothing started before \(o\) finished, hence before \(g\) finished either) and does not change the count. So \(O' = (O\setminus\{o\})\cup\{g\}\) is also optimal and contains the greedy choice. The remaining problem — activities compatible with \(g\) — is a smaller instance of the same form; by induction the greedy algorithm solves it optimally. Hence the greedy solution is optimal. ∎
Greedy is seductive but often wrong. For 0/1 knapsack, taking the highest value-per-weight item first can be strictly suboptimal — that problem needs the DP of Section 7. For coin change with arbitrary denominations (say coins {1, 3, 4} making 6), greedy takes 4+1+1 = three coins while the optimum is 3+3 = two. The discipline: never trust a greedy algorithm without an exchange-argument proof — or a DP fallback.
SECTION 9Hashing & Hash Tables
Binary search bought us \(O(\log n)\) by demanding sorted data. Hashing strikes a bolder bargain: give up order entirely, and lookups, inserts, and deletes all become \(O(1)\) — on average. It is the structure quietly powering nearly every dictionary, set, and database index you have ever touched.
A hash table stores key–value pairs in an array of \(m\) buckets. A hash function \(h:\text{keys}\to\{0,1,\dots,m-1\}\) maps each key to a bucket index. To store a key we compute \(h(k)\) and place it there; to look it up we recompute \(h(k)\) and inspect only that one bucket — never scanning the whole table.
The load factor \(\alpha = n/m\) is the average number of keys per bucket, where \(n\) is the number of stored keys. It is the single dial that governs performance.
Because we are squeezing a huge key space into \(m\) slots, two keys can map to the same bucket — a collision. The pigeonhole principle guarantees this once \(n > m\), so every real implementation needs a resolution strategy.
Separate chaining. Each bucket holds a list of every key that hashed to it. Insert appends to the list; search walks it. Simple, and it degrades gracefully as \(\alpha\) grows past 1.
Open addressing. Keep everything inside the array; on collision, probe a deterministic sequence of other slots (linear probing tries \(h(k)+1, h(k)+2,\dots\)) until an empty one appears. No side lists and excellent cache behavior, but it requires \(\alpha < 1\) and careful deletion.
A chaining hash table in Octave
Octave ships a real map as containers.Map, but building one by hand makes the mechanics concrete. We use a cell array of buckets, each bucket a matrix of [key, value] rows.
function T = ht_new(m)
% create a hash table with m empty buckets
T.m = m;
T.buckets = cell(1, m); % each cell holds a k-by-2 matrix of [key value] rows
endfunction
function h = ht_hash(T, key)
% division method, shifted into Octave's 1-based index range
h = mod(key, T.m) + 1;
endfunction
function T = ht_put(T, key, value)
h = ht_hash(T, key);
B = T.buckets{h};
if ~isempty(B)
row = find(B(:,1) == key, 1); % key already stored in this bucket?
if ~isempty(row)
B(row, 2) = value; % update in place
T.buckets{h} = B;
return;
endif
endif
T.buckets{h} = [B; key, value]; % otherwise chain the new pair on
endfunction
function [found, value] = ht_get(T, key)
h = ht_hash(T, key);
B = T.buckets{h};
found = false; value = nan;
if ~isempty(B)
row = find(B(:,1) == key, 1); % scan only this one chain
if ~isempty(row)
found = true; value = B(row, 2);
endif
endif
endfunction
Under the simple uniform hashing assumption, a search in a chaining hash table takes expected time \(\Theta(1+\alpha)\). With \(m=\Theta(n)\) the expected cost of every operation is therefore \(\Theta(1)\).
Simple uniform hashing assumes each key is equally likely to land in any of the \(m\) buckets, independently of the others. Consider an unsuccessful search for a key \(k\): it must scan every element of bucket \(h(k)\). For each of the \(n\) stored keys define an indicator \(X_i=1\) when key \(i\) hashes to \(h(k)\). Then \(\mathbb{E}[X_i]=1/m\), and by linearity of expectation the expected length of the scanned chain is \[\mathbb{E}\!\left[\sum_{i=1}^{n}X_i\right]=\sum_{i=1}^{n}\frac1m=\frac nm=\alpha.\] Computing \(h(k)\) costs \(O(1)\); scanning the chain costs \(\Theta(\alpha)\) in expectation; the total is \(\Theta(1+\alpha)\). The successful-search bound is identical up to constants. Choosing \(m=\Theta(n)\) makes \(\alpha=O(1)\), giving \(\Theta(1)\) expected per operation. ∎
That \(O(1)\) is an expectation, not a guarantee. The worst case is \(O(n)\): an adversary who knows \(h\) can choose keys that all collide into one bucket, collapsing the table into a single linked list. Production systems defend with universal hashing — drawing \(h\) at random from a carefully built family — or with randomized seeds, so that no fixed input is reliably catastrophic.
SECTION 10Amortized Analysis
Some operations are usually cheap but occasionally expensive. Amortized analysis asks the honest question: averaged over a worst-case sequence of operations, what does each one cost? No probability is involved — the averaging is over time, not over random inputs.
The amortized cost of an operation is the total cost of a sequence of \(n\) operations divided by \(n\). If any such sequence costs \(O(T)\) in total, each operation has amortized cost \(O(T/n)\) — even when individual operations are far more expensive. Three classic techniques establish such bounds: the aggregate, accounting, and potential methods.
The motivating example: a growing array
Octave's convenient A(end+1) = x hides a trap. If the array is reallocated and fully copied on every append, \(n\) appends cost \(1+2+\dots+n=\Theta(n^2)\). The cure is to double the capacity whenever the array fills, so copying happens only rarely.
function S = darray_new()
S.data = zeros(1, 1); % backing store with spare capacity
S.cap = 1; % current capacity
S.n = 0; % number of elements actually used
endfunction
function S = darray_push(S, x)
if S.n == S.cap % full: grow by doubling
S.cap = 2 * S.cap;
bigger = zeros(1, S.cap);
bigger(1:S.n) = S.data(1:S.n); % copy old contents -- the costly step
S.data = bigger;
endif
S.n = S.n + 1;
S.data(S.n) = x; % cheap: write into existing capacity
endfunction
Starting from an empty array that doubles its capacity whenever full, any sequence of \(n\) push operations runs in \(O(n)\) total time — an amortized cost of \(O(1)\) per push.
Each push does \(1\) unit of work to write the new element, plus the cost of copying when a resize fires. A resize occurs exactly when the size hits a power of two, and the resize copies the elements already present. Across \(n\) pushes those copy counts are \(1,2,4,\dots,2^{t}\) with the largest satisfying \(2^{t} < n\), so the total copying work is \[\sum_{j=0}^{t} 2^{j} \;=\; 2^{t+1}-1 \;<\; 2n.\] Adding the \(n\) element-writes, the entire sequence costs less than \(3n=O(n)\). Dividing by the \(n\) operations gives amortized cost \(O(1)\) per push. ∎
Accounting method. Charge each push \(3\) credits: \(1\) pays to write the element now, and \(2\) are banked on it. When a resize copies a full array of \(c\) elements, each element in the freshly-filled upper half still carries its \(2\) saved credits — enough to pay \(1\) to copy itself and \(1\) to copy an older partner. The bank never goes negative, so the true cost never exceeds the \(3\) charged: amortized \(O(1)\).
Potential method. Define \(\Phi = 2(\text{size}) - (\text{capacity})\ge 0\). A push with no resize raises size by \(1\), so \(\Delta\Phi=2\) and the amortized cost is \(1+2=3\). A push that triggers a resize copies \(c=\text{size}\) elements (actual cost \(c+1\)) but the doubling drops \(\Phi\) from \(c\) to \(2\), so \(\Delta\Phi=2-c\) and the amortized cost is \((c+1)+(2-c)=3\). Either way, \(O(1)\).
This is exactly why the survival kit warns against growing a vector with A(end+1)=x inside a hot loop and recommends v = zeros(1,n) preallocation. An interpreter that copies on every append hands you \(\Theta(n^2)\) behavior; a doubling strategy keeps you at \(\Theta(n)\). The lab below lets you watch the two strategies diverge in real time.
SECTION 11P, NP & the Edge of the Map
Some problems we can solve quickly; some we can only check quickly; and for a vast, important class, nobody knows which it is. This is the deepest open question in computer science.
P — decision problems solvable in polynomial time, \(O(n^k)\) for some constant \(k\). These are the problems we consider "tractable." (Sorting, shortest paths, and everything coded above live here.)
NP — decision problems whose "yes" answers can be verified in polynomial time given a certificate. Example: given a proposed route, checking it visits every city under a budget is easy — even though finding such a route seems hard.
Clearly \(P \subseteq NP\) (if you can solve it fast, you can verify it fast). Whether \(P = NP\) — whether every quickly-checkable problem is also quickly-solvable — is unsolved, and carries a \$1,000,000 Clay Millennium Prize. The hardest problems in NP are called NP-complete: the traveling salesman, Boolean satisfiability, graph coloring, and thousands more. A polynomial algorithm for any one of them would solve them all.
When you face a problem that smells NP-hard, stop hunting for a fast exact algorithm. Instead reach for: approximation algorithms (provably near-optimal), heuristics (fast, usually good), parameterized algorithms (fast when some structural parameter is small), or accepting exponential cost on inputs you know are small. Knowing a problem is hard is itself valuable knowledge — it tells you where not to spend your effort.
SECTION 12Quick Reference
A pocket card for the road. Octave idioms on the left, complexity facts on the right.
Octave survival kit
| Task | Octave |
|---|---|
| Length of vector | numel(A) or length(A) |
| Rows / columns of matrix | rows(M), columns(M), size(M) |
| Slice (1-indexed, inclusive) | A(1:mid), A(mid+1:end) |
| Append to a vector | A(end+1) = x; |
| Delete an element | A(1) = []; |
| Built-in sort / search | sort(A), [tf,loc]=ismember(x,A) |
| Pre-allocate (avoid O(n²) growth) | v = zeros(1,n); |
| Boolean array of falses | false(1,n) |
| Infinity / not-a-number | inf, nan |
| Time a snippet | tic; f(x); toc |
The complexity cheat sheet
| Operation | Time |
|---|---|
| Array index / hash lookup | O(1) |
| Hash insert / search (expected) | O(1), worst O(n) |
| Dynamic-array push (amortized) | O(1) |
| Binary search (sorted) | O(log n) |
| Linear scan | O(n) |
| Optimal comparison sort | O(n log n) |
| BFS / DFS | O(V + E) |
| Dijkstra (heap) | O((V + E) log V) |
| 0/1 Knapsack / LCS (DP) | O(n·W) / O(m·n) |
| Bellman–Ford | O(V·E) |
| Naive matrix multiply | O(n³) |
1 · Always state the input size your bound is in terms of. 2 · Prove correctness with an invariant before you optimize. 3 · Write the recurrence, then reach for the Master Theorem. 4 · A loose upper bound is honest; a wrong tight bound is not. 5 · The best optimization is almost always a better asymptotic class, not a faster constant.
SECTION 13Practice Exercises
Theory sticks when you wrestle with it. Try each problem with pencil first; the worked solution is one click away. They span the whole guide — asymptotics, recurrences, invariants, hashing, amortization, and graphs.
Using only the definition of \(O\), prove that \(2^{n+1}=O(2^n)\), and prove that \(2^{2n}\neq O(2^n)\).
Upper bound. Since \(2^{n+1}=2\cdot 2^n\), take \(c=2,\ n_0=1\): for all \(n\ge 1\), \(2^{n+1}=2\cdot 2^n\le c\cdot 2^n\). Hence \(2^{n+1}=O(2^n)\). An additive shift in the exponent is just a constant multiplier.
Separation. Suppose \(2^{2n}=O(2^n)\). Then there exist \(c,n_0\) with \(2^{2n}\le c\,2^n\) for all \(n\ge n_0\). Dividing by \(2^n\) gives \(2^{n}\le c\). But \(2^n\to\infty\), so any \(n>\log_2 c\) breaks the inequality — a contradiction. So \(2^{2n}=(2^n)^2\neq O(2^n)\): a multiplicative factor in the exponent is not a constant.
Order these functions by asymptotic growth, slowest first: \(\;n!,\quad n\log n,\quad \sqrt n,\quad 2^{\log_2 n},\quad \log^2 n,\quad n^{1.5}.\)
First simplify: \(2^{\log_2 n}=n\). The ordering is \[\log^2 n \;\prec\; \sqrt n \;\prec\; n \;(=2^{\log_2 n}) \;\prec\; n\log n \;\prec\; n^{1.5} \;\prec\; n!.\]
Why. Any positive power of \(n\) dominates any power of \(\log n\), so \(\log^2 n\prec\sqrt n=n^{0.5}\prec n\). The term \(2^{\log_2 n}\) is exactly \(n\), so it ties with \(n\). Then \(n\prec n\log n\prec n^{1.5}\), because comparing \(n\log n\) with \(n^{1.5}=n\cdot n^{0.5}\) reduces to \(\log n\prec n^{0.5}\). Finally the factorial outgrows every polynomial, so \(n^{1.5}\prec n!\).
Solve \(T(n)=3T(n/2)+n\) with the Master Theorem, and say in one line what kind of algorithm produces it.
Here \(a=3,\ b=2,\ f(n)=n=n^1\). The critical exponent is \(\log_b a=\log_2 3\approx 1.585\). Since \(f(n)=n^{1}=O\!\left(n^{\log_2 3-\varepsilon}\right)\) for \(\varepsilon\approx 0.585>0\), this is Case 1: \[T(n)=\Theta\!\left(n^{\log_2 3}\right)\approx\Theta\!\left(n^{1.585}\right).\] It arises when an algorithm splits into three half-size subproblems with only linear combine work — the leaves of the recursion tree dominate (the same flavor as Karatsuba multiplication).
Solve \(T(n)=T(n-1)+n\) with \(T(1)=1\). Why can't the Master Theorem be used here?
The Master Theorem needs the form \(aT(n/b)+f(n)\), where the subproblem shrinks by a factor \(b>1\). Here the subproblem shrinks only by a constant amount \((n\to n-1)\), so the theorem does not apply. Unroll instead: \[T(n)=n+(n-1)+\dots+2+1=\sum_{k=1}^{n}k=\frac{n(n+1)}{2}=\Theta(n^2).\] This is precisely the cost of selection sort, and of the naive \(A(\text{end}+1)=x\) growth loop from Section 10.
The code below finds the maximum of a non-empty array. State a loop invariant and use it to prove correctness.
m = A(1);
for i = 2:numel(A)
if A(i) > m
m = A(i);
endif
endfor
Invariant. At the start of the iteration for index \(i\), \(m=\max\big(A[1..i-1]\big)\).
Initialization. Before the loop runs (\(i=2\)), \(m=A(1)=\max(A[1..1])\). ✓
Maintenance. If \(m=\max(A[1..i-1])\), the body sets \(m\leftarrow\max(m,A[i])=\max(A[1..i])\), which is the invariant for the next index \(i+1\). ✓
Termination. The loop ends after processing \(i=\text{numel}(A)\), so \(m=\max(A[1..\text{numel}(A)])\) — the maximum of the whole array. ✓ The work is one comparison per element, \(\Theta(n)\).
Insert the keys \(22,\,1,\,13,\,11,\,24,\,33\) into an 11-bucket chaining table using \(h(k)=k\bmod 11\). Show the chains and give the load factor.
Compute each hash: \(22\bmod 11=0\), \(1\bmod 11=1\), \(13\bmod 11=2\), \(11\bmod 11=0\), \(24\bmod 11=2\), \(33\bmod 11=0\). The resulting chains:
bucket 0: \(22\to 11\to 33\) · bucket 1: \(1\) · bucket 2: \(13\to 24\) · all other buckets empty.
Load factor \(\alpha=n/m=6/11\approx 0.55\); the longest chain has length \(3\). Reproduce it in Lab 5 with \(m=11\).
A stack supports push, pop, and multipop(k) (pop the top \(\min(k,\text{size})\) items). A single multipop can cost \(\Theta(k)\). Prove that any sequence of \(n\) operations on an initially empty stack costs \(O(n)\) total.
Accounting method. Charge \(2\) credits per push: \(1\) pays for the push itself, and \(1\) is banked on the pushed element. Charge \(0\) for pop and multipop. Every element that is ever removed — whether by pop or inside a multipop — was pushed exactly once and carries exactly \(1\) banked credit, which pays for its own removal.
The bank therefore never goes negative, so the true total cost is at most the total charged: \(2\cdot(\#\text{pushes})\le 2n=O(n)\). Amortized costs: push \(=2\), pop and multipop \(=0\) — all \(O(1)\). The intuition in one line: you can never pop more than you pushed.
Give a three-vertex graph with a single negative edge on which Dijkstra returns a wrong shortest distance, and explain the failure.
Take vertices \(s,u,v\) with edges \(s\to u=2\), \(s\to v=1\), and \(u\to v=-3\).
True answer. The path \(s\to u\to v\) costs \(2+(-3)=-1\), beating the direct edge \(s\to v=1\). So the real \(\operatorname{dist}(v)=-1\).
What Dijkstra does. After relaxing \(s\) it holds tentative \(v=1,\ u=2\). It extracts the minimum, \(v=1\), and finalizes it. Only later does it extract \(u\) and relax \(u\to v\) to \(2+(-3)=-1\) — but \(v\) is already finalized and never lowered. Dijkstra reports \(\operatorname{dist}(v)=1\), which is wrong.
Why. Dijkstra's greedy proof (Section 6) assumes that once a vertex is extracted at minimum tentative distance, no cheaper path can exist — valid only when all edges are non-negative. A negative edge can make a later path cheaper, so reach for Bellman–Ford when negative weights are possible.