An algorithm is a finite, unambiguous sequence of instructions that solves a problem. Given some input, it follows a fixed procedure and eventually produces an output.
The definition has two parts worth paying attention to. First: finite. An algorithm must always terminate. A procedure that runs forever is not an algorithm. Second: unambiguous. Every step must be completely clear — there is no room for interpretation, no "use your judgment here." The instructions are mechanical. This is what makes them executable, even by a machine with no understanding whatsoever of the problem being solved.
Three things:
The problem statement defines the relationship between input and output. It specifies what the input space looks like (what kinds of values are valid) and what the output should be for each possible input. There are infinitely many sequences of instructions that could satisfy the same problem statement — that is, infinitely many algorithms that compute the same function. They may differ in speed, in memory usage, in elegance, or in nothing at all. Part of what we study is how to compare them and choose the best one.
The greatest common divisor (GCD) of two integers and is the largest integer that divides both without a remainder. The question is: given and , how do you compute it?
Euclid figured this out around 300 BC. His insight: if you divide by and get remainder , then . The GCD of the original pair equals the GCD of the divisor and the remainder. Keep applying this until the remainder is zero — at that point, the last non-zero divisor is the answer.
In pseudocode:
function GCD(x, y):
if y == 0:
return x
else:
r = x mod y
return GCD(y, r)
This is a recursive algorithm — it calls itself with smaller inputs. Each call reduces the problem: gets smaller, and eventually it reaches zero. At that point the recursion bottoms out and the answer is returned.
Notice that this is also the abstract nature of a good algorithm: the idea is simple enough to state in a few lines, it terminates provably, and it gives the correct answer. You do not need to understand number theory to implement it — you just follow the steps.
Before we dive into specific algorithms, it is worth knowing what questions we are always trying to answer. They fall into three categories.
Understanding the problem. What are the valid inputs? What must the output look like? Are there mathematical properties of the problem we can exploit? Most algorithmic breakthroughs come not from clever code but from a deeper insight about the mathematical structure of the problem. Euclid's algorithm works because of a number-theoretic property of GCD, not because of a programming trick.
Measuring efficiency. How fast does the algorithm run? How much memory does it need? These are the two resources we care about. We want algorithms that are both fast and frugal with memory — and when there is a tension between the two, we want to understand the tradeoff explicitly.
Learning patterns. Most new problems are not entirely new. They share structure with problems that have already been solved. If you know your classical algorithms and data structures, you can often solve a new problem by recognising which pattern it belongs to and adapting a known solution. This is why we study a catalogue of techniques — not to memorise them, but to have them available as building blocks.
Now we get to the real work. The lecture uses Fibonacci numbers as a running example, and it is the perfect choice. The problem is simple enough that you can explain it in one sentence, but the journey from the worst solution to the best solution involves every major concept in algorithmic analysis. Pay attention.
The Fibonacci sequence is:
Each number is the sum of the two that precede it. Formally, the sequence is defined by the recurrence relation:
(By convention, is sometimes used, but the course uses .)
The problem: given , compute .
This sounds trivial. It is not. There are at least six meaningfully different algorithms for computing , ranging from a closed formula to a logarithmic-time matrix method — and understanding why each one is better or worse than the previous one is the whole point.
There is an exact formula for , derived from the theory of linear recurrences:
where (the golden ratio) and .
In pseudocode:
function Fib1(n):
return round( (1/√5) * (φⁿ - φ̂ⁿ) )
Time: — constant. The computation does not depend on in terms of number of steps.
Space: — constant. Just a few variables.
Sounds perfect. Sadly, it does not work.
The problem is numerical precision. Both and are irrational numbers. They cannot be represented exactly in any floating-point format. Every computation with them introduces a tiny rounding error. For small this error rounds correctly — gives , which rounds to the right answer. But for , gives , which rounds to — but the correct answer is . Off by one.
You might think: use higher precision arithmetic. But the errors grow with , and no fixed-precision representation can keep up indefinitely. The formula is mathematically exact but computationally useless for large . We need a different approach.
The recurrence definition of Fibonacci translates directly into a recursive algorithm:
function Fib2(n):
if n ≤ 2:
return 1
else:
return Fib2(n-1) + Fib2(n-2)
This is the most natural algorithm anyone would write. Follow the definition. If the base case, return 1. Otherwise, recurse on both predecessors and add.
Does it work? Yes, it produces the correct answer. Is it any good? No. It is catastrophically slow.
To understand why, draw out what happens when you call :
Notice that is computed twice — once as a child of , once as a child of . And is computed three times. The recursion tree grows explosively because sub-problems are solved repeatedly without any memory of previous results.
This is the fundamental flaw. The algorithm forgets everything it has computed the moment a call returns.
Each recursive call occupies memory. When a function calls itself, the current call is not finished — it is suspended, waiting for the recursive call to return. The suspended call must be stored somewhere. That storage is the call stack, and each suspended call occupies an activation record containing the local variables and the return address.
The memory usage of Fib2 at any moment equals the depth of the deepest active chain of calls — the longest root-to-leaf path in the recursion tree. For , this path has length (one call per level, decreasing by 1 each time down the leftmost branch). So memory usage is .
Here is an important methodological point. We do not measure running time in seconds. A second is machine-dependent, compiler-dependent, operating-system-dependent, and even mood-of-the-hardware-dependent. Instead, we count elementary operations — basic computational steps that each take a fixed, constant amount of time regardless of input. Things like: one addition, one comparison, one assignment, one function return.
The total running time is then proportional to the total number of elementary operations. This count is machine-independent.
For Fib2, define as the number of nodes in the recursion tree — which is proportional to the total number of elementary operations, since each call does at most a constant amount of work (one comparison, possibly one addition, one return).
satisfies the recurrence:
The extra counts the root node itself. Now we want to bound this.
We want to show grows at least exponentially. The key step: since the recursion tree for is at least as large as for (larger input = more nodes), we have . So:
Now unroll this inequality repeatedly:
At the -th step:
We stop when , which means . At that point , and the geometric sum . So:
Conclusion: . The running time grows at least as fast as .
For the upper bound, use the reverse inequality: , so:
Unrolling similarly:
At the -th step:
Stop when , i.e. . Then:
Conclusion: . Together with the lower bound: the running time of Fib2 is exponential in .
What does exponential mean in practice? For , you are computing roughly million operations. For , it is around operations. On a modern CPU doing operations per second, would take over a million seconds — about two weeks. Fib2 is completely unusable for any meaningful .
[!WARNING] Exponential time algorithms are not just "slow" — they are practically useless. The difference between an and an algorithm is not a matter of degree; it is a categorical difference in what is computable in practice.
The root cause of Fib2's failure: repeated computation. Every sub-problem is solved from scratch, as if it had never been solved before. The fix is obvious in hindsight — remember what you have computed.
The insight: compute Fibonacci numbers bottom-up, starting from and , and store every intermediate result so you never compute anything twice.
function Fib3(n):
let F[1..n] be a new array
F[1] = 1
F[2] = 1
for i = 3 to n:
F[i] = F[i-1] + F[i-2]
return F[n]
Time analysis. Count the operations:
Running time is — linear. Dramatically better than exponential.
Space analysis. We store an array of integers, plus the loop variable and the input . Total space: .
This is correct, fast, and simple. But can we do better on memory?
Look at the loop in Fib3 again. To compute , you only need and . You never look at or anything earlier. So why are you storing all values?
You do not need to. You only need the two most recent values:
function Fib4(n):
a = 1
b = 1
for i = 3 to n:
c = a + b
a = b
b = c
return b
Here a and b always hold the two most recent Fibonacci numbers. Each iteration: compute their sum into c, then shift: a gets the old b, b gets the new c. After iterations, b holds .
Time analysis. Count operations:
Slightly more operations than Fib3 (the constant factor is larger: 5 vs 3). But the asymptotic class is the same: .
Space analysis. Five variables: , , , , . That is — constant space, regardless of . We eliminated the array entirely.
[!NOTE] This illustrates a crucial lesson: you can sometimes trade space for time and vice versa. Fib3 uses space to compute each result exactly once. Fib4 uses space by computing the same things in a different order, without storing intermediate results. The time complexity is the same; the space complexity is not.
Here is an elegant mathematical result that opens the door to a faster algorithm.
Theorem. Let . For every :
So appears in the top-left corner of . If you can compute matrix powers, you can compute Fibonacci numbers.
Proof by mathematical induction. We need to introduce this proof technique carefully because you will use it constantly.
Mathematical induction is a method for proving that a property holds for all natural numbers above some starting value. It works in two steps:
The intuition: if you can walk from the first step to any subsequent step, and you are already on the first step, you can reach any step.
Base case (): where , , . ✓
Inductive step: Assume the theorem holds for , i.e. . Then:
which is exactly the required form for . ✓
The proof is complete.
A naive matrix-power algorithm just multiplies by itself times:
function Fib5(n):
A = [[1,1],[1,0]]
for i = 3 to n:
A = A × [[1,1],[1,0]]
return A[1][1]
Time: — no better than Fib4. Space: — three variables. No improvement yet. But the theorem sets up something much better.
The whole point of the matrix formulation is that we can now use fast exponentiation.
The key observation: to compute , you do not need to do multiplications. You can use the following:
Why does this help? Because each step halves the exponent. To go from to , you just square once, not multiply 32 more times. The total number of multiplications needed to reach is proportional to .
For example, to compute :
Four multiplications, not fifteen.
The recursive algorithm:
function FibMatPow(n):
A = [[1,1],[1,0]]
if n > 1:
M = FibMatPow(n/2) ⊳ integer division
A = M × M
if n mod 2 ≠ 0: ⊳ n is odd
A = A × [[1,1],[1,0]]
return A
function Fib6(n):
M = FibMatPow(n-1)
return M[1][1]
Time: . Each recursive call halves , so there are at most recursive calls. Each call does a constant number of matrix multiplications (each of which is for matrices).
Space: . There are active recursive calls at most, each holding a constant-size activation record.
Going from to is enormous. For :
That is the difference between tens of seconds and a microsecond.
[!MATH] Exponentiation by squaring is a general technique that applies to any associative operation, not just matrix multiplication. It works for modular exponentiation in cryptography, polynomial powers, and even string operations. Recognising the pattern is a skill: whenever you see "compute " for some object and large , exponentiation by squaring is almost always the right tool.
Here is the full picture:
| Algorithm | Method | Time | Space |
|---|---|---|---|
| Fib1 | Closed formula | ||
| Fib2 | Naive recursion | ||
| Fib3 | Iterative + array | ||
| Fib4 | Iterative, 2 vars | ||
| Fib5 | Matrix, naive power | ||
| Fib6 | Matrix, fast power |
Fib1 looks best but is broken. Fib2 is correct but exponential — unusable. Fib3 and Fib4 are both , with Fib4 using less memory. Fib5 is with matrices (no better than Fib4 on its own). Fib6 is the winner: time, space.
All six algorithms compute exactly the same mathematical function — the -th Fibonacci number. Their correctness is not what distinguishes them. Their computational efficiency is everything.
There is a subtle but important point that the lecture raises at the end, and it is worth understanding precisely.
Throughout the analysis above, we measured time and space in terms of — the input value, the actual number you pass to the function. But computer scientists prefer to measure complexity in terms of the input size — the number of bits needed to represent the input.
To represent the integer in binary, you need bits. Call this . Then:
Now re-express all complexities in terms of :
| Algorithm | Time (in ) | Space (in ) | |---|---|---| | Fib1 | | | | Fib2 | | | | Fib3 | | | | Fib4 | | | | Fib5 | | | | Fib6 | | |
This is a different table even though the actual running times are identical. What changed is the lens through which we evaluate them.
Fib4 is in terms of input value, which sounds linear and efficient. But in terms of input size it is — exponential. This means: doubling the number of bits in the input squares the running time. That is not a polynomial algorithm in the proper theoretical sense.
Only Fib6 is genuinely efficient by this measure: in input size. The algorithm's running time grows linearly with the number of bits used to represent the input. That is what it means to be a polynomial-time algorithm.
[!NOTE] This distinction — input value vs input size — becomes critical in complexity theory. An algorithm that runs in where is a numerical input value might still be exponential in the formal sense. This is why problems like the Travelling Salesman Problem are hard: the input size is (the graph), but naive algorithms run in which is super-exponential in the input size. We will return to this distinction when we study NP-completeness.
What this lecture establishes is not a set of facts to memorise — it is a way of thinking.
Every algorithm has a time cost and a space cost. Both are measured in terms of input size using asymptotic notation. The same function can be computed by algorithms that differ by an exponential factor in efficiency. The difference is not academic: it is the difference between an algorithm that runs in a microsecond and one that would outlast the universe.
The Fibonacci example compresses the entire arc of algorithm design into one problem: naive approach, recognise the flaw (repeated computation), fix it iteratively, reduce the memory footprint, find the mathematical structure (matrix representation), exploit the structure (fast exponentiation), and arrive at a logarithmic algorithm.
That arc — from naive to optimal — is the template for everything that follows in this course.