Computer Science · Numerical Computing

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.

★ Formal proofs ⌨ Runnable Octave ▶ 4 interactive labs ∑ Sorting · Graphs · DP · Greedy

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.

Definition · Knuth's Five Properties

A procedure is an algorithm if it has:

  1. Finiteness — it terminates after finitely many steps.
  2. Definiteness — each step is precisely, unambiguously specified.
  3. Input — zero or more quantities supplied before it begins.
  4. Output — one or more quantities with a specified relation to the input.
  5. 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.

Why Octave?

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.

Octave
% 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.

Definition · Big-O (asymptotic upper bound)

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.

Definition · Big-Ω and Big-Θ

\(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.

Proposition 1.1

\(3n^2 + 5n + 2 = O(n^2).\)

Proof

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

Proposition 1.2

\(n^2 \ne O(n).\)

Proof (by contradiction)

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)\).

The limit shortcut

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

ClassNamen = 1,000,000 means…Typical source
O(1)constant1 steparray index, hash lookup
O(log n)logarithmic~20 stepsbinary search, balanced trees
O(n)linear10⁶ stepsone scan of the input
O(n log n)linearithmic~2×10⁷ stepsmerge sort, heap sort, FFT
O(n²)quadratic10¹² stepsnested loops, naive sorts
O(n³)cubic10¹⁸ stepsnaive matrix multiply
O(2ⁿ)exponentialastronomically largebrute-force subsets
O(n!)factorialbeyond astronomicalbrute-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.

▶ Interactive Lab 1

Growth-Rate Explorer

Drag n and watch the curves diverge. The y-axis is logarithmic so every class stays visible — note how each "step up" in the hierarchy eventually buries everything below it, no matter the constant factors.

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)

Claim

The recurrence \(T(n) = 2T(n/2) + n\), with \(T(1)=1\), satisfies \(T(n) = O(n\log n)\).

Proof (induction)

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}\).

Theorem 2.1 · Master Theorem

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.

Why \(n^{\log_b a}\)?

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)\).

▶ Interactive Lab 2

Master Theorem Calculator

Enter a recurrence \(T(n)=a\,T(n/b)+f(n)\). For \(f\), supply the polynomial degree \(d\) (i.e. \(f(n)=\Theta(n^d)\); use \(d=0\) for constant work, \(d=1\) for linear, etc.). The calculator computes the watershed \(c^\star=\log_b a\), picks the case, and reports the closed-form \(\Theta\).

Enter values and press solve.

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.

Definition · Loop-invariant proof

To prove correctness with a loop invariant, show three things:

  1. Initialization — the invariant holds before the first iteration.
  2. Maintenance — if it holds before an iteration, it holds before the next.
  3. 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.

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
Theorem 3.1

insertion_sort returns a permutation of its input arranged in non-decreasing order.

Proof

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.

Cost, both ends

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.

Octave
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)\).

Octave
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
Theorem 4.1

Merge sort runs in \(\Theta(n\log n)\) time in the worst case.

Proof

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.

Octave
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
Average vs. worst case

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.

AlgorithmBestAverageWorstSpaceStable?
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
▶ Interactive Lab 3

Sorting Visualizer

Watch each algorithm move data in real time. Gold = a comparison, red = a swap/write, teal = at rest, green = finalized. The comparison and swap counters make the complexity classes visible: try the same array with bubble vs. merge.

resting comparing swap / write sorted
comparisons: 0swaps/writes: 0

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.

Theorem 4.2 · Comparison-sort lower bound

Any comparison sort makes \(\Omega(n\log n)\) comparisons in the worst case.

Proof (decision tree)

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

Octave
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.

Octave
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
Theorem 5.1

Binary search is correct and runs in \(O(\log n)\) time.

Proof

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. ∎

▶ Interactive Lab 4

Binary Search Stepper

Step through the algorithm on a sorted array. The shaded band is the live window [lo..hi]; gold is the current mid under examination. Notice that even a 32-element array is decided in 5–6 probes.

Press “step” to begin. lo and hi bracket the search window.

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)\).

Octave
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)\).

Octave
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".

Octave
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
Theorem 6.1

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\).

Proof (greedy exchange)

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. ∎

Caveat — negative edges break it

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.

Definition · When DP applies

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)\).

Octave
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\)).

Octave
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.

Octave
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")
Memoization vs. tabulation

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.

Definition · The greedy-choice property

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.

Octave
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
Theorem 8.1

Choosing the compatible activity with the earliest finish time yields a maximum-size set of non-overlapping activities.

Proof (exchange argument)

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. ∎

When greedy fails

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.

Definition · Hash table

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.

Two ways to resolve collisions

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.

Octave
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
Theorem 9.1

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)\).

Proof

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. ∎

The fine print

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.

▶ Interactive Lab 5

Hash Table Explorer

Insert integer keys and watch them scatter across \(m\) buckets via \(h(k)=k \bmod m\). Collisions stack into chains (the most recent collision flashes red). Watch the load factor \(\alpha\) and the longest chain grow as the table fills — then slide \(m\) to rehash the same keys and see more buckets flatten the chains.

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.

Definition · Amortized cost

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.

Octave
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
Theorem 10.1

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.

Proof · aggregate method

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. ∎

Two more lenses on the same fact

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)\).

Why this matters in Octave

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.

▶ Interactive Lab 6

Amortized Cost Visualizer

Each bar is the actual cost of one push (1 to write, plus any copies a resize forces). The gold line is the running average — the amortized cost. With doubling it settles near a small constant; switch to "grow by one" and watch the same average climb without bound, the signature of \(\Theta(n)\) per push.

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.

Definition · P and NP

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.

The practical takeaway

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

TaskOctave
Length of vectornumel(A) or length(A)
Rows / columns of matrixrows(M), columns(M), size(M)
Slice (1-indexed, inclusive)A(1:mid), A(mid+1:end)
Append to a vectorA(end+1) = x;
Delete an elementA(1) = [];
Built-in sort / searchsort(A), [tf,loc]=ismember(x,A)
Pre-allocate (avoid O(n²) growth)v = zeros(1,n);
Boolean array of falsesfalse(1,n)
Infinity / not-a-numberinf, nan
Time a snippettic; f(x); toc

The complexity cheat sheet

OperationTime
Array index / hash lookupO(1)
Hash insert / search (expected)O(1), worst O(n)
Dynamic-array push (amortized)O(1)
Binary search (sorted)O(log n)
Linear scanO(n)
Optimal comparison sortO(n log n)
BFS / DFSO(V + E)
Dijkstra (heap)O((V + E) log V)
0/1 Knapsack / LCS (DP)O(n·W) / O(m·n)
Bellman–FordO(V·E)
Naive matrix multiplyO(n³)
Five habits of an algorithmist

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.

Exercise 1Asymptotics

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.

Exercise 2Asymptotics

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!\).

Exercise 3Recurrences

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).

Exercise 4Recurrences

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.

Exercise 5Correctness

The code below finds the maximum of a non-empty array. State a loop invariant and use it to prove correctness.

Octave
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)\).

Exercise 6Hashing

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\).

Exercise 7Amortized

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.

Exercise 8Graphs

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.