Step-by-step recipes for solving problems, and how to tell a fast one from a slow one.
From what an algorithm is to dynamic programming, backtracking and shortest paths. You learn to count the steps an algorithm takes, then the classic searches and sorts, recursion, divide and conquer, two pointers, graph search, greedy choices and dynamic programming, each written in C++, compiled and tested in the page. The last modules teach how to approach a problem you have never seen.
What you’ll be able to do
Describe an algorithm precisely, and work out its running time and memory with Big O.
Write and explain linear and binary search, and the classic sorts: insertion, merge sort and quicksort.
Think recursively, and turn a recursive solution into dynamic programming with memoisation or a table.
Search trees and graphs breadth-first and depth-first, sort tasks topologically, and find shortest paths and spanning trees.
Recognise the patterns behind common problems (two pointers, sliding windows, hashing, greedy, backtracking), and check a solution with edge cases and a brute-force oracle.
Modules
What an algorithm is· 9 slides
Counting steps: Big O· 10 slides
Searching a list· 9 slides
Binary search· 10 slides
Simple sorts· 10 slides
Recursion· 8 slides
Merge sort· 9 slides
Quicksort· 10 slides
Two pointers and sliding windows· 10 slides
Counting and hashing· 10 slides
Walking a tree· 10 slides
Searching a graph· 10 slides
Shortest paths· 10 slides
Greedy algorithms· 9 slides
Dynamic programming· 10 slides
Dynamic programming in two dimensions· 10 slides
Backtracking· 10 slides
Minimum spanning trees· 10 slides
Solving problems you have never seen· 10 slides
Mastery challenge· 10 slides
Interactive parts (exercises, code to run, walkthroughs, flashcards, checklists) are drawn still and marked “Interactive · disabled in the PDF”. Do them in the app; the answers are at the end.
Module 1: What an algorithm is
Algorithms1 / 194
Module 1 · What an algorithm is
An algorithm is a recipe a machine could follow
An algorithm is a list of steps that solves a problem: given some input, it does a fixed series of things and ends with an output. A recipe is one: the input is flour, eggs and milk, the steps say exactly what to do and in which order, and the output is pancakes. A computer program is an algorithm written in a language a computer can run. In this course that language is C++.
The input: here, a list of scores.
The steps: look at each score and remember the biggest so far.
The output: the biggest score, 9.
Every algorithm has the same shape: something goes in, steps run, something comes out.
Algorithm
A finite list of precise steps that turns an input into an output.
Input
What the algorithm is given to work on: a list, a number, a word.
Output
What it gives back at the end: the answer.
Step
One small action with no room for guessing, such as “compare these two numbers”.
Algorithms2 / 194
Module 1 · What an algorithm is
You already run algorithms every day
Looking up a word in a paper dictionary, sorting a hand of playing cards, or finding the tallest person in a queue are all algorithms. You do them without thinking, but each can be written as steps. The hard part, and the whole point of this course, is writing the steps so precisely that someone with no common sense, a computer, gets the right answer every time.
Task
Input
Steps, roughly
Output
Find a word
A dictionary, a word
Open near its letter, flip forward or back
The page
Sort your cards
A hand of cards
Take each card, slide it into place
A sorted hand
Pick the cheapest
A list of prices
Look at each, remember the lowest
One price
QuizInteractive · disabled in the PDF
Which instruction is precise enough for a computer to follow?
Which instruction is precise enough for a computer to follow?
Algorithms3 / 194
Module 1 · What an algorithm is
A good algorithm is correct and finishes
Two things matter before anything else. Correctness: for every valid input, the output is the right answer, not just for the examples you tried. Termination: it always stops. A recipe that says “stir until it looks right” may never end; one that says “stir for 2 minutes” always does.
Might never stop
Roll a die until you get a 7.
Keep halving the number until it is 0.
Always stops
Roll a die 10 times.
Halve the number until it is below 1.
QuizInteractive · disabled in the PDF
Why does “keep halving the number until it is 0” never stop, starting from 8 as a double (a number with a fractional part)?
Why does “keep halving the number until it is 0” never stop, starting from 8 as a double (a number with a fractional part)?
Algorithms4 / 194
Module 1 · What an algorithm is
From steps in words to pseudocode
Here is the problem this module solves: given a list of scores, find the largest. In words: look at the first score and remember it. Then look at each of the others; whenever one is bigger than the one you remember, remember that one instead. At the end, the one you remember is the answer. Pseudocode writes the same steps in a short, code-like way that is not any real language.
Text
1largest(scores):2 if scores is empty: answer "nothing"3 best ← the first score4 for each other score s:5 if s > best:6 best ← s7 answer best
← means “now remember this value as”. Pseudocode is for people; it skips what a language fusses over.
Put in orderInteractive · disabled in the PDF
Put the steps for finding the largest score in order.
1.Remember the first score as the best so far
2.If it is bigger than the best, remember it instead
3.If the list is empty, there is no answer
4.Move on to the next score
5.Repeat until every score has been seen, then give back the best
Algorithms5 / 194
Module 1 · What an algorithm is
From pseudocode to C++
Each line of pseudocode becomes a line or two of C++. The scores come in a std::vector<int>, passed as const& so the function reads the caller’s vector without copying it. “Nothing” needs a type that can be empty: std::optional<int> holds either an int or nothing, written std::nullopt. The return type says so, and the caller must deal with an empty list.
WalkthroughInteractive · disabled in the PDF
C++
1#include<iostream>2#include<optional>3#include<vector>45std::optional<int>largest(const std::vector<int>& scores){6if(scores.empty())return std::nullopt;7int best = scores[0];8for(size_t i =1; i < scores.size(); i++){9if(scores[i]> best){10 best = scores[i];11}12}13return best;14}1516intmain(){17 std::cout <<*largest({4,9,2})<<"\n"; // 918 std::cout <<largest({}).has_value()<<"\n"; // 019}
Lines 1-3: The library parts it uses: printing, std::optional, std::vector.
Lines 5: The input is a vector of ints, read-only; the output an int, or nothing for an empty list.
Lines 6: The awkward case first: no scores, no answer.
Lines 7: The first score is the best seen so far. It will change, so it is a plain int.
Lines 8: Visit every other score: indexes 1 up to the last, size() - 1. size_t is the unsigned type sizes are measured in.
Lines 9-11: A bigger one replaces the best.
Lines 13: After the loop, every score has been compared, so best is the largest.
Lines 17-18: Two runs. * takes the value out of an optional that has one; has_value() asks whether it has one, and prints as 0 for false.
Fill the blanksInteractive · disabled in the PDF
Turn it around: find the smallest price.
C++
1std::optional<int>smallest(const std::vector<int>& prices){2if(prices.empty())return;3int best = prices[0];4for(size_t i =1; i < prices.; i++){5if(prices[i] best) best = prices[i];6}7return best;8}
Algorithms6 / 194
Module 1 · What an algorithm is
Trace it by hand on a small input
Tracing means running the steps yourself, on paper, and writing down every variable after every step. It is the surest way to understand an algorithm, and to find out why one is wrong. Here is largest on {4, 9, 2, 11}.
Step
i
scores[i]
Bigger than best?
best after
Start
–
–
–
4
1
1
9
yes
9
2
2
2
no
9
3
3
11
yes
11
End
–
–
–
11
Find the bugInteractive · disabled in the PDF
This works for {4, 9, 2} but gives the wrong answer for {-5, -2, -8}. Which line is wrong?
C++
Algorithms7 / 194
Module 1 · What an algorithm is
Try it: find the largest score
Write it yourself. Think about the edge cases first: the inputs at the edges of what is allowed, like an empty list, a list of one, or all negative numbers.
Code challengeInteractive · disabled in the PDF
Finish largest(scores): return the biggest number in the vector, or std::nullopt if it is empty.
C++
1#include<optional>2#include<vector>34std::optional<int>largest(const std::vector<int>& scores){5int best =0;6 // TODO: look at every score and keep the biggest7return best;8}
Why not std::ranges::max?. The library has std::ranges::max(scores) and std::ranges::max_element(scores) (in <algorithm>), and in real code you would use them. But max on an empty range is undefined behaviour: C++ makes no promise at all about what happens. max_element returns scores.end(), a position one past the last element, which you must check before reading through it. Knowing what the loop inside does is how you know which edge cases a library function leaves to you.
Algorithms8 / 194
Module 1 · What an algorithm is
The same idea works on structs
Real data is usually records. A struct groups named fields into one type; to find the cheapest book, compare a field, price, but remember the whole book. And decide what happens on a tie: here, the first book found wins, so the comparison is a strict <.
Code challengeInteractive · disabled in the PDF
Finish cheapest(books): return the book with the lowest price, or std::nullopt for an empty vector. If two books cost the same, return the one that comes first.
1#include<iostream>2#include<vector>34int comparisons =0;56intlargest(const std::vector<int>& scores){7int best = scores[0];8for(size_t i =1; i < scores.size(); i++){9 comparisons++;10if(scores[i]> best) best = scores[i];11}12return best;13}1415intmain(){16for(int n : {10,100,1000}){17 std::vector<int>scores(n);18for(int i =0; i < n; i++) scores[i]=(i *37)%101;19 comparisons =0;20largest(scores);21 std::cout << n <<" scores: "<< comparisons22<<" comparisons\n";23}24}
Count the work: 10 scores take 9 comparisons, 1000 take 999. Add 100000 to the list. The next module turns this counting into a tool.
Algorithms9 / 194
Module 1 · What an algorithm is
Recap
An algorithm is a finite list of precise steps from an input to an output.
It must be correct for every valid input and must terminate.
Write it in words, then pseudocode, then code; each step maps to a line or two.
Trace it by hand on a small input to see what every variable does.
Test the edge cases: empty, one element, all negative, ties.
std::optional says “maybe no answer” in the type; check it before reading scores[0].
Finding the largest: remember the best so far, compare each one, answer at the end.
FlashcardsInteractive · disabled in the PDF
Algorithm
A finite list of precise steps that turns an input into an output.
Correctness
The output is right for every valid input, not just the examples.
Termination
The algorithm always stops after a finite number of steps.
Pseudocode
Steps written in a short, code-like way that is no real language.
Tracing
Running the steps by hand and writing down every variable as it changes.
Edge case
An input at the edge of what is allowed: empty, one element, a tie.
std::optional<int>
An int or nothing (std::nullopt): an answer that may be missing.
Why start best at the first element, not 0?
So a list of only negative numbers still gives one of its own numbers.
Module 2: Counting steps: Big O
Algorithms10 / 194
Module 2 · Counting steps: Big O
Speed depends on how much input there is
Finding the cheapest of 10 prices is instant; of 10 million, it takes a moment. So “how fast is it?” has no single answer: it depends on the input size, written n (the number of prices, songs, books). Seconds also depend on the computer, so instead we count operations, the small steps like a comparison or an addition, and ask how that count grows as n grows.
Input size (n)
How big the input is: usually the number of elements in a list.
Operation
One basic step that takes a fixed time: compare, add, read an element.
Running time
How many operations an algorithm does, as a function of n.
Growth
How fast that count rises as n gets bigger. It is what we compare.
Why not just time it?. A stopwatch measures one program on one machine with one input. Counting operations tells you what will happen on any machine when the input is ten or a thousand times bigger, before you have the data to try.
Algorithms11 / 194
Module 2 · Counting steps: Big O
Count what runs for each element
To count, look at what runs once and what runs once per element. Adding up a playlist’s song lengths sets a total once, then does one addition per song, then returns once. For n songs that is about n + 2 operations.
C++
1inttotalLength(const std::vector<int>& songs){2int total =0; // once3for(int s : songs){4 total += s; // n times5}6return total; // once7}
n + 2 operations. Double the playlist, and the work about doubles.
Predict the outputInteractive · disabled in the PDF
How many times does the std::cout line run when n is 4?
C++
1int n =4;2for(int i =0; i < n; i++){3for(int j =0; j < n; j++){4 std::cout << i <<" "<< j <<"\n";5}6}
How many times does the std::cout line run when n is 4?
Algorithms12 / 194
Module 2 · Counting steps: Big O
Big O keeps only what matters as n grows
Big O is a short way to write how the count grows. Two rules: keep only the fastest-growing term, and drop constant factors. So n + 2 is O(n), 3n is O(n), and 5n² + 100n + 7 is O(n²): when n is a million, the n² part is so much bigger that the rest does not matter. You read O(n) as “order n” or “linear”.
Exact count
Big O
Why
n + 2
O(n)
The + 2 is the same for every n
3n
O(n)
A constant factor: still doubles when n doubles
n² + n
O(n²)
n² wins once n is big
42
O(1)
The same however big the input is
QuizInteractive · disabled in the PDF
An algorithm does 2n² + 50n + 1000 operations. What is its Big O?
An algorithm does 2n² + 50n + 1000 operations. What is its Big O?
Algorithms13 / 194
Module 2 · Counting steps: Big O
The common growth rates
A few shapes come up again and again. log n (the logarithm) is how many times you can halve n before you reach 1: about 10 for a thousand, 20 for a million. It grows so slowly it is almost constant. 2ⁿ doubles with every extra element, and is hopeless past about 40.
Big O
Name
n = 10
n = 1,000
n = 1,000,000
O(1)
constant
1
1
1
O(log n)
logarithmic
≈ 3
≈ 10
≈ 20
O(n)
linear
10
1,000
1,000,000
O(n log n)
linearithmic
≈ 33
≈ 10,000
≈ 20,000,000
O(n²)
quadratic
100
1,000,000
10¹²
O(2ⁿ)
exponential
1,024
a 302-digit number
never finishes
O(1): reading list[5], or the front of a queue.
O(log n): finding a word in a dictionary by halving (module 4).
O(n): looking at every price once.
O(n log n): good sorting, like merge sort (module 7).
O(n²): comparing every pair of songs.
O(2ⁿ): trying every possible playlist made from n songs.
Put in orderInteractive · disabled in the PDF
Order these from slowest-growing (best) to fastest-growing (worst).
1.O(1)
2.O(n²)
3.O(2ⁿ)
4.O(log n)
5.O(n)
6.O(n log n)
Algorithms14 / 194
Module 2 · Counting steps: Big O
Reading Big O from loops
Most of the time you can read Big O straight from the shape of the code. One loop over the input is O(n). A loop inside a loop, both over the input, is O(n²). Two loops one after the other are O(n) + O(n), still O(n). A loop that halves what is left each time is O(log n).
Fill the blanksInteractive · disabled in the PDF
Write the Big O of each piece, for a list of n elements (n² can be typed n^2).
C++
1int first = list[0]; // O()2for(int x : list) total += x; // O()3for(int a : list){4for(int b : list) total += a * b; // O()5}6for(size_t k = list.size(); k >1; k = k /2){7 total += k; // O()8}
WalkthroughInteractive · disabled in the PDF
C++
1boolhasDuplicate(const std::vector<std::string>& items){2for(size_t i =0; i < items.size(); i++){3for(size_t j = i +1; j < items.size(); j++){4if(items[i]== items[j])returntrue;5}6}7returnfalse;8}
Lines 2: The outer loop picks each item in turn, by index i.
Lines 3: The inner loop starts afteri, so each pair is compared once, never an item with itself.
Lines 4: Two the same: stop early, there is a duplicate.
Lines 7: No pair matched. With 5 items that took 4 + 3 + 2 + 1 = 10 comparisons; with n it is n(n − 1) / 2, which is O(n²).
Algorithms15 / 194
Module 2 · Counting steps: Big O
Try it: count the comparisons
Code can count its own work: a global variable, declared outside every function, is shared by all of them. Here same(a, b) adds 1 to comparisons each time it is called, so after a run the tests can check not just the answer, but how much work it took.
Code challengeInteractive · disabled in the PDF
Fix hasDuplicate(items) so it compares each pair of different positions once, using same(a, b) for every comparison. For 5 items with no duplicates that is exactly 10 calls.
C++
1#include<string>2#include<vector>34int comparisons =0;5boolsame(const std::string& a,const std::string& b){6 comparisons++;7return a == b;8}910boolhasDuplicate(const std::vector<std::string>& items){11for(size_t i =0; i < items.size(); i++){12for(size_t j =0; j < items.size(); j++){13if(same(items[i], items[j]))returntrue;14}15}16returnfalse;17}
Code challengeInteractive · disabled in the PDF
Finish halvings(n): count how many times you replace n with n / 2 until it reaches 0. halvings(8) is 4 (8 → 4 → 2 → 1 → 0).
C++
1inthalvings(int n){2int steps =0;3 // TODO: halve n until it is 0, counting each step4return steps;5}
Algorithms16 / 194
Module 2 · Counting steps: Big O
Memory counts too: space complexity
Space complexity is how much extra memory an algorithm needs as n grows, beyond the input itself. A few variables are O(1) space. Building a new list as big as the input is O(n) space. Often you can trade one for the other: use more memory to save time, or less memory at the cost of time.
O(n) extra space
std::vector<int>reversed(const std::vector<int>& list){ std::vector<int> out;for(size_t i = list.size(); i >0; i--){ out.push_back(list[i -1]);}return out;}
O(1) extra space
voidreverseInPlace(std::vector<int>& list){if(list.empty())return;size_t i =0;size_t j = list.size()-1;while(i < j){int t = list[i]; list[i]= list[j]; list[j]= t; i++; j--;}}
QuizInteractive · disabled in the PDF
Both reverse a list in O(n) time. What is the difference?
Both reverse a list in O(n) time. What is the difference?
Algorithms17 / 194
Module 2 · Counting steps: Big O
Best case, worst case
The same algorithm can do very different amounts of work on inputs of the same size. Looking for a song in a playlist of n songs takes 1 comparison if it is first (the best case) and n if it is last or missing (the worst case). The average case is in between. When people give one Big O without saying which, they mean the worst case: it is a promise that holds for every input.
QuizInteractive · disabled in the PDF
hasDuplicate from before, on n items. Which input is its worst case?
hasDuplicate from before, on n items. Which input is its worst case?
Big O hides constants, and constants are real. Big O says what happens as n grows, not which is faster at n = 20. An O(n²) loop over a small array that sits together in memory can beat an O(n log n) one that jumps around, because the processor’s cache makes nearby reads cheap. That is why real sorting functions switch to insertion sort, O(n²), for short runs. Use Big O to rule out the hopeless, then measure.
Algorithms18 / 194
Module 2 · Counting steps: Big O
See the difference: time it
std::chrono::steady_clock::now() (in <chrono>) reads a clock that only ever moves forward. Read it before and after, and subtract. Here an O(n²) duplicate check races an O(n) one that remembers what it has seen in a std::unordered_set (a collection that answers “have I seen this?” in about one step; module 10 explains it).
PlaygroundInteractive · disabled in the PDF
C++
1#include<chrono>2#include<iostream>3#include<unordered_set>4#include<vector>56boolslow(const std::vector<int>& items){7for(size_t i =0; i < items.size(); i++)8for(size_t j = i +1; j < items.size(); j++)9if(items[i]== items[j])returntrue;10returnfalse;11}1213boolfast(const std::vector<int>& items){14 std::unordered_set<int> seen;15for(int x : items){16if(seen.contains(x))returntrue;17 seen.insert(x);18}19returnfalse;20}2122intmain(){23 std::vector<int> items;24for(int i =0; i <10000; i++) items.push_back(i);25auto t0 = std::chrono::steady_clock::now();26bool a =slow(items);27auto t1 = std::chrono::steady_clock::now();28bool b =fast(items);29auto t2 = std::chrono::steady_clock::now();30 std::chrono::duration<double, std::milli> s = t1 - t0, f = t2 - t1;31 std::cout <<"slow "<< a <<", "<< s.count()<<" ms\n";32 std::cout <<"fast "<< b <<", "<< f.count()<<" ms\n";33}
Try 5000 and 20000 items: slow takes about 4 times as long when n doubles, fast about twice. A duration<double, std::milli> is a time span counted in milliseconds, with fractions.
Timings are noisy. Other programs steal time, and memory that was just read is faster to read again, so a first run can differ from the next. And the compiler is clever: work whose result is never used can be removed completely, which is why the timer prints whether each check found anything. Run each version several times, on large inputs, before you believe a number.
Algorithms19 / 194
Module 2 · Counting steps: Big O
Recap
Count operations as a function of the input size n, rather than seconds.
Big O keeps the fastest-growing term and drops constants: 3n + 2 is O(n).
One loop is O(n), nested loops O(n²), halving O(log n).
Space complexity counts the extra memory: a copy is O(n), a few variables O(1).
Without a qualifier, Big O means the worst case.
FlashcardsInteractive · disabled in the PDF
Input size n
How big the input is, usually its number of elements.
Big O
How the work grows with n, keeping the biggest term and no constants.
O(log n)
Halving what is left each step: about 20 steps for a million.
O(n²)
Every pair: a loop inside a loop over the same input.
O(2ⁿ)
Doubles with each extra element; hopeless past about 40.
Space complexity
The extra memory an algorithm needs as n grows.
In place
Changing the input itself instead of building a new copy.
Worst case
The input that makes the algorithm do the most work for its size.
Module 3: Searching a list
Algorithms20 / 194
Module 3 · Searching a list
Searching means: is it there, and where?
A friend’s bookshelf is in no order and you want Dune. There is only one way: look at the first book, then the next, and so on, until you find it or run out of shelf. That is linear search: check each element in turn. It works on any list, sorted or not, and it is what the standard library’s std::find does inside.
Search
Finding whether a value is in a collection, and usually where.
Target
The value you are looking for.
Linear search
Checking each element in order, from the first, until the target is found.
Sentinel value
A special answer that means “not found”, such as -1 for an index, std::nullopt for a value, or end() for a position.
Index 0 is Emma, not Dune. Move on.
Index 1 is Kim. Move on.
Index 2 is Dune: found it, answer 2.
Ulysses is never looked at: the search stopped as soon as it could.
Looking for Dune: one comparison per book, left to right.
Algorithms21 / 194
Module 3 · Searching a list
Linear search in code
Walk the indexes from 0, compare each element with the target, and return the index the moment they match. If the loop finishes, every element was checked and none matched, so return the sentinel -1. The index is an int, which can be negative; std::ssize(list) gives the size as a signed number too, so the two compare cleanly.
Lines 1-2: The input is a list and a target, both by const&, read without copying; the output an index, or -1.
Lines 3: Visit every index, 0 up to size - 1.
Lines 4-6: A match: return ends the function at once, with this index.
Lines 8: Only reached when no element matched.
Lines 14: Dune is checked third: 3 comparisons.
Lines 15: Hamlet is missing: all 4 books are compared before giving up, the worst case.
QuizInteractive · disabled in the PDF
A list holds 1,000 song titles, all different. How many comparisons does a linear search for a title that is not there make?
A list holds 1,000 song titles, all different. How many comparisons does a linear search for a title that is not there make?
Algorithms22 / 194
Module 3 · Searching a list
The standard library’s searches are linear too
<algorithm> has searches built in. They all walk from the start, so they are all O(n); they differ in what you give them and what they give back. Most give back an iterator: a position in the container. *it reads the element there, it - v.begin() turns it into an index, and v.end(), the position one past the last element, means “not found”.
Function
You give it
It gives back
If missing
std::ranges::find(v, x)
a value
an iterator to the first match
v.end()
std::ranges::find_if(v, f)
a test function
an iterator to the first that passes
v.end()
std::ranges::any_of(v, f)
a test function
true
false
std::ranges::count(v, x)
a value
how many match (it never stops early)
0
Predict the outputInteractive · disabled in the PDF
What does this print?
C++
1std::vector<int> v ={4,9,2};2auto it = std::ranges::find(v,7);3std::cout <<(it == v.end())<<" "<<(it - v.begin());
What does this print?
find never finds NaN.double has a value called NaN, “not a number”, the result of 0.0 / 0.0. It is not equal to anything, itself included: nan == nan is false. So std::ranges::find(v, nan) always gives end(), even when the vector holds one. Search with a test instead: std::ranges::find_if(v, [](double x) { return std::isnan(x); }).
Algorithms23 / 194
Module 3 · Searching a list
Try it: write linear search
Write it without std::find, so you know exactly what it does. Then a twist: search from the other end.
Code challengeInteractive · disabled in the PDF
Finish linearSearch(prices, target): return the index of the first price equal to target, or -1 if there is none. Don’t use std::find.
C++
1#include<vector>23intlinearSearch(const std::vector<int>& prices,int target){4 // TODO: check each price in turn5return-1;6}
Code challengeInteractive · disabled in the PDF
Finish lastIndex(songs, title): return the index of the last song with that title, or -1. Walk from the end, so you can stop at the first match you meet.
C++
1#include<string>2#include<vector>34intlastIndex(const std::vector<std::string>& songs,5const std::string& title){6for(int i =0; i < std::ssize(songs); i++){7if(songs[i]== title)return i;8}9return-1;10}
Algorithms24 / 194
Module 3 · Searching a list
Searching with a condition
Often you don’t know the exact value, only what it must be like: the first price under 10, the first song longer than five minutes. Pass the condition in as a function, called a predicate: it takes one element and answers true or false. A lambda writes one on the spot: [](int s) { return s > 300; } is a nameless function of an int. This is how find_if works.
Fill the blanksInteractive · disabled in the PDF
Find the first song longer than 300 seconds.
C++
1std::vector<int> lengths ={215,187,342,401};2auto isLong =[](int s){return s 300;};3auto it = std::ranges::(lengths, isLong);4std::cout <<*it; // prints
Code challengeInteractive · disabled in the PDF
Write findFirst(prices, test): return the first price for which test(price) is true, or std::nullopt. Call test as few times as possible: stop at the first match.
An early exit leaves the loop the moment the answer is known, with return (leave the function) or break (leave just the loop). It does not change the worst case, a missing target still costs n comparisons, but it makes the best and average cases much cheaper. Forgetting it is a common bug, and not only for speed.
Find the bugInteractive · disabled in the PDF
This should give the first index of target, but firstIndex({"a", "b", "a"}, "a") gives 2. Which line is wrong?
C++
PlaygroundInteractive · disabled in the PDF
C++
1#include<iostream>2#include<vector>34int steps =0;56intsearch(const std::vector<int>& list,int target){7 steps =0;8for(int i =0; i < std::ssize(list); i++){9 steps++;10if(list[i]== target)return i;11}12return-1;13}1415intmain(){16 std::vector<int> list;17for(int i =0; i <10000; i++) list.push_back(i *2);18for(int target : {4,10000,7}){19int index =search(list, target);20 std::cout << target <<": index "<< index <<", "<< steps21<<" steps\n";22}23}
The list holds 0, 2, 4… Search for other numbers, or make the list longer, and watch steps.
Algorithms26 / 194
Module 3 · Searching a list
Edge cases: empty, missing, and index 0
Every search must decide what to answer when there is nothing to find, and every caller must check for it. A sneaky trap: testing an index with if (index). In C++, if on an int means “is it not 0”, so a real find at index 0 looks like a miss, and the sentinel -1 looks like a find.
Predict the outputInteractive · disabled in the PDF
What does this print?
C++
1std::vector<int> fines ={0,3,5};2int spot =linearSearch(fines,0);3if(spot) std::cout <<"found a free one";4else std::cout <<"none free";
What does this print?
The sentinel trick. Linear search does two checks per element: “is this the target?” and “am I past the end?” The classic sentinel search puts the target at the end of the list first, so the loop is sure to stop on it and only needs the first check; afterwards you see whether you stopped on the real end. It saved real time on old machines. Today’s compilers and processors make the plain loop fast (the “past the end” check is nearly free when it is always false), so prefer it for clarity.
Algorithms27 / 194
Module 3 · Searching a list
Searching records by a field
With a list of structs you usually search on one field: a book by its ISBN, a member by their email. The condition can combine fields, too: the first book by an author that is not on loan.
Code challengeInteractive · disabled in the PDF
Finish firstAvailable(books, author): return the first book by author whose onLoan is false, or std::nullopt if there is none.
Linear search checks each element in turn: O(n), and it works on any list.
Return the first match at once; return a sentinel (-1, std::nullopt) only after the loop.
std::ranges::find looks for a value with ==; find_if and any_of take a predicate.
A failed find gives end(), one past the last element: compare with it before reading *it.
Counting down needs a signed index: a size_t never goes below 0.
Check “not found” against the sentinel, not with if (index): index 0 is a real find.
FlashcardsInteractive · disabled in the PDF
Linear search
Check each element from the first until the target is found: O(n).
Sentinel value
A special answer meaning “not found”: -1, std::nullopt, end().
Predicate
A function that takes an element and returns true or false.
Lambda
A function written on the spot: [](int x) { return x > 3; }.
Iterator
A position in a container: *it reads it, end() is one past the last.
Early exit
Leaving the loop with return or break as soon as the answer is known.
Why does find never find NaN?
It compares with ==, and NaN is not equal to anything, not even itself.
Module 4: Binary search
Algorithms29 / 194
Module 4 · Binary search
A sorted list can be halved
Nobody looks up “otter” in a dictionary by reading from page one. You open it near the middle, see “monkey”, and know “otter” is in the second half: half the book is ruled out in one look. Open the middle of what is left, and again. Binary search does exactly this, on a list that is sorted: in order, smallest first. It is the reason sorted data is worth so much.
Items in the list
Linear search, worst case
Binary search, worst case
10
10 comparisons
4
1,000
1,000
10
1,000,000
1,000,000
20
1,000,000,000
1,000,000,000
30
Sorted
In order, each element no smaller than the one before.
Binary search
Searching a sorted list by comparing with its middle and throwing away the half that cannot hold the target.
O(log n)
The number of times n can be halved: about 20 for a million.
QuizInteractive · disabled in the PDF
Why can’t binary search work on a list that isn’t sorted?
Why can’t binary search work on a list that isn’t sorted?
Algorithms30 / 194
Module 4 · Binary search
Tracing binary search
Keep two markers: lo, the first index that could still hold the target, and hi, the last. Look at the element halfway between, mid. If it is the target, done. If it is smaller, the target must be to its right, so move lo past it. If bigger, move hi before it. When lo passes hi, nothing is left: not found. Here is a search for 24 in [3, 8, 12, 19, 24, 31, 40].
Round
lo
hi
mid
Value at mid
So
1
0
6
3
19
19 < 24: lo = 4
2
4
6
5
31
31 > 24: hi = 4
3
4
4
4
24
Found: answer 4
QuizInteractive · disabled in the PDF
Now search the same list for 20, which is missing. What are lo and hi when the search gives up?
Now search the same list for 20, which is missing. What are lo and hi when the search gives up?
Algorithms31 / 194
Module 4 · Binary search
Binary search in code
WalkthroughInteractive · disabled in the PDF
C++
1intbinarySearch(const std::vector<int>& sorted,int target){2int lo =0;3int hi = std::ssize(sorted)-1;4while(lo <= hi){5int mid = lo +(hi - lo)/2;6if(sorted[mid]== target)return mid;7if(sorted[mid]< target){8 lo = mid +1;9}else{10 hi = mid -1;11}12}13return-1;14}
Lines 2-3: At first, every index from 0 to the last could hold the target.
Lines 4: Keep going while at least one index is left: lo <= hi, since lo == hi is one index.
Lines 5: The middle: half the distance from lo to hi, added to lo. Dividing ints rounds down, so it is a whole index.
Lines 6: Found it.
Lines 7-8: Too small: the target is to the right, and mid itself is ruled out, so mid + 1.
Lines 9-10: Too big: the target is to the left of mid.
Lines 13: The range is empty: the target is not in the list.
Put in orderInteractive · disabled in the PDF
Put one round of binary search in order.
1.Work out mid, halfway between them
2.Otherwise move lo or hi past mid, and go round again
3.Compare the element at mid with the target
4.Check that lo is not past hi
5.If equal, return mid
Algorithms32 / 194
Module 4 · Binary search
Try it: write binary search
The last test runs 100,000 searches in a sorted list of a million numbers. A linear scan would need billions of steps and runs out of the 2 seconds a test may take; binary search needs about 2 million.
Code challengeInteractive · disabled in the PDF
Finish binarySearch(sorted, target): return the index of target in the sorted vector, or -1. Halve the range each round; don’t look at every element.
C++
1#include<vector>23intbinarySearch(const std::vector<int>& sorted,int target){4int lo =0;5int hi = std::ssize(sorted)-1;6 // TODO: halve the range until it is found or empty7return-1;8}
Algorithms33 / 194
Module 4 · Binary search
The off-by-one traps
Binary search is short and famously easy to get wrong. An off-by-one error is being one away from the right index: a < that should be <=, a mid that should be mid + 1. The rule that keeps it straight: decide what lo and hi mean (here, both are indexes that could still hold the target) and make every line keep that true.
Find the bugInteractive · disabled in the PDF
Searching [10, 20] for 20 never finishes. Which line is wrong?
C++
QuizInteractive · disabled in the PDF
With while (lo < hi) instead of lo <= hi (and nothing else changed), which search goes wrong?
With while (lo < hi) instead of lo <= hi (and nothing else changed), which search goes wrong?
A bug that hid for years. In 2006, Java’s own binary search was found to crash on huge arrays: (lo + hi) / 2 overflowed, because an int stops at about 2 billion, and two indexes past a billion add up to more. The safe way to write it is lo + (hi - lo) / 2, which never goes past hi. In C++ it matters even more: a signed int that overflows is undefined behaviour, so the compiler may assume it never happens and the program can do anything. That is why the code here always writes the middle that way.
Algorithms34 / 194
Module 4 · Binary search
Lower bound: where it would go
Often you want not “is it there?” but “where does it belong?”: the insertion point, the first index whose element is at least the target. This is called the lower bound. It answers how many items are smaller, where to insert to keep the list sorted, and finds the first of several equal values. Here hi starts at the size, one past the end, because the answer can be “after everything”.
WalkthroughInteractive · disabled in the PDF
C++
1intlowerBound(const std::vector<int>& sorted,int x){2int lo =0;3int hi = std::ssize(sorted);4while(lo < hi){5int mid = lo +(hi - lo)/2;6if(sorted[mid]< x) lo = mid +1;7else hi = mid;8}9return lo;10}1112intmain(){13 std::vector<int> v ={10,20,20,30};14 std::cout <<lowerBound(v,20)<<"\n"; // 115 std::cout <<lowerBound(v,25)<<"\n"; // 316 std::cout <<lowerBound(v,99)<<"\n"; // 417}
Lines 2-3: The answer is somewhere from 0 to the size, both included.
Lines 4: Stop when lo meets hi: one candidate left, and it is the answer.
Lines 6: Too small: the answer is after mid.
Lines 7:mid is big enough, so it could be the answer: keep it with hi = mid, not mid - 1.
Lines 14: The first 20 is at index 1.
Lines 15-16: 25 would go before 30; 99 after everything, at the size, 4.
Fill the blanksInteractive · disabled in the PDF
Insert a price into a sorted list, keeping it sorted.
C++
1std::vector<int> prices ={4,9,15,22};2int at =lowerBound(prices,); // 23prices.(prices.begin()+ at,12);4// prices is now {4, 9, , 15, 22}
Algorithms35 / 194
Module 4 · Binary search
Try it: how many can I afford?
A variation: the first index whose price is more than the budget. Every price before it is affordable, so that index is the count. Only one comparison changes from lowerBound.
Code challengeInteractive · disabled in the PDF
Finish countAffordable(sorted, budget): how many prices in the sorted vector are at mostbudget. Use binary search, not a loop over every price.
C++
1#include<vector>23intcountAffordable(const std::vector<int>& sorted,int budget){4int lo =0;5int hi = std::ssize(sorted);6 // TODO: find the first index whose price is over budget7return0;8}
Algorithms36 / 194
Module 4 · Binary search
Searching for an answer
Binary search works on anything where the answer to a yes/no question flips once: “no, no, no, yes, yes”. A courier must deliver parcels in order, one van load per day, in at most days days. Is a van of capacity C big enough? If C works, any bigger van works too. So binary search the capacities for the smallest that works, between the heaviest parcel (anything less cannot carry it) and the total (one trip).
Code challengeInteractive · disabled in the PDF
Finish smallestCapacity(weights, days): the smallest van capacity that delivers every parcel in at most days days. trips is written for you.
C++
1#include<algorithm>2#include<vector>34inttrips(const std::vector<int>& weights,int cap){5int count =1, load =0;6for(int w : weights){7if(load + w > cap){8 count++;9 load =0;10}11 load += w;12}13return count;14}1516intsmallestCapacity(const std::vector<int>& weights,int days){17int lo =0, hi =0; // the heaviest parcel, the total18for(int w : weights) lo = std::max(lo, w), hi += w;19 // TODO: binary search for the smallest cap that works20return hi;21}
Binary search on the answer. This pattern, called binary search on the answer, turns “find the best value” into “check whether a value is good enough”, which is often far easier. It needs one property: the check must be monotonic, never flipping back from yes to no as the value grows. Look for it whenever a problem asks for the smallest or largest value that satisfies something.
Algorithms37 / 194
Module 4 · Binary search
Linear against binary, and the library’s versions
<algorithm> has binary search built in, for any sorted range. Like find, most give back an iterator, a position; subtract v.begin() for the index. They only promise a right answer when the range really is sorted: they cannot check it without reading everything, which would undo the point.
Function
Gives back
Same as
std::ranges::binary_search(v, x)
true or false
is it there?
std::ranges::lower_bound(v, x)
the first position whose element is at least x
lowerBound
std::ranges::upper_bound(v, x)
the first position whose element is more than x
countAffordable
std::ranges::equal_range(v, x)
both of those: every element equal to x lies between
two searches
PlaygroundInteractive · disabled in the PDF
C++
1#include<algorithm>2#include<iostream>3#include<vector>45intmain(){6for(int n : {100,10'000,1'000'000}){7 std::vector<int>list(n);8for(int i =0; i < n; i++) list[i]= i;9int x = n -1;1011int linear =0;12for(int item : list){13 linear++;14if(item == x)break;15}1617int binary =0;18auto less =[&binary](int a,int b){ binary++;return a < b;};19auto it = std::ranges::lower_bound(list, x, less);2021 std::cout << n <<": linear "<< linear <<", lower_bound "22<< binary <<", found at "<< it - list.begin()<<"\n";23}24}
Searching for the last element: linear reads all n, lower_bound compares about log₂ n times. The lambda less is a comparator, a function that says whether a comes before b; this one counts its calls. Search for -1 (missing) or 0 (the first) too.
Algorithms38 / 194
Module 4 · Binary search
Recap
Binary search needs a sorted list, and halves the range each round: O(log n).
Keep lo and hi meaning one thing; move them past mid with mid + 1 and mid - 1.
Every round must shrink the range, or the loop never ends.
Lower bound: the first index whose element is at least x, which is also its insertion point.
The library has them: std::ranges::lower_bound, upper_bound, binary_search, equal_range.
Test the first, last and missing elements, an empty list and one element: off-by-one errors hide there.
Binary search on the answer: when “is C enough?” flips once, search for the smallest C that is.
FlashcardsInteractive · disabled in the PDF
Binary search
Compare with the middle of a sorted list and throw away the half that cannot hold the target.
Why must the list be sorted?
Only then does the middle tell you which half the target is in.
lo and hi
The first and last indexes that could still hold the target.
Off-by-one error
Being one index away: < for <=, or mid for mid + 1.
Lower bound
The first index whose element is at least the target: where it would be inserted.
Upper bound
The first index whose element is more than the target.
Why lo + (hi - lo) / 2?
lo + hi can overflow an int, which is undefined behaviour in C++; this never goes past hi.
Monotonic check
A yes/no test that never flips back from yes to no as the value grows.
Module 5: Simple sorts
Algorithms39 / 194
Module 5 · Simple sorts
Sorted data is easier to use
Sorting means putting items in order: prices from low to high, names from A to Z, scores from best to worst. People sort to read a list more easily, and algorithms sort to make later work fast. In a sorted list, the smallest item is first, equal items sit next to each other, and binary search (the previous module) can find anything in a few steps.
Ascending
From smallest to largest: 1, 2, 3, or A to Z.
Descending
From largest to smallest: 3, 2, 1, or Z to A.
In place
Sorting by moving items around inside the same vector, with no second copy.
Swap
Exchanging two items’ positions: std::swap(a, b).
QuizInteractive · disabled in the PDF
A list of 1,000 prices is sorted in ascending order. Which questions become easy? Pick all that apply.
A list of 1,000 prices is sorted in ascending order. Which questions become easy? Pick all that apply.
Algorithms40 / 194
Module 5 · Simple sorts
std::sort puts things in order with <
<algorithm> has std::ranges::sort(v) (the older spelling is std::sort(v.begin(), v.end())). It changes the vector you give it, and, without instructions, it orders items with <. For numbers that is what you expect. For std::string, < compares character by character, by each character’s code: every capital letter comes before every small one, and the text “10” comes before “9”, because the character 1 comes before 9.
Scores were read from a file as text. What order are they in after sorting?
Algorithms41 / 194
Module 5 · Simple sorts
A comparator tells sort which comes first
A comparator is a function (a, b) that returns true when a must come beforeb, and false otherwise, equal items included. It is usually a lambda, a function written on the spot. std::greater<>() is a ready-made one that uses >, for descending order. A comparator that reads one field is so common that C++20 has a short form, a projection: std::ranges::sort(books, {}, &Book::price) sorts by price with the default <.
Strict weak ordering: never <=. The comparator must be a strict weak ordering: never true for an item and itself, consistent (if a before b and b before c, then a before c), and the same answer every time. a <= b breaks the first rule, because it says an item comes before its equal. std::sort trusts that rule to stop its inner loops, so with <= and many equal items it runs off the end of the vector. Here, the library’s checks catch it and stop the program: “Would read out of bounds, does your comparator satisfy the strict-weak ordering requirement?” Without them, it is undefined behaviour. A comparator that answers at random breaks it too: it is no way to shuffle.
Algorithms42 / 194
Module 5 · Simple sorts
Bubble sort swaps neighbours until nothing moves
To see what sort does inside, write one. The simplest is bubble sort: walk along the list comparing each pair of neighbours, and swap them if they are in the wrong order. After one pass the largest item has “bubbled” to the end. Repeat, each pass stopping one place earlier, until the whole list is sorted.
WalkthroughInteractive · disabled in the PDF
C++
1voidbubbleSort(std::vector<int>& nums){2for(int end = std::ssize(nums)-1; end >0; end--){3for(int i =0; i < end; i++){4if(nums[i]> nums[i +1]){5 std::swap(nums[i], nums[i +1]);6}7}8}9}1011intmain(){12 std::vector<int> v ={5,1,4,2};13bubbleSort(v); // v is now {1, 2, 4, 5}14}
Lines 1: The vector comes by reference, &, with no const: the function sorts the caller’s vector itself, in place, and returns nothing (void).
Lines 2:end is where the pass stops. After each pass the item at end is in its final place.
Lines 3: Compare every pair i and i + 1 up to end. i < end keeps i + 1 inside the vector.
Lines 4-6: Out of order: swap them. std::swap does what you would by hand: copy one aside, overwrite it, put the copy in the other place.
Lines 13:v itself changed: there is nothing to return.
Pass
List after it
In place at the end
Start
{5, 1, 4, 2}
–
1
{1, 4, 2, 5}
5
2
{1, 2, 4, 5}
4, 5
3
{1, 2, 4, 5}
all
Algorithms43 / 194
Module 5 · Simple sorts
Selection sort picks the smallest each time
Selection sort is how many people sort a handful of exam papers by mark: find the lowest mark, put it first; find the lowest of the rest, put it second; and so on. Each round is the “find the smallest” algorithm from the first module, run on what is left. It does at most one swap per round, which helps when moving an item is expensive.
C++
1voidselectionSort(std::vector<int>& nums){2for(int i =0; i +1< std::ssize(nums); i++){3int min = i;4for(int j = i +1; j < std::ssize(nums); j++){5if(nums[j]< nums[min]) min = j;6}7 std::swap(nums[i], nums[min]);8}9}
Lines 3 to 6 find the index of the smallest item from i on; line 7 swaps it into place i.
Put in orderInteractive · disabled in the PDF
Selection sort on {4, 1, 3}. Put what happens in order.
1.Swap 3 with 4: {1, 3, 4}
2.Only one item is left, so it is already in place
3.Look through all three: the smallest is 1
4.Look through the last two: the smallest is 3
5.Swap 1 with 4: {1, 4, 3}
Algorithms44 / 194
Module 5 · Simple sorts
Insertion sort is how you sort a hand of cards
Picking up playing cards one at a time, you slide each new card left past the bigger ones until it sits in place. The cards in your hand are always sorted. That is insertion sort: the left part of the vector is a sorted hand, and each next item is inserted into it by shifting bigger items one place right.
WalkthroughInteractive · disabled in the PDF
C++
1voidinsertionSort(std::vector<int>& nums){2for(int i =1; i < std::ssize(nums); i++){3int card = nums[i];4int j = i -1;5while(j >=0&& nums[j]> card){6 nums[j +1]= nums[j];7 j--;8}9 nums[j +1]= card;10}11}
Lines 2: Items 0 to i - 1 are the sorted hand. Start with one card, index 0, which is sorted on its own.
Lines 3: Pick up the next card and keep it aside; its spot may be overwritten.
Lines 4-8: Walk left while the card there is bigger, shifting each one right to make room.
Lines 5:j >= 0 stops at the start of the vector: the new card may be the smallest of all. It is checked first, so nums[-1] is never read: && skips its right side when the left is false.
Lines 9: Drop the card into the gap, just after the first card that is not bigger.
Find the bugInteractive · disabled in the PDF
insertionSort leaves {3, 1} as {3, 1}. Which line is wrong?
C++
Algorithms45 / 194
Module 5 · Simple sorts
Try it: write insertion sort, and use a comparator
Code challengeInteractive · disabled in the PDF
Finish insertionSort(nums): sort the numbers in ascending order in place, changing the vector it is given. Do not use std::sort.
C++
1#include<vector>23voidinsertionSort(std::vector<int>& nums){4for(int i =1; i < std::ssize(nums); i++){5int card = nums[i];6 // TODO: shift bigger items right, then drop card in7}8}
Code challengeInteractive · disabled in the PDF
Finish sortBooks(books): return a new vector sorted by price, cheapest first; books with the same price go in title order, A to Z. books is const, so it stays as it was.
A sort is stable if items that compare as equal stay in the order they were in. It matters when you sort by one thing after another: sort orders by time, then sort them stably by customer, and each customer’s orders are still in time order. Bubble and insertion sort are stable, because they only move an item past a strictly bigger one. Selection sort is not: its long-distance swap can jump an item over its equal.
Before (by time)
Sorted by customer, stable
Not stable (possible)
Kim, 9:00
Ada, 9:30
Ada, 10:15
Ada, 9:30
Ada, 10:15
Ada, 9:30
Ada, 10:15
Kim, 9:00
Kim, 9:00
QuizInteractive · disabled in the PDF
Selection sort runs on cards {5♥, 5♠, 2♣}, comparing numbers only. What is the result after the first swap?
Selection sort runs on cards {5♥, 5♠, 2♣}, comparing numbers only. What is the result after the first swap?
std::sort is not stable; std::stable_sort is.std::sort is usually introsort: quicksort (module 8), switching to heapsort if quicksort is going badly and to insertion sort for short stretches. It takes O(n log n) comparisons and uses almost no extra memory, but equal items may come out in any order. When ties must keep their order, call std::ranges::stable_sort, which is a merge sort (module 7): also O(n log n), stable, at the price of a buffer of up to n extra items. Short vectors often look stable with std::sort, because they only reach the insertion sort; that is luck, not a promise.
Algorithms47 / 194
Module 5 · Simple sorts
These sorts take n² steps, except on nearly sorted data
All three use a loop inside a loop. For a list of n items, the outer loop runs about n times and the inner one up to n times, so in the worst case they take about n² steps: O(n²), quadratic. Doubling the list makes them four times slower. Insertion sort has a best case, though: on a list that is already nearly sorted, each card moves only a little, and the whole sort is close to n steps.
Sort
Worst case
Already sorted
Stable
Swaps
Bubble
O(n²)
O(n²), or O(n) if it stops after a pass with no swap
yes
many
Selection
O(n²)
O(n²)
no
at most n
Insertion
O(n²)
O(n)
yes
many shifts
PlaygroundInteractive · disabled in the PDF
C++
1#include<iostream>2#include<random>3#include<string>4#include<vector>56voidrun(const std::string& name, std::vector<int> nums){7longlong shifts =0;8for(int i =1; i < std::ssize(nums); i++){9int card = nums[i];10int j = i -1;11while(j >=0&& nums[j]> card){12 nums[j +1]= nums[j];13 j--;14 shifts++;15}16 nums[j +1]= card;17}18 std::cout << name <<": "<< shifts <<" shifts\n";19}2021intmain(){22int n =2000;23 std::mt19937random(42); // makes random numbers, from seed 4224 std::vector<int>shuffled(n),nearly(n);25for(int i =0; i < n; i++) shuffled[i]=random()% n;26for(int i =0; i < n; i++) nearly[i]= i;27 nearly[5]=900;28run("random", shuffled);29run("nearly sorted", nearly);30}
Random data takes about a million shifts; nearly sorted, about a thousand. Double n and watch the random case grow four times. long long is an integer type that holds far bigger numbers than int.
QuizInteractive · disabled in the PDF
A list of 10,000 scores is sorted, then 3 new scores are added at the end. Which simple sort re-sorts it fastest?
A list of 10,000 scores is sorted, then 3 new scores are added at the end. Which simple sort re-sorts it fastest?
Algorithms48 / 194
Module 5 · Simple sorts
Recap
Sorting makes the smallest easy to find, groups duplicates and allows binary search.
std::ranges::sort sorts in place with <: strings compare as text, capitals first.
A comparator returns true when a must come before b; it must be strict: <, never <=.
Bubble sort swaps neighbours; selection sort picks the smallest; insertion sort slides each item into a sorted hand.
All three are O(n²); insertion sort is close to O(n) on nearly sorted data.
A stable sort keeps equal items in their original order: std::stable_sort is, std::sort is not.
FlashcardsInteractive · disabled in the PDF
Sorting {"10", "9", "1"} as strings
{"1", "10", "9"}: strings compare as text, character by character.
Comparator
A function (a, b) that returns true when a must come before b.
Strict weak ordering
What a comparator must be: never true for an item and itself, and consistent. < is; <= is not.
In place
Sorting inside the same vector, without making a copy.
Bubble sort
Swap out-of-order neighbours, pass after pass, until nothing moves.
Selection sort
Find the smallest of the rest and swap it to the front, round by round.
Insertion sort
Take each item and slide it left into the sorted part, like a hand of cards.
Stable sort
Equal items keep the order they had before sorting.
std::sort vs std::stable_sort
Both O(n log n). Only stable_sort keeps equal items in order; it needs extra memory.
Module 6: Recursion
Algorithms49 / 194
Module 6 · Recursion
A recursive function solves a smaller copy of the problem
You are in a long queue at a cinema and want to know your place. You cannot see the front, so you ask the person ahead: “What is your place?” They ask the person ahead of them, and so on, until the person at the front answers “1”. Each answer comes back one step: “2”, “3”… and you add one to what you hear. Nobody solved the whole problem; each person solved a smaller one by asking the next. That is recursion: a function that calls itself on a smaller input.
Recursion
Solving a problem by solving a smaller version of the same problem.
Recursive function
A function that calls itself, each time with an input closer to the simplest case.
Base case
The simplest input, answered straight away with no further call: the front of the queue.
Recursive case
Every other input: make it smaller, call yourself, and use the answer.
C++
1voidcountdown(int n){2if(n ==0){3 std::cout <<"Lift off\n"; // base case: stop here4return;5}6 std::cout << n <<"\n";7countdown(n -1); // recursive case: a smaller n8}910intmain(){11countdown(3); // 3, 2, 1, Lift off12}
Each call handles one number and leaves the rest to a call on a smaller one.
Algorithms50 / 194
Module 6 · Recursion
Every recursion needs a base case it is sure to reach
The factorial of a whole number n, written n!, is 1 × 2 × … × n: the number of ways to arrange n books on a shelf. It has a recursive definition: n! = n × (n - 1)!, and 1! = 1. That definition is already the code. The base case stops the calls; the recursive case moves towards it, because n - 1 is smaller than n.
Lines 1: Factorials grow fast, so the answer is a long long, an integer type far bigger than int. Even it overflows past 20!.
Lines 2: The base case: 1! is 1. Writing <= 1 also covers 0!, which is 1 too.
Lines 3: The recursive case: work out (n - 1)! with a call, then multiply by n. This call waits for that answer.
Lines 7:factorial(4) asks factorial(3), which asks factorial(2), then factorial(1), which answers 1.
Fill the blanksInteractive · disabled in the PDF
Write power(base, exp), base multiplied by itself exp times, recursively. Anything to the power 0 is 1.
C++
1longlongpower(longlong base,int exp){2if(exp ==)return;3return base *power(base,);4}
Algorithms51 / 194
Module 6 · Recursion
The call stack remembers every call that is waiting
When a function calls another, the computer pauses the first and remembers where it was, with all its variables. That memory is a stack frame, and the frames pile up on the call stack: a stack, like a pile of plates, where the newest goes on top and comes off first (the Data structures course covers stacks). A recursive function puts one frame per call on the pile, each with its own n.
factorial(1)base case: returns 1
factorial(2)waiting to do 2 × ?
factorial(3)waiting to do 3 × ?
mainwaiting to print the answer
The stack at its deepest, during factorial(3). The top frame answers first; then each one below finishes.
Put in orderInteractive · disabled in the PDF
Put what happens during factorial(3) in order.
1.factorial(3) returns 3 × 2 = 6
2.factorial(2) returns 2 × 1 = 2
3.factorial(3) calls factorial(2) and waits
4.factorial(1) returns 1
5.factorial(2) calls factorial(1) and waits
Predict the outputInteractive · disabled in the PDF
What does this print?
C++
1voidshow(int n){2if(n ==0)return;3show(n -1);4 std::cout << n <<"\n";5}67intmain(){show(3);}
What does this print?
Algorithms52 / 194
Module 6 · Recursion
A list is its first item and the rest
Recursion fits lists well once you see a list as “the first item, followed by a shorter list”. The sum of {3, 5, 2} is 3 plus the sum of {5, 2}. The base case is the empty list, whose sum is 0. In C++ “the rest” is cheapest as an index: sumFrom(nums, i) is the sum of the items from index i on, so sumFrom(nums, 0) = 3 + sumFrom(nums, 1) = 3 + 5 + sumFrom(nums, 2) = 3 + 5 + 2 + sumFrom(nums, 3) = 3 + 5 + 2 + 0 = 10.
Code challengeInteractive · disabled in the PDF
Finish sumFrom(nums, i)recursively: the sum of the numbers from index i to the end. When i is the size there is nothing left, so the sum is 0; otherwise it is nums[i] plus the sum from i + 1.
C++
1#include<vector>23intsumFrom(const std::vector<int>& nums,int i){4 // TODO: a base case, and a recursive case5return0;6}
Copying the rest costs more than it looks. You could pass the rest as a new vector, std::vector<int>(nums.begin() + 1, nums.end()), but that copies the rest of the list on every call, so summing n numbers copies about n²/2 items: O(n²) for an O(n) job. An index copies nothing. So does std::span<const int> (C++20, in <span>): a view of part of a vector, just a pointer and a length, whose subspan(1) is “the rest” without a copy.
Algorithms53 / 194
Module 6 · Recursion
Recursion is natural for nested data
Some data contains smaller copies of itself: a folder holds files and other folders, which hold files and folders, to any depth. A loop does not know how deep to go; recursion does not need to. To find a folder’s total size, add its own files, then ask each subfolder for its total, the same question on a smaller folder. A folder with no subfolders is the base case: no calls are made.
Photosfiles: 2 · total 17
2024files: 5, 3 · total 11
Summerfiles: 3 · total 3
2025files: 4 · total 4
Sizes in megabytes. Each folder’s total is its files plus the totals of its folders.
Code challengeInteractive · disabled in the PDF
Finish totalSize(folder): the sizes in its own files, plus the totalSize of each folder in folders, however deep.
C++
1#include<string>2#include<vector>34structFolder{5 std::string name;6 std::vector<int> files;7 std::vector<Folder> folders;8};910inttotalSize(constFolder& folder){11int total =0;12for(int size : folder.files) total += size;13 // TODO: add each subfolder's total14return total;15}
Algorithms54 / 194
Module 6 · Recursion
Too deep, and the stack overflows
The call stack has a limited size, fixed when the program starts: 8 MB here, often 1 to 8 MB on a desktop. If the calls never reach a base case, or the input is simply very deep, the stack fills up: a stack overflow. C++ cannot recover from it. A desktop program dies with “segmentation fault”; here the runner stops it and says so. Simple functions get tens of thousands of calls deep before that; bigger frames, fewer.
C++
1int depth =0;23voiddive(){4 depth++;5dive(); // no base case: it never stops calling6 depth--; // never reached7}89intmain(){10dive();11 std::cout << depth;12}1314// It crashed: the recursion went too deep (a stack overflow).
What the runner says. Nothing after the crash runs, so depth is never printed.
Find the bugInteractive · disabled in the PDF
sumFrom({1, 2}, 0) never finishes. Which line is wrong?
C++
PlaygroundInteractive · disabled in the PDF
C++
1#include<algorithm>2#include<iostream>3#include<vector>45int deepest =0;67longlongsumFrom(const std::vector<int>& nums,int i){8 deepest = std::max(deepest, i);9if(i == std::ssize(nums))return0;10return nums[i]+sumFrom(nums, i +1);11}1213intmain(){14 std::vector<int>nums(10'000,1);15 std::cout <<"recursion: "<<sumFrom(nums,0)<<", "16<< deepest <<" calls deep\n";1718 std::vector<int>many(1'000'000,1);19longlong total =0;20for(int x : many) total += x;21 std::cout <<"a loop over a million: "<< total <<"\n";22}
Recursion is fine 10,000 deep. For a million, use the loop: it needs no frames at all.
Algorithms55 / 194
Module 6 · Recursion
Recursion and loops can do the same work
Anything written with recursion can be written with a loop, and the other way round. Choose by clarity. A loop is better for flat, long work (summing a list, counting to a million): no frames pile up, and no overflow. Recursion is better when the problem is nested or splits into smaller pieces, like folders, trees, or the sorts in the next two modules, where a loop would need a stack of its own.
Recursive
longlongfactorial(int n){if(n <=1)return1;return n *factorial(n -1);}
With a loop
longlongfactorial(int n){longlong result =1;for(int i =2; i <= n; i++) result *= i;return result;}
Tail calls: the optimiser may help, but C++ does not promise it. A tail call is a call that is the very last thing a function does, as in return sumFrom(nums, i + 1, total + nums[i]);. An optimising compiler such as Clang can reuse the frame for it, turning the recursion into a loop, and sometimes it rewrites simple cases like nums[i] + sumFrom(…) too. But the C++ standard never requires it, and a build without optimisation (the usual one while debugging) does not do it. So code that is correct only if the optimiser flattens it is a crash waiting to happen: any recursion that might go many thousands of calls deep should be a loop.
QuizInteractive · disabled in the PDF
Which job is the best fit for recursion?
Which job is the best fit for recursion?
Algorithms56 / 194
Module 6 · Recursion
Recap
A recursive function calls itself on a smaller input, and trusts that call to be right.
The base case answers directly; the recursive case must move towards it.
Every waiting call is a frame on the call stack; the newest finishes first.
Work before the call happens on the way down; work after it, on the way back up.
Recursion suits nested data (folders, trees) and problems that split in pieces.
Too many frames overflow the stack and the program dies; C++ does not promise to optimise tail calls, so use a loop for long, flat work.
FlashcardsInteractive · disabled in the PDF
Recursion
Solving a problem by solving a smaller version of the same problem.
Base case
The input simple enough to answer with no further call.
Recursive case
Make the input smaller, call yourself, use the answer.
Call stack
The pile of frames for the calls still waiting, newest on top.
Stack frame
One call’s memory: where it was and its own variables.
Stack overflow
Too many frames at once: the stack is full and the program crashes.
Tail call
A call that is the last thing a function does. The optimiser may reuse its frame; C++ does not promise it.
n! (factorial)
1 × 2 × … × n; recursively n × (n - 1)!, with 1! = 1.
Module 7: Merge sort
Algorithms57 / 194
Module 7 · Merge sort
Divide and conquer: split, solve, combine
A teacher has 200 exam papers to sort by name. She splits the pile in two and hands each half to a helper, who splits theirs again, and so on, until everyone holds a single paper, which is sorted already. Then the piles come back together: each pair of sorted piles is combined into one sorted pile, all the way up. This is divide and conquer: split a problem into smaller ones of the same kind, solve those (often recursively), and combine their answers.
Divide and conquer
Split the problem into smaller copies, solve each, and combine the results.
Merge
Combine two sorted lists into one sorted list.
Merge sort
Split the list in half, merge-sort each half, then merge the two sorted halves.
Divide: cut the list in two halves.
Conquer: sort each half, the same way, recursively.
Combine: merge the two sorted halves into one sorted list.
The three moves of divide and conquer, on a list.
Algorithms58 / 194
Module 7 · Merge sort
Merging two sorted piles is easy
Two piles of cards, each sorted with the smallest on top. To combine them, look at the two top cards, take the smaller one, and put it on your new pile. Repeat. When one pile runs out, put the whole other pile on the end: it is already sorted. Every card is looked at once, so merging lists of total length n takes about n steps. Two indexes, i and j, mark the top of each pile. (People call them pointers, though here they are plain indexes, not C++ pointers.)
a (top at i)
b (top at j)
Take
Result so far
2, 5
1, 8
1 from b
{1}
2, 5
8
2 from a
{1, 2}
5
8
5 from a
{1, 2, 5}
(empty)
8
the rest of b
{1, 2, 5, 8}
Predict the outputInteractive · disabled in the PDF
This merge forgets something. What does merge({1, 4}, {2, 3}) return?
This merge forgets something. What does merge({1, 4}, {2, 3}) return?
Algorithms59 / 194
Module 7 · Merge sort
Try it: merge two sorted lists
Code challengeInteractive · disabled in the PDF
Finish merge(a, b): both vectors are sorted, smallest first. Return one new sorted vector with every number from both, in about a.size() + b.size() steps (no std::sort).
C++
1#include<vector>23std::vector<int>merge(const std::vector<int>& a,4const std::vector<int>& b){5 std::vector<int> out;6size_t i =0, j =0;7 // TODO: take the smaller front item until one runs out8return out;9}
Algorithms60 / 194
Module 7 · Merge sort
Merge sort: split, sort both halves, merge
With merge in hand, merge sort is three lines of recursion. The base case: a list of 0 or 1 items is already sorted. The recursive case: cut the list at its middle, merge-sort each half, and merge the results.
WalkthroughInteractive · disabled in the PDF
C++
1std::vector<int>mergeSort(const std::vector<int>& nums){2if(nums.size()<=1)return nums;3auto mid = nums.begin()+ nums.size()/2;4auto left =mergeSort(std::vector<int>(nums.begin(), mid));5auto right =mergeSort(std::vector<int>(mid, nums.end()));6returnmerge(left, right);7}89intmain(){10 std::vector<int> v ={5,2,8,1,9};11 std::vector<int> sorted =mergeSort(v); // {1, 2, 5, 8, 9}12}
Lines 2: The base case: nothing or one item needs no sorting. Returning nums copies it.
Lines 3: An iterator to the middle. Dividing the size rounds down, so 5 items split into 2 and 3.
Lines 4-5:std::vector<int>(first, last) copies the items from first up to, not including, last: the left half, then the right. Each half is sorted by a recursive call. auto lets the compiler write the type, std::vector<int>, for you.
Lines 6: Both halves are sorted now, so merge from the last slide combines them.
Lines 11: It returns a new vector; v is not changed.
Put in orderInteractive · disabled in the PDF
Put the steps of merge sort, for one call, in order.
1.Find the middle index
2.Merge the two sorted halves and return the result
3.If the list has 0 or 1 items, return it
4.Merge-sort the left half
5.Merge-sort the right half
Algorithms61 / 194
Module 7 · Merge sort
Why merge sort takes n log n steps
Draw every call as a tree. The list halves at each level, so after log₂ n levels the pieces have one item each: 8 items take 3 levels, a million about 20. (log₂ n is how many times you can halve n before reaching 1.) On the way back up, each level merges pieces whose lengths add up to n, so each level costs about n steps. log n levels of n steps each: O(n log n), in every case, sorted input or not.
{5, 2, 8, 1}merges to {1, 2, 5, 8}
{5, 2}merges to {2, 5}
{5}
{2}
{8, 1}merges to {1, 8}
{8}
{1}
Splitting goes down; each node’s sorted result is merged on the way back up.
n
log₂ n
n log n (merge sort)
n² (insertion sort)
8
3
24
64
1,000
10
10,000
1,000,000
1,000,000
20
20,000,000
1,000,000,000,000
QuizInteractive · disabled in the PDF
Merge sort runs on 1,024 items. How many levels of splitting until every piece has one item?
Merge sort runs on 1,024 items. How many levels of splitting until every piece has one item?
Algorithms62 / 194
Module 7 · Merge sort
Try it: merge sort with a comparator
A generic merge sort takes a comparator, like std::sort, so it can sort books, scores or anything else: template <typename T, typename Less> makes one function for any item type T and any comparator Less. merge is written for you; write the recursion. The last test counts the comparisons with a lambda that adds 1 to a counter each time, to check the sort is really n log n.
Code challengeInteractive · disabled in the PDF
Finish sortBy(items, less) as a merge sort: return a new sorted vector. A vector of 0 or 1 items comes back as it is; otherwise sort each half and merge them.
C++
1#include<vector>23template<typenameT,typenameLess>4std::vector<T>merge(const std::vector<T>& a,5const std::vector<T>& b,Less less){6 std::vector<T> out;7size_t i =0, j =0;8while(i < a.size()&& j < b.size()){9if(less(b[j], a[i])) out.push_back(b[j++]);10else out.push_back(a[i++]);11}12while(i < a.size()) out.push_back(a[i++]);13while(j < b.size()) out.push_back(b[j++]);14return out;15}1617template<typenameT,typenameLess>18std::vector<T>sortBy(const std::vector<T>& items,Less less){19 // TODO: the base case, then split, sort both halves, merge20return items;21}
Algorithms63 / 194
Module 7 · Merge sort
Merge sort is stable, if the merge takes left on ties
Recall that a stable sort keeps equal items in their original order. Everything in the left half came before everything in the right half, so if merge takes the left item when two are equal, ties never swap. That one character, <= rather than <, is the whole difference.
Find the bugInteractive · disabled in the PDF
This merge sorts by price correctly, but books with the same price come out in the wrong order. Which line?
C++
std::stable_sort is a merge sort.std::ranges::stable_sort is a merge sort, tuned: it sorts short stretches with insertion sort, which is fastest on tiny lists, and merges them, taking from the left on ties. It asks for a buffer of n items; if memory is short it falls back to merging in place, which is slower, O(n log² n). Python’s and Java’s sorts for objects (TimSort) are merge sorts for the same reason: stability. std::sort is not one, which is why it may reorder equal items.
Algorithms64 / 194
Module 7 · Merge sort
The price is extra memory
Merging needs somewhere to put the result, so merge sort uses about n extra slots of memory: O(n) extra space. Insertion sort works in place with O(1) extra space. That is the trade: merge sort is far faster on big lists, insertion sort needs no extra memory and wins on tiny or nearly sorted ones. Merge sort also always does the same amount of work, n log n, even on a sorted list.
PlaygroundInteractive · disabled in the PDF
C++
1#include<algorithm>2#include<chrono>3#include<iostream>4#include<random>5#include<vector>6usingClock= std::chrono::steady_clock;78voidinsertionSort(std::vector<int>& nums){9for(int i =1; i < std::ssize(nums); i++){10int card = nums[i], j = i -1;11for(; j >=0&& nums[j]> card; j--) nums[j +1]= nums[j];12 nums[j +1]= card;13}14}1516intmain(){17 std::mt19937random(7);18for(int n : {10'000,20'000,40'000}){19 std::vector<int>a(n);20for(int& x : a) x =random()%1'000'000;21 std::vector<int> b = a;22auto t0 = Clock::now();23insertionSort(a);24auto t1 = Clock::now();25 std::ranges::stable_sort(b); // a merge sort26auto t2 = Clock::now();27 std::chrono::duration<double, std::milli> ins = t1 - t0;28 std::chrono::duration<double, std::milli> mer = t2 - t1;29 std::cout << n <<": insertion "<< ins.count()<<" ms, merge "30<< mer.count()<<" ms, same: "<<(a == b)<<"\n";31}32}
Each doubling makes insertion sort about four times slower and merge sort only a little over two. using Clock = … gives the clock’s long name a short one; int& x in the loop lets it change each element. The last column uses both results, so the compiler cannot skip a sort whose result is never read.
Merge sort and memory. Merging reads both inputs and writes the output in order, front to back. Processors love that: memory is fetched in blocks (cache lines), and a sequential scan uses every byte of each block. It is also why merge sort is how databases sort data bigger than memory (external sorting): sort chunks that fit, write them to disk, then merge the sorted files, reading each from the front. On linked lists, merging relinks nodes, so it needs no extra memory at all: std::list has its own sort(), a merge sort.
QuizInteractive · disabled in the PDF
Which statements about merge sort are true? Pick all that apply.
Which statements about merge sort are true? Pick all that apply.
Algorithms65 / 194
Module 7 · Merge sort
Recap
Divide and conquer: split the problem, solve the parts recursively, combine the answers.
Merging two sorted lists takes the smaller front item each time, then adds the leftovers: O(n).
Merge sort: a list of 0 or 1 is sorted; otherwise sort both halves and merge them.
There are log₂ n levels of n steps each, so merge sort is O(n log n) in every case.
Taking from the left on ties (<=) makes it stable.
It needs O(n) extra memory; std::stable_sort is a tuned merge sort, std::ranges::merge merges.
FlashcardsInteractive · disabled in the PDF
Divide and conquer
Split into smaller problems of the same kind, solve each, combine.
Merge
Combine two sorted lists into one sorted list in O(n), taking the smaller front item.
Merge sort’s base case
A list of 0 or 1 items, which is already sorted.
log₂ n
How many times you can halve n before reaching 1: about 20 for a million.
Merge sort’s running time
O(n log n): log n levels, each merging n items. Best and worst case alike.
Why merge sort is stable
On a tie, the merge takes the item from the left half, which came first.
Merge sort’s extra space
O(n), for the merged output. Insertion sort needs O(1).
External sorting
Sorting data bigger than memory: sort chunks, then merge the sorted files.
Module 8: Quicksort
Algorithms66 / 194
Module 8 · Quicksort
Pick a pivot, split the rest around it
Imagine sorting a pile of exam papers by mark. Pick one paper, say the one marked 5: the pivot. Put every paper with a lower mark on its left, every other paper on its right. The pivot is now exactly where it belongs in the final order. Do the same to the left pile and to the right pile, and keep going until every pile has one paper or none. That is quicksort.
Like merge sort, it is divide and conquer: split the problem into smaller problems of the same kind, solve them with the same function (recursion: a function that calls itself, stopping at a base case), and combine. Merge sort does the work when combining; quicksort does it when splitting, so there is nothing left to combine.
{7, 2, 9, 1, 5}pivot 5
{2, 1}pivot 1
{ }
{2}
5in place
{7, 9}pivot 9
{7}
{ }
The last element is the pivot each time. Once a pivot is placed, it never moves again.
Pivot
The element the others are compared with to split the list.
Partition
Rearranging a list so smaller items come before the pivot and the rest after it.
In place
Sorting inside the vector itself, without making a second one.
Algorithms67 / 194
Module 8 · Quicksort
Partitioning: one scan, one wall
The splitting step is called partitioning. This course uses Lomuto’s scheme, the easiest to get right. The pivot is the last element. A marker i is a wall: everything left of it is smaller than the pivot. A second index j scans from left to right; each time it finds something smaller, it swaps it over the wall and moves the wall one place right. At the end, the pivot is swapped onto the wall, its final place.
Lines 1: Partition the part of a from index lo to index hi, both included. a is a reference: the caller’s vector is rearranged.
Lines 2: The pivot is the last element of that part.
Lines 3: The wall starts at the left: nothing is known to be smaller yet.
Lines 4:j visits every element except the pivot itself.
Lines 5-8: A smaller one goes over the wall, into place i, and the wall moves right.
Lines 10-11: The pivot goes onto the wall. Its index is returned: that is where it belongs.
Algorithms68 / 194
Module 8 · Quicksort
Trace a partition by hand
Here is partition on {7, 2, 9, 1, 5}, with lo = 0, hi = 4, so the pivot is 5.
j
a[j]
Smaller than 5?
Vector after
i after
start
–
–
{7, 2, 9, 1, 5}
0
0
7
no
{7, 2, 9, 1, 5}
0
1
2
yes: swap a[0], a[1]
{2, 7, 9, 1, 5}
1
2
9
no
{2, 7, 9, 1, 5}
1
3
1
yes: swap a[1], a[3]
{2, 1, 9, 7, 5}
2
end
–
swap a[2], a[4]
{2, 1, 5, 7, 9}
returns 2
Put in orderInteractive · disabled in the PDF
Put the steps of a Lomuto partition in order.
1.Swap the pivot onto the wall and return the wall’s index
2.Take the last element as the pivot
3.Put the wall at the start of the range
4.Scan each other element from left to right
5.If it is smaller than the pivot, swap it onto the wall and move the wall right
Predict the outputInteractive · disabled in the PDF
What is in a afterwards, and what is p?
C++
1std::vector<int> a ={3,8,1,4};2int p =partition(a,0,3);
What is in a afterwards, and what is p?
Algorithms69 / 194
Module 8 · Quicksort
Try it: write partition
Code challengeInteractive · disabled in the PDF
Finish partition(a, lo, hi) with the last element as pivot: move every element smaller than the pivot before it, everything else after it, and return the pivot’s new index.
C++
1#include<utility>2#include<vector>34intpartition(std::vector<int>& a,int lo,int hi){5int pivot = a[hi];6 // TODO: move the smaller ones left, then place the pivot7return lo;8}
Algorithms70 / 194
Module 8 · Quicksort
Quicksort: partition, then sort each side
With partition written, quicksort is short. Partition the range; the pivot is now in place at p. Sort the part before it, lo to p - 1, and the part after it, p + 1 to hi. The base case: a range of one element or none (lo >= hi) is already sorted.
On {1, 2} this crashes: “the recursion went too deep (a stack overflow)”. Which line is wrong?
C++
Algorithms71 / 194
Module 8 · Quicksort
Try it: sort in place
Code challengeInteractive · disabled in the PDF
partition is written for you. Finish quickSort(a, lo, hi) so it sorts a from lo to hi in place, smallest first. The one-argument quickSort(a) sorts the whole vector with it.
Quicksort is fast when the pivot lands near the middle: each split halves the problem, so there are about log₂ n levels of splitting, each costing n comparisons, O(n log n) in all. But if the pivot is always the largest or smallest element, one side is empty and the other has n - 1 elements. Then there are n levels, and the work is n + (n - 1) + … + 1, O(n²).
QuizInteractive · disabled in the PDF
With the last element as pivot, which input is the slowest to sort?
With the last element as pivot, which input is the slowest to sort?
PlaygroundInteractive · disabled in the PDF
C++
1#include<iostream>2#include<random>3#include<vector>45longlong comparisons =0;67intpartition(std::vector<int>& a,int lo,int hi){8int i = lo;9for(int j = lo; j < hi; j++){10 comparisons++;11if(a[j]< a[hi]) std::swap(a[i++], a[j]);12}13 std::swap(a[i], a[hi]);14return i;15}1617voidquickSort(std::vector<int>& a,int lo,int hi){18if(lo >= hi)return;19int p =partition(a, lo, hi);20quickSort(a, lo, p -1);21quickSort(a, p +1, hi);22}2324intmain(){25int n =2000;26 std::mt19937random(1);27 std::vector<int>shuffled(n),sorted(n);28for(int i =0; i < n; i++) shuffled[i]=random(), sorted[i]= i;29for(auto* a : {&shuffled,&sorted}){30 comparisons =0;31quickSort(*a,0, n -1);32 std::cout << comparisons <<" comparisons\n";33}34}
Shuffled: about 22,000 comparisons. Sorted: 1,999,000, about n²/2. Double n and watch the sorted count quadruple. (auto* a walks two pointers, one to each vector, so the loop sorts the vectors themselves, not copies.)
The stack runs out too. On sorted input the recursion is also n calls deep. Each call waits on the call stack, and with tens of thousands of elements the stack overflows and the program dies. Library quicksorts guard against it by recursing only into the smaller side and looping on the bigger one, which keeps the depth under log₂ n.
Algorithms73 / 194
Module 8 · Quicksort
A random pivot fixes the worst case
The fix is simple: before partitioning, pick a random element and swap it to the end, so it becomes the pivot. No particular input is bad any more: a sorted list is as fast as a shuffled one. Bad splits can still happen by chance, but long runs of them are so unlikely that the expected time is O(n log n) on every input.
std::mt19937 (in <random>) makes random numbers, seeded from std::random_device, the system’s source of randomness; pick(rng) is an index from lo to hi, both included. (Not named random: outside a function, that name is taken by an old C function.) The rest of quicksort is unchanged.
Quicksort
Merge sort
Average time
O(n log n)
O(n log n)
Worst time
O(n²), rare with a random pivot
O(n log n)
Extra memory
O(log n) expected, for the call stack
O(n) for merging
Stable
No
Yes
Duplicates, stability and what engines use. Two weaknesses remain. A list of all-equal values is also O(n²) with Lomuto, since nothing is smaller than the pivot; real libraries use a three-way partition (smaller, equal, larger). And swapping over long distances makes quicksort unstable: equal items can change order. That is why the C++ library has two: std::sort is usually introsort, a quicksort that picks its pivot as the median of a few elements, falls back to heapsort when the recursion gets too deep (so its worst case is O(n log n) too), and finishes short stretches with insertion sort. std::stable_sort is the merge sort, for when ties must keep their order.
Algorithms74 / 194
Module 8 · Quicksort
Quickselect finds the k-th smallest
Sometimes you only need one element of the sorted order: the median price, the third-fastest lap. Quickselect partitions once, then looks at where the pivot landed. If it is the position you want, done. If the position is to its left, carry on only in the left part; otherwise only in the right. Throwing half away each time costs n + n/2 + n/4 + … ≈ 2n: O(n) on average, faster than sorting.
Code challengeInteractive · disabled in the PDF
partition is given. Finish kthSmallest(a, k): return the k-th smallest number, where k = 1 is the smallest. a is taken by value, a copy, so you may rearrange it freely. Don’t sort the whole list.
C++
1#include<utility>2#include<vector>34intpartition(std::vector<int>& a,int lo,int hi){5int pivot = a[hi];6int i = lo;7for(int j = lo; j < hi; j++){8if(a[j]< pivot) std::swap(a[i++], a[j]);9}10 std::swap(a[i], a[hi]);11return i;12}1314intkthSmallest(std::vector<int> a,int k){15 // TODO: partition until the pivot lands at index k - 116return a[0];17}
The library’s quickselect.std::ranges::nth_element(v, v.begin() + 2) rearranges v so that v[2] holds what would be there if v were sorted, everything before it is no bigger and everything after no smaller: quickselect, O(n) on average. For the median, use v.begin() + v.size() / 2. And std::ranges::partial_sort sorts just the first few, for a top ten.
Algorithms75 / 194
Module 8 · Quicksort
Recap
Quicksort picks a pivot, partitions the rest around it, then sorts each side.
Lomuto’s partition: last element as pivot, a wall i, a scan j, a final swap onto the wall.
Recurse on lo..p-1 and p+1..hi; a range of one or none is the base case.
Good pivots give O(n log n); always picking the largest or smallest gives O(n²), as on sorted input.
A random pivot makes the expected time O(n log n) on every input.
It sorts in place with O(log n) extra memory, but is not stable.
Quickselect follows only one side and finds the k-th smallest in O(n) on average: std::ranges::nth_element.
FlashcardsInteractive · disabled in the PDF
Pivot
The element the others are compared with to split the list.
Partition
Smaller items before the pivot, the rest after; the pivot ends in its final place.
Lomuto’s wall i
Everything left of it is smaller than the pivot.
Quicksort’s worst case
O(n²), when every pivot is the largest or smallest, as with a sorted list and last-element pivots.
Why a random pivot?
So no input is reliably bad: expected O(n log n) on every input.
Is quicksort stable?
No: long-distance swaps can reorder equal items.
Quickselect
Partition, then follow only the side holding index k - 1: O(n) on average.
What does std::sort use?
Introsort: quicksort, with heapsort if it goes too deep and insertion sort for short stretches. Not stable.
std::nth_element
The library’s quickselect: it puts at a chosen index k what sorting would put there, O(n) on average.
Module 9: Two pointers and sliding windows
Algorithms76 / 194
Module 9 · Two pointers and sliding windows
Two fingers on a list beat trying every pair
Many problems ask about pairs or stretches of a list: two prices that add up to a gift card, the best five days of sales in a row. The obvious way tries every pair with two nested loops, which is O(n²). The two-pointer technique keeps two indexes, like two fingers on the list, and moves them in a way that never needs to look back. Each finger crosses the list at most once, so the work is O(n).
Pointer
Here, just an index into a vector that you move along: lo, hi, left, right. Not a C++ pointer (a memory address), though an iterator or a real pointer could play the same part.
Two pointers
Two indexes moving through a list, often from both ends towards the middle.
Window
The stretch of the list between two pointers, from left to right.
Sliding window
A window that moves along the list, adding an item on one side and dropping one on the other.
QuizInteractive · disabled in the PDF
A list has 1000 prices. Roughly how many different pairs does “try every pair” check?
A list has 1000 prices. Roughly how many different pairs does “try every pair” check?
Algorithms77 / 194
Module 9 · Two pointers and sliding windows
A pair with a given sum, in a sorted list
You have a gift card for 13 and a sorted list of book prices, {1, 3, 4, 6, 9}; find two books that use it exactly. Put one finger on the cheapest book (lo) and one on the dearest (hi). If the two add up to too little, the cheap one is too cheap: move lo right. Too much: the dear one is too dear, move hi left. Equal: done. If the fingers meet, there is no such pair. The answer is two indexes, so it comes back as a std::pair<int, int>, two values called first and second, inside an std::optional for “no pair”.
WalkthroughInteractive · disabled in the PDF
C++
1std::optional<std::pair<int,int>>pairWithSum(2const std::vector<int>& prices,int target){3int lo =0;4int hi = std::ssize(prices)-1;5while(lo < hi){6int sum = prices[lo]+ prices[hi];7if(sum == target)return std::pair{lo, hi};8if(sum < target) lo++;9else hi--;10}11return std::nullopt;12}
Lines 1-2: Maybe a pair of indexes, from a sorted list and a target.
Lines 3-4: One pointer at each end of the sorted list.
Lines 5: Keep going while they point at two different books.
Lines 6-7: Add the two prices. A match is the answer: their indexes, as a pair.
Lines 8: Too little: only a dearer left book can help, so move lo right.
Lines 9: Too much: only a cheaper right book can help, so move hi left.
Lines 11: The pointers met without a match: no pair adds up to the target.
lo
hi
Prices
Sum
Move
0
4
1 + 9
10, too little
lo++
1
4
3 + 9
12, too little
lo++
2
4
4 + 9
13
found: {2, 4}
Algorithms78 / 194
Module 9 · Two pointers and sliding windows
Why moving one pointer never skips the answer
When the sum is too small, prices[lo] cannot be part of any answer: even with the dearest book left, prices[hi], it falls short, and every other book is cheaper. So dropping it loses nothing. The same reasoning, mirrored, drops prices[hi] when the sum is too big. Each step rules out one book for good, which is why at most n steps are needed. And it is why the list must be sorted.
Find the bugInteractive · disabled in the PDF
pairWithSum({5}, 10) returns {0, 0}, pairing the only book with itself. Which line is wrong?
C++
Code challengeInteractive · disabled in the PDF
Finish pairWithSum(prices, target): prices is sorted from cheapest to dearest. Return the indexes {i, j} (with i < j) of two prices adding up to target, or std::nullopt if there are none. Use two pointers.
To reverse a playlist without a second vector, swap the first and last songs, then the second and the second-last, and so on, moving both pointers inwards until they meet. Each swap fixes two places, so n/2 swaps reverse the whole list. The same walk checks whether a word is a palindrome, one that reads the same both ways (“level”): compare the two ends instead of swapping them.
Fill the blanksInteractive · disabled in the PDF
Reverse the list in place.
C++
1voidreverse(std::vector<std::string>& songs){2int left =0;3int right = std::ssize(songs)-;4while(left right){5 std::(songs[left], songs[right]);6 left;7 right;8}9}
Code challengeInteractive · disabled in the PDF
Finish isPalindrome(word): return true if the word reads the same forwards and backwards, ignoring upper and lower case. Use two pointers, not a reversed copy.
C++
1#include<cctype>2#include<string>34boolisPalindrome(const std::string& word){5 std::string w = word;6for(char& c : w) c = std::tolower(c);7 // TODO: compare from both ends8returnfalse;9}
Algorithms80 / 194
Module 9 · Two pointers and sliding windows
A read pointer and a write pointer
Two pointers can also move the same way at different speeds. To remove duplicates from a sorted list in place, a read pointer visits every element, and a write pointer marks where the next kept element goes. Because the list is sorted, copies sit next to each other: an element is new if it differs from the last one kept.
You met this in merge sort: one pointer in each of two sorted lists. Compare the two elements they point at, take the smaller into the output, and move that pointer on. When one list runs out, copy the rest of the other. Every element is taken once, so merging lists of length n and m is O(n + m).
a[i++] reads a[i], then adds 1 to i. <= takes from a on a tie, which keeps the merge stable.
Put in orderInteractive · disabled in the PDF
Put the steps of merging two sorted lists in order.
1.Point at the first element of each list
2.Add the smaller to the output and move its pointer on
3.Compare the two elements pointed at
4.Copy whatever is left of the other list
5.Repeat until one list is used up
Algorithms82 / 194
Module 9 · Two pointers and sliding windows
A sliding window reuses the last sum
A shop wants its best k days in a row of sales. Adding up every stretch of k days from scratch costs k additions per stretch: O(n × k). But neighbouring stretches share all but two days. Slide the window one day right: add the day that comes in, subtract the day that falls out. One addition and one subtraction per step: O(n).
Window (k = 3)
Days
Sum
How
0 to 2
4, 2, 7
13
added up once
1 to 3
2, 7, 1
10
13 + 1 − 4
2 to 4
7, 1, 9
17
10 + 9 − 2
3 to 5
1, 9, 3
13
17 + 3 − 7
PlaygroundInteractive · disabled in the PDF
C++
1#include<algorithm>2#include<chrono>3#include<iostream>4#include<random>5#include<vector>6usingClock= std::chrono::steady_clock;78intmain(){9int n =200'000, k =2'000;10 std::mt19937random(3);11 std::vector<int>sales(n);12for(int& s : sales) s =random()%100;1314auto t0 = Clock::now();15longlong best =0;16for(int start =0; start + k <= n; start++){17longlong sum =0;18for(int i = start; i < start + k; i++) sum += sales[i];19 best = std::max(best, sum);20}21auto t1 = Clock::now();22longlong sum =0;23for(int i =0; i < k; i++) sum += sales[i];24longlong best2 = sum;25for(int i = k; i < n; i++){26 sum += sales[i]- sales[i - k];27 best2 = std::max(best2, sum);28}29auto t2 = Clock::now();30 std::chrono::duration<double, std::milli> a = t1 - t0, b = t2 - t1;31 std::cout <<"from scratch: "<< best <<", "<< a.count()<<" ms\n";32 std::cout <<"sliding: "<< best2 <<", "<< b.count()<<" ms\n";33}
Both find the same best sum. Make k 4000 and see which one slows down.
Algorithms83 / 194
Module 9 · Two pointers and sliding windows
Try it: the best k days
Code challengeInteractive · disabled in the PDF
Finish bestRun(sales, k): return the largest sum of k consecutive days, or std::nullopt if there are fewer than k days. Slide the window; don’t add up each stretch from scratch.
C++
1#include<algorithm>2#include<optional>3#include<vector>45std::optional<int>bestRun(const std::vector<int>& sales,int k){6int sum =0;7for(int i =0; i < k && i < std::ssize(sales); i++) sum += sales[i];8 // TODO: slide the window along the rest9return sum;10}
Algorithms84 / 194
Module 9 · Two pointers and sliding windows
A window that grows and shrinks
Some windows change size. Find the longest stretch with no repeats, say of letters in a word. Move right along one letter at a time, adding each to an std::unordered_set (a collection that holds each value once and answers “is this in it?” in about one step; the next module shows how). If the new letter is already in the window, shrink from the left, removing letters, until it is not. The window is then always repeat-free, and its best size is the answer.
WalkthroughInteractive · disabled in the PDF
C++
1intlongestUnique(const std::string& s){2 std::unordered_set<char> inWindow;3int left =0;4int best =0;5for(int right =0; right < std::ssize(s); right++){6while(inWindow.contains(s[right])){7 inWindow.erase(s[left]);8 left++;9}10 inWindow.insert(s[right]);11 best = std::max(best, right - left +1);12}13return best;14}1516// longestUnique("abcabcbb") is 3, for "abc"
Lines 2-4: The letters in the window, its left edge, and the best size so far.
Lines 5: The right edge moves one letter at a time.
Lines 6-9: The new letter is a repeat: drop letters from the left until its copy is gone.
Lines 10-11: Add it. The window is left to right, so its size is right - left + 1.
Lines 16: In “abcabcbb” no window longer than 3 avoids a repeat.
A loop inside a loop, and still O(n). The while inside the for looks like O(n²), but count what happens to each letter: it is added once, when right reaches it, and removed at most once, when left passes it. Over the whole run that is at most 2n steps, so the loop is O(n). This style of counting, the total over the whole run rather than the worst single step, is called amortised analysis.
Code challengeInteractive · disabled in the PDF
Finish longestFresh(songs): return the length of the longest stretch of the playlist in which no song appears twice.
C++
1#include<algorithm>2#include<string>3#include<unordered_set>4#include<vector>56intlongestFresh(const std::vector<std::string>& songs){7 std::unordered_set<std::string> inWindow;8int best =0;9 // TODO: slide a window with left and right pointers10return best;11}
Algorithms85 / 194
Module 9 · Two pointers and sliding windows
Recap
Two pointers replace “try every pair” (O(n²)) with one pass (O(n)).
From both ends of a sorted list: a too-small sum moves lo right, too big moves hi left.
The loop condition is where off-by-one bugs live: lo < hi keeps two different elements.
Swap the ends and move inwards to reverse in place or test a palindrome.
A read and a write pointer remove duplicates from a sorted list in place.
A sliding window adds what comes in and drops what falls out, instead of starting over.
A variable window grows on the right and shrinks on the left; each item enters and leaves once.
FlashcardsInteractive · disabled in the PDF
Two pointers
Two indexes moving through a list so each element is visited about once.
Pair sum: the sum is too small. Which pointer moves?
lo moves right, to a bigger number.
Why must the list be sorted for the pair-sum walk?
So a too-small sum proves the left element can be ruled out for good.
Palindrome
A word that reads the same forwards and backwards, like “level”.
Read and write pointers
One visits every element; the other marks where the next kept one goes.
Sliding window
A stretch that moves along the list, updated by adding one item and dropping one.
Amortised analysis
Counting the total work over the whole run, not the worst single step.
Module 10: Counting and hashing
Algorithms86 / 194
Module 10 · Counting and hashing
Remembering what you have seen saves looking again
To find a topic in a big book, you could read every page, or you could use the index at the back, which someone built once so every later lookup is quick. Many algorithms do the same: spend some memory on a lookup table so they never search the same list twice. In C++ the table is an std::unordered_map (keys to values, in <unordered_map>) or an std::unordered_set (values, each held once, in <unordered_set>).
Both are hash tables (the Data structures course builds one): a key is turned into a number, its hash, which says which slot, or bucket, to keep it in. Finding it again means computing the hash and looking in that one bucket, not scanning. So m.find(k), m[k] and s.contains(x) take about the same time however much is stored: O(1) on average. std::ranges::find on a vector has to check element after element: O(n).
Question
With a vector
With an unordered map or set
Is x in it?
std::ranges::find(v, x), O(n)
s.contains(x), O(1) on average
What goes with key k?
std::ranges::find_if(…), O(n)
m.find(k), O(1) on average
Memory
The list
The list, plus the table
QuizInteractive · disabled in the PDF
For each of n orders, you check whether its customer is on a vector of n members with std::ranges::find(members, id). How much work is that?
For each of n orders, you check whether its customer is on a vector of n members with std::ranges::find(members, id). How much work is that?
“On average” is doing work. Two keys can land in the same bucket: a collision. The table keeps a short list in each bucket, and with a good hash function those lists stay tiny. In the worst case, every key collides and a lookup is O(n). Attackers have used this, sending keys chosen to collide to slow a server down (hash flooding). The standard library does not defend against it: in libc++, std::hash<int> of a number is the number itself, the same in every run. Languages such as Python and Rust randomise their hashing for this reason.
Algorithms87 / 194
Module 10 · Counting and hashing
Two-sum in one pass
In the last module, two pointers found a pair with a given sum, but only in a sorted list. For an unsorted list, walk it once with an unordered_map from each price already seen to its index. For each new price x, the partner it needs is target - x. If the map has it, done; if not, remember x and move on. seen.find(k) gives an iterator to the entry, or seen.end(); an entry is a pair, its key in first and its value in second.
WalkthroughInteractive · disabled in the PDF
C++
1std::optional<std::pair<int,int>>twoSum(2const std::vector<int>& nums,int target){3 std::unordered_map<int,int> seen; // price -> index4for(int i =0; i < std::ssize(nums); i++){5auto it = seen.find(target - nums[i]);6if(it != seen.end())return std::pair{it->second, i};7 seen[nums[i]]= i;8}9return std::nullopt;10}1112// twoSum({8, 3, 5, 7}, 10) is {1, 3}
Lines 3: The lookup table: a price already seen, and the index it was at.
Lines 5: Look up the partner this price needs to make the target.
Lines 6: Seen it before? it->second is the index stored with it; the pair is that earlier index and this one.
Lines 7:seen[k] = v adds the entry, or replaces its value. Remember this price only after checking, so it never pairs with itself.
Lines 12: 3 is stored at index 1; when 7 arrives it needs 3, and finds it.
i
Price
Needs
In seen?
seen after
0
8
2
no
{8: 0}
1
3
7
no
{8: 0, 3: 1}
2
5
5
no (not yet)
{8: 0, 3: 1, 5: 2}
3
7
3
yes, at 1
answer {1, 3}
Algorithms88 / 194
Module 10 · Counting and hashing
Try it: two-sum
PlaygroundInteractive · disabled in the PDF
C++
1#include<chrono>2#include<iostream>3#include<unordered_map>4#include<vector>5usingClock= std::chrono::steady_clock;67intmain(){8int n =20'000, target =-1; // impossible: all the work is done9 std::vector<int>nums(n);10for(int i =0; i < n; i++) nums[i]= i *2;1112auto t0 = Clock::now();13int found =0;14for(int i =0; i < n; i++)15for(int j = i +1; j < n; j++)16if(nums[i]+ nums[j]== target) found++;17auto t1 = Clock::now();18 std::unordered_map<int,int> seen;19for(int i =0; i < n; i++){20if(seen.contains(target - nums[i])) found++;21 seen[nums[i]]= i;22}23auto t2 = Clock::now();24 std::chrono::duration<double, std::milli> a = t1 - t0, b = t2 - t1;25 std::cout <<"every pair: "<< a.count()<<" ms\n";26 std::cout <<"with a map: "<< b.count()<<" ms\n";27 std::cout << found <<" pairs found\n";28}
Double n to 40000: the pairs version takes about four times as long, the map version about twice.
Code challengeInteractive · disabled in the PDF
Finish twoSum(nums, target): the list is not sorted. Return {i, j}, with i < j, the indexes of two different numbers adding up to target, or std::nullopt. Do it in one pass with an unordered_map.
A frequency count says how many times each value appears: how many books of each genre, how many votes for each song. Walk the list once; for each value, add 1 to its count. In C++ that is one short line, because counts[k] on a key that is not there yet adds it with the value 0 (an int that starts empty is 0) and gives you a reference to it. One pass, O(n).
Fill the blanksInteractive · disabled in the PDF
Count the books in each genre.
C++
1std::vector<std::string> genres ={"crime","poetry","crime"};2std::<std::string,int> counts;3for(const std::string& genre : genres) counts[];4// counts["crime"] is , counts["poetry"] is 1
Find the bugInteractive · disabled in the PDF
Every count comes out as 0, however many times a word appears. Which line is wrong?
C++
Algorithms90 / 194
Module 10 · Counting and hashing
Try it: the most borrowed book
Code challengeInteractive · disabled in the PDF
Finish mostCommon(titles): return the title that appears most often, or std::nullopt for an empty list. On a tie, return the title that was seen first.
C++
1#include<optional>2#include<string>3#include<unordered_map>4#include<vector>56std::optional<std::string>mostCommon(7const std::vector<std::string>& titles){8 // TODO: count each title, then pick the biggest count9if(titles.empty())return std::nullopt;10return titles[0];11}
unordered_map or map?.std::map (in <map>) does the same job with a balanced search tree instead of a hash table: each lookup is O(log n) rather than O(1) on average, but it keeps its keys sorted, so a loop over it comes out in key order, and it has no bad worst case. Choose unordered_map for speed when order does not matter, map when you want the keys in order or the range searches lower_bound gives. Neither remembers insertion order.
Algorithms91 / 194
Module 10 · Counting and hashing
Same letters, different order: anagrams
Two words are anagrams if one is the other’s letters rearranged: “listen” and “silent”. Their letter counts are equal. Count the letters of the first word, then go through the second, taking each letter off its count. If a count would go below zero, or the lengths differ, they are not anagrams. Sorting both words and comparing also works, but costs O(n log n) instead of O(n).
Code challengeInteractive · disabled in the PDF
Finish isAnagram(a, b): return true if the two words use exactly the same letters, each the same number of times. Count with an unordered_map<char, int>.
C++
1#include<string>2#include<unordered_map>34boolisAnagram(const std::string& a,const std::string& b){5 // TODO: count the letters of a, then take off those of b6return a.size()== b.size();7}
Algorithms92 / 194
Module 10 · Counting and hashing
A set spots duplicates in one pass
To find whether a list has any repeat, comparing every pair is O(n²). With an unordered_set, walk the list once: if the value is already in the set, it is a repeat; otherwise add it. insert even tells you which happened: it returns a pair whose second is true only if the value was new.
C++
1std::optional<std::string>firstRepeat(2const std::vector<std::string>& ids){3 std::unordered_set<std::string> seen;4for(const std::string& id : ids){5if(seen.contains(id))return id;6 seen.insert(id);7}8return std::nullopt;9}1011// firstRepeat({"b7", "a2", "c9", "a2"}) is "a2"
Predict the outputInteractive · disabled in the PDF
std::set is the tree-based set, like std::map without values. What does this print?
C++
1std::set<int> s ={5,1,5,3};2for(int x : s) std::cout << x <<" ";
std::set is the tree-based set, like std::map without values. What does this print?
Algorithms93 / 194
Module 10 · Counting and hashing
Grouping: a map from a key to a list
Grouping puts items that share something into the same list: books by author, words by first letter. Work out each item’s key, then add the item to that key’s list, making the list the first time the key is seen. To group anagrams, the key can be the word’s letters sorted, since every anagram sorts to the same string: “eat”, “tea” and “ate” all become “aet”. A std::string is a sequence of chars, so std::ranges::sort(key) sorts its letters.
Code challengeInteractive · disabled in the PDF
Finish groupAnagrams(words): return the words grouped so each group holds words that are anagrams of each other. Groups come in the order their first word appears, and words keep their order inside a group. where maps a key to its group’s index in groups.
C++
1#include<algorithm>2#include<string>3#include<unordered_map>4#include<vector>5usingGroup= std::vector<std::string>;67std::vector<Group>groupAnagrams(constGroup& words){8 std::vector<Group> groups;9 std::unordered_map<std::string,int> where;10 // TODO: group by each word's sorted letters11for(const std::string& w : words) groups.push_back({w});12return groups;13}
Why not a map of vectors?.std::unordered_map<std::string, std::vector<std::string>> groups; with groups[key].push_back(w); groups in one line, and is the usual way when order does not matter. A loop over it gives the groups in hash-table order, though. With std::map they would come out sorted by key (“abt” before “cow”). Only the vector-plus-index above keeps the order the words came in.
Algorithms94 / 194
Module 10 · Counting and hashing
Counting sort: sorting without comparing
Every sort so far compares elements, and a sort that only compares can never beat O(n log n) in the worst case. But if the values are small whole numbers, ages from 0 to 120 or marks out of 10, you can count instead. Make one tally per possible value, count each element into its tally, then write the values out in order, each as many times as it was counted. That is counting sort: O(n + k), where k is the number of possible values.
WalkthroughInteractive · disabled in the PDF
C++
1std::vector<int>countingSort(const std::vector<int>& marks,2int max){3 std::vector<int>counts(max +1,0);4for(int m : marks) counts[m]++;5 std::vector<int> out;6for(int value =0; value <= max; value++){7for(int c =0; c < counts[value]; c++) out.push_back(value);8}9return out;10}1112// countingSort({3, 0, 2, 3, 1}, 3) is {0, 1, 2, 3, 3}
Lines 3: One tally per possible value, 0 to max: a vector of max + 1 zeros.
Lines 4: The value itself is the index: no comparing, no searching. A mark above max would be out of bounds, and stop the program.
Lines 6-8: Walk the tallies in order, writing each value out as many times as it was seen.
Lines 12: Tallies {1, 1, 1, 2}: one 0, one 1, one 2, two 3s.
When counting sort is the wrong tool. O(n + k) is only fast when k, the range of values, is small next to n. Sorting ten prices between 0 and 1 000 000 would make a million tallies for ten numbers. It also needs whole numbers to use as indexes. Radix sort stretches the idea to big numbers by counting-sorting them one digit (or one byte) at a time.
Code challengeInteractive · disabled in the PDF
Finish sortByStars(reviews): return a new vector of the reviews from 1 star up to 5 stars, without comparing. Put each review in the bucket for its stars, then add the buckets to out in order. Reviews with the same stars keep their order.
C++
1#include<string>2#include<vector>34structReview{5 std::string book;6int stars; // 1 to 57};89std::vector<Review>sortByStars(const std::vector<Review>& reviews){10 std::vector<std::vector<Review>>buckets(6);11 // TODO12 std::vector<Review> out = reviews;13return out;14}
Algorithms95 / 194
Module 10 · Counting and hashing
Recap
An unordered_map or unordered_set answers “seen it?” in O(1) on average, where a vector scan is O(n).
Spending O(n) memory on a lookup table often turns O(n²) into O(n).
Two-sum on an unsorted list: look for target - x with find, then store x.
Count with counts[k]++: [] adds a missing key with 0. To only look, use find or contains.
Anagrams have equal letter counts; a sorted-letter key groups them.
Unordered containers have no order; std::map and std::set keep keys sorted, in O(log n).
Counting sort tallies small whole numbers: O(n + k), no comparisons, and stable with buckets.
FlashcardsInteractive · disabled in the PDF
Hash table
A table that turns each key into a bucket number, so finding it again is O(1) on average.
unordered_map vs unordered_set
A map pairs keys with values; a set holds values, each once.
Hash collision
Two keys in the same bucket. Rare with a good hash; all colliding makes lookups O(n).
Two-sum in O(n)
For each x, look up target - x among the values seen, then remember x.
What does m[k] do when k is missing?
Adds k with a default value (0 for an int) and returns a reference to it.
Frequency count
How many times each value appears, kept in a map from value to count.
Anagram
A word made by rearranging another’s letters, each used the same number of times.
Counting sort
Tally each small whole number, then write the values out in order: O(n + k).
std::map vs std::unordered_map
A sorted tree, O(log n), keys in order; a hash table, O(1) on average, no order.
Module 11: Walking a tree
Algorithms96 / 194
Module 11 · Walking a tree
A tree branches out from one root
A family tree, the folders on your computer, the chapters and sections of a book: each is a tree. One item at the top, the root, branches into children, which branch into theirs. An item with no children is a leaf. In a binary tree each node has at most two children, a left and a right. (The Data structures course builds trees; here you learn to walk them.)
8root
3
1leaf
6leaf
10
14leaf
The tree used through this module. 6 and 14 are leaves; so is 1.
C++
1#include<memory>2#include<vector>34structTreeNode{5int value;6 std::unique_ptr<TreeNode> left;7 std::unique_ptr<TreeNode> right;8};9usingTree= std::unique_ptr<TreeNode>;1011Treenode(int value,Tree left =nullptr,Tree right =nullptr){12return std::make_unique<TreeNode>(13 TreeNode{value, std::move(left), std::move(right)});14}
Each node owns its children through a std::unique_ptr, which deletes them when the node goes. An empty one (nullptr) means no child there. node(…) builds a node in one call.
The walks in this module only look at a tree, so they take a plain const TreeNode*: a pointer to a node they may read but not change, or nullptr for an empty tree. n->left.get() hands over a plain pointer to the left child without taking ownership of it.
Traversal
Visiting every node of a tree exactly once, in some order.
Root
The one node at the top, with no parent.
Leaf
A node with no children.
Subtree
A node together with everything below it: itself a tree.
Algorithms97 / 194
Module 11 · Walking a tree
Depth first: before, between or after the children
Exploring a maze, you follow one corridor to its end before backing up to try the next. Walking a tree that way is depth first. At each node you do three things: visit the node, walk its left subtree, walk its right subtree. The only question is when you visit the node, and each answer has a name.
Order
At each node
On the tree above
Pre-order
node, left, right
8 3 1 6 10 14
In-order
left, node, right
1 3 6 8 10 14
Post-order
left, right, node
1 6 3 14 10 8
What each order is good for. Pre-order meets a parent before its children: copying a tree, printing a table of contents. Post-order finishes the children first: deleting a folder (empty it, then remove it), or working out a total that depends on the parts. In-order on a binary search tree gives the values sorted.
QuizInteractive · disabled in the PDF
A tree has root A, whose children are B (left) and C (right); B has one left child, D. What is its post-order?
A tree has root A, whose children are B (left) and C (right); B has one left child, D. What is its post-order?
Algorithms98 / 194
Module 11 · Walking a tree
Recursion writes itself for trees
A tree is made of smaller trees, so a walk is a function that walks the two subtrees by calling itself. Recall from the recursion module: a recursive function needs a base case that stops it. Here it is the empty tree, nullptr, where there is nothing to do.
Lines 1:walk takes the vector by reference (&), so every call adds to the same one instead of a copy.
Lines 2: Reaching nullptr means “no child here”: return.
Lines 3: Pre-order: visit the node before its children.
Lines 4-5: Walk the whole left subtree, then the whole right one. Each call waits on the call stack until its subtree is done.
Lines 8-12: Start at the root. Each node is visited once, so this is O(n) time, and the stack holds one call per level: O(height) memory.
Fill the blanksInteractive · disabled in the PDF
Make this function walk the tree in post-order.
C++
1voidwalk(constTreeNode* n, std::vector<int>& out){2if(n nullptr)return;3walk(n->.get(), out);4walk(n->.get(), out);5 out.(n->);6}
Algorithms99 / 194
Module 11 · Walking a tree
Try it: in-order, sorted for free
A binary search tree (BST) keeps every value in a node’s left subtree smaller than it, and every value in its right subtree larger. Walk one in-order and the values come out sorted.
Code challengeInteractive · disabled in the PDF
Finish inOrder(root): return the values of the tree in in-order (left subtree, node, right subtree). An empty tree (nullptr) gives {}. A helper like walk above is a good start.
C++
1#include<memory>2#include<vector>34structTreeNode{5int value;6 std::unique_ptr<TreeNode> left;7 std::unique_ptr<TreeNode> right;8};9usingTree= std::unique_ptr<TreeNode>;1011Treenode(int value,Tree left =nullptr,Tree right =nullptr){12return std::make_unique<TreeNode>(13 TreeNode{value, std::move(left), std::move(right)});14}1516std::vector<int>inOrder(constTreeNode* root){17 std::vector<int> out;18 // TODO: walk the tree19return out;20}
Algorithms100 / 194
Module 11 · Walking a tree
The same walk with your own stack
Recursion keeps its place on the call stack, which has a fixed size: on these pages it runs out after some tens of thousands of calls. A tree shaped like a chain that long overflows it, and the program simply crashes. You can keep the to-do list yourself instead, in a std::stack (last in, first out). Take a node, visit it, and push its children for later.
pop() returns nothing, so read top() first. Right is pushed first so that left, pushed last, is taken first.
Find the bugInteractive · disabled in the PDF
This should give pre-order, 8 3 1 6 10 14 on our tree, but gives 8 10 14 3 6 1. Which line?
C++
In-order and post-order without recursion. In-order with a stack: go left as far as you can, pushing each node; take one, visit it, then move to its right child and repeat. Post-order is easiest as a trick: do “node, right, left” with the loop above and reverse the result. Morris traversal even walks in-order with O(1) extra memory, by temporarily threading links back up the tree.
Going deeper
Deleting a deep tree recurses too. When a std::unique_ptr<TreeNode> is destroyed it destroys its children, which destroy theirs: a recursion as deep as the tree. Freeing a chain 100 000 nodes long can overflow the stack just like walking it. Code that must survive such trees takes the children apart in a loop before letting go.
Algorithms101 / 194
Module 11 · Walking a tree
Level by level with a queue
Sometimes you want the tree a row at a time: the root, then its children, then their children. This is breadth first, or level order. Swap the stack for a queue (first in, first out), like people waiting at a counter: children join at the back, and the next node to visit leaves from the front.
Lines 7-9:front() reads the first in line and pop() removes it. std::queue sits on a std::deque, which removes from the front in O(1); erasing the front of a std::vector would shift every element along, O(n).
Lines 10-12: Visit 8 and queue its children: [3, 10]. Visit 3, queue 1 and 6: [10, 1, 6]. Visit 10, queue 14. Then 1, 6 and 14.
Lines 14: Out comes 8 3 10 1 6 14: every node of one level before any of the next.
Put in orderInteractive · disabled in the PDF
Put one round of the level-order loop in order.
1.Check that the queue still has nodes
2.Take the node at the front of the queue
3.Record its value
4.Add its right child to the back, if it has one
5.Add its left child to the back, if it has one
Algorithms102 / 194
Module 11 · Walking a tree
Try it: one vector per level
To know where one level ends, note the queue’s size() when a level begins: exactly that many nodes belong to it. The result is a vector of vectors, std::vector<std::vector<int>>: one inner vector per level.
Code challengeInteractive · disabled in the PDF
Finish levels(root): return the values level by level, one vector per level, each left to right. Our tree gives {{8}, {3, 10}, {1, 6, 14}}; an empty tree gives {}.
C++
1#include<memory>2#include<vector>34structTreeNode{5int value;6 std::unique_ptr<TreeNode> left;7 std::unique_ptr<TreeNode> right;8};9usingTree= std::unique_ptr<TreeNode>;1011Treenode(int value,Tree left =nullptr,Tree right =nullptr){12return std::make_unique<TreeNode>(13 TreeNode{value, std::move(left), std::move(right)});14}1516std::vector<std::vector<int>>levels(constTreeNode* root){17 std::vector<std::vector<int>> out;18 // TODO: a queue, one level at a time19return out;20}
Algorithms103 / 194
Module 11 · Walking a tree
Height and path sums: answers built from the children
Many questions about a tree are answered in post-order: ask each child, then combine. The height of a tree, the number of nodes on its longest path from root to leaf, is 1 plus the taller child’s height, and the empty tree’s height is 0.
PlaygroundInteractive · disabled in the PDF
C++
1#include<algorithm>2#include<iostream>3#include<string>4#include<memory>5#include<vector>67structTreeNode{8int value;9 std::unique_ptr<TreeNode> left;10 std::unique_ptr<TreeNode> right;11};12usingTree= std::unique_ptr<TreeNode>;1314Treenode(int value,Tree left =nullptr,Tree right =nullptr){15return std::make_unique<TreeNode>(16 TreeNode{value, std::move(left), std::move(right)});17}1819intheight(constTreeNode* n,int depth =0){20if(n ==nullptr)return0;21 std::stringpad(depth *2,' ');22 std::cout << pad <<"asking "<< n->value <<"\n";23int left =height(n->left.get(), depth +1);24int right =height(n->right.get(), depth +1);25int h =1+ std::max(left, right);26 std::cout << pad << n->value <<" has height "<< h <<"\n";27return h;28}2930intmain(){31Tree tree =node(8,32node(3,node(1),node(6)),33node(10,nullptr,node(14)));34 std::cout <<height(tree.get())<<"\n";35}
Each node answers only after both children have. Put node(7) under 6, as node(6, node(7)), and see the height grow to 4.
Information can also flow down: pass each child what is left to find. A root-to-leaf path starts at the root and ends at a leaf.
Code challengeInteractive · disabled in the PDF
Finish hasPathSum(root, target): return true if some root-to-leaf path’s values add up to target. An empty tree has no paths.
C++
1#include<memory>2#include<vector>34structTreeNode{5int value;6 std::unique_ptr<TreeNode> left;7 std::unique_ptr<TreeNode> right;8};9usingTree= std::unique_ptr<TreeNode>;1011Treenode(int value,Tree left =nullptr,Tree right =nullptr){12return std::make_unique<TreeNode>(13 TreeNode{value, std::move(left), std::move(right)});14}1516boolhasPathSum(constTreeNode* root,int target){17returnfalse;18}
Algorithms104 / 194
Module 11 · Walking a tree
Lowest common ancestor in a search tree
The lowest common ancestor (LCA) of two nodes is the deepest node that has both below it (a node counts as below itself). In a family tree, it is the most recent shared ancestor. In a BST you can find it with one walk down: while both values are smaller than the current node, go left; while both are larger, go right. The first node where they split up, or that is one of them, is the answer. The walk takes one step per level, so its cost depends on the tree’s height.
QuizInteractive · disabled in the PDF
A binary tree has 7 nodes. What heights can it have?
A binary tree has 7 nodes. What heights can it have?
Code challengeInteractive · disabled in the PDF
Finish lca(root, a, b) for a binary search tree that contains both a and b: return the value of their lowest common ancestor.
C++
1#include<memory>2#include<vector>34structTreeNode{5int value;6 std::unique_ptr<TreeNode> left;7 std::unique_ptr<TreeNode> right;8};9usingTree= std::unique_ptr<TreeNode>;1011Treenode(int value,Tree left =nullptr,Tree right =nullptr){12return std::make_unique<TreeNode>(13 TreeNode{value, std::move(left), std::move(right)});14}1516intlca(constTreeNode* root,int a,int b){17return root->value;18}
Where traversals turn up. Compilers walk the tree of your program (its syntax tree) in post-order to work out types and generate code; Clang, which compiles the C++ on these pages, does exactly that. Browsers walk the DOM tree to lay out a page. Version control finds the “merge base” of two branches as their lowest common ancestor in the commit history.
Recursion uses the call stack; a std::stack does the same without its depth limit.
A std::queue gives level order: breadth first, one level at a time.
Answers flow up (height: 1 + the taller child) or down (path sums: what is left to find).
In a BST, in-order is sorted, and the LCA is where two values part ways.
FlashcardsInteractive · disabled in the PDF
Pre-order
Node, then left subtree, then right subtree.
In-order
Left subtree, node, right subtree. Sorted on a BST.
Post-order
Left subtree, right subtree, then node. Children before parents.
Level order
Breadth first, row by row, using a queue.
Stack vs queue
Stack: last in, first out (depth first). Queue: first in, first out (breadth first).
std::stack and std::queue: reading and removing
top() or front() reads; pop() removes and returns nothing.
Height of a tree
Nodes on its longest root-to-leaf path; 1 + the taller child’s height.
Lowest common ancestor
The deepest node with both nodes below it (or being one of them).
Memory of a depth-first walk
O(h), one stack entry per level: O(log n) balanced, O(n) for a chain.
Module 12: Searching a graph
Algorithms106 / 194
Module 12 · Searching a graph
A graph is places and the links between them
Think of a city map: places, and the streets that join them. A graph is exactly that, as data. Each place is a node (also called a vertex), and each link between two nodes is an edge. If a link only goes one way, like a one-way street or “this task must come before that one”, the graph is directed. The nodes an edge leads to from a node are its neighbours.
The Data structures course stores a graph as an adjacency list: for each node, the list of its neighbours. Number the nodes 0, 1, 2… and the whole graph is a vector of vectors, std::vector<std::vector<int>>: graph[3] is the list of node 3’s neighbours. An enum gives the numbers names, so the code can say school instead of 2.
C++
1#include<vector>23usingGraph= std::vector<std::vector<int>>;45// The places, numbered 0 to 56enum{home,park,school,library,shop,pool};78// Who you can walk to directly from each place9Graph town ={10{park, school}, // home11{library}, // park12{library, shop}, // school13{pool}, // library14{pool}, // shop15{} // pool16};
A directed graph of six places. town[school] is {library, shop}, the neighbours of school.
Why number the nodes. With nodes numbered 0 to n − 1, anything you need to remember per node (found yet? how far?) is a plain std::vector indexed by the node: one memory read, no hashing. When the nodes come as names, give each a number once, with a std::unordered_map<std::string, int>, then work with numbers.
Graph
Nodes, and edges joining pairs of them.
Node (vertex)
One thing in the graph: a place, a person, a task.
Edge
A link between two nodes; one-way in a directed graph.
Adjacency list
For each node, the list of its neighbours.
Path
A route from node to node, following edges.
`enum`
A list of names for whole numbers: here home is 0, park is 1, and so on.
QuizInteractive · disabled in the PDF
In town, which places can you reach from school, by any path?
In town, which places can you reach from school, by any path?
Algorithms107 / 194
Module 12 · Searching a graph
Breadth-first search explores in rings
Drop a stone in a pond: the ripple reaches everything one step away, then everything two steps away, and so on. Breadth-first search (BFS) explores a graph the same way. It keeps a queue, a line where the first to join is the first served, of places found but not yet explored. Because nearer places join the line first, they are always explored first.
Start: the queue holds home, 0 steps away.
Explore home: its neighbours join the queue, 1 step away.
Explore park, then school: library and shop join, 2 steps away. library is only added once.
Explore library and shop: pool joins, 3 steps away. Nothing is left.
BFS from home visits the town ring by ring.
Why BFS finds the fewest steps. Every node at distance k joins the queue before any node at distance k + 1, so the first time BFS reaches a node is along a path with the fewest edges. Each node joins the queue once and each edge is looked at once, so BFS takes O(V + E) time, where V is the number of nodes and E the number of edges.
Put in orderInteractive · disabled in the PDF
Put one round of the BFS loop in order.
1.Add it to the back of the queue
2.Take the node at the front of the queue
3.Record its distance as one more than the current node
To explore a maze, you can keep one hand on the wall: go as deep as you can down one corridor, and only when it ends, back up to the last junction and try the next way. That is depth-first search (DFS). Written recursively, the call stack does the backing up for you (the recursion module showed how each call waits for the one it made). Written with a loop, a std::stack, where the last thing added is the first taken out, does the same job.
Swap the order of school’s neighbours, or give pool an edge back to home, and run it again. Which order changes?
Deep graphs and the call stack. Each recursive call takes space on the call stack, which has a fixed size: on these pages it runs out after some tens of thousands of calls. A path that long overflows it, and the program crashes. The version with an explicit std::stack keeps its to-do list on the heap, with no such limit, so prefer it for big inputs.
Algorithms110 / 194
Module 12 · Searching a graph
A visited set stops you going round in circles
A town has loops: from the park to the library and back. A search that forgets where it has been walks round a loop for ever. The visited set (here seen, one bool per node) holds every node already found, and a node in it is never added again. That also makes the work bounded: each node is handled once.
Find the bugInteractive · disabled in the PDF
This should mark every place reachable from start, but it misses some. Which line is wrong?
C++
Mark a node when you add it. Setting seen[next] when a node joins the queue, not when it leaves, means it can only join once. Marking on the way out also gives right answers in DFS, but the same node can sit in the queue or stack many times, which wastes time and memory on dense graphs.
Going deeper
`std::vector<bool>` is special. The standard library packs a std::vector<bool> into single bits, eight flags per byte, so a visited set for a million nodes takes about 125 KB. The price: seen[i] is not a real bool& but a small stand-in object, so auto& x = seen[i] does not compile. Where that matters, people use std::vector<char> instead.
Algorithms111 / 194
Module 12 · Searching a graph
Connected components are the separate groups
In a group of people where friendship goes both ways (an undirected graph), who is linked to whom, directly or through friends? Each separate cluster is a connected component. To count them, go through the people: each time you meet someone not yet seen, that is a new group, and one search from them marks everyone in it.
The friendships arrive as a vector of pairs, std::vector<std::pair<int, int>>. for (auto [a, b] : friendships) unpacks each pair into two names at once; that is a structured binding.
Code challengeInteractive · disabled in the PDF
Write countGroups(people, friendships): the people are numbered 0 to people - 1; return how many separate friend groups there are. A friendship {a, b} links both ways; someone with no friends is a group of one.
A drawing app’s paint bucket fills every touching pixel of the same colour. Under it is a graph search, though no graph is written down: each cell of the grid is a node, and its neighbours are the cells above, below, left and right. Flood fill is DFS (or BFS) over those cells, stopping at the edge of the grid and at cells of another colour.
C++
1constint dr[]={1,-1,0,0};2constint dc[]={0,0,1,-1};3for(int k =0; k <4; k++){4int r = row + dr[k];5int c = col + dc[k];6if(r <0|| r >= rows || c <0|| c >= cols)continue;7 // (r, c) is a neighbour inside the grid8}
The four neighbours of a cell, worked out instead of stored. Check the bounds before reading grid[r][c].
Off the grid is not “nothing”. Reading grid[-1] in C++ does not give an empty answer: it reads whatever memory sits there. The library on these pages is built with checks that stop the program instead (“index out of bounds”); a normal release build would carry on with garbage. Test the row and column first, every time.
Code challengeInteractive · disabled in the PDF
Write floodFill(grid, row, col, paint): the grid is rows of one-character cells. Return the grid with the cell at row, col, and every cell joined to it (up, down, left, right) with the same character, changed to paint.
A cycle is a path that comes back to where it started: task A needs B, B needs C, C needs A, so nothing can ever start. DFS finds it by remembering which nodes are on the current path, being explored right now. Meeting one of those again means you have gone round in a loop. A node that is merely done (explored earlier, by another route) is no cycle.
Fill the blanksInteractive · disabled in the PDF
Complete the cycle check. A node is active while it is on the current path.
Undirected graphs. In an undirected graph every edge goes both ways, so the step back to the node you came from is not a cycle. There, a cycle is meeting any seen node other than your parent in the search, or two nodes that union-find says are already joined.
Algorithms114 / 194
Module 12 · Searching a graph
Topological sort puts tasks in a safe order
Baking a cake: you must buy flour and eggs before you mix, mix and heat the oven before you bake, bake before you ice. A topological order lists the tasks so that every task comes after everything it depends on. It exists exactly when the graph has no cycle: a DAG, a directed acyclic graph. Kahn’s algorithm builds one by counting, for each task, how many tasks it still waits for (its in-degree), and repeatedly taking one that waits for nothing.
In-degree 0: these wait for nothing, so they are ready.
Once both shopping tasks are done, mix waits for nothing.
With mix and oven done, bake is ready.
Last, ice. Every task was taken, so there was no cycle.
Each arrow means “must come before”.
When there is no valid order, the function has nothing to return. std::optional<T> says that in the type: it holds either a T or nothing, std::nullopt. The caller checks has_value() (or just if (result)) and reads the value with *result.
Code challengeInteractive · disabled in the PDF
Write buildOrder(tasks, deps) with Kahn’s algorithm. The tasks are numbered 0 to tasks - 1; a dependency {a, b} means a must come before b. Return every task in a valid order, or std::nullopt if there is a cycle.
Where topological sort runs. Build tools like Make and CMake compile and link in an order that respects what depends on what, package managers install dependencies first, spreadsheets recalculate a cell after the cells its formula reads, and course prerequisites are checked the same way. Kahn’s algorithm is O(V + E); reversing the order in which DFS finishes nodes gives a topological order too.
Algorithms115 / 194
Module 12 · Searching a graph
Recap
A graph is nodes and edges, stored as an adjacency list: std::vector<std::vector<int>>, each node’s neighbours.
BFS uses a std::queue and explores ring by ring, so it finds the fewest edges to every node.
DFS goes deep first, with recursion or a std::stack, and backs up at dead ends.
A visited set (one bool per node) stops endless loops and makes each search O(V + E).
Counting the searches it takes to visit everyone counts the connected components.
A grid is a graph whose neighbours are worked out: flood fill is DFS over cells, bounds checked first.
DFS finds a cycle by meeting a node still on its current path.
Kahn’s algorithm orders tasks by repeatedly taking one with nothing left to wait for.
FlashcardsInteractive · disabled in the PDF
BFS
Breadth-first search: a queue, ring by ring; gives fewest edges.
DFS
Depth-first search: a stack (or recursion), one path to its end first.
Queue vs stack
Queue: first in, first out. Stack: last in, first out.
Visited set
The nodes already found, so none is handled twice.
Connected component
A group of nodes all reachable from one another, cut off from the rest.
In-degree
How many edges point into a node: what a task waits for.
DAG
A directed graph with no cycle; it has a topological order.
Cost of BFS or DFS
O(V + E): every node and every edge once.
std::optional<T>
A T or nothing (std::nullopt); check it before reading *result.
Structured binding
auto [a, b] = pair; unpacks a pair (or struct) into two names.
Module 13: Shortest paths
Algorithms116 / 194
Module 13 · Shortest paths
When roads have lengths, fewest steps is not enough
A map app does not count streets: it counts minutes. In a weighted graph every edge has a number, its weight (or cost): minutes of driving, kilometres, a fare. The shortest path is the one whose weights add up to the least. BFS from the last module finds the path with the fewest edges, and two long roads can take longer than three short ones.
Minutes on each one-way road. BFS picks home → mill → shop (2 roads, 5 minutes); home → bridge → mill → shop takes 4.
The same adjacency list as before, but each entry is an Edge: where the road goes, and how long it takes. {mill, 4} builds one.
QuizInteractive · disabled in the PDF
BFS from home reaches shop first through which route, and how long does it take?
BFS from home reaches shop first through which route, and how long does it take?
Algorithms117 / 194
Module 13 · Shortest paths
Dijkstra settles the nearest place first
Imagine water poured in at home, flowing along every road at the same speed. It reaches places in order of travel time, and the first time it reaches a place is along its fastest route. Dijkstra’s algorithm imitates that. It keeps a best-known time for each place, and repeatedly settles the unsettled place with the smallest time: that time is now final. Then it relaxes that place’s roads: if going through it gives a neighbour a smaller time, that becomes the neighbour’s new best.
Settle
home
mill
bridge
shop
(start)
0
∞
∞
∞
home, at 0
0
4
1
∞
bridge, at 1
0
3
1
7
mill, at 3
0
3
1
4
shop, at 4
0
3
1
4
PlaygroundInteractive · disabled in the PDF
C++
1#include<iostream>2#include<vector>34structEdge{int to;int weight;};5enum{home,mill,bridge,shop};6constchar* names[]={"home","mill","bridge","shop"};7std::vector<std::vector<Edge>> roads ={8{{mill,4},{bridge,1}},{{shop,1}},{{mill,2},{shop,6}},{}9};10constint INF =1000000; // "not reached yet"1112intmain(){13 std::vector<int>best(4, INF);14 std::vector<bool>settled(4,false);15 best[home]=0;16while(true){17int node =-1; // the unsettled place with the smallest time18for(int n =0; n <4; n++)19if(!settled[n]&& best[n]< INF &&20(node ==-1|| best[n]< best[node]))21 node = n;22if(node ==-1)break;23 settled[node]=true;24 std::cout <<"settle "<< names[node]<<" at "25<< best[node]<<"\n";26for(auto[to, minutes]: roads[node])27if(best[node]+ minutes < best[to])28 best[to]= best[node]+ minutes;29}30}
Make the road from bridge to mill take 5 minutes and run it again: which route wins now? This version scans for the nearest place each time, O(V²) in all.
Why a settled time is final. When a place is settled at time t, every other unsettled place is at least t away, and no road has a negative weight, so no route through them can come back in under t. That one sentence is the proof, and it is also exactly what fails when weights can be negative.
Algorithms118 / 194
Module 13 · Shortest paths
A priority queue hands out the nearest place
Scanning every place for the smallest time is slow on a big map. A priority queue is a collection that always gives back the item with the best key (here, the smallest time). The Data structures course builds one with a heap, where adding and taking out cost O(log n), and C++ has one ready-made: std::priority_queue, in <queue>.
PlaygroundInteractive · disabled in the PDF
C++
1#include<functional>2#include<iostream>3#include<queue>4#include<utility>5#include<vector>67usingItem= std::pair<longlong,int>; // {time, place}89intmain(){10 std::priority_queue<Item, std::vector<Item>, std::greater<>> queue;11 queue.push({7,3});12 queue.push({3,1});13 queue.push({4,3});14while(!queue.empty()){15auto[time, place]= queue.top();16 queue.pop();17 std::cout <<"place "<< place <<" at "<< time <<"\n";18}19}
Out come the smallest times first. Delete , std::vector<Item>, std::greater<> and run it again: what order now?
By default std::priority_queue puts the largest item on top. The two extra template arguments say “store the items in a std::vector, and compare them with std::greater<>”, which turns it upside down: smallest on top. A std::pair compares by its first part, then its second, so {time, place} puts the smallest time on top. There is no way to lower a key already inside, so Dijkstra simply pushes the place again with its better time.
Put in orderInteractive · disabled in the PDF
Put one round of Dijkstra’s loop in order.
1.Mark it settled: its time is final
2.For each road out of it, work out the time through it
3.If that beats the neighbour’s best time, record it and push the neighbour
4.Skip it if it was already settled
5.Pop the place with the smallest time from the priority queue
How fast it is. With a heap, each of the E edges can push once and each push and pop is O(log E), so Dijkstra runs in O((V + E) log V). Routers use it to pick paths through a network (the OSPF protocol), and map apps run it, with extra precomputed shortcuts, on graphs of millions of junctions.
Lines 2:dist holds the best time known for each place. INF is std::numeric_limits<long long>::max(), the largest long long, standing for “not reached yet”: any real time is smaller.
Lines 3-6: Only start is known, at 0. It goes in the queue as {0, start}.
Lines 8-9: Take out the place with the smallest time, with that time as d. auto [d, node]copies the top item, which matters: a reference to it would dangle once pop() removes it.
Lines 10-11: An old entry for a place already settled is skipped. Otherwise, settle it.
Lines 12-13: For each road, is going through node better than the best known? A place never reached is at INF, so any route beats it.
Lines 14-15: Record the better time and push the neighbour with it as its key.
Lines 19: Every reachable place with its cheapest total. Places that cannot be reached still say INF.
Why `long long`. An int holds up to about 2.1 billion. A million roads of 10 000 metres each add up past that, and an int that overflows does not stop: it goes wrong silently. long long holds up to about 9.2 × 10¹⁸, so totals of path weights are kept in it.
Find the bugInteractive · disabled in the PDF
This version says every place is 0 minutes from start, whatever the graph. Which line is wrong?
C++
Algorithms120 / 194
Module 13 · Shortest paths
Try it: cheapest times from one place
The types and INF are ready-made in the starter. Write the loop yourself, from the idea rather than from memory.
Code challengeInteractive · disabled in the PDF
Write cheapest(graph, start): for every place, the least total weight of a path to it from start (start itself is 0), or INF if it cannot be reached. Use a std::priority_queue.
A time alone does not tell you which way to drive. Each time Dijkstra improves a place, it can also record the place it came from, in a std::vector<int> previous that starts at -1 (“came from nowhere”). To read the route back, start at the goal and follow previous step by step to the start, like following breadcrumbs, then reverse the list.
One extra line in the loop. For the town: shop came from mill, mill from bridge, bridge from home.
Code challengeInteractive · disabled in the PDF
Write buildPath(previous, start, goal): the route from start to goal as a list of places, read from previous (where -1 means “no step recorded”). If goal cannot be traced back to start, return {}.
A weight can be negative: a road that earns you money, a refund, a step that saves time. Then a place settled early can later be reached more cheaply by a longer detour, and Dijkstra has already made its decision. Bellman–Ford makes no early decisions: it relaxes every edge, V − 1 times over. A cheapest path that repeats no place has at most V − 1 edges, and each round fixes one more edge of it, so after V − 1 rounds every time is right.
Predict the outputInteractive · disabled in the PDF
Run cheapest (Dijkstra, with the done check) from S. What does it print for T?
Run cheapest (Dijkstra, with the done check) from S. What does it print for T?
Negative cycles. If a loop of edges adds up to less than 0, you can go round it for ever, getting cheaper each time: no cheapest path exists. Bellman–Ford detects this with one extra round. If any edge can still be relaxed after V − 1 rounds, there is a negative cycle.
Where it is used. Bellman–Ford costs O(V × E), slower than Dijkstra, so it is used when weights really can be negative. With exchange rates as weights (the negative logarithm of each rate), a negative cycle is a series of currency trades that ends with more money than it started: an arbitrage. Early internet routing (distance-vector protocols such as RIP) worked on the same idea.
Algorithms123 / 194
Module 13 · Shortest paths
Try it: Bellman–Ford
Adding to “infinity” overflows.INF is only the largest long long, not a real infinity: INF + 1 overflows, which is undefined behaviour and in practice comes out hugely negative, and INF + (-4) is a fake, finite-looking time. Bellman–Ford relaxes edges out of places not reached yet, so it must skip any edge whose start is still at INF.
Code challengeInteractive · disabled in the PDF
Write bellmanFord(places, edges, start). Each edge is {from, to, weight}. Return the cheapest total from start to every place (INF if unreachable), or std::nullopt if there is a negative cycle.
Dijkstra spreads out evenly in every direction, even away from where you want to go. When you have one goal and a good guess of the distance left, such as the straight-line distance on a map, A* (said “A star”) uses it. It orders the queue by time so far + guessed time left, so places in the right direction come out first. The guess is a heuristic: a rule of thumb.
Fill the blanksInteractive · disabled in the PDF
Turn Dijkstra’s loop into A*: stop at the goal, and order the queue by cost plus the guess.
What happens if the guess sometimes overestimates the distance left?
What happens if the guess sometimes overestimates the distance left?
Where A* runs. Game characters finding their way round a grid of tiles, robots planning a route, and puzzle solvers (the 15-puzzle, with “how many tiles are out of place” as the guess) all use A. On a grid, the guess is often the Manhattan distance*: rows apart plus columns apart.
Algorithms125 / 194
Module 13 · Shortest paths
Recap
In a weighted graph, the shortest path has the smallest total weight, not the fewest edges.
Dijkstra settles the nearest unsettled place each time, and relaxes its edges.
std::priority_queue with std::greater<> hands out the smallest {time, place}; Dijkstra is O((V + E) log V).
Recording previous for each improvement lets you walk the route back from the goal.
A negative weight breaks Dijkstra’s settled-is-final rule; Bellman–Ford relaxes every edge V − 1 times.
One more round of Bellman–Ford that still improves something reveals a negative cycle.
INF is the largest long long: never add to it, and keep path totals in long long.
A* adds a guess of the distance left; if it never overestimates, the route is still the cheapest.
FlashcardsInteractive · disabled in the PDF
Weighted graph
A graph whose edges each carry a number: a cost, length or time.
Relax an edge
If going through this edge reaches its end more cheaply, record the new cost.
Settle (Dijkstra)
Take the unsettled node with the least cost; its cost is now final.
Priority queue
A collection that always hands out the item with the best key.
A min-heap std::priority_queue
std::priority_queue<T, std::vector<T>, std::greater<>>: smallest on top. The default is largest on top.
Lazy deletion
Leaving old queue entries in, and skipping them when they come out.
Bellman–Ford
Relax every edge V − 1 times; copes with negative weights. O(V × E).
Negative cycle
A loop whose weights add up below 0: no cheapest path exists.
Admissible heuristic
A guess of the distance left that never overestimates.
Module 14: Greedy algorithms
Algorithms126 / 194
Module 14 · Greedy algorithms
A greedy algorithm takes the best-looking step now
Picture a shop assistant giving you 63 cents in change. Nobody plans the whole answer: they grab the biggest coin that still fits, then the biggest that fits what is left, and so on. That habit is a greedy algorithm: build the answer one choice at a time, each time taking the option that looks best right now, and never going back to change an earlier choice.
Greedy algorithms are short and fast, often a sort and one loop. The catch is that a choice that is best locally (for this one step) is not always part of the best answer overall. This module is about telling the two apart.
Greedy algorithm
One that makes the locally best choice at each step and never undoes it.
Locally best
Best for this one step, judged without looking ahead.
Optimal
The best possible answer overall: the fewest coins, the most meetings.
Greedy rule
What “best right now” means for a problem: biggest coin, earliest finish, best value per kilo.
QuizInteractive · disabled in the PDF
Which of these is a greedy strategy?
Which of these is a greedy strategy?
Algorithms127 / 194
Module 14 · Greedy algorithms
Making change: biggest coin first
The greedy rule for change: take the largest coin that is not more than what is left, as many times as it fits, then move to the next coin down. Here it is on 63 cents with coins of 25, 10, 5 and 1.
Left to give
Largest coin that fits
Coins so far
63
25
25
38
25
25, 25
13
10
25, 25, 10
3
1
25, 25, 10, 1
2
1
25, 25, 10, 1, 1
1
1
25, 25, 10, 1, 1, 1
0
done
6 coins
Six coins, and for these coins that is the fewest possible. The work is tiny: sort the coins once, then each step either uses a coin or moves on to a smaller one.
Fill the blanksInteractive · disabled in the PDF
Count the coins greedily. coins is sorted from largest to smallest.
Now imagine a country with coins of 1, 3 and 4. Give 6 greedily: take a 4 (2 left), then 1, then 1. Three coins. But 3 + 3 is two coins. The first choice, the 4, looked best and was wrong, and greedy never goes back to fix it.
Amount
Greedy (1, 3, 4)
Best possible
5
4 + 1: 2 coins
2 coins
6
4 + 1 + 1: 3 coins
3 + 3: 2 coins
7
4 + 3: 2 coins
2 coins
10
4 + 4 + 1 + 1: 4 coins
4 + 3 + 3: 3 coins
Works on examples is not proof. Greedy change works for euros and dollars, so it is easy to believe it always works. It doesn’t: whether it is right depends on the coins. Systems where it always works are called canonical. For any coins, the next module’s dynamic programming finds the true fewest.
PlaygroundInteractive · disabled in the PDF
C++
1#include<algorithm>2#include<functional>3#include<iostream>4#include<vector>56intgreedyCount(int amount, std::vector<int> coins){7 std::sort(coins.begin(), coins.end(), std::greater<>());8int count =0;9for(int coin : coins){10 count += amount / coin;11 amount %= coin;12}13return count;14}1516// Tries every coin as the first one: slow, but always right.17intbestCount(int amount,const std::vector<int>& coins){18if(amount ==0)return0;19int best =1000000;20for(int coin : coins)21if(coin <= amount)22 best = std::min(best,1+bestCount(amount - coin, coins));23return best;24}2526intmain(){27 std::vector<int> coins ={1,3,4};28for(int amount =1; amount <=12; amount++){29int g =greedyCount(amount, coins), b =bestCount(amount, coins);30 std::cout << amount <<": greedy "<< g <<", best "<< b31<<(g == b ?"":" <- greedy loses")<<"\n";32}33}
Try {1, 5, 10, 25}, then {1, 7, 10} with amounts up to 20, and see for which amounts greedy loses. std::greater<>() sorts largest first.
Going deeper
Where to look for a counterexample. A result by Kozen and Zaks: if greedy change is ever wrong for a coin system, it is already wrong for some amount smaller than the sum of the two largest coins. So checking whether a system is canonical only needs a short, finite search, like the one the playground above does.
Algorithms129 / 194
Module 14 · Greedy algorithms
Try it: give change greedily
Write the greedy rule yourself. The coins may arrive in any order, so sort them first, from largest to smallest, and return the coins you hand over. std::sort puts the smallest first unless you give it a comparator: a function that answers “should a come before b?”. The shortest way to write one is a lambda, a small unnamed function written in place: [](int a, int b) { return a > b; }.
Code challengeInteractive · disabled in the PDF
Finish greedyChange(amount, coins): return the coins the greedy rule gives, largest first. Assume there is always a coin of 1.
C++
1#include<algorithm>2#include<vector>34std::vector<int>greedyChange(int amount, std::vector<int> coins){5 std::vector<int> used;6 // TODO: take the largest coin that fits, again and again7return used;8}
A comparator must say “strictly before”. Write a > b, never a >= b. std::sort relies on the comparator saying false for two equal items (a strict weak ordering). With >= it can run past the end of the vector looking for a stopping point, and the program stops or, without the library’s checks, corrupts memory.
Algorithms130 / 194
Module 14 · Greedy algorithms
Choosing meetings: the earliest finish wins
One meeting room, many requests, each with a start and an end time. You want to fit in as many meetings as possible, with no two overlapping. This is interval scheduling. Several greedy rules sound reasonable: pick the shortest meeting first, or the one that starts earliest. Both can lose. The rule that always wins is: pick the meeting that finishes first, drop everything that clashes with it, and repeat.
Lines 1: A meeting is a small struct: when it starts and when it ends. One may start the moment another ends.
Lines 3-7: Sort the copy (taken by value) by end time, so the meeting that finishes first comes first. The lambda answers “does a come before b?”. This sort is the whole cost: O(n log n).
Lines 8-9:freeAt is when the room is next free. At first it is free from the smallest int there is: the beginning of time.
Lines 10-15: Walk the meetings in order of finish. If one starts once the room is free, take it, and the room is busy until it ends. Otherwise skip it: it clashes with one already chosen.
Lines 16: One pass after the sort: the count of meetings chosen.
Put in orderInteractive · disabled in the PDF
Put the earliest-finish rule in order.
1.Skip each meeting that starts before then
2.Take the next one that starts after, and repeat
3.Sort the meetings by when they end
4.Take the first meeting in that order
5.Remember when the room becomes free
Algorithms131 / 194
Module 14 · Greedy algorithms
Why the earliest finish is always right
Trying examples cannot prove a greedy rule; an exchange argument can. The idea in plain words: take any best answer, and show you can swap its first choice for the greedy choice without making it worse. Then the greedy choice is part of some best answer, and the same argument repeats on what is left.
Let g be the meeting that finishes first of all.
Take any best schedule, and let f be its first meeting. f ends no earlier than g, since g ends first of all.
Swap f for g. Everything else in the schedule started after f ended, so after g ended too: no clash, and the same number of meetings.
So some best schedule starts with g. Drop every meeting that clashes with g and repeat the argument on the rest.
The two ingredients. A greedy algorithm is right when the problem has the greedy-choice property (some best answer contains the greedy first choice, which the exchange shows) and optimal substructure (what is left after that choice is a smaller copy of the same problem). Missing either, greedy is a guess. Famous greedy algorithms with such proofs: Dijkstra’s shortest paths (module 13), Kruskal’s and Prim’s spanning trees (module 18), and Huffman coding, which picks the short bit codes used inside zip and gzip files.
QuizInteractive · disabled in the PDF
Try the exchange argument on “shortest meeting first”. Where does it break, with meetings 1–5, 4–7 and 6–10?
Try the exchange argument on “shortest meeting first”. Where does it break, with meetings 1–5, 4–7 and 6–10?
Algorithms132 / 194
Module 14 · Greedy algorithms
Try it: cancel as few meetings as possible
The same idea, asked the other way round. The room is double-booked: which is the fewest meetings you must cancel so that no two of the rest overlap? Keeping the most is cancelling the fewest.
Code challengeInteractive · disabled in the PDF
Finish fewestToCancel(meetings): return how many meetings must go so that the rest never overlap. A meeting may start exactly when another ends.
This compiles and runs, but sometimes keeps too few meetings. Which line is wrong?
C++
Algorithms133 / 194
Module 14 · Greedy algorithms
Packing a bag: greedy by value per kilo
A spice merchant has a bag that holds 50 kilos and sacks of spices, each with a weight and a total value. Spices can be scooped, so part of a sack is fine. This is the fractional knapsack. The greedy rule: work out each spice’s value per kilo, and fill the bag with the most valuable per kilo first, taking only part of the last sack if it does not all fit.
Spice
Weight
Value
Value per kilo
Saffron
10
60
6
Vanilla
20
100
5
Pepper
30
120
4
Saffron (10 kilos, 60), then vanilla (20 kilos, 100): 30 kilos used, 20 left. Two thirds of the pepper fits: 80 more. Total 240, and nothing beats it.
QuizInteractive · disabled in the PDF
Now sacks cannot be split: take a whole sack or leave it (the 0/1 knapsack). Same bag of 50, same spices. What does greedy by value per kilo give, and what is the best?
Now sacks cannot be split: take a whole sack or leave it (the 0/1 knapsack). Same bag of 50, same spices. What does greedy by value per kilo give, and what is the best?
Code challengeInteractive · disabled in the PDF
Finish bestLoad(items, capacity): return the most value that fits in the bag, when any part of an item may be taken.
A greedy algorithm makes the locally best choice at each step and never undoes it: usually a sort and one pass.
Greedy change is right for canonical coins like 1, 5, 10, 25, and wrong for coins like 1, 3, 4.
Interval scheduling: sort by end time and keep each meeting that starts after the last one kept.
An exchange argument proves a greedy rule: swap a best answer’s choice for the greedy one without making it worse.
Fractional knapsack is greedy by value per kilo; the 0/1 knapsack is not, and needs dynamic programming.
When greedy fails, compare it with a brute-force answer on small inputs to find the counterexample.
FlashcardsInteractive · disabled in the PDF
Greedy algorithm
Builds an answer by taking the locally best choice at each step, never undoing one.
Coins where greedy change fails
1, 3, 4: for 6 it gives 4 + 1 + 1, but 3 + 3 is fewer.
Canonical coin system
One where the greedy rule always gives the fewest coins.
Interval scheduling rule
Always pick the meeting that finishes first among those that still fit.
Exchange argument
Swap a best answer’s choice for the greedy one and show it is no worse: the greedy choice is safe.
Fractional vs 0/1 knapsack
Splitting allowed: greedy by value per weight is best. Whole items only: greedy can lose.
Greedy-choice property
Some best answer contains the greedy first choice.
Comparator for std::sort
A function (often a lambda) that says whether a comes strictly before b: [](int a, int b) { return a > b; } for largest first.
7 / 2 in C++
3: dividing two ints drops the fraction. double(7) / 2 is 3.5.
Module 15: Dynamic programming
Algorithms135 / 194
Module 15 · Dynamic programming
Some recursions solve the same problem many times
A quick recap of recursion (module 6): a recursive function solves a problem by calling itself on smaller versions of it, until it reaches a base case small enough to answer directly. The Fibonacci numbers are the classic example: each is the sum of the two before it, starting 0, 1, 1, 2, 3, 5, 8.
Correct, short, and very slow for large n. It returns a long long because Fibonacci numbers outgrow an int after fib(46).
fib(4)
fib(3)
fib(2)
fib(1)
fib(0)
fib(1)
fib(2)
fib(1)
fib(0)
The calls fib(4) makes. fib(2) is worked out twice, and fib(1) three times.
Each call splits in two, so the tree grows about 1.6 times bigger with every step of n. Yet there are only n + 1 different questions in it: fib(0) to fib(n). The same small problems come up again and again. They are overlapping subproblems, and dynamic programming is the art of answering each of them only once.
Subproblem
A smaller version of the problem, such as fib(3) inside fib(5).
Overlapping subproblems
The same subproblem is needed in many places of the recursion.
Dynamic programming (DP)
Solving each subproblem once, remembering the answer, and building bigger answers from smaller ones.
Algorithms136 / 194
Module 15 · Dynamic programming
Count the calls to see the waste
QuizInteractive · disabled in the PDF
How many times is fib called, in total, to work out fib(5) with the code above?
How many times is fib called, in total, to work out fib(5) with the code above?
The fix is to memoise: keep a note of every answer, and before working anything out, look it up. Run both versions and compare the counters.
PlaygroundInteractive · disabled in the PDF
C++
1#include<iostream>2#include<unordered_map>34longlong naiveCalls =0;5longlongnaiveFib(int n){6 naiveCalls++;7if(n <2)return n;8returnnaiveFib(n -1)+naiveFib(n -2);9}1011longlong memoCalls =0;12std::unordered_map<int,longlong> memo;13longlongmemoFib(int n){14 memoCalls++;15if(n <2)return n;16if(memo.contains(n))return memo[n];17longlong result =memoFib(n -1)+memoFib(n -2);18 memo[n]= result;19return result;20}2122intmain(){23int n =25;24 std::cout <<"naive: "<<naiveFib(n)<<" in "<< naiveCalls <<"\n";25 std::cout <<"memo: "<<memoFib(n)<<" in "<< memoCalls <<"\n";26}
Change n to 30, then 32: the naive count grows about 1.6 times per step, the memoised one by 2. Try 80 with the memoised version alone.
Algorithms137 / 194
Module 15 · Dynamic programming
Memoisation: remember every answer
A memo is a lookup table from a subproblem to its answer, here a std::unordered_map<int, long long> from n to fib(n) (a plain std::vector indexed by n works just as well when the questions are small whole numbers). Memoising a recursive function takes three lines: look the question up, and if it is new, work it out and store it before returning it. This is called top-down DP: you start from the big question and let the recursion reach down to the small ones.
WalkthroughInteractive · disabled in the PDF
C++
1usingMemo= std::unordered_map<int,longlong>;23longlongfib(int n,Memo& memo){4if(n <2)return n;5auto found = memo.find(n);6if(found != memo.end())return found->second;7longlong result =fib(n -1, memo)+fib(n -2, memo);8 memo[n]= result;9return result;10}1112intmain(){13Memo memo;14 std::cout <<fib(50, memo); // 12586269025, in 99 calls15}
Lines 3: The memo is passed by reference (Memo&), so every recursive call shares the same one. Passed by value, each call would get its own copy and remember nothing.
Lines 4: The base cases stay as they were.
Lines 5-6: Look it up first. find returns an iterator to the entry, or memo.end() if there is none; found->second is the stored answer.
Lines 7-9: A new question: work it out the same recursive way, then store the answer so no one has to work it out again.
Lines 14: Each n from 2 to 50 is worked out once, so the time is O(n), down from O(1.6ⁿ). The memo costs O(n) memory.
Fill the blanksInteractive · disabled in the PDF
Memoise a function that counts paths. Look up first, store before returning.
The other way round: start from the base cases and fill a table upwards, each entry from entries already filled. No recursion at all. This is bottom-up DP, also called tabulation.
i
0
1
2
3
4
5
6
7
fib(i)
0
1
1
2
3
5
8
13
C++
1longlongfib(int n){2if(n <2)return n;3 std::vector<longlong>table(n +1); // n + 1 zeros4 table[1]=1;5for(int i =2; i <= n; i++)6 table[i]= table[i -1]+ table[i -2];7return table[n];8}
Each entry needs only the two to its left, so filling left to right always works. The early return matters: for n = 0 the table has one slot, and table[1] would be out of bounds.
Since each entry only looks two back, the whole table is not needed: two variables can slide along it, and the memory drops to O(1).
Find the bugInteractive · disabled in the PDF
This keeps only two values instead of a table, but fib(3) gives 3, not 2. Which line is wrong?
C++
Algorithms139 / 194
Module 15 · Dynamic programming
Climbing stairs: count the ways
You climb a staircase of n steps, one or two at a time. In how many different ways can you reach the top? Think about your last move: you arrived either from step n - 1 (a single step) or from step n - 2 (a double). So the ways to reach n are the ways to reach n - 1 plus the ways to reach n - 2. That rule, an answer in terms of smaller answers, is the recurrence.
Step
0
1
2
3
4
5
Ways
1
1
2
3
5
8
ways(0) is 1: there is exactly one way to climb no stairs, which is to do nothing. Defining the empty case as 1 makes the recurrence work from step 2 on. The numbers are Fibonacci again, shifted by one: the same problem in disguise.
Code challengeInteractive · disabled in the PDF
Finish ways(n, steps): how many orders of moves climb exactly n stairs, when each move is one of the sizes in steps? Fill a table from 0 up.
C++
1#include<vector>23longlongways(int n,const std::vector<int>& steps){4 std::vector<longlong>table(n +1,0);5 // TODO: the base case, then each entry from smaller ones6return table[n];7}
Algorithms140 / 194
Module 15 · Dynamic programming
Coin change: how many ways to pay?
How many ways can you pay 5 with coins of 1, 2 and 5? Here 2 + 2 + 1 and 1 + 2 + 2 are the same way: only which coins, not their order. Stair-counting would count them twice. The trick is to bring in one coin at a time: first count the ways with 1s only, then let 2s join in, then 5s. ways[a] is the number of ways to pay a with the coins so far.
Amount
0
1
2
3
4
5
Start
1
0
0
0
0
0
With 1s
1
1
1
1
1
1
With 1s and 2s
1
1
2
2
3
3
With 1s, 2s and 5s
1
1
2
2
3
4
When coin c joins, every amount a gains the ways to pay a - c and then add one more c: ways[a] += ways[a - c]. The four ways to pay 5: 5; 2 + 2 + 1; 2 + 1 + 1 + 1; and five 1s.
Predict the outputInteractive · disabled in the PDF
What do the two loop orders count, for paying 3 with coins 1 and 2?
C++
1// A2for(int c : coins)3for(int a = c; a <=3; a++) ways[a]+= ways[a - c];45// B6for(int a =1; a <=3; a++)7for(int c : coins)8if(c <= a) ways[a]+= ways[a - c];
What do the two loop orders count, for paying 3 with coins 1 and 2?
Code challengeInteractive · disabled in the PDF
Finish countWays(amount, coins): the number of different sets of coins that pay amount exactly (order does not matter).
C++
1#include<vector>23longlongcountWays(int amount,const std::vector<int>& coins){4 std::vector<longlong>ways(amount +1,0);5 ways[0]=1;6 // TODO: let each coin join in turn7return ways[amount];8}
Algorithms141 / 194
Module 15 · Dynamic programming
Coin change: the fewest coins, for any coins
The last module showed greedy change failing for coins 1, 3 and 4: it pays 6 with three coins when two will do. DP fixes it by trying every coin as the last one. To pay a with a last coin c, pay a - c as cheaply as possible and add one coin. So fewest[a] is the smallest fewest[a - c] + 1 over all coins that fit.
Amount
0
1
2
3
4
5
6
Fewest (1, 3, 4)
0
1
2
1
1
2
2
Last coin
1
1
3
4
1 or 4
3
For 6, the choices are 1 + fewest[5] = 3, 1 + fewest[3] = 2, and 1 + fewest[2] = 3; the best is 2, ending with a 3. Some amounts cannot be paid at all (3 with only 2s): mark them with a stand-in for infinity, INF, and turn it into an answer like -1 at the end.
Code challengeInteractive · disabled in the PDF
Finish fewestCoins(amount, coins): the fewest coins that pay amount exactly, or -1 if it cannot be done. INF is ready for “not payable yet”.
C++
1#include<algorithm>2#include<vector>34constint INF =1000000000; // more coins than any real answer56intfewestCoins(int amount,const std::vector<int>& coins){7return-1;8}
Going deeper
Pseudo-polynomial time. O(amount × coins) looks fast, but it grows with the value of the amount, not the length of its digits: an amount of a billion needs a billion entries. Algorithms like this are called pseudo-polynomial. For huge amounts with canonical coins, greedy is the practical answer.
Algorithms142 / 194
Module 15 · Dynamic programming
Getting the coins back, not just the count
A count is often not enough: you want to know which coins. Store, next to each entry, the choice that won (the “Last coin” row above). Then walk back from the answer: take the last coin for 6 (a 3), go to 6 - 3 = 3, take its last coin (a 3), reach 0. This is called reconstructing the answer, and almost every DP can do it.
C++
1// last[a] is the coin that won for amount a (filled with fewest)2std::vector<int>coinsFor(int amount,const std::vector<int>& last){3 std::vector<int> used;4while(amount >0){5 used.push_back(last[amount]);6 amount -= last[amount];7}8return used;9}10// coinsFor(6, {0, 1, 1, 3, 4, 1, 3}) gives {3, 3}
Where DP runs every day. Spell checkers and diff measure how far apart two texts are with a DP table (next module). Speech recognition and phone keyboards pick the most likely words with the Viterbi algorithm, a DP. TeX breaks paragraphs into lines by DP over every possible break. The Bellman–Ford shortest-path algorithm (module 13) is DP over the number of edges used.
Algorithms143 / 194
Module 15 · Dynamic programming
Top-down or bottom-up
Top-down (memoised recursion)
Write the recurrence as recursion, add a memo
Only solves the subproblems the question needs
Order comes for free from the recursion
Deep recursion can overflow the call stack (here, after some tens of thousands of calls)
Bottom-up (a table)
Fill a table from the base cases, in a loop
Solves every subproblem, needed or not
You choose an order where parts come first
No recursion; easy to keep only the last row
Both do the same work: time is the number of subproblems times the work per subproblem. Start top-down when the recurrence is clear but the order is not; switch to bottom-up for speed, to save memory, or when inputs are large enough to overflow the stack.
Put in orderInteractive · disabled in the PDF
Put the steps of designing a DP solution in order.
1.Read the answer from the table, and rebuild the choices if needed
2.Say in words what one subproblem is: “fewest coins to pay a”
3.Write the recurrence: the answer from smaller answers
4.Pick an order (or memoise) so smaller answers are ready first
5.Fix the base cases: fewest[0] = 0
Algorithms144 / 194
Module 15 · Dynamic programming
Recap
DP helps when a recursion solves the same overlapping subproblems many times.
Top-down: recursion plus a memo (passed by reference). Bottom-up: a std::vector filled from the base cases.
Look a memo up with find or contains: memo[n] on a missing key adds a 0.
Keep counts in long long, and pick an INF that survives INF + 1.
Find the recurrence by thinking about the last choice: the last step, the last coin.
Time is the number of subproblems times the work for each one: Fibonacci drops from exponential to O(n).
Coin ways: coins in the outer loop count sets; amounts outside count orders.
Fewest coins tries every coin as the last one, and is right where greedy fails.
Keep the winning choice per entry to rebuild the answer, not just its size.
FlashcardsInteractive · disabled in the PDF
Overlapping subproblems
The same smaller problem is needed many times by a recursion.
Memoisation
Storing each answer the first time, and looking it up after.
Top-down vs bottom-up
Recursion with a memo, from the big question down; or a table filled from the base cases up.
Recurrence
A rule giving an answer in terms of smaller answers.
Naive fib(n) calls
About 1.6ⁿ: exponential. Memoised: O(n).
Fewest coins recurrence
fewest[a] = min over coins c ≤ a of fewest[a - c] + 1, with fewest[0] = 0.
Coin ways loop order
Coins outer, amounts inner counts sets; the other way round counts orders.
memo[n] when n is not in an unordered_map
It adds n with the value 0 and returns that. Check with contains or find first.
Reconstructing a DP answer
Store the winning choice for each entry, then walk back from the final one.
Module 16: Dynamic programming in two dimensions
Algorithms145 / 194
Module 16 · Dynamic programming in two dimensions
Some subproblems need two numbers
A quick recap of dynamic programming (the module before): when a problem breaks into smaller problems that repeat, you solve each small one once, keep its answer, and build bigger answers from smaller ones. For climbing stairs or making change, one number described each subproblem (“the best for 7 cents”), so a one-row vector held every answer.
Many problems need two numbers to say which small problem you mean: “how many ways to reach row 2, column 3”, or “how alike are the first 4 letters of one word and the first 3 of another”. Then the answers fit in a table, a grid with a row for one number and a column for the other. The method is the same as before: say what one cell means, say how it is built from cells already filled, fill the table in an order that respects that, and read the answer from one cell.
State
The numbers that pick out one subproblem, such as (row, column) or (i, j).
Table
A 2-D grid dp[i][j] holding the answer for every state: in C++, a std::vector<std::vector<int>>, a vector of rows.
Recurrence
The rule that builds one cell from cells already filled.
Base case
Cells known without any rule, usually the first row and column.
Prefix
The start of a string or list: the first i letters of a word.
Four questions for every table. What does dp[i][j] mean, in a sentence? What are the base cases? Which cells does one cell depend on? Where is the final answer? Answer them before writing a loop, and the code nearly writes itself.
Algorithms146 / 194
Module 16 · Dynamic programming in two dimensions
Counting paths through a grid
You walk through a city of square blocks from the top-left corner to a café at the bottom-right, and you only ever go right or down (any other step takes you further away). How many different routes are there?
Think about the last step. You reach any corner either from the one above it or from the one to its left, so the routes to a corner are the routes to the corner above plus the routes to the corner on the left. Corners along the top edge or the left edge have exactly one route: a straight line. That gives the whole table for a grid of 3 rows and 4 columns:
col 0
col 1
col 2
col 3
row 0
1
1
1
1
row 1
1
2
3
4
row 2
1
3
6
10
QuizInteractive · disabled in the PDF
Add a fourth row (row 3) to the table above. What goes in its last cell, the bottom-right corner of a 4 by 4 grid?
Add a fourth row (row 3) to the table above. What goes in its last cell, the bottom-right corner of a 4 by 4 grid?
Algorithms147 / 194
Module 16 · Dynamic programming in two dimensions
Filling the grid row by row
In code, fill row by row, left to right, so the cell above and the cell on the left are always ready. A wall (a closed street) simply has 0 routes through it, and the rule needs no other change. The table is a vector of rows: std::vector<std::vector<long long>> ways(rows, std::vector<long long>(cols, 0)) makes rows copies of a row of cols zeros.
PlaygroundInteractive · disabled in the PDF
C++
1#include<iostream>2#include<vector>34longlongroutes(int rows,int cols){5 std::vector<std::vector<longlong>>ways(6 rows, std::vector<longlong>(cols,0));7for(int r =0; r < rows; r++)8for(int c =0; c < cols; c++){9if(r ==0|| c ==0) ways[r][c]=1;10else ways[r][c]= ways[r -1][c]+ ways[r][c -1];11}12return ways[rows -1][cols -1];13}1415intmain(){16 std::cout <<routes(3,4)<<"\n"; // 1017 std::cout <<routes(17,17)<<"\n";18}
Time and memory are both O(rows × cols): one step per cell. Try routes(34, 34): the counts grow fast, which is why the cells are long long. At 35 by 35 even a long long overflows.
Code challengeInteractive · disabled in the PDF
Finish countPaths(grid): grid[r][c] is 1 for a wall and 0 for open ground. Count the routes from the top-left to the bottom-right moving only right or down, never through a wall. A wall on the start or the end means 0 routes.
C++
1#include<vector>23usingGrid= std::vector<std::vector<int>>;45longlongcountPaths(constGrid& grid){6int rows = grid.size(), cols = grid[0].size();7 std::vector<std::vector<longlong>>ways(8 rows, std::vector<longlong>(cols,0));9 // TODO: fill ways[r][c] for every cell, row by row10return ways[rows -1][cols -1];11}
Going deeper
One block of memory instead of many rows. A std::vector<std::vector<long long>> is one small vector per row, each row a separate block somewhere on the heap. Fast table code often uses one flat std::vector<long long> ways(rows * cols) instead and reads cell (r, c) as ways[r * cols + c]: one allocation, and every row sits right after the one before it, which the processor’s cache loves. The answers are the same; only the speed changes.
Algorithms148 / 194
Module 16 · Dynamic programming in two dimensions
The longest common subsequence
A subsequence of a word is what is left after crossing out some letters, keeping the rest in order: ACE is a subsequence of ABCDE, AEC is not. The longest common subsequence (LCS) of two words is the longest one that both contain. For BAKE and CAKE it is AKE. Comparing two versions of a text this way shows what stayed the same, and so what changed.
Let dp[i][j] be the LCS length of the first i letters of one word and the first j of the other. Look at the last letter of each prefix. If they match, that letter ends a common subsequence: dp[i][j] = dp[i - 1][j - 1] + 1. If not, at least one of them is not used, so drop one or the other and keep the better: max(dp[i - 1][j], dp[i][j - 1]). With an empty prefix, the answer is 0.
Written as plain recursion, the rule asks the same (i, j) again and again, and with no matching letters the calls double at every level. The table has only (a.size() + 1) × (b.size() + 1) cells. Run this to see the gap.
PlaygroundInteractive · disabled in the PDF
C++
1#include<algorithm>2#include<iostream>3#include<string>45longlong calls =0;6intnaive(const std::string& a,const std::string& b,int i,int j){7 calls++;8if(i ==0|| j ==0)return0;9if(a[i -1]== b[j -1])returnnaive(a, b, i -1, j -1)+1;10return std::max(naive(a, b, i -1, j),naive(a, b, i, j -1));11}1213intmain(){14 std::string a ="ABCDEFGHI";15 std::string b ="JKLMNOPQR";16int length =naive(a, b, a.size(), b.size());17 std::cout <<"LCS length "<< length <<" after "<< calls18<<" calls\n";19 std::cout <<"table cells: "<<(a.size()+1)*(b.size()+1)20<<"\n";21}
Add one letter to each word and run it again: the calls roughly quadruple, the cells barely move. Then make the words share letters.
Code challengeInteractive · disabled in the PDF
Finish lcsLength(a, b) with the table: fill dp[i][j] for every i from 1 and j from 1, using the rule from the slide before, and return the bottom-right cell.
C++
1#include<algorithm>2#include<string>3#include<vector>45intlcsLength(const std::string& a,const std::string& b){6int n = a.size(), m = b.size();7 std::vector<std::vector<int>>dp(n +1, std::vector<int>(m +1,0));8for(int i =1; i <= n; i++)9for(int j =1; j <= m; j++){10 // TODO: fill dp[i][j] from its neighbours11}12return dp[n][m];13}
Algorithms150 / 194
Module 16 · Dynamic programming in two dimensions
Edit distance: how far apart two words are
A spell checker that sees teh suggests the because it is only a small change away. The edit distance (also called Levenshtein distance) is the fewest single-letter edits that turn one word into another, where an edit is inserting a letter, deleting one, or substituting one for another.
Let dp[i][j] be the edits that turn the first i letters of a into the first j of b. If the last letters match, keep them: dp[i - 1][j - 1]. Otherwise take the cheapest of three: substitute (dp[i - 1][j - 1] + 1), delete a’s last letter (dp[i - 1][j] + 1), or insert b’s last letter (dp[i][j - 1] + 1). Turning i letters into nothing takes i deletions, and nothing into j letters takes j insertions. Here is TEA into EAT:
""
E
A
T
""
0
1
2
3
T
1
1
2
2
E
2
1
2
3
A
3
2
1
2
Find the bugInteractive · disabled in the PDF
This says the distance from abc to an empty word is 0. Which line is wrong?
C++
Where this runs. Spell checkers rank suggestions by edit distance. Biologists align DNA with the same table, with costs per letter (Needleman–Wunsch). Diff tools such as git diff find what changed between two files as a longest common subsequence of lines; they use Myers’ algorithm, which gets the same answer faster when the files are mostly alike.
Algorithms151 / 194
Module 16 · Dynamic programming in two dimensions
The 0/1 knapsack: packing a bag
You are packing a bag that holds 4 kg for a hike. Each thing has a weight and a value to you, and you either take it or leave it: no half a camera. That “all or nothing” is the 0/1 in the 0/1 knapsack problem. Which things give the most value without going over the weight?
Item
Weight
Value
Map
1
15
Water
3
20
Camera
4
30
Let best[i][c] be the most value from the first i items with room c. For item i, either skip it (best[i - 1][c]) or, if it fits, take it: its value plus the best for the earlier items in the room left over (value + best[i - 1][c - weight]). Rows are items, columns are room from 0 to 4 kg:
0
1
2
3
4
no items
0
0
0
0
0
+ map
0
15
15
15
15
+ water
0
15
15
20
35
+ camera
0
15
15
20
35
Fill the blanksInteractive · disabled in the PDF
Complete the knapsack loop.
C++
1for(int i =1; i <=int(items.size()); i++){2auto[weight, value]= items[i -1];3for(int c =0; c <=; c++){4int skip = best[i -1][c];5bool fits = weight <= c;6int take = fits ? value + best[i -1][c -]:0;7 best[i][c]= std::(skip, take);8}9}
QuizInteractive · disabled in the PDF
A greedy packer takes the most valuable item first, then the next that still fits. With the 4 kg bag above, how does it do?
A greedy packer takes the most valuable item first, then the next that still fits. With the 4 kg bag above, how does it do?
Algorithms152 / 194
Module 16 · Dynamic programming in two dimensions
Reading the answer back from the table
The bottom-right cell says how good the best answer is, not what it is. To find which items, walk back from that cell: at each row, if the value differs from the row above, this item must have been taken, so note it and move left by its weight. If it is the same, skipping it was just as good, so go up.
WalkthroughInteractive · disabled in the PDF
C++
1structItem{ std::string name;int weight;int value;};2usingTable= std::vector<std::vector<int>>;34std::vector<std::string>packed(const std::vector<Item>& items,5constTable& best,int room){6 std::vector<std::string> chosen;7int c = room;8for(int i = items.size(); i >0; i--){9if(best[i][c]!= best[i -1][c]){10 chosen.push_back(items[i -1].name);11 c -= items[i -1].weight;12}13}14 std::reverse(chosen.begin(), chosen.end());15return chosen;16}
Lines 7-8: Start at the answer cell: the last row (all items) and the full room.
Lines 9: Camera row, room 4: 35, same as the row above. Skipping the camera was as good, so go up.
Lines 9-11: Water row, room 4: 35, but 15 above. The water was taken: note it, and 4 − 3 leaves room 1.
Lines 9-11: Map row, room 1: 15, but 0 above. The map was taken, leaving room 0.
Lines 14-15: The choices were found last to first, so reverse them: {"Map", "Water"}.
Code challengeInteractive · disabled in the PDF
Finish lcs(a, b), which returns the longest common subsequence itself. table(a, b) already fills the LCS table. Walk back from dp[a.size()][b.size()]: on matching letters, collect the letter and go up-left; otherwise move towards the larger of the cell above and the cell on the left.
C++
1#include<algorithm>2#include<string>3#include<vector>45usingTable= std::vector<std::vector<int>>;67Tabletable(const std::string& a,const std::string& b){8Tabledp(a.size()+1, std::vector<int>(b.size()+1,0));9for(size_t i =1; i <= a.size(); i++)10for(size_t j =1; j <= b.size(); j++)11 dp[i][j]= a[i -1]== b[j -1]12? dp[i -1][j -1]+113: std::max(dp[i -1][j], dp[i][j -1]);14return dp;15}1617std::stringlcs(const std::string& a,const std::string& b){18Table dp =table(a, b);19 std::string out;20 // TODO: walk back from i = a.size(), j = b.size()21return out;22}
Algorithms153 / 194
Module 16 · Dynamic programming in two dimensions
Saving memory with one row
Every rule in this module reads only the current row and the one above. Once a row is done, the rows above it are never read again, so you can keep one row and overwrite it as you go: memory drops from O(n × capacity) to O(capacity). For grid paths, row[c] += row[c - 1] does it: before the update row[c] still holds the cell above.
The knapsack needs care. The rule reads best[c - weight] from the row above, meaning “before this item”. Go through the room from largest to smallest, and the smaller cells you read have not yet been overwritten for this item.
Predict the outputInteractive · disabled in the PDF
With one row and only the map (weight 1, value 15), what does the bag of room 4 end up worth if the loop goes up, from c = 1 to 4?
C++
1std::vector<int> best ={0,0,0,0,0};2for(int c =1; c <=4; c++)3 best[c]= std::max(best[c], best[c -1]+15);4std::cout << best[4];
With one row and only the map (weight 1, value 15), what does the bag of room 4 end up worth if the loop goes up, from c = 1 to 4?
Code challengeInteractive · disabled in the PDF
Finish bestValue(items, capacity) for the 0/1 knapsack using only the one row best, where best[c] is the most value with room c. Each item can be packed at most once.
C++
1#include<algorithm>2#include<vector>34structItem{int weight;int value;};56intbestValue(const std::vector<Item>& items,int capacity){7 std::vector<int>best(capacity +1,0);8for(constItem& item : items){9 // TODO: update best for this item10}11return best[capacity];12}
Fast, but only for small numbers. O(n × capacity) looks polynomial, but the capacity is a number, not a count of things: a capacity with 30 digits makes the table impossibly wide. The knapsack is NP-hard, and this is called pseudo-polynomial time. Keeping one row loses the information needed to read back the answer; Hirschberg’s algorithm recovers an LCS in linear memory by splitting the problem in half recursively.
Algorithms154 / 194
Module 16 · Dynamic programming in two dimensions
Recap
When two numbers pick out a subproblem, the answers fit in a table dp[i][j]: a std::vector<std::vector<int>>.
Say what a cell means, the base cases, the cells it depends on and where the answer is.
Grid paths: a cell is the cell above plus the cell on the left.
LCS: a match is up-left plus one; otherwise the larger of up and left.
Edit distance: a match is free; otherwise one plus the cheapest of substitute, delete and insert.
0/1 knapsack: the better of skipping the item and taking it with the room left over.
Read the choices back by retracing from the answer cell; keep one row to save memory when you only need the number.
FlashcardsInteractive · disabled in the PDF
State
The numbers that pick out one subproblem, like (i, j).
Subsequence
What is left after crossing out some letters, the rest kept in order.
Polynomial in a number’s value (like the capacity), not in its number of digits: slow when the value is huge.
Module 17: Backtracking
Algorithms155 / 194
Module 17 · Backtracking
Choose, explore, undo
In a maze, you pick a passage and follow it. At a dead end you walk back to the last fork and try a passage you have not tried yet. Keep doing that and you either find the way out or prove there is none. That is backtracking: build a solution one choice at a time, and when a choice leads nowhere (or once you have seen all it leads to), undo it and make the next one.
Choose: make one choice and add it to the partial solution.
Explore: recurse to make the rest of the choices.
Undo: take the choice back, so the partial solution is as it was before, and try the next one.
It leans on recursion (an earlier module): each call makes one choice, and the call stack remembers where to come back to. The difference from plain recursion is the undo step: one shared partial solution is changed and changed back, instead of copied at every call. In C++ that means passing it by reference (&) to every call, so they all work on the same one.
Backtracking
Trying choices one by one, recursing, and undoing each before the next.
Partial solution
The choices made so far, such as the letters placed so far.
Dead end
A partial solution that cannot lead to an answer.
Pruning
Stopping early at a dead end, without exploring what lies beyond it.
Search tree
Every sequence of choices drawn as a tree: each node a partial solution, each leaf a finished one.
Algorithms156 / 194
Module 17 · Backtracking
Subsets: take it or leave it
A subset is any selection of the items, from none of them to all of them. To list every subset, walk through the items and make one choice for each: take it, or skip it. Every path from the top of this tree to a leaf is one subset.
start[]
take a[a]
take b[a, b]
skip b[a]
skip a[]
take b[b]
skip b[]
The search tree for the subsets of [a, b]: two choices per item, so four leaves.
Lines 3-4: The items are letters in a string. chosen is the one partial solution and result collects finished subsets: both by reference, shared by every call.
Lines 5-8: Every item has been decided: this is a leaf. push_back stores a copy of chosen, so later changes to chosen leave the saved one alone.
Lines 9-10: Choose to take item i, and explore every way to decide the rest. push_back and pop_back work on a std::string just as on a vector.
Lines 11: Undo: remove it again, so chosen is exactly as it was before line 9.
Lines 12: The other choice: skip item i and explore the rest without it.
Lines 15-20: Start at item 0. For "ab" this gives "ab", "a", "b", "".
QuizInteractive · disabled in the PDF
How many subsets does a list of 10 items have?
How many subsets does a list of 10 items have?
Algorithms157 / 194
Module 17 · Backtracking
Choosing k of n
Picking a team of 3 from 5 friends is a combination: which ones matter, not the order. Instead of “take or skip”, each call can loop over the items still allowed, and only ever pick items after the last one chosen. That way [a, b] is listed and [b, a] never is. Stop as soon as k are chosen.
Code challengeInteractive · disabled in the PDF
Finish explore so that choose(items, k) returns every way to pick k of the letters in items, each in the order the letters come, with the picks in that order too. Use chosen with push_back, recurse and pop_back.
C++
1#include<string>2#include<vector>34usingLists= std::vector<std::string>;56voidexplore(const std::string& items,size_t k,size_t start,7 std::string& chosen,Lists& result){8 // TODO: save chosen at k letters, or try each item from start9}1011Listschoose(const std::string& items,size_t k){12 std::string chosen;13Lists result;14explore(items, k,0, chosen, result);15return result;16}
Prune what cannot finish. If fewer items are left than you still need (items.size() - start is less than k - chosen.size()), no path below can reach k, so return at once. The answer is the same and the tree is smaller. The count of combinations is n!/(k!(n − k)!), “n choose k”.
Algorithms158 / 194
Module 17 · Backtracking
Permutations: every order
A permutation is an arrangement of all the items in some order: the ways to seat guests in a row of chairs. For the first chair any guest can go; for the next, any guest not yet seated; and so on. A std::vector<bool> used remembers who is seated, and undoing a choice means un-seating them as well as removing them from the row.
start
a
ab
abc
ac
acb
b
ba
bac
bc
bca
c
ca
cab
cb
cba
The search tree for abc: 3 choices, then 2, then 1, so 3 × 2 × 1 = 6 leaves.
Find the bugInteractive · disabled in the PDF
For "abc" this finds only {"abc"}, not all six orders. Which line is wrong?
C++
The standard library can do this one.std::next_permutation(s.begin(), s.end()) from <algorithm> rearranges s into the next order in dictionary order, and returns false once it wraps round. Start from a sorted string and loop until it returns false to visit every permutation, repeated letters counted once, with no recursion.
Algorithms159 / 194
Module 17 · Backtracking
Try it: every arrangement of a word
Now write it yourself, with a std::string as the partial solution: push_back adds a letter, pop_back cuts it off again, and the used flags say which letters are already placed.
Code challengeInteractive · disabled in the PDF
Finish explore so that arrangements(word) returns every ordering of the word's letters, in the order the search tree finds them (first letter first). The letters are all different.
C++
1#include<string>2#include<vector>34usingLists= std::vector<std::string>;56voidexplore(const std::string& word, std::vector<bool>& used,7 std::string& current,Lists& result){8 // TODO: save current when it is full, or try each unused letter9}1011Listsarrangements(const std::string& word){12 std::vector<bool>used(word.size(),false);13 std::string current;14Lists result;15explore(word, used, current, result);16return result;17}
Algorithms160 / 194
Module 17 · Backtracking
Choices that add up to a target
Which of these price tags add up to exactly 7? Backtracking tries the tags one at a time, keeping track of how much is left to reach. Hitting 0 left means an answer. Sorting the numbers first gives a strong prune: once a number is bigger than what is left, every number after it is too, so stop the loop there.
Fill the blanksInteractive · disabled in the PDF
Complete the pruning line and the recursive call.
C++
1for(size_t i = start; i < sorted.size(); i++){2if(sorted[i]>);3 chosen.push_back(sorted[i]);4explore(sorted,, left - sorted[i], chosen, result);5 chosen.pop_back();6}
Code challengeInteractive · disabled in the PDF
Finish explore so that sumsTo(nums, target) returns every list of numbers from nums (distinct and positive, each used at most once) that adds up to target, each list in ascending order. The order of the lists does not matter.
C++
1#include<algorithm>2#include<vector>34usingNums= std::vector<int>;56voidexplore(constNums& sorted,size_t start,int left,7Nums& chosen, std::vector<Nums>& result){8 // TODO: save chosen when nothing is left, or try each number9}1011std::vector<Nums>sumsTo(Nums nums,int target){12 std::sort(nums.begin(), nums.end());13Nums chosen;14 std::vector<Nums> result;15explore(nums,0, target, chosen, result);16return result;17}
Algorithms161 / 194
Module 17 · Backtracking
Pruning: the N-queens puzzle
Place N queens on an N × N chessboard so that no two attack each other: none share a row, a column or a diagonal. Put one queen per row, so rows never clash, and for each row try every column. The pruning is the point: check a queen as soon as it is placed, and abandon the branch at once if it is attacked, instead of filling the board and checking at the end.
PlaygroundInteractive · disabled in the PDF
C++
1#include<cstdlib>2#include<iostream>3#include<vector>45longlong calls =0;67boolsafe(const std::vector<int>& cols,int c){8int row = cols.size();9for(int qr =0; qr < row; qr++)10if(cols[qr]== c || std::abs(cols[qr]- c)== row - qr)11returnfalse;12returntrue;13}1415intqueens(int n, std::vector<int>& cols){16 calls++;17if(int(cols.size())== n)return1;18int found =0;19for(int c =0; c < n; c++){20if(!safe(cols, c))continue; // prune21 cols.push_back(c);22 found +=queens(n, cols);23 cols.pop_back();24}25return found;26}2728intmain(){29int n =6;30 std::vector<int> cols;31 std::cout <<queens(n, cols)<<" solutions after "<< calls32<<" calls\n";33longlong boards =1;34for(int i =0; i < n; i++) boards *= n;35 std::cout <<"boards without pruning: "<< boards <<"\n";36}
cols[r] is the column of the queen in row r. Try n from 4 to 9 and compare the calls with the boards a check-at-the-end search would fill.
QuizInteractive · disabled in the PDF
Queens at (row 0, column 1) and (row 2, column 3): std::abs(1 - 3) == 2 - 0 is true. Are they safe from each other?
Queens at (row 0, column 1) and (row 2, column 3): std::abs(1 - 3) == 2 - 0 is true. Are they safe from each other?
Still exponential, but much less so. Pruning does not change the worst case: backtracking is exponential, which is why it is kept for small inputs or problems where pruning is strong. It is the core of Sudoku solvers, puzzle and timetabling solvers, and SAT solvers that check chip designs, which add clever pruning and learn from each dead end.
Going deeper
Sudoku is the same pattern: find an empty cell, try each digit from 1 to 9 that is not already in its row, column or 3 × 3 box, recurse, and clear the cell again if the rest cannot be filled. Choosing the empty cell with the fewest allowed digits first prunes far more: a cell with one option is no choice at all.
Algorithms162 / 194
Module 17 · Backtracking
Try it: find a word in a letter grid
In a word-search puzzle, a word is traced through neighbouring letters (up, down, left or right), and no square is used twice in one word. From each square, try each neighbour for the next letter. To stop a path from reusing a square, mark it while it is on the path, and unmark it when you back out: the undo step again.
Code challengeInteractive · disabled in the PDF
Finish hasWord(grid, word): return true if word can be traced through neighbouring squares, using each square at most once. Mark a square by setting it to '#' while on the path, and put the letter back afterwards, so the grid is unchanged at the end. A helper like from(grid, word, r, c, i), “can the word from letter i on start at this square?”, is a good start.
C++
1#include<string>2#include<vector>34usingGrid= std::vector<std::string>;56boolhasWord(Grid& grid,const std::string& word){7 // TODO: try every square as the start of the word8returnfalse;9}
Algorithms163 / 194
Module 17 · Backtracking
When to reach for backtracking
Backtracking fits when you need all the answers, or any answer that meets rules (“fill the board so that…”), and the answers are built from a sequence of choices. If you only need how many or the best and the same smaller question keeps coming back, dynamic programming (the modules before) is usually far faster: counting coin combinations is DP, listing them is backtracking.
Put in orderInteractive · disabled in the PDF
Put one call of a backtracking search in order.
1.If the partial solution is complete, save a copy and return
2.Recurse to make the next choices
3.Loop over the choices still allowed here
4.Make the choice: add it to the partial solution
5.Skip a choice that breaks a rule (prune)
6.Undo the choice before trying the next
QuizInteractive · disabled in the PDF
Which of these are best done with backtracking? Pick every one.
Which of these are best done with backtracking? Pick every one.
Backtracking hides in regular expressions. Many regular expression engines, including C++’s std::regex, match by backtracking. A pattern such as (a+)+b on a long string of as with no b tries an exponential number of ways to split them before failing. On a server, that can freeze it on one request (a ReDoS attack). Avoid nested repetition in patterns that see outside input.
Algorithms164 / 194
Module 17 · Backtracking
Recap
Backtracking builds a solution one choice at a time: choose, explore, undo.
One shared partial solution, passed by reference, is changed and changed back; push_back saves a copy when it is complete.
Subsets: take or skip each item, 2ⁿ of them. Combinations: only pick items after the last one.
Permutations: any unused item next, tracked with a std::vector<bool> used, n! of them.
Prune: stop a branch as soon as it cannot lead to an answer; sorting often makes that easy.
Mark squares while on a path and unmark them when backing out.
All answers, or any answer under rules: backtracking. A count or the best with repeats: DP.
FlashcardsInteractive · disabled in the PDF
Backtracking
Try a choice, recurse, undo it, try the next.
Partial solution
The choices made so far on the current path.
Why is result.push_back(chosen) safe?
It stores a copy, so later changes to the shared chosen do not reach the saved answer.
Why pass chosen by reference?
So every call changes the same partial solution, with no copy per call.
Pruning
Abandoning a branch as soon as it cannot lead to an answer.
Subsets of n items
2ⁿ: each item is taken or skipped.
Permutations of n items
n!: n choices, then n − 1, and so on.
Combinations without repeats
Pass i + 1 so later picks only come after earlier ones.
Backtracking or DP?
List or find answers: backtracking. Count or optimise with repeated subproblems: DP.
Module 18: Minimum spanning trees
Algorithms165 / 194
Module 18 · Minimum spanning trees
Connect every town with the least cable
Five towns want to share an internet line. Cable can be laid along certain roads, and each road has a cost. Every town must be reachable from every other, through any number of hops, and you want to spend as little as possible. You never need a loop: if three towns are joined in a triangle, one of its three cables can be removed and everyone is still connected.
That is a weighted graph: towns are vertices, roads are edges, and each edge has a weight (its cost). Recall from graph search that a graph is just things and the links between them. What you are looking for is a minimum spanning tree.
Spanning tree
A set of edges that connects every vertex and has no loop (cycle).
Minimum spanning tree (MST)
The spanning tree whose weights add up to the least.
Cycle
A path that comes back to where it started without reusing an edge.
QuizInteractive · disabled in the PDF
A spanning tree connects 5 towns. How many edges does it have?
A spanning tree connects 5 towns. How many edges does it have?
Algorithms166 / 194
Module 18 · Minimum spanning trees
Take the cheapest edge that does not make a loop
Kruskal’s idea is greedy (as in the greedy module: take the locally best step and never undo it). Sort the roads from cheapest to dearest. Go down the list, and lay each cable unless its two towns are already connected, because then it would only close a loop. Stop when every town is in one group.
A–C costs 1, the cheapest. A and C are apart, so take it.
B–C costs 2. B is alone, so take it: {A, B, C}.
D–E costs 3. Both are alone, so take it: {D, E}.
A–B costs 4, but A and B are already joined through C. Skip it.
B–D costs 5 and joins the two groups. Take it.
One group, 4 edges: done. Total 1 + 2 + 3 + 5 = 11. C–D and C–E are never needed.
Five towns and the cost of each road. Step through Kruskal’s choices.
Why greedy is right here. The cut property: split the towns into any two sides, and the cheapest edge crossing between them belongs to some MST. Proof by exchange: if a best tree left it out, adding it makes a loop that crosses the split somewhere else, at an edge that costs at least as much; swap that one out and the tree is no dearer. Each edge Kruskal takes is the cheapest across the split between its group and the rest, so every choice is safe.
Algorithms167 / 194
Module 18 · Minimum spanning trees
Trace it: a table of decisions
Kruskal on paper is a table: the edges in cost order, the groups before each one, and what you decide. The only question at each row is “are these two already in the same group?”
Edge
Cost
Groups before
Decision
A–C
1
{A} {B} {C} {D} {E}
Take
B–C
2
{A, C} {B} {D} {E}
Take
D–E
3
{A, B, C} {D} {E}
Take
A–B
4
{A, B, C} {D, E}
Skip: same group
B–D
5
{A, B, C} {D, E}
Take: now one group
C–D
8
{A, B, C, D, E}
Not needed
Put in orderInteractive · disabled in the PDF
Put the steps of Kruskal’s algorithm in order.
1.Put every vertex in a group of its own
2.Stop once you have kept n - 1 edges
3.If its ends are in different groups, keep the edge and merge the groups
4.Sort the edges from cheapest to dearest
5.Take the next edge in the list
Algorithms168 / 194
Module 18 · Minimum spanning trees
Union-find answers “same group?” fast
A recap from the Data structures course: union-find (a disjoint-set) keeps items in groups. Each item points to a parent; following parents leads to the group’s root, its representative. find(x) returns the root, and two items are in the same group when their roots are equal. Joining two groups (a union) points one root at the other.
C++
1structUnionFind{2 std::vector<int> parent; // parent[i] == i means i is a root34UnionFind(int n):parent(n){5for(int i =0; i < n; i++) parent[i]= i;6}7intfind(int x){8while(parent[x]!= x){9 parent[x]= parent[parent[x]]; // skip a step next time10 x = parent[x];11}12return x;13}14boolunite(int a,int b){15int ra =find(a), rb =find(b);16if(ra == rb)returnfalse; // already together17 parent[ra]= rb;18returntrue;19}20};
A small struct with its data and its two operations. unite (union is a word C++ keeps for itself) says whether it merged anything, which is exactly Kruskal’s “take or skip”.
UnionFind(int n) : parent(n) is the constructor, run when a UnionFind groups(5); is made: the part after the colon makes parent a vector of n slots, and the loop makes each vertex its own root. find and unite are member functions: called as groups.find(3), they read and change that object’s parent.
Fill the blanksInteractive · disabled in the PDF
Complete Kruskal’s loop using the UnionFind from above.
C++
1std::sort(edges.begin(), edges.end(),2[](constEdge& x,constEdge& y){3return x.cost y.cost;4});5UnionFindgroups(n);6longlong total =0;7for(auto[a, b, cost]: edges)8if(groups.(a, b)) total +=;
How fast union-find is. With the parent-skipping in find (path halving) and, in full versions, always hanging the smaller group under the larger (union by size), each operation takes amortised time so close to constant that it is never above 5 for any input that fits on Earth (the inverse Ackermann function). Kruskal’s cost is therefore the sort: O(E log E) for E edges.
Lines 1: An edge joins vertices a and b, numbered from 0, at a cost.
Lines 3-7:edges is taken by value, so this sorts the function’s own copy, cheapest first, and the caller’s list is left alone.
Lines 8: Every vertex starts as its own root, in its own group.
Lines 10-11:unite returns false when both ends already share a root: this edge would close a loop, so skip it.
Lines 12: Otherwise the groups are now merged; keep the edge.
Lines 13: With n - 1 edges the tree is complete; the rest of the list cannot help.
Find the bugInteractive · disabled in the PDF
This runs, and always adds up the cost of a spanning tree, but not the cheapest one. Which line is wrong?
C++
Algorithms170 / 194
Module 18 · Minimum spanning trees
Try it: the cost of a minimum spanning tree
Write Kruskal yourself. A UnionFind with only find is ready; you sort the edges, decide edge by edge whether to keep it, and merge the groups yourself. The edges arrive as const std::vector<Edge>&, so the compiler will not let you sort them in place: sort a copy.
Code challengeInteractive · disabled in the PDF
Finish mstCost(n, edges): the total cost of a minimum spanning tree of vertices 0 to n - 1. The graph is connected.
C++
1#include<algorithm>2#include<vector>34structEdge{int from;int to;int cost;};56structUnionFind{7 std::vector<int> parent; // parent[i] == i means i is a root89UnionFind(int n):parent(n){10for(int i =0; i < n; i++) parent[i]= i;11}12intfind(int x){13while(parent[x]!= x){14 parent[x]= parent[parent[x]]; // skip a step next time15 x = parent[x];16}17return x;18}19};2021longlongmstCost(int n,const std::vector<Edge>& edges){22UnionFindgroups(n);23 // TODO: cheapest edges first; keep each that joins two groups24return0;25}
Algorithms171 / 194
Module 18 · Minimum spanning trees
Prim grows one tree outwards
Prim’s algorithm reaches the same total differently. Start from any town. Repeatedly add the cheapest edge that leaves the tree you have so far, pulling one new town in. Like a stain spreading, the tree only ever grows at its edge. It is the cut property again: the split is “in the tree” versus “not yet”.
Tree so far
Edges leaving it
Cheapest
Total
{A}
A–C 1, A–B 4
A–C 1
1
{A, C}
B–C 2, A–B 4, C–D 8, C–E 9
B–C 2
3
{A, B, C}
B–D 5, C–D 8, C–E 9
B–D 5
8
{A, B, C, D}
D–E 3, C–E 9
D–E 3
11
Finding “the cheapest edge leaving the tree” quickly is the job of a priority queue, exactly as in Dijkstra’s shortest paths. The two algorithms are almost the same loop; Dijkstra ranks a town by its distance from the start, Prim by the cost of the single edge that would bring it in.
Kruskal
Looks at all edges, cheapest first
Grows many small groups that merge
Needs union-find
O(E log E): good when edges are few
Prim
Grows one tree from a start vertex
Always one connected piece
Needs a priority queue, or a simple array
O(V²) with an array: good when every pair is linked
Algorithms172 / 194
Module 18 · Minimum spanning trees
Try it: Prim on a table of costs
When every pair of towns can be linked (points on a map, say), the costs fit in a table: cost[i][j] is the cable from i to j, and a large INF means no road. Then Prim needs no queue: keep best[v], the cheapest edge from the tree to v, and scan it each round.
Code challengeInteractive · disabled in the PDF
Finish primCost(cost). Each round, pick the town not in the tree with the smallest best, add it (and its best to total), then lower best[v] to cost[picked][v] for every town outside the tree where that is cheaper.
C++
1#include<algorithm>2#include<vector>34constint INF =1000000000; // no road5usingTable= std::vector<std::vector<int>>;67longlongprimCost(constTable& cost){8int n = cost.size();9 std::vector<bool>inTree(n,false);10 std::vector<int>best(n, INF);11 best[0]=0;12longlong total =0;13for(int round =0; round < n; round++){14 // TODO: pick, add, then lower best[] around the pick15}16return total;17}
Algorithms173 / 194
Module 18 · Minimum spanning trees
What a spanning tree does and does not promise
PlaygroundInteractive · disabled in the PDF
C++
1#include<algorithm>2#include<iostream>3#include<vector>45structRoad{char a;char b;int cost;};67std::vector<int> parent ={0,1,2,3,4}; // A to E8intfind(int x){return parent[x]== x ? x :find(parent[x]);}910intmain(){11 std::vector<Road> roads ={{'A','B',4},{'A','C',1},12{'B','C',2},{'B','D',5},{'C','D',8},{'D','E',3},13{'C','E',9}};14 std::sort(roads.begin(), roads.end(),15[](constRoad& x,constRoad& y){16return x.cost < y.cost;17});18int total =0;19for(auto[a, b, cost]: roads){20int ra =find(a -'A'), rb =find(b -'A');21 std::cout <<(ra == rb ?"skip ":"take ")<< a <<'-'<< b22<<" ("<< cost <<")\n";23if(ra == rb)continue;24 parent[ra]= rb;25 total += cost;26}27 std::cout <<"total "<< total <<"\n";28}
A char is a small number, so b - 'A' turns 'A'…'E' into 0…4. Make A–B cost 2, tying with B–C, and run it: the tree may change, the total does not. (std::sort does not promise which of two equal roads comes first.)
QuizInteractive · disabled in the PDF
Roads: A–B costs 2, B–C costs 2, A–C costs 3. In the minimum spanning tree, how far is it from A to C?
Roads: A–B costs 2, B–C costs 2, A–C costs 3. In the minimum spanning tree, how far is it from A to C?
Where spanning trees are used. Designing cable, pipe and power networks; clustering, by running Kruskal and stopping when k groups are left (the remaining groups are the clusters); a quick approximation for the travelling-salesperson tour (walking around an MST is at most twice the best tour when distances obey the triangle rule); and the spanning-tree protocol that keeps network switches from forwarding packets round in loops. If all weights are different, the MST is unique.
Algorithms174 / 194
Module 18 · Minimum spanning trees
Recap
A spanning tree connects all n vertices with n - 1 edges and no cycle; a minimum one has the least total weight.
Kruskal: sort the edges, keep each that joins two different groups. Union-find answers “same group?”.
Prim: grow one tree from a start, always adding the cheapest edge that leaves it.
Both are greedy and both are right, by the cut property: the cheapest edge across any split is safe.
Kruskal is O(E log E); Prim is O(E log V) with a heap, or O(V²) with an array for dense graphs.
An MST minimises total cost, not the distance between two particular vertices.
FlashcardsInteractive · disabled in the PDF
Spanning tree
Edges that connect every vertex with no cycle: always n - 1 of them.
Minimum spanning tree
The spanning tree with the smallest total weight.
Kruskal’s algorithm
Cheapest edge first; keep it unless its ends are already joined.
Prim’s algorithm
Grow one tree, always adding the cheapest edge leaving it.
Union-find
Groups as parent links: find gives a root, unite points one root at the other.
Cut property
Across any split of the vertices, the cheapest crossing edge is in some MST.
Constructor
The function that sets up a new object: UnionFind(int n) : parent(n) { … } runs for UnionFind groups(5);.
MST vs shortest paths
MST: least total cable. Shortest path: least cost of one trip.
Module 19: Solving problems you have never seen
Algorithms175 / 194
Module 19 · Solving problems you have never seen
A method beats a flash of insight
Faced with a new problem, experts rarely see the answer at once. They follow steps, the way a mechanic checks a car in an order rather than guessing. Each step is small, and each one either solves the problem or tells you where to look next.
Understand: say the problem in your own words. What goes in, what comes out, how big can it be?
Examples: work two or three small cases by hand, including an odd one (empty, one item, all the same).
Brute force: write the obvious, slow solution that tries everything. Now you have something correct.
Find the bottleneck: which part repeats work, or looks at the same thing over and over?
Match a pattern: does a known technique remove that repetition?
Test: edge cases, and the fast version against the slow one.
Put in orderInteractive · disabled in the PDF
You are given a problem you have never seen. Put the steps in order.
1.Write a brute-force solution
2.Replace it with a known technique
3.Restate it and note the input sizes
4.Find the step that repeats work
5.Compare the fast solution with the brute force on many inputs
6.Solve small examples by hand
Algorithms176 / 194
Module 19 · Solving problems you have never seen
The input size tells you how fast to be
A computer does roughly 10 to 100 million simple steps a second. So the largest input you must handle says which Big O will do, before you write a line. That narrows the techniques worth trying.
Largest n
Steps you can afford
Likely techniques
about 10
n! (permutations)
Backtracking over every order
about 20
2ⁿ (subsets)
Backtracking with pruning, DP over subsets
a few thousand
n²
Nested loops, 2-D dynamic programming
up to a million
n log n or n
Sorting, binary search, two pointers, hashing, BFS
far beyond
log n or 1
A formula, or binary search on the answer
QuizInteractive · disabled in the PDF
You must choose which of up to 18 parcels to pack in a van, and the brute force tries every choice. Is that fast enough?
You must choose which of up to 18 parcels to pack in a van, and the brute force tries every choice. Is that fast enough?
Algorithms177 / 194
Module 19 · Solving problems you have never seen
From brute force to fast: the best run of days
A shop logs its profit each day, sometimes negative. What is the largest total over any run of consecutive days? The brute force tries every start and every end, keeping a running sum: O(n²). Its bottleneck: for each new start, it adds up nearly the same days again.
Brute force, O(n²)
intbestRunSlow(const std::vector<int>& xs){int best = std::numeric_limits<int>::min();for(size_t i =0; i < xs.size(); i++){int sum =0;for(size_t j = i; j < xs.size(); j++){ sum += xs[j]; best = std::max(best, sum);}}return best;}
One pass, O(n)
intbestRun(const std::vector<int>& xs){int best = xs[0];int here = xs[0];for(size_t i =1; i < xs.size(); i++){ here = std::max(xs[i], here + xs[i]); best = std::max(best, here);}return best;}
The pattern is dynamic programming (recap: answer a question from the answers to smaller ones). Ask “what is the best run that ends today?” It is either today alone, or today added to the best run ending yesterday. One number carries it, so one pass suffices. This is known as Kadane’s algorithm.
Predict the outputInteractive · disabled in the PDF
What does bestRun return?
C++
1std::cout <<bestRun({2,-5,3,-1,4,-6});
What does bestRun return?
Algorithms178 / 194
Module 19 · Solving problems you have never seen
Clues that point to a technique
Most problems are an old technique in new clothes. Words in the problem hint at which one, and the bottleneck you found tells you what the technique must remove.
Clue in the problem
Try
A sorted list, a pair or a range that slides
Two pointers, a sliding window, binary search
“Seen before?”, “how many of each?”
Hashing with std::unordered_map or std::unordered_set
Fewest steps, moves or hops
Breadth-first search
Tasks that depend on other tasks
Topological sort
Cheapest route with costs
Dijkstra
Count the ways, or best total, with overlapping choices
Dynamic programming
An obvious best next move you can prove safe
Greedy
Every arrangement or combination
Backtracking
The k largest, or always the next smallest
A heap: std::priority_queue
QuizInteractive · disabled in the PDF
On a chessboard with some squares blocked, find the fewest moves a knight needs from one square to another. Which technique fits?
On a chessboard with some squares blocked, find the fewest moves a knight needs from one square to another. Which technique fits?
Algorithms179 / 194
Module 19 · Solving problems you have never seen
Check the fast answer against the slow one
Your brute force is slow but easy to trust. Keep it as an oracle: generate many small random inputs, run both versions, and stop at the first input where they disagree. That input is a bug report written for you. This is how competitive programmers and library authors catch the mistakes their hand-picked tests missed.
C++
1#include<random>23std::mt19937rng(42); // a random-number engine, seeded with 4245std::vector<int>randomList(){6 std::uniform_int_distribution<int>length(1,6),value(-5,5);7 std::vector<int>xs(length(rng)); // 1 to 6 items8for(int& x : xs) x =value(rng); // each -5 to 59return xs;10}1112for(int round =0; round <1000; round++){13 std::vector<int> xs =randomList();14if(bestRun(xs)!=bestRunSlow(xs)){15 std::cout <<"They disagree on round "<< round <<"\n";16break;17}18}
Small inputs, small numbers: a failure found this way is easy to trace by hand.
std::mt19937 (from <random>) makes a long stream of random-looking numbers, and a std::uniform_int_distribution turns them into whole numbers in a range. The number it starts from is the seed. A fixed seed gives the same “random” inputs on every run, so a failure found once can be found again, and fixed.
To work with any pair of functions, firstMismatch below is a template (recap: template <typename T> lets one function work for many types; the compiler writes a version for each type it is used with). Fast and Slow can be plain functions or lambdas.
Code challengeInteractive · disabled in the PDF
Write firstMismatch(fast, slow, inputs): return the first input on which the two functions give different results, or std::nullopt if they agree on all.
An edge case is an input at the boundary of what is allowed. Hand-written examples tend to be “nice”, so make a habit of trying these on purpose:
Empty input, one item, two items
All values equal, all negative, zero
Already sorted, sorted backwards
Duplicates, and the answer at the very first or very last position
The largest allowed size, to check the speed
Find the bugInteractive · disabled in the PDF
This passes every test with a profitable day in it. On which line does it fail for a list of all losses?
C++
PlaygroundInteractive · disabled in the PDF
C++
1#include<algorithm>2#include<iostream>3#include<limits>4#include<random>5#include<vector>67intbestRun(const std::vector<int>& xs){8int best =0; // the bug9int here =0;10for(int x : xs){11 here = std::max(x, here + x);12 best = std::max(best, here);13}14return best;15}16intbestRunSlow(const std::vector<int>& xs){17int best = std::numeric_limits<int>::min();18for(size_t i =0; i < xs.size(); i++){19int sum =0;20for(size_t j = i; j < xs.size(); j++){21 sum += xs[j];22 best = std::max(best, sum);23}24}25return best;26}2728intmain(){29 std::mt19937rng(1); // the seed30 std::uniform_int_distribution<int>length(1,4),value(-5,5);31for(int round =1; round <=1000; round++){32 std::vector<int>xs(length(rng));33for(int& x : xs) x =value(rng);34if(bestRun(xs)!=bestRunSlow(xs)){35 std::cout <<"round "<< round <<":";36for(int x : xs) std::cout <<' '<< x;37 std::cout <<"\n";38break;39}40}41}
It finds a list of losses, on the same round every run, because the seed is fixed. Change the seed to 2 and it finds another. Fix the line marked as the bug and it finds nothing.
Algorithms181 / 194
Module 19 · Solving problems you have never seen
Amortised cost: expensive now and then, cheap on average
Amortised analysis counts the total cost of a whole sequence of operations, then divides by how many there were. It matters when most operations are cheap but a few are expensive. A growable array is the classic case: when it fills up, it moves everything into one twice the size.
Pushes so far
Copies at this push
Total copies
1
0
0
2
1 (capacity 1 → 2)
1
3
2 (2 → 4)
3
5
4 (4 → 8)
7
9
8 (8 → 16)
15
n
at most n − 1
less than 2n
The copies are 1 + 2 + 4 + … up to n, which adds up to less than 2n. Spread over n pushes, that is fewer than 2 copies each: O(1) amortised, even though a single push can cost O(n). It is why push_back on a std::vector is fast. Watch a vector do it:
PlaygroundInteractive · disabled in the PDF
C++
1#include<iostream>2#include<vector>34intmain(){5 std::vector<int> v;6size_t last = v.capacity();7for(int i =0; i <1000; i++){8 v.push_back(i);9if(v.capacity()!= last){10 std::cout <<"size "<< v.size()<<": room for "11<< v.capacity()<<"\n";12 last = v.capacity();13}14}15}
capacity() is how many items fit before the vector must move. Each move doubles it. Add v.reserve(1000); on the line after std::vector<int> v; and see it never move at all.
Fill the blanksInteractive · disabled in the PDF
Complete the growth step so pushes are O(1) amortised.
Count what each item can do. A second way to argue amortised cost: give each item a fixed budget. In a loop with an inner while that pops from a stack, each item can be pushed once and popped once, so all the inner loops together do at most n pops, however uneven they are. Nested loops are not always O(n²); count what can happen to each item.
Algorithms182 / 194
Module 19 · Solving problems you have never seen
Try it: days until a warmer day
Given daily temperatures, say for each day how many days you wait for a warmer one (0 if it never comes). Brute force looks ahead from every day: O(n²). Instead, keep a stack of days still waiting for a warmer day; their temperatures never rise from bottom to top. Each new day settles every colder day on top of the stack, then waits itself.
Code challengeInteractive · disabled in the PDF
Finish daysUntilWarmer(temps) with a stack of day numbers, so that it handles 100 000 days in time. {30, 31, 29, 33} gives {1, 2, 1, 0}.
C++
1#include<vector>23std::vector<int>daysUntilWarmer(const std::vector<int>& temps){4 std::vector<int>wait(temps.size(),0);5 std::vector<int> stack; // days still waiting, as a stack6 // TODO: for each day, settle the colder days on the stack7return wait;8}
Algorithms183 / 194
Module 19 · Solving problems you have never seen
Choosing between correct solutions
Often you end up with two solutions that are both right. Pick with the problem’s real constraints in mind, not the fanciest Big O:
Fast enough wins over fastest. If n is at most 1 000, a clear O(n²) beats a tricky O(n log n) that is hard to review.
Memory counts. A hash set is O(n) extra; sorting in place is O(1) extra (std::sort uses only O(log n) for its recursion), but changes the input.
Worst case or typical? Quicksort and hash maps are fast on average and slow on rare inputs; that matters if someone can choose the input.
Readable code is cheaper to keep correct. The next person, maybe you, has to change it.
QuizInteractive · disabled in the PDF
Check a list of up to 10 million IDs for duplicates. The list must not change, and memory is plentiful. Which is the best choice?
Check a list of up to 10 million IDs for duplicates. The list must not change, and memory is plentiful. Which is the best choice?
Constant factors are real. Big O hides constants. For small inputs, insertion sort beats merge sort and quicksort, which is why std::sort (an introsort: quicksort that falls back to heapsort if it goes badly) switches to insertion sort below a couple of dozen items. Memory matters too: a std::vector scanned in order is often faster than a std::list or a tree with a better Big O, because its items sit side by side in the processor’s cache. When two choices have the same Big O, measure on realistic data instead of arguing.
Algorithms184 / 194
Module 19 · Solving problems you have never seen
Recap
Understand, work examples, brute force, find the bottleneck, match a pattern, test.
The largest input size tells you which Big O, and so which techniques, can work.
Brute force is a starting point, and later the oracle that checks the fast solution on random inputs from a seeded std::mt19937.
Test edge cases on purpose: empty, one item, all equal, all negative, sorted, largest size.
Amortised cost averages over a sequence: doubling arrays and push-pop stacks are O(1) per item.
Between correct solutions, choose the simplest one that meets the real constraints.
FlashcardsInteractive · disabled in the PDF
Brute force
Try every possibility. Slow, but easy to trust.
Bottleneck
The part of a solution that repeats work and dominates its running time.
Oracle
A trusted, usually slow, solution to compare a fast one against on many inputs.
Edge case
An input at the boundary of what is allowed: empty, one item, the largest.
Amortised cost
The total cost of a sequence of operations divided by their number.
Seed
The number a random-number engine starts from: the same seed gives the same numbers every run.
n ≤ 20
Trying all 2ⁿ subsets is fine.
n up to a million
Aim for O(n) or O(n log n).
Kadane’s algorithm
Best run ending here = max(this item, this item + best ending before).
Module 20: Mastery challenge
Algorithms185 / 194
Module 20 · Mastery challenge
How this challenge works
Each slide is a problem of the kind asked in interviews and met in real code. None says which technique to use: that is part of the problem. Work through the method from the problem-solving module, on paper first. If one stumps you, the explanation names the module it comes from.
Put in orderInteractive · disabled in the PDF
Put the method for an unseen problem in order.
1.Write a brute force you are sure is right
2.Restate the problem, its inputs and its limits
3.Work two or three small examples by hand
4.Test the fast version against the brute force
5.Find the step that makes it slow
6.Match a pattern that removes that step
Predict the outputInteractive · disabled in the PDF
How many times does the loop body run?
C++
1int runs =0;2for(int i =1000; i >1; i = i /2) runs++;3std::cout << runs;
How many times does the loop body run?
Algorithms186 / 194
Module 20 · Mastery challenge
Name the technique
QuizInteractive · disabled in the PDF
Find the length of the longest stretch of a string with no letter repeated. Which approach is O(n)?
Find the length of the longest stretch of a string with no letter repeated. Which approach is O(n)?
QuizInteractive · disabled in the PDF
Coins are worth 1, 5, 21 and 25. What is the fewest coins for 63, and what finds it reliably?
Coins are worth 1, 5, 21 and 25. What is the fewest coins for 63, and what finds it reliably?
Algorithms187 / 194
Module 20 · Mastery challenge
Write it: count the islands
A map is a grid of # (land) and . (water). Land squares that touch up, down, left or right belong to the same island; diagonals do not count. A grid is a graph: each square is a node, joined to its four neighbours.
Code challengeInteractive · disabled in the PDF
Finish islands(grid): return how many islands the map has. Each string in grid is one row. The grid is your own copy (it is taken by value), so you may change it.
C++
1#include<stack>2#include<string>3#include<utility>4#include<vector>56intislands(std::vector<std::string> grid){7int count =0;8 // TODO: for each unseen land square, count it and flood its island9return count;10}
Another way: union-find. Give each land square a set of its own and union it with its land neighbours; the number of sets left is the number of islands. It costs about the same, but shines when land is added a square at a time and you want the count after each addition: a flood fill would start over every time.
Algorithms188 / 194
Module 20 · Mastery challenge
Write it: the shortest word ladder
A word ladder turns one word into another by changing one letter at a time, every step a real word: cold, cord, word, worm, warm. Each word is a node, joined to the words one letter away. The shortest ladder is the path with the fewest edges, which is exactly what breadth-first search finds.
Code challengeInteractive · disabled in the PDF
Finish ladder(start, goal, words): return the number of words in the shortest ladder from start to goal, counting both ends, using only words in words after start. Return 0 if there is none. oneApart is written for you.
Search from both ends. Bidirectional BFS grows one frontier from the start and one from the goal, and stops when they meet. If each word has b neighbours and the ladder is d long, one search explores about bᵈ words, two meeting in the middle about 2 × b^(d/2): far fewer. Route planners use the same idea.
Algorithms189 / 194
Module 20 · Mastery challenge
Write it: merge overlapping bookings
A calendar has bookings as {start, end} pairs, std::pair<int, int>, in no particular order. Combine every group that overlaps or touches into one booking, so {1, 3} and {2, 6} become {1, 6}, and {6, 8} joins them too.
Code challengeInteractive · disabled in the PDF
Finish merge(bookings): return the merged bookings sorted by start. Bookings that touch (one ends where the next starts) merge.
A subsequence keeps some elements in their order, not necessarily side by side. In {3, 1, 4, 1, 5, 9, 2, 6}, 1 4 5 9 is an increasing subsequence of length 4, and none is longer. Let best[i] be the length of the longest increasing subsequence that ends at element i.
Code challengeInteractive · disabled in the PDF
Finish longestRise(nums): return the length of the longest strictly increasing subsequence. An empty vector gives 0.
C++
1#include<algorithm>2#include<vector>34intlongestRise(const std::vector<int>& nums){5 std::vector<int>best(nums.size(),1);6 // TODO: extend the best run that ends before i7return0;8}
PlaygroundInteractive · disabled in the PDF
C++
1#include<algorithm>2#include<iostream>3#include<random>4#include<vector>56intlongestRise(const std::vector<int>& nums){7 std::vector<int>best(nums.size(),1);8int most =0;9for(size_t i =0; i < nums.size(); i++){10for(size_t j =0; j < i; j++)11if(nums[j]< nums[i]) best[i]= std::max(best[i], best[j]+1);12 most = std::max(most, best[i]);13}14return most;15}1617// The oracle: try all 2^n subsequences (bit i of mask: take nums[i])18intbrute(const std::vector<int>& nums){19int n = nums.size(), most =0;20for(int mask =0; mask <(1<< n); mask++){21int length =0, last =0;22bool rising =true;23for(int i =0; i < n; i++)24if(mask &(1<< i)){25if(length >0&& nums[i]<= last) rising =false;26 last = nums[i];27 length++;28}29if(rising) most = std::max(most, length);30}31return most;32}3334intmain(){35 std::mt19937rng(3);36 std::uniform_int_distribution<int>value(0,5);37int differ =0;38for(int run =0; run <200; run++){39 std::vector<int>nums(8);40for(int& x : nums) x =value(rng);41if(longestRise(nums)!=brute(nums)) differ++;42}43 std::cout <<"200 random vectors checked, "<< differ <<" differ\n";44}
A brute-force oracle checks the fast answer on random inputs. Change nums[j] < nums[i] to <= in longestRise and watch it catch the bug.
O(n log n) with patience. Keep tails[k], the smallest value that ends an increasing run of length k + 1. For each number, binary search for the first tail that is not smaller and replace it, or append if there is none. The length of tails is the answer. It is named after patience, the card game whose piles it mimics, and the same idea drives the diff tool’s patience algorithm.
Algorithms191 / 194
Module 20 · Mastery challenge
Write it: how many rooms are needed
Meetings are [start, end] pairs. How many rooms must a building have so that every meeting gets one? Go through the meetings by start time, keeping the end times of the rooms in use in a priority queue: a collection that always hands back its smallest item. If the room that frees up soonest is free by the time a meeting starts, reuse it.
Code challengeInteractive · disabled in the PDF
Finish rooms(meetings): return the fewest rooms needed. A meeting may start in a room at the moment another ends. Use a std::priority_queue that keeps the smallest end time on top.
This topological sort (Kahn’s algorithm) leaves some tasks out of the order even though there is no cycle. Which line is wrong?
C++
Algorithms193 / 194
Module 20 · Mastery challenge
Read a recurrence: edit distance
The edit distance between two words is the fewest single-letter insertions, deletions and replacements that turn one into the other: a spell checker suggests the dictionary words closest to what you typed. d[i][j] is the distance between the first i letters of a and the first j letters of b.
Fill the blanksInteractive · disabled in the PDF
Complete the step that fills one cell of the table.
C++
1if(a[i -1] b[j -1]){2 d[i][j]= d[i -1][j -1]; // last letters match: free3}else{4 d[i][j]=+ std::({5 d[i -1][j], // delete a letter of a6 d[i][j -1], // insert a letter of b7 d[i -1][j -1] // replace one with the other8 });9}
QuizInteractive · disabled in the PDF
What is the edit distance from kitten to sitting?
What is the edit distance from kitten to sitting?
Algorithms194 / 194
Module 20 · Mastery challenge
Recap: the course in seven lines
Count the steps: Big O says how the work grows with the input.
Halving, sorting and hashing turn repeated scans into O(log n), O(n log n) or O(1) steps.
Recursion splits a problem into smaller copies; divide and conquer recombines them.
Trees and graphs are walked depth first (a stack) or breadth first (a queue), with a visited set.
Weighted paths need Dijkstra; spanning trees need Kruskal or Prim.
Greedy is fast when an exchange argument proves it; otherwise dynamic programming tries every choice once.
Backtracking explores choices and undoes them; pruning keeps it fast enough. Test every clever answer against a brute force.
FlashcardsInteractive · disabled in the PDF
Fewest steps in an unweighted graph or grid
Breadth-first search, level by level, with a visited set.
Cheapest path with non-negative weights
Dijkstra’s algorithm with a priority queue.
Overlapping intervals
Sort by start, then sweep, merging into the last one kept.
Same subproblems over and over
Dynamic programming: memoise top-down, or fill a table bottom-up.
Greedy or dynamic programming?
Greedy only if you can argue the local choice is never worse; when in doubt, DP.
How to trust a clever solution
Compare it with a brute force on many small random inputs.
Longest increasing subsequence
best[i] = 1 + max(best[j]) over smaller earlier j: O(n²); O(n log n) with patience tails.
Answer key
1. What an algorithm is · You already run algorithms every day · QuizWhich instruction is precise enough for a computer to follow?B. Take the book with the most pages; if two tie, take the leftmost.“Good” and “roughly” need judgement, which a computer does not have. The second rule says exactly what to compare (pages) and even what to do when two books tie, so anyone following it gets the same book. Thinking about ties and other awkward cases is a large part of writing algorithms.
1. What an algorithm is · A good algorithm is correct and finishes · QuizWhy does “keep halving the number until it is 0” never stop, starting from 8 as a double (a number with a fractional part)?A. Halving 8 gives 4, 2, 1, 0.5, 0.25… which gets close to 0 but never reaches itEach half is smaller, but still above 0, for a very long time. (A double has limited precision, so after about a thousand halvings it does round to 0, but only by accident of how it is stored, which is no way to plan an ending.) A loop needs a condition that is sure to become true. “Below 1” works: 8, 4, 2, 1, 0.5, and it stops. When you write a loop, ask: what changes on every pass, and why does that make the end certain?
1. What an algorithm is · From steps in words to pseudocode · Put in orderPut the steps for finding the largest score in order.1. If the list is empty, there is no answer 2. Remember the first score as the best so far 3. Move on to the next score 4. If it is bigger than the best, remember it instead 5. Repeat until every score has been seen, then give back the bestThe empty check comes first, because there is no “first score” to remember otherwise. Then the loop: move to the next score, compare, maybe update, repeat. The answer is only ready once every score has been compared, so it is given at the very end.
1. What an algorithm is · From pseudocode to C++ · Fill the blanksTurn it around: find the smallest price.1: std::nullopt, 2: size(), 3: <Only the comparison changes: < keeps the lower price. The loop runs while i < prices.size(), so the last index it visits is size() - 1, the last element. With <= there, it would read one past the end: here the standard library is built with checks on, so the program stops with “vector[] index out of bounds” instead of quietly reading whatever memory comes next.
1. What an algorithm is · Trace it by hand on a small input · Find the bugThis works for {4, 9, 2} but gives the wrong answer for {-5, -2, -8}. Which line is wrong?Line 2: int best = scores[0];Starting at 0 assumes some score is above 0. With all negative scores, none beats 0, so it answers 0, a score that is not even in the list. Start from a real element (the first, once you know the list is not empty), or from INT_MIN, the smallest int there is, which every score ties or beats. Tracing on an unusual input, all negatives, is what finds this.
1. What an algorithm is · Try it: find the largest score · Code challengeFinish largest(scores): return the biggest number in the vector, or std::nullopt if it is empty.One solution:
Solution
1#include<optional>2#include<vector>34std::optional<int>largest(const std::vector<int>& scores){5if(scores.empty())return std::nullopt;6int best = scores[0];7for(int s : scores){8if(s > best) best = s;9}10return best;11}
Handle the empty list first, then start from a real score, so negatives work. The range-for for (int s : scores) visits every element, the first included; comparing it with itself is harmless, since it is never bigger than itself. The tests are chosen to break the common mistakes: starting at 0 fails the negatives, and forgetting the empty case reads scores[0] of an empty vector, which stops the program with “vector[] index out of bounds”.
1. What an algorithm is · The same idea works on structs · Code challengeFinish cheapest(books): return the book with the lowest price, or std::nullopt for an empty vector. If two books cost the same, return the one that comes first.One solution:
Solution
1#include<optional>2#include<string>3#include<vector>45structBook{6 std::string title;7int price;8};910std::optional<Book>cheapest(const std::vector<Book>& books){11if(books.empty())return std::nullopt;12Book best = books[0];13for(constBook& book : books){14if(book.price < best.price) best = book;15}16return best;17}
< and not <= is what makes ties go to the first book: with <=, Kim, an equal price found later, would replace Dune. The loop variable is a const Book&, a reference to each book in the vector, so looking at a book does not copy its title; only a new best is copied into best. In the tests, ->title reads a field of the book inside the optional.
2. Counting steps: Big O · Count what runs for each element · QuizHow many times does the std::cout line run when n is 4?C. 16The inner loop runs all 4 of its passes for each of the outer loop’s 4 passes: 4 × 4 = 16. Loops one inside another multiply; loops one after another add, so two separate loops of 4 would be 8.
2. Counting steps: Big O · Big O keeps only what matters as n grows · QuizAn algorithm does 2n² + 50n + 1000 operations. What is its Big O?B. O(n²)Drop the slower terms, 50n and 1000, and the constant 2: O(n²). For small n the 1000 is the biggest part, but Big O describes what happens as n grows large, and there n² dominates everything.
2. Counting steps: Big O · The common growth rates · Put in orderOrder these from slowest-growing (best) to fastest-growing (worst).1. O(1) 2. O(log n) 3. O(n) 4. O(n log n) 5. O(n²) 6. O(2ⁿ)Constant never grows; log n barely does; n grows in step with the input; n log n a little faster; n² squares it; 2ⁿ doubles with every extra element. At a million elements, an O(n²) algorithm does a million times the work of an O(n) one.
2. Counting steps: Big O · Reading Big O from loops · Fill the blanksWrite the Big O of each piece, for a list of n elements (n² can be typed n^2).1: 1, 2: n, 3: n², 4: log nReading one element takes the same time however long the vector is: O(1), since its elements sit side by side in memory and the address of list[i] is one sum away. One pass is O(n). The nested loops do n passes of n, n². The last loop starts at n and halves k each time, so it runs about log₂ n times: 9 times for 1,000 elements (1000, 500, 250, 125, 62, 31, 15, 7, 3), and log₂ 1000 ≈ 10.
2. Counting steps: Big O · Try it: count the comparisons · Code challengeFix hasDuplicate(items) so it compares each pair of different positions once, using same(a, b) for every comparison. For 5 items with no duplicates that is exactly 10 calls.One solution:
Solution
1#include<string>2#include<vector>34int comparisons =0;5boolsame(const std::string& a,const std::string& b){6 comparisons++;7return a == b;8}910boolhasDuplicate(const std::vector<std::string>& items){11for(size_t i =0; i < items.size(); i++){12for(size_t j = i +1; j < items.size(); j++){13if(same(items[i], items[j]))returntrue;14}15}16returnfalse;17}
Starting j at 0 compares every item with itself, so the very first check, items[0] with items[0], says “duplicate”. Starting at i + 1 skips the item itself and every pair already done, halving the work. Half of n² is still O(n²), though: module 10 shows an O(n) way with a std::unordered_set.
2. Counting steps: Big O · Try it: count the comparisons · Code challengeFinish halvings(n): count how many times you replace n with n / 2 until it reaches 0. halvings(8) is 4 (8 → 4 → 2 → 1 → 0).One solution:
Solution
1inthalvings(int n){2int steps =0;3while(n >0){4 n = n /2;5 steps++;6}7return steps;8}
This is log n made concrete: a million shrinks to 0 in 20 halvings. Any algorithm that throws away half of what is left at each step, like binary search in module 4, takes O(log n) steps. The type matters: n is an int, and dividing two ints throws away the fraction, so 1 / 2 is 0 and the loop ends. With a double, 1 / 2 is 0.5, the trap from module 1. (1'000'000 is a million: C++ lets you group digits with '.)
2. Counting steps: Big O · Memory counts too: space complexity · QuizBoth reverse a list in O(n) time. What is the difference?A. The left makes a new array of n elements; the right swaps inside the original with two variablesThe right one only ever adds i, j and t, whatever n is: O(1) extra space. It takes the vector by plain reference (std::vector<int>&, no const), so it changes the caller’s vector, called working in place. The left takes const&, cannot change the original, and pays for a second vector of n elements. (Both count down with care: size_t cannot go below 0, so i >= 0 would always be true.)
2. Counting steps: Big O · Best case, worst case · QuizhasDuplicate from before, on n items. Which input is its worst case?B. No two items are the sameWith a match it can stop early; the first two being equal is the best case, 1 comparison. With no duplicates it must check every pair, n(n − 1) / 2 comparisons, before it can say false. “Not found” is usually the worst case for a search.
3. Searching a list · Linear search in code · QuizA list holds 1,000 song titles, all different. How many comparisons does a linear search for a title that is not there make?D. 1,000To be sure something is missing you must look at everything: 1,000 comparisons. A title that is there takes 500 on average. Both are O(n): double the list, double the work. Module 4 shows how a sorted list gets this down to about 10.
3. Searching a list · The standard library’s searches are linear too · QuizWhat does this print?A. 1 3Not found is v.end(), so the comparison prints 1 (true). And end() is the position just after the last element, index 3, not -1: turning a failed search into an index gives the size. Always compare with end() before using the result; reading *it there is undefined behaviour, a mistake C++ promises nothing about.
3. Searching a list · Try it: write linear search · Code challengeFinish linearSearch(prices, target): return the index of the first price equal to target, or -1 if there is none. Don’t use std::find.One solution:
Solution
1#include<vector>23intlinearSearch(const std::vector<int>& prices,int target){4for(int i =0; i < std::ssize(prices); i++){5if(prices[i]== target)return i;6}7return-1;8}
The return inside the loop is what makes it the first match: the function ends there. The -1 after the loop is only reached when nothing matched, including the empty list, where the loop never runs. Returning 0 for “missing” would be a bug, because 0 is a real index.
3. Searching a list · Try it: write linear search · Code challengeFinish lastIndex(songs, title): return the index of the last song with that title, or -1. Walk from the end, so you can stop at the first match you meet.One solution:
Solution
1#include<string>2#include<vector>34intlastIndex(const std::vector<std::string>& songs,5const std::string& title){6for(int i = std::ssize(songs)-1; i >=0; i--){7if(songs[i]== title)return i;8}9return-1;10}
Start at size - 1, the last index, and count down while i >= 0. The classic slips are starting at size, which reads one past the end, and i > 0, which never checks index 0. And the index must be signed: with size_t i, i >= 0 is always true, so after 0 comes i--, which wraps round to the biggest size_t there is, and songs[i] stops the program with “index out of bounds”.
3. Searching a list · Searching with a condition · Fill the blanksFind the first song longer than 300 seconds.1: >, 2: find_if, 3: 342find_if calls isLong on 215, then 187, then 342, which passes, and stops there: 401 is never looked at. *it reads the element at the position it found. std::ranges::count_if would have counted both long songs, but only after looking at every one.
3. Searching a list · Searching with a condition · Code challengeWrite findFirst(prices, test): return the first price for which test(price) is true, or std::nullopt. Call test as few times as possible: stop at the first match.One solution:
Solution
1#include<optional>2#include<vector>34template<typenameTest>5std::optional<int>findFirst(const std::vector<int>& prices,6Test test){7for(int p : prices){8if(test(p))return p;9}10return std::nullopt;11}
template <typename Test> lets test be anything you can call, such as a lambda. The starter gives the right answers, but it tests all four prices before picking the first: the third test catches it with a lambda that counts its calls ([&calls] lets the lambda change calls, a variable outside it). Returning inside the loop stops after 12 and 8. On a list of a million where the match is near the front, that is two steps instead of a million.
3. Searching a list · Stop as soon as you know · Find the bugThis should give the first index of target, but firstIndex({"a", "b", "a"}, "a") gives 2. Which line is wrong?Line 5: if (list[i] == target) return i;The loop never stops, so each later match overwrites found, and the answer is the last match: index 2. Returning at the first match fixes both the answer and the wasted work. Tests with the target appearing twice are what catch this.
3. Searching a list · Edge cases: empty, missing, and index 0 · QuizWhat does this print?B. none freelinearSearch does find the 0, at index 0, but if (0) is false, so it says “none free”. Worse, a missing fine gives -1, which is not 0, so it would say “found”. Compare with the sentinel instead: if (spot != -1). An std::optional avoids the trap: if (result) on an optional asks whether it holds a value, so an optional holding 0 is still true.
3. Searching a list · Searching records by a field · Code challengeFinish firstAvailable(books, author): return the first book by author whose onLoan is false, or std::nullopt if there is none.One solution:
Both conditions go in one if with &&, so the loop keeps going past Emma, which is by Austen but on loan. std::ranges::find_if with a lambda would be just as right. If you search the same list by ISBN thousands of times, build an std::unordered_map from ISBN to book once, and each search becomes about one step: module 10.
4. Binary search · A sorted list can be halved · QuizWhy can’t binary search work on a list that isn’t sorted?B. Looking at the middle tells you nothing about which half the target is inThe whole trick is “the middle is smaller than the target, so everything before it is too”. That is only true when the list is in order. On an unsorted list, binary search does not fail loudly: it quietly answers “not found” for values that are there.
4. Binary search · Tracing binary search · QuizNow search the same list for 20, which is missing. What are lo and hi when the search gives up?A. lo 4, hi 3Rounds 1 and 2 go as before (19 < 20, then 31 > 20). In round 3, mid is 4 and 24 > 20, so hi = 3. Now lo (4) is past hi (3): no index is left, so it stops with -1. Notice where lo ended: at 4, exactly where 20 would go to keep the list sorted. That comes back later.
4. Binary search · Binary search in code · Put in orderPut one round of binary search in order.1. Check that lo is not past hi 2. Work out mid, halfway between them 3. Compare the element at mid with the target 4. If equal, return mid 5. Otherwise move lo or hi past mid, and go round againThe check comes first, so an empty list (where hi is -1) never reads an element. Each round rules out mid and everything on its wrong side, so the range gets strictly smaller every time: that is why it always ends.
4. Binary search · Try it: write binary search · Code challengeFinish binarySearch(sorted, target): return the index of target in the sorted vector, or -1. Halve the range each round; don’t look at every element.One solution:
Solution
1#include<vector>23intbinarySearch(const std::vector<int>& sorted,int target){4int lo =0;5int hi = std::ssize(sorted)-1;6while(lo <= hi){7int mid = lo +(hi - lo)/2;8if(sorted[mid]== target)return mid;9if(sorted[mid]< target) lo = mid +1;10else hi = mid -1;11}12return-1;13}
The first test tries every position, which catches most off-by-one errors: the first and last elements are where they hide. If lo and hi are declared but never move, you have a linear scan or a loop that never ends, and the last test runs out of time. A million elements take at most 20 rounds, each reading sorted[mid] once or twice.
4. Binary search · The off-by-one traps · Find the bugSearching [10, 20] for 20 never finishes. Which line is wrong?Line 7: if (sorted[mid] < target) lo = mid + 1;lo 0, hi 1, so mid is 0, and 10 < 20 sets lo = 0 again: nothing changed, so the same round repeats forever. mid was just ruled out, so it must not stay in the range: mid + 1. Every round has to shrink the range, or the loop has no reason to end.
4. Binary search · The off-by-one traps · QuizWith while (lo < hi) instead of lo <= hi (and nothing else changed), which search goes wrong?A. Searching [7] for 7For [7], lo and hi are both 0, so lo < hi is false at once and it answers -1 without ever looking. Whenever the range shrinks to one index, that last candidate is skipped. Searching [3, 8, 12] for 8 still works: 8 is the first mid.
4. Binary search · Lower bound: where it would go · Fill the blanksInsert a price into a sorted list, keeping it sorted.1: 12, 2: insert, 3: 1212 is between 9 and 15, so its insertion point is 2. insert takes a position, an iterator, so prices.begin() + at turns the index into one, and puts 12 there. Finding the spot is O(log n), but insert moves every later element one place along to make room, O(n): for many inserts, a different data structure, such as the balanced tree inside std::set, does better.
4. Binary search · Try it: how many can I afford? · Code challengeFinish countAffordable(sorted, budget): how many prices in the sorted vector are at mostbudget. Use binary search, not a loop over every price.One solution:
Solution
1#include<vector>23intcountAffordable(const std::vector<int>& sorted,int budget){4int lo =0;5int hi = std::ssize(sorted);6while(lo < hi){7int mid = lo +(hi - lo)/2;8if(sorted[mid]<= budget) lo = mid +1;9else hi = mid;10}11return lo;12}
With <=, equal prices count as affordable and lo moves past them, so the answer lands after the last 5. With < you get the lower bound instead, which stops at the first 5 and undercounts: the third test catches that. This “first index past” search is known as the upper bound.
4. Binary search · Searching for an answer · Code challengeFinish smallestCapacity(weights, days): the smallest van capacity that delivers every parcel in at most days days. trips is written for you.One solution:
Solution
1#include<algorithm>2#include<vector>34inttrips(const std::vector<int>& weights,int cap){5int count =1, load =0;6for(int w : weights){7if(load + w > cap){8 count++;9 load =0;10}11 load += w;12}13return count;14}1516intsmallestCapacity(const std::vector<int>& weights,int days){17int lo =0, hi =0; // the heaviest parcel, the total18for(int w : weights) lo = std::max(lo, w), hi += w;19while(lo < hi){20int mid = lo +(hi - lo)/2;21if(trips(weights, mid)<= days) hi = mid;22else lo = mid +1;23}24return lo;25}
It is lowerBound again, with “does mid work?” in place of sorted[mid] < x. If it works, it might be the answer, so hi = mid; if not, the answer is bigger. Each guess costs one O(n) call to trips, and there are O(log(total)) guesses, far fewer than trying every capacity from the heaviest up.
5. Simple sorts · Sorted data is easier to use · QuizA list of 1,000 prices is sorted in ascending order. Which questions become easy? Pick all that apply.A. What is the cheapest price? (look at the first one); B. Are any two prices the same? (compare each with its neighbour); C. Is the price 12.50 in the list? (binary search)Sorting puts the smallest first, puts duplicates side by side, and allows binary search. It forgets the original order, though: once sorted, you can no longer tell which price came last. If you need both, sort a copy.
5. Simple sorts · std::sort puts things in order with < · QuizScores were read from a file as text. What order are they in after sorting?C. "100", "25", "3", "42"They are strings, so < compares them as text: “100” starts with 1, so it comes first; then “25”, “3”, “42”. The sort did its job; the data had the wrong type. Turn them into numbers first (std::stoi("25") gives 25), or give sort a comparator that compares them as numbers, next.
5. Simple sorts · A comparator tells sort which comes first · Fill the blanksSort the books by price, cheapest first, and books with the same price by title, A to Z.1: !=, 2: <, 3: <Compare by the main key first; only when it ties does the second key decide. a.price < b.price is true when a is cheaper, so a goes first; > there would put the most expensive first. A short form you will see: return std::tie(a.price, a.title) < std::tie(b.price, b.title); (from <tuple>), which compares the prices and, only if they are equal, the titles.
5. Simple sorts · Selection sort picks the smallest each time · Put in orderSelection sort on {4, 1, 3}. Put what happens in order.1. Look through all three: the smallest is 1 2. Swap 1 with 4: {1, 4, 3} 3. Look through the last two: the smallest is 3 4. Swap 3 with 4: {1, 3, 4} 5. Only one item is left, so it is already in placeEach round finds the smallest of the unsorted part, then makes one swap to put it at the front of that part. The sorted part grows by one each round, so after n - 1 rounds the last item has nowhere else to go.
5. Simple sorts · Insertion sort is how you sort a hand of cards · Find the buginsertionSort leaves {3, 1} as {3, 1}. Which line is wrong?Line 5: while (j >= 0 && nums[j] > card) {An off-by-one: j > 0 never compares with index 0, so no card can move to the very front. With {3, 1}, j starts at 0 and the loop does not run at all. Index 0 is a real element, so the condition must be j >= 0. Off-by-one mistakes hide at the ends of a range: check the first and last index by hand.
5. Simple sorts · Try it: write insertion sort, and use a comparator · Code challengeFinish insertionSort(nums): sort the numbers in ascending order in place, changing the vector it is given. Do not use std::sort.One solution:
Solution
1#include<vector>23voidinsertionSort(std::vector<int>& nums){4for(int i =1; i < std::ssize(nums); i++){5int card = nums[i];6int j = i -1;7while(j >=0&& nums[j]> card){8 nums[j +1]= nums[j];9 j--;10}11 nums[j +1]= card;12}13}
Keep the card aside, shift every bigger item one place right, then write the card into the gap at j + 1. The usual slips are j > 0, which never reaches the front (the second test), and forgetting nums[j + 1] = card, which loses the card. An empty vector or a vector of one never enters the loop, so it comes back unchanged. (using Ints = … in the tests just gives the type a short name.)
5. Simple sorts · Try it: write insertion sort, and use a comparator · Code challengeFinish sortBooks(books): return a new vector sorted by price, cheapest first; books with the same price go in title order, A to Z. books is const, so it stays as it was.One solution:
books comes as const&, so the compiler refuses to sort it: copy it into out and sort that. The comparator compares prices, and only when they tie compares titles. Another way is to take the parameter by value, std::vector<Book> books: the caller’s vector is copied on the way in, and you may sort the copy. Both cost one copy, O(n).
5. Simple sorts · A stable sort keeps ties in their original order · QuizSelection sort runs on cards {5♥, 5♠, 2♣}, comparing numbers only. What is the result after the first swap?A. {2♣, 5♠, 5♥}The smallest, 2♣, is swapped with the first card, 5♥, which jumps to the end: {2♣, 5♠, 5♥}. The two fives have changed order, so selection sort is not stable. Insertion sort would slide 2♣ left past both fives and leave them as they were.
5. Simple sorts · These sorts take n² steps, except on nearly sorted data · QuizA list of 10,000 scores is sorted, then 3 new scores are added at the end. Which simple sort re-sorts it fastest?A. Insertion sortInsertion sort walks the 9,999 sorted items once, moving nothing, then slides each of the 3 new scores left to its place: roughly n steps. Selection sort still scans the whole unsorted part every round, about 50 million comparisons, whatever the order.
6. Recursion · Every recursion needs a base case it is sure to reach · Fill the blanksWrite power(base, exp), base multiplied by itself exp times, recursively. Anything to the power 0 is 1.1: 0, 2: 1, 3: exp - 1The base case is the smallest exponent, 0, whose answer is 1 by definition. The recursive case uses base^exp = base × base^(exp - 1), and exp - 1 moves towards 0. Writing exp + 1 there would move away from the base case, and the calls would never stop.
6. Recursion · The call stack remembers every call that is waiting · Put in orderPut what happens during factorial(3) in order.1. factorial(3) calls factorial(2) and waits 2. factorial(2) calls factorial(1) and waits 3. factorial(1) returns 1 4. factorial(2) returns 2 × 1 = 2 5. factorial(3) returns 3 × 2 = 6The calls go down first, each pushing a frame, until the base case. Then the answers come back up in the opposite order, each frame finishing its multiplication and being removed. Last in, first out: that is why it is a stack.
6. Recursion · The call stack remembers every call that is waiting · QuizWhat does this print?B. 1, 2, 3The printing comes after the recursive call, so each call waits for the smaller ones to finish before printing. show(1) prints first, on the way back up. Swap the two lines and it prints 3, 2, 1, like countdown: work before the call happens on the way down.
6. Recursion · A list is its first item and the rest · Code challengeFinish sumFrom(nums, i)recursively: the sum of the numbers from index i to the end. When i is the size there is nothing left, so the sum is 0; otherwise it is nums[i] plus the sum from i + 1.One solution:
Solution
1#include<vector>23intsumFrom(const std::vector<int>& nums,int i){4if(i == std::ssize(nums))return0;5return nums[i]+sumFrom(nums, i +1);6}
Two lines: the base case answers “nothing left”, and the recursive case takes one item and trusts sumFrom to handle the rest. Trusting the smaller call, without tracing it all the way down, is the knack of writing recursion. The vector is passed by const& to every call, so all of them read the same one; only i changes.
6. Recursion · Recursion is natural for nested data · Code challengeFinish totalSize(folder): the sizes in its own files, plus the totalSize of each folder in folders, however deep.One solution:
Solution
1#include<string>2#include<vector>34structFolder{5 std::string name;6 std::vector<int> files;7 std::vector<Folder> folders;8};910inttotalSize(constFolder& folder){11int total =0;12for(int size : folder.files) total += size;13for(constFolder& sub : folder.folders) total +=totalSize(sub);14return total;15}
Each call handles one folder’s own files and asks its subfolders for theirs. The base case is hidden in the data: a folder with an empty folders vector makes no calls, so the recursion stops by itself. Adding only sub.files would get one level down but miss Summer, which the last test checks. (A struct may hold a std::vector of its own type: the vector keeps its folders elsewhere in memory, so a Folder stays a fixed size.)
6. Recursion · Too deep, and the stack overflows · Find the bugsumFrom({1, 2}, 0) never finishes. Which line is wrong?Line 3: return nums[i] + sumFrom(nums, i + 1);The call passes the same i, so every call asks the very same question and the base case is never reached. Built without optimisation, it crashes with a stack overflow; here the optimiser turns the calls into a loop (the next slide says how), which runs until it is stopped. Each recursive call must make the problem strictly smaller. When you write one, check both: is there a base case, and does every call move towards it?
6. Recursion · Recursion and loops can do the same work · QuizWhich job is the best fit for recursion?B. Listing every file in a folder and all its subfoldersFolders nest to a depth you do not know in advance, and each subfolder is the same problem again: a perfect fit. The other two are flat, so a loop is simpler, and a million recursive calls would overflow the stack.
7. Merge sort · Merging two sorted piles is easy · QuizThis merge forgets something. What does merge({1, 4}, {2, 3}) return?B. {1, 2, 3}It takes 1, 2, then 3, and now b is used up, so the loop stops with 4 still in a. The leftovers of whichever vector did not run out must be added at the end: while (i < a.size()) out.push_back(a[i++]);, and the same for b. a[i++] reads a[i] and then adds 1 to i, a common short form.
7. Merge sort · Try it: merge two sorted lists · Code challengeFinish merge(a, b): both vectors are sorted, smallest first. Return one new sorted vector with every number from both, in about a.size() + b.size() steps (no std::sort).One solution:
The loop runs while both vectors have items left, and takes the smaller front item each time. When it stops, one is used up and the other’s leftovers are all bigger than everything taken, so they go on the end as they are. Only one of the two last loops ever does anything. Using <= takes from a on a tie, which keeps the sort stable, later in this module. The library has this as std::ranges::merge.
7. Merge sort · Merge sort: split, sort both halves, merge · Put in orderPut the steps of merge sort, for one call, in order.1. If the list has 0 or 1 items, return it 2. Find the middle index 3. Merge-sort the left half 4. Merge-sort the right half 5. Merge the two sorted halves and return the resultThe base case is checked first, or the calls would never stop: a list of one would be split into an empty list and itself, forever. The two halves can be sorted in either order, but both must be finished before the merge, which needs two sorted lists.
7. Merge sort · Why merge sort takes n log n steps · QuizMerge sort runs on 1,024 items. How many levels of splitting until every piece has one item?A. 101,024 halves to 512, 256, 128, 64, 32, 16, 8, 4, 2, 1: ten halvings, because 2¹⁰ = 1,024. Each level merges 1,024 items in total, so the sort does about 10 × 1,024 ≈ 10,000 steps, against about a quarter of a million for insertion sort on random data.
7. Merge sort · Try it: merge sort with a comparator · Code challengeFinish sortBy(items, less) as a merge sort: return a new sorted vector. A vector of 0 or 1 items comes back as it is; otherwise sort each half and merge them.One solution:
The base case returns a copy, so the result is always a new vector. Each half is sorted by the same function, then merged. The comparison count is at most n log₂ n (10 levels of at most 1,024 comparisons), while insertion sort on this reversed list would need over half a million. Stability comes from merge: it takes from the right half only when less(b[j], a[i]), the right item strictly smaller, so on a tie the left one goes first. Kim was before Dune, and stays there. std::ranges::merge makes the same choice.
7. Merge sort · Merge sort is stable, if the merge takes left on ties · Find the bugThis merge sorts by price correctly, but books with the same price come out in the wrong order. Which line?Line 6: if (a[i].price <= b[j].price) out.push_back(a[i++]);With <, a tie goes to the else and takes from b, the right half, so a later book jumps ahead of an earlier one with the same price. The result is still sorted by price, which is why this bug hides; only the order among equals is wrong. <= takes from the left on ties and keeps the sort stable.
7. Merge sort · The price is extra memory · QuizWhich statements about merge sort are true? Pick all that apply.A. It takes O(n log n) time even on a list that is already sorted; C. It is stable when ties are taken from the left halfMerge sort always splits and merges, so a sorted list costs the same n log n; insertion sort would do it in n. It needs O(n) extra memory for merging. On tiny lists its overhead (copies, calls) makes it slower than insertion sort, which is why std::stable_sort and std::sort both switch to insertion sort for short stretches.
8. Quicksort · Trace a partition by hand · Put in orderPut the steps of a Lomuto partition in order.1. Take the last element as the pivot 2. Put the wall at the start of the range 3. Scan each other element from left to right 4. If it is smaller than the pivot, swap it onto the wall and move the wall right 5. Swap the pivot onto the wall and return the wall’s indexThe wall can only be set once you know the pivot, and the pivot can only be placed once every other element has been compared with it. Placing it last is what guarantees everything on its left is smaller.
8. Quicksort · Trace a partition by hand · QuizWhat is in a afterwards, and what is p?A. {3, 1, 4, 8}, 2The pivot is 4. 3 is smaller, swapped with itself, wall at 1. 8 is not. 1 is smaller, swapped with 8: {3, 1, 8, 4}, wall at 2. Then the pivot swaps with 8: {3, 1, 4, 8}, and 2 is returned. The left part {3, 1} is not sorted yet; partitioning only promises it is all smaller.
8. Quicksort · Try it: write partition · Code challengeFinish partition(a, lo, hi) with the last element as pivot: move every element smaller than the pivot before it, everything else after it, and return the pivot’s new index.One solution:
The wall starts at lo, not 0, and j stops before hi, so only the given range is touched: the third test checks that, because quicksort calls partition on smaller and smaller pieces of one vector. Forgetting the final swap leaves the pivot at the end, and the returned index pointing at the wrong element. (std::swap lives in <utility>.)
8. Quicksort · Quicksort: partition, then sort each side · Fill the blanksFinish quicksort.1: >=, 2: p - 1, 3: p + 1The pivot at p is already where it belongs, so both recursive calls leave it out. The base case uses >= because an empty range, where lo is past hi, happens all the time: when the pivot lands at the very start, quickSort(a, lo, lo - 1) is called.
8. Quicksort · Quicksort: partition, then sort each side · Find the bugOn {1, 2} this crashes: “the recursion went too deep (a stack overflow)”. Which line is wrong?Line 4: quickSort(a, lo, p - 1);On {1, 2} the pivot 2 is already last, so p is 1 and line 4 calls quickSort(a, 0, 1): the very same range. It partitions, gets p = 1 again, and calls itself again, forever, until the call stack runs out. Each call must work on a strictly smaller range, which is why the pivot is left out.
8. Quicksort · Try it: sort in place · Code challengepartition is written for you. Finish quickSort(a, lo, hi) so it sorts a from lo to hi in place, smallest first. The one-argument quickSort(a) sorts the whole vector with it.One solution:
Solution
1#include<utility>2#include<vector>34intpartition(std::vector<int>& a,int lo,int hi){5int pivot = a[hi];6int i = lo;7for(int j = lo; j < hi; j++){8if(a[j]< pivot) std::swap(a[i++], a[j]);9}10 std::swap(a[i], a[hi]);11return i;12}1314voidquickSort(std::vector<int>& a,int lo,int hi){15if(lo >= hi)return;16int p =partition(a, lo, hi);17quickSort(a, lo, p -1);18quickSort(a, p +1, hi);19}2021voidquickSort(std::vector<int>& a){22quickSort(a,0, std::ssize(a)-1);23}
Three lines after the base case: partition, then recurse on each side of p, leaving p out. An empty vector gives hi = -1, so lo >= hi stops it at once. Two functions may share a name when their parameters differ, called overloading: quickSort(a) and quickSort(a, lo, hi) are different functions. In partition, std::swap(a[i++], a[j]) swaps and then moves the wall, in one line.
8. Quicksort · A bad pivot makes quicksort slow · QuizWith the last element as pivot, which input is the slowest to sort?B. A list that is already sortedIn a sorted list the last element is the largest, so every partition puts everything on the left and nothing on the right. Each step removes only the pivot. The cruel part: “already sorted” or “nearly sorted” is extremely common in real data.
8. Quicksort · Quickselect finds the k-th smallest · Code challengepartition is given. Finish kthSmallest(a, k): return the k-th smallest number, where k = 1 is the smallest. a is taken by value, a copy, so you may rearrange it freely. Don’t sort the whole list.One solution:
Solution
1#include<utility>2#include<vector>34intpartition(std::vector<int>& a,int lo,int hi){5int pivot = a[hi];6int i = lo;7for(int j = lo; j < hi; j++){8if(a[j]< pivot) std::swap(a[i++], a[j]);9}10 std::swap(a[i], a[hi]);11return i;12}1314intkthSmallest(std::vector<int> a,int k){15int lo =0;16int hi = std::ssize(a)-1;17while(true){18int p =partition(a, lo, hi);19if(p == k -1)return a[p];20if(p < k -1) lo = p +1;21else hi = p -1;22}23}
The k-th smallest sits at index k - 1 once sorted, and a placed pivot is exactly at its sorted index. So compare p with k - 1 and narrow lo/hi to the side that holds it. A loop works as well as recursion here, since only one side is ever followed. The off-by-one to watch is k versus k - 1: the first test catches it. The last test passes for free, because the parameter is a copy: that is what taking a std::vector by value means.
9. Two pointers and sliding windows · Two fingers on a list beat trying every pair · QuizA list has 1000 prices. Roughly how many different pairs does “try every pair” check?C. About 500 000Each of the 1000 prices pairs with each of the 999 others, and each pair is counted twice that way, so 1000 × 999 / 2 ≈ 500 000. That is n²/2, which is O(n²). On a sorted list, two pointers find a pair with a given sum in at most 1000 steps.
9. Two pointers and sliding windows · Why moving one pointer never skips the answer · Find the bugpairWithSum({5}, 10) returns {0, 0}, pairing the only book with itself. Which line is wrong?Line 5: while (lo < hi) {With <=, the loop also runs when both pointers are on the same index, and 5 + 5 is 10. A pair needs two different books, so the loop must stop as soon as they meet: lo < hi. Off-by-one errors in two-pointer code nearly always live in this condition.
9. Two pointers and sliding windows · Why moving one pointer never skips the answer · Code challengeFinish pairWithSum(prices, target): prices is sorted from cheapest to dearest. Return the indexes {i, j} (with i < j) of two prices adding up to target, or std::nullopt if there are none. Use two pointers.One solution:
Solution
1#include<optional>2#include<utility>3#include<vector>45std::optional<std::pair<int,int>>pairWithSum(6const std::vector<int>& prices,int target){7int lo =0;8int hi = std::ssize(prices)-1;9while(lo < hi){10int sum = prices[lo]+ prices[hi];11if(sum == target)return std::pair{lo, hi};12if(sum < target) lo++;13else hi--;14}15return std::nullopt;16}
The loop condition lo < hi keeps the two books different, which the third test checks. Moving the wrong pointer (say hi-- when the sum is too small) makes the sum smaller still and walks past the answer. An empty list sets hi to -1, so the loop never runs; that is one reason the indexes are signed ints.
9. Two pointers and sliding windows · Reversing in place: swap the ends, move inwards · Fill the blanksReverse the list in place.1: 1, 2: <, 3: swap, 4: ++, 5: --right starts at the last index, size - 1. The loop stops when the pointers meet or cross: in an odd-length list, the middle song stays where it is. With <= it would swap the middle with itself, harmless here; with left < std::ssize(songs), it would carry on past the middle and swap everything back. The library has it as std::ranges::reverse(songs).
9. Two pointers and sliding windows · Reversing in place: swap the ends, move inwards · Code challengeFinish isPalindrome(word): return true if the word reads the same forwards and backwards, ignoring upper and lower case. Use two pointers, not a reversed copy.One solution:
Solution
1#include<cctype>2#include<string>34boolisPalindrome(const std::string& word){5 std::string w = word;6for(char& c : w) c = std::tolower(c);7int left =0;8int right = std::ssize(w)-1;9while(left < right){10if(w[left]!= w[right])returnfalse;11 left++;12 right--;13}14returntrue;15}
One mismatch is enough to answer false at once: that early exit is why this beats building a reversed copy and comparing, which always does all the work. Reaching the middle with no mismatch means true, including for the empty word, where the loop never runs. The starter lowercases a copy, w, one char at a time with std::tolower; that is right for plain English letters, which are one byte each.
9. Two pointers and sliding windows · A read pointer and a write pointer · QuizWhat does this print?A. 3 63 is kept, the second 3 skipped, 5 and the first 8 copied to indexes 1 and 2, the other 8s skipped, so scores is now {3, 5, 8}: size 3. But resize to a smaller size never gives memory back: the capacity, how many elements the vector has room for, stays 6, ready for the vector to grow again without moving. shrink_to_fit() asks for the spare room to be freed. The library’s version of this walk is a.erase(std::unique(a.begin(), a.end()), a.end()).
9. Two pointers and sliding windows · Merging two sorted lists · Put in orderPut the steps of merging two sorted lists in order.1. Point at the first element of each list 2. Compare the two elements pointed at 3. Add the smaller to the output and move its pointer on 4. Repeat until one list is used up 5. Copy whatever is left of the other listThe leftovers can be copied without comparing, because they are sorted and all at least as big as everything already taken. Forgetting that last step is the classic merging bug: the output silently loses the biggest elements.
9. Two pointers and sliding windows · Try it: the best k days · Code challengeFinish bestRun(sales, k): return the largest sum of k consecutive days, or std::nullopt if there are fewer than k days. Slide the window; don’t add up each stretch from scratch.One solution:
Solution
1#include<algorithm>2#include<optional>3#include<vector>45std::optional<int>bestRun(const std::vector<int>& sales,int k){6if(k > std::ssize(sales))return std::nullopt;7int sum =0;8for(int i =0; i < k; i++) sum += sales[i];9int best = sum;10for(int i = k; i < std::ssize(sales); i++){11 sum += sales[i]- sales[i - k];12 best = std::max(best, sum);13}14return best;15}
The first window is added up once; after that, the day at i comes in and the day at i - k falls out. best starts at the first window’s sum, not 0, so all-negative sales still work: the same trap as finding the largest number. The window starting at the last possible day is covered because i runs to the last index. Checking k against the size first matters in C++: reading sales[i] past the end would stop the program.
9. Two pointers and sliding windows · A window that grows and shrinks · Code challengeFinish longestFresh(songs): return the length of the longest stretch of the playlist in which no song appears twice.One solution:
Solution
1#include<algorithm>2#include<string>3#include<unordered_set>4#include<vector>56intlongestFresh(const std::vector<std::string>& songs){7 std::unordered_set<std::string> inWindow;8int best =0;9int left =0;10for(int right =0; right < std::ssize(songs); right++){11while(inWindow.contains(songs[right])){12 inWindow.erase(songs[left]);13 left++;14}15 inWindow.insert(songs[right]);16 best = std::max(best, right - left +1);17}18return best;19}
In the second test the best window is B, C, A, D, E: when the second A arrives, left moves past the first A, but B and C stay. A common mistake is to empty the whole window on a repeat and start again at right, which loses B and C and answers 3. Shrink only as far as the copy.
10. Counting and hashing · Remembering what you have seen saves looking again · QuizFor each of n orders, you check whether its customer is on a vector of n members with std::ranges::find(members, id). How much work is that?C. O(n²)Each find can scan all n members, and it runs n times: n × n. Building std::unordered_set<int> ids(members.begin(), members.end()) once costs O(n), then each contains is O(1), so the whole job drops to O(n). A find inside a loop is the first thing to look for when code is slow.
10. Counting and hashing · Try it: two-sum · Code challengeFinish twoSum(nums, target): the list is not sorted. Return {i, j}, with i < j, the indexes of two different numbers adding up to target, or std::nullopt. Do it in one pass with an unordered_map.One solution:
Solution
1#include<optional>2#include<unordered_map>3#include<utility>4#include<vector>56std::optional<std::pair<int,int>>twoSum(7const std::vector<int>& nums,int target){8 std::unordered_map<int,int> seen;9for(int i =0; i < std::ssize(nums); i++){10auto it = seen.find(target - nums[i]);11if(it != seen.end())return std::pair{it->second, i};12 seen[nums[i]]= i;13}14return std::nullopt;15}
Checking before storing is what stops 5 pairing with itself, while two separate 5s still pair. The last test catches a classic slip, if (seen[need]): reading a missing key with []adds it, with the value 0, and index 0 is false in an if, so a real partner at index 0 looks missing too. Use find or contains to look without changing the map.
10. Counting and hashing · Counting how often each thing appears · Fill the blanksCount the books in each genre.1: unordered_map, 2: genre, 3: ++, 4: 2The first time a genre is seen, counts[genre] makes an entry holding 0, and ++ makes it 1; the next time, it finds the entry and adds 1 again. Here that is exactly what you want. When you only want to read a count, [] still adds missing keys, so use counts.find(k) or counts.contains(k) instead (and on a const map, [] does not even compile).
10. Counting and hashing · Counting how often each thing appears · Find the bugEvery count comes out as 0, however many times a word appears. Which line is wrong?Line 5: int& count = counts[w];int count = counts[w]; copies the value into a new variable, so count++ changes the copy, and the entry in the map stays 0. A reference, int& count, is another name for the entry itself, so ++ changes the map. Or skip the variable: counts[w]++;. Copy versus reference is one of the commonest C++ bugs, and the compiler will not stop you: both are legal.
10. Counting and hashing · Try it: the most borrowed book · Code challengeFinish mostCommon(titles): return the title that appears most often, or std::nullopt for an empty list. On a tie, return the title that was seen first.One solution:
Solution
1#include<optional>2#include<string>3#include<unordered_map>4#include<vector>56std::optional<std::string>mostCommon(7const std::vector<std::string>& titles){8 std::unordered_map<std::string,int> counts;9for(const std::string& t : titles) counts[t]++;10 std::optional<std::string> best;11int bestCount =0;12for(const std::string& t : titles){13if(counts[t]> bestCount){14 best = t;15 bestCount = counts[t];16}17}18return best;19}
Two passes: count, then find the largest count, exactly like finding the largest number in the first module. The second pass walks titles, not the map, on purpose: an unordered_map keeps its entries in no particular order, so looping over it could meet Dune before Emma. Walking the list in its own order, with a strict >, keeps the earlier title on a tie. An std::optional starts empty, so best needs no special start value.
10. Counting and hashing · Same letters, different order: anagrams · Code challengeFinish isAnagram(a, b): return true if the two words use exactly the same letters, each the same number of times. Count with an unordered_map<char, int>.One solution:
The second test breaks the tempting shortcut of comparing sets of letters: “aab” and “abb” use the same two letters, but not the same number of times. With equal lengths, if no count ever goes below zero, every count ends at exactly zero, so there is nothing to check at the end. A char is one byte, so a plain std::array<int, 256> of counts, indexed by the byte, would do the same job with no hashing at all.
10. Counting and hashing · A set spots duplicates in one pass · Quizstd::set is the tree-based set, like std::map without values. What does this print?A. 1 3 5A set holds each value once, so the second 5 is dropped, and std::set keeps its values sorted, so the loop gives 1, 3, 5. An std::unordered_set would also drop the repeat, but print the three in an order that depends on the hash table, which you should never rely on. Neither keeps the order the values arrived in.
10. Counting and hashing · Grouping: a map from a key to a list · Code challengeFinish groupAnagrams(words): return the words grouped so each group holds words that are anagrams of each other. Groups come in the order their first word appears, and words keep their order inside a group. where maps a key to its group’s index in groups.One solution:
key is a copy of the word, so sorting it leaves w alone. The first word with a new key starts a group at the end of groups, and where remembers its index; later words with that key go into the same group. Keeping the groups in a vector is what keeps them in order of first appearance. using Group = … gives the long type a short name.
10. Counting and hashing · Counting sort: sorting without comparing · Code challengeFinish sortByStars(reviews): return a new vector of the reviews from 1 star up to 5 stars, without comparing. Put each review in the bucket for its stars, then add the buckets to out in order. Reviews with the same stars keep their order.One solution:
Solution
1#include<string>2#include<vector>34structReview{5 std::string book;6int stars; // 1 to 57};89std::vector<Review>sortByStars(const std::vector<Review>& reviews){10 std::vector<std::vector<Review>>buckets(6);11for(constReview& r : reviews) buckets[r.stars].push_back(r);12 std::vector<Review> out;13for(constauto& bucket : buckets)14for(constReview& r : bucket) out.push_back(r);15return out;16}
Six buckets, 0 to 5, so bucket stars holds that many stars without subtracting 1; bucket 0 stays empty. Adding to the buckets in input order and joining them in order is what makes it stable: Kim stays ahead of Emma because Kim was put into bucket 5 first. reviews is const&, so the input cannot change; the result is a new vector.
11. Walking a tree · Depth first: before, between or after the children · QuizA tree has root A, whose children are B (left) and C (right); B has one left child, D. What is its post-order?C. D B C APost-order puts each node after both of its subtrees. Left of A is the subtree B, whose post-order is D B; then the right subtree, C; then A last. A B D C is pre-order and D B A C is in-order. The root always comes last in post-order and first in pre-order.
11. Walking a tree · Recursion writes itself for trees · Fill the blanksMake this function walk the tree in post-order.1: ==, 2: left, 3: right, 4: push_back, 5: valueMoving one line is all it takes: in post-order, push_back comes after both recursive calls, so a node is recorded only once everything below it has been. Swapping the two walk lines instead would give a mirror image, right before left, which is a different order altogether.
11. Walking a tree · Try it: in-order, sorted for free · Code challengeFinish inOrder(root): return the values of the tree in in-order (left subtree, node, right subtree). An empty tree (nullptr) gives {}. A helper like walk above is a good start.One solution:
Solution
1#include<memory>2#include<vector>34structTreeNode{5int value;6 std::unique_ptr<TreeNode> left;7 std::unique_ptr<TreeNode> right;8};9usingTree= std::unique_ptr<TreeNode>;1011Treenode(int value,Tree left =nullptr,Tree right =nullptr){12return std::make_unique<TreeNode>(13 TreeNode{value, std::move(left), std::move(right)});14}1516voidwalk(constTreeNode* n, std::vector<int>& out){17if(n ==nullptr)return;18walk(n->left.get(), out);19 out.push_back(n->value);20walk(n->right.get(), out);21}2223std::vector<int>inOrder(constTreeNode* root){24 std::vector<int> out;25walk(root, out);26return out;27}
Visiting between the two calls is what makes it in-order. Passing one vector by reference keeps it O(n). Returning a new vector from each call and joining left, value and right also works and reads well, but copies values at every level, which costs O(n²) on a tree shaped like a long chain.
11. Walking a tree · The same walk with your own stack · Find the bugThis should give pre-order, 8 3 1 6 10 14 on our tree, but gives 8 10 14 3 6 1. Which line?Line 7: if (n->right) stack.push(n->right.get()); // and left on line 8A stack hands back the last thing pushed. Pushing left then right means right is taken first, so the walk goes down the right side before the left. Push right first, then left. (Swap lines 7 and 8.)
11. Walking a tree · Level by level with a queue · Put in orderPut one round of the level-order loop in order.1. Check that the queue still has nodes 2. Take the node at the front of the queue 3. Record its value 4. Add its left child to the back, if it has one 5. Add its right child to the back, if it has oneTake from the front, add to the back: that is what makes it a queue, and what makes the walk go level by level. Left before right keeps each level in left-to-right order. The loop ends when the queue is empty, which happens once the last leaf has been taken.
11. Walking a tree · Try it: one vector per level · Code challengeFinish levels(root): return the values level by level, one vector per level, each left to right. Our tree gives {{8}, {3, 10}, {1, 6, 14}}; an empty tree gives {}.One solution:
Here the queue is kept as two vectors, the level you are reading and the next one you are filling, which makes the end of each level obvious. A single std::queue with “take size() nodes, then stop” works just as well. Either way each node is handled once, O(n). Level order is the tree case of breadth-first search, which the next module runs on any graph.
11. Walking a tree · Height and path sums: answers built from the children · Code challengeFinish hasPathSum(root, target): return true if some root-to-leaf path’s values add up to target. An empty tree has no paths.One solution:
Each call subtracts its own value and hands the rest down. The check happens only at a leaf: testing rest == 0 at any node would wrongly accept 8 + 10 = 18, which stops halfway. || stops early once one side finds a path, so not every node is always visited.
11. Walking a tree · Lowest common ancestor in a search tree · QuizA binary tree has 7 nodes. What heights can it have?B. Anything from 3 to 7Packed as tightly as possible, levels of 1, 2 and 4 nodes hold 7, so height 3. Strung out as a chain, one node per level, height 7. That gap is why tree algorithms are quoted in terms of height h: O(h) is O(log n) for a balanced tree but O(n) for a chain.
11. Walking a tree · Lowest common ancestor in a search tree · Code challengeFinish lca(root, a, b) for a binary search tree that contains both a and b: return the value of their lowest common ancestor.One solution:
Solution
1#include<memory>2#include<vector>34structTreeNode{5int value;6 std::unique_ptr<TreeNode> left;7 std::unique_ptr<TreeNode> right;8};9usingTree= std::unique_ptr<TreeNode>;1011Treenode(int value,Tree left =nullptr,Tree right =nullptr){12return std::make_unique<TreeNode>(13 TreeNode{value, std::move(left), std::move(right)});14}1516intlca(constTreeNode* root,int a,int b){17constTreeNode* n = root;18while(true){19if(a < n->value && b < n->value) n = n->left.get();20elseif(a > n->value && b > n->value) n = n->right.get();21elsereturn n->value;22}23}
The BST rule tells you which side each value is on without searching. While both are on the same side, the answer is deeper on that side (and since both values are in the tree, that side is never empty); the moment they part, or one of them is the current node, nothing deeper holds both. That is O(h) time and O(1) memory. In an ordinary tree with no ordering you would instead search both subtrees, O(n).
12. Searching a graph · A graph is places and the links between them · QuizIn town, which places can you reach from school, by any path?B. library, shop and poolschool leads directly to library and shop, and both lead on to pool. You cannot get back to home or to park: the edges are one-way, and none points at them from there. Searching a graph is answering questions like this one, for graphs too big to do by eye.
12. Searching a graph · Breadth-first search explores in rings · Put in orderPut one round of the BFS loop in order.1. Take the node at the front of the queue 2. Look at each of its neighbours in turn 3. Skip a neighbour that has already been found 4. Record its distance as one more than the current node 5. Add it to the back of the queueFront out, back in: that is what makes it a queue, and what keeps the rings in order. Skipping nodes already found is what stops BFS from going round a loop for ever, and it also keeps the first (shortest) distance found.
12. Searching a graph · BFS in C++ · Code challengeWrite hops(graph, start, goal): the fewest edges on a path from start to goal, or -1 if there is no path. start to itself is 0.One solution:
BFS reaches nodes in order of distance, so the moment goal comes out of the queue, its distance is the smallest. Returning early saves exploring the rest. Without the dist[next] != -1 check, the loop a → b → d → a would refill the queue for ever, until the test runs out of time or memory. DFS would also find a path, but not necessarily the shortest one.
12. Searching a graph · Depth-first search follows one path to its end · QuizIn what order does the recursive dfs visit this graph from A?B. A B D C EDFS goes deep first: A, then its first neighbour B, then B’s neighbour D. D is a dead end, so it backs up to A and tries C. D is already seen, so it goes on to E. A B C D E is the BFS order, ring by ring. The stack version pushes neighbours in reverse so that it takes them in the same order as the recursive one. ("ABCDE"[node] picks one letter of the string by its position.)
12. Searching a graph · A visited set stops you going round in circles · Find the bugThis should mark every place reachable from start, but it misses some. Which line is wrong?Line 10: if (seen[next]) continue;break leaves the whole for loop at the first neighbour already seen, so the neighbours after it are never looked at. continue skips only that one. With Graph{{0, 1}, {}} (node 0 has an edge to itself, then to 1) this marks only node 0, though node 1 is one step away. Small test graphs with a loop catch this kind of slip.
12. Searching a graph · Connected components are the separate groups · Code challengeWrite countGroups(people, friendships): the people are numbered 0 to people - 1; return how many separate friend groups there are. A friendship {a, b} links both ways; someone with no friends is a group of one.One solution:
Solution
1#include<utility>2#include<vector>34usingPairs= std::vector<std::pair<int,int>>;5usingGraph= std::vector<std::vector<int>>;67voidvisit(constGraph& friends,int p, std::vector<bool>& seen){8 seen[p]=true;9for(int f : friends[p])10if(!seen[f])visit(friends, f, seen);11}1213intcountGroups(int people,constPairs& friendships){14Graphfriends(people);15for(auto[a, b]: friendships){16 friends[a].push_back(b);17 friends[b].push_back(a);18}19 std::vector<bool>seen(people,false);20int groups =0;21for(int p =0; p < people; p++){22if(seen[p])continue;23 groups++;24visit(friends, p, seen);25}26return groups;27}
Build the adjacency list with both directions of each friendship, then search from every unseen person: each search marks one whole group, so the count of searches is the answer. Forgetting the reverse edge is the usual slip: eli → ben alone would let a search from ben miss eli, and the count comes out too high. The whole thing is O(V + E). The Data structures course’s union-find answers the same question as friendships arrive one by one.
12. Searching a graph · A grid is a graph too · Code challengeWrite floodFill(grid, row, col, paint): the grid is rows of one-character cells. Return the grid with the cell at row, col, and every cell joined to it (up, down, left, right) with the same character, changed to paint.One solution:
Solution
1#include<stack>2#include<string>3#include<utility>4#include<vector>56usingGrid= std::vector<std::string>;78GridfloodFill(Grid grid,int row,int col,char paint){9char old = grid[row][col];10if(old == paint)return grid;11int rows = grid.size(), cols = grid[0].size();12 std::stack<std::pair<int,int>> stack;13 stack.push({row, col});14while(!stack.empty()){15auto[r, c]= stack.top();16 stack.pop();17if(r <0|| r >= rows || c <0|| c >= cols)continue;18if(grid[r][c]!= old)continue;19 grid[r][c]= paint;20 stack.push({r +1, c});21 stack.push({r -1, c});22 stack.push({r, c +1});23 stack.push({r, c -1});24}25return grid;26}
Here the cell’s own colour does the job of the visited set: once a cell is painted it no longer matches old, so it is never pushed twice. That is why painting with the same colour must return early, or the search would never end. The bounds check comes before grid[r][c], so the edges are safe. Taking gridby value (no &) gives the function its own copy to paint, so the caller’s grid is left as it was.
12. Searching a graph · Finding a cycle in a directed graph · Fill the blanksComplete the cycle check. A node is active while it is on the current path.1: active, 2: false, 3: true, 4: doneMeeting an active node means an edge points back into the path being explored: a cycle. A done node was fully explored with no cycle found, so it is safe to skip. Once all of a node’s neighbours are explored it leaves the path and becomes done. With a single “seen” flag instead of three states, Graph{{1, 2}, {2}, {}} would wrongly report a cycle at node 2, reached twice by different routes.
12. Searching a graph · Topological sort puts tasks in a safe order · Code challengeWrite buildOrder(tasks, deps) with Kahn’s algorithm. The tasks are numbered 0 to tasks - 1; a dependency {a, b} means a must come before b. Return every task in a valid order, or std::nullopt if there is a cycle.One solution:
Solution
1#include<optional>2#include<utility>3#include<vector>45usingDeps= std::vector<std::pair<int,int>>;67std::optional<std::vector<int>>buildOrder(int tasks,8constDeps& deps){9 std::vector<std::vector<int>>after(tasks);10 std::vector<int>waits(tasks,0);11for(auto[first, then]: deps){12 after[first].push_back(then);13 waits[then]++;14}15 std::vector<int> ready;16for(int t =0; t < tasks; t++)17if(waits[t]==0) ready.push_back(t);18for(size_t i =0; i < ready.size(); i++)19for(int next : after[ready[i]])20if(--waits[next]==0) ready.push_back(next);21if(int(ready.size())!= tasks)return std::nullopt;22return ready;23}
A task joins ready only when its count of unfinished dependencies drops to 0, so it is always placed after all of them. Here ready is both the queue and the answer, read with an index as it grows. In a cycle, every task in it waits for another, none ever reaches 0, so fewer tasks come out than went in: that is the cycle check for free. Tests check the rules, not one exact order, because several orders are right.
13. Shortest paths · When roads have lengths, fewest steps is not enough · QuizBFS from home reaches shop first through which route, and how long does it take?B. A two-road route, which takes 5 or 7 minutes depending on neighbour orderBFS only counts roads. shop is two roads away by mill (5 minutes) and by bridge (7 minutes), and which one BFS meets first depends on the order of home’s neighbours. Neither is the 4-minute route, which has three roads. Weights need a different algorithm.
13. Shortest paths · A priority queue hands out the nearest place · Put in orderPut one round of Dijkstra’s loop in order.1. Pop the place with the smallest time from the priority queue 2. Skip it if it was already settled 3. Mark it settled: its time is final 4. For each road out of it, work out the time through it 5. If that beats the neighbour’s best time, record it and push the neighbourA place can be pushed several times, once for each improvement, so an old, larger entry can come out after the place was settled: the skip throws those away (this is called lazy deletion). Only after the pop is a time final; a time recorded while relaxing may still improve.
13. Shortest paths · Dijkstra in C++ · Find the bugThis version says every place is 0 minutes from start, whatever the graph. Which line is wrong?Line 1: std::vector<long long> dist(graph.size(), INF);A place not reached yet should count as infinitely far, so that any route to it is an improvement. Starting every place at 0, no positive time beats it, nothing is ever recorded or pushed, and the search stops after start with every answer still 0. C++ has no built-in “infinity” for whole numbers, so the largest value, INF, plays that part.
13. Shortest paths · Try it: cheapest times from one place · Code challengeWrite cheapest(graph, start): for every place, the least total weight of a path to it from start (start itself is 0), or INF if it cannot be reached. Use a std::priority_queue.One solution:
The loop settles places in order of their cheapest time, so shop is first pushed at 7 (through bridge), then improved to 4 once mill is settled at 3. The 7 entry comes out later and is skipped because shop is done. The road from mill back to home is relaxed too, but 3 + 1 never beats 0, so the loop ends. island has a road tohome but none from it, so it stays at INF. Only popped times are added to, and they are never INF, so d + weight cannot overflow.
13. Shortest paths · Remember where each best time came from · Code challengeWrite buildPath(previous, start, goal): the route from start to goal as a list of places, read from previous (where -1 means “no step recorded”). If goal cannot be traced back to start, return {}.One solution:
Each place keeps only its last step, so the walk back goes goal first and is reversed at the end with std::reverse. previous takes as much memory as dist and nothing more, which is why it beats storing a whole route per place. The -1 check is what stops the walk at a place that was never reached; without it, previous[-1] would read outside the vector, and the program would stop with “index out of bounds”.
13. Shortest paths · Negative weights break Dijkstra · QuizRun cheapest (Dijkstra, with the done check) from S. What does it print for T?B. 3Dijkstra settles A at 2 and T at 3 before it ever looks at B. Popping B at 5 improves A to 1, but A is already done, so the improvement never reaches T. The true cheapest is S → B → A → T: 5 − 4 + 1 = 2. Bellman–Ford finds it.
13. Shortest paths · Try it: Bellman–Ford · Code challengeWrite bellmanFord(places, edges, start). Each edge is {from, to, weight}. Return the cheapest total from start to every place (INF if unreachable), or std::nullopt if there is a negative cycle.One solution:
The edges in the test are listed in an unhelpful order on purpose: A → T comes first, before A has a time. One round is not enough, which is why there are V − 1 rounds. The dist[from] != INF check matters here: X is never reached, and without it INF + 1 for the road X → T would overflow into a huge negative “time” for T. The last loop is the negative-cycle check: after V − 1 rounds nothing should still improve, and A → B → A adds up to −3, so it does.
13. Shortest paths · A* aims at the goal · Fill the blanksTurn Dijkstra’s loop into A*: stop at the goal, and order the queue by cost plus the guess.1: goal, 2: guessThe key in the queue is now cost so far plus guess(to), so the true cost is read from dist, not from the key. Popping the goal ends the search: with an honest guess, its cost is final then. With guess always 0, this is plain Dijkstra stopping at the goal.
13. Shortest paths · A* aims at the goal · QuizWhat happens if the guess sometimes overestimates the distance left?B. A* may return a route that is not the cheapestA* is guaranteed the cheapest route only when the guess never overestimates (an admissible heuristic). Straight-line distance is admissible for driving, since no road is shorter than a straight line. A guess that is too big can push the true best route to the back of the queue, and the goal is popped by a worse one first. Some games accept that trade for speed.
14. Greedy algorithms · A greedy algorithm takes the best-looking step now · QuizWhich of these is a greedy strategy?B. At each junction, taking the road that points most directly at your destinationTaking whichever road looks best at each junction, and never turning back, is greedy: one local choice at a time. Trying every route is brute force; it is always right but slow. Halving a sorted list is binary search. Note that the greedy route can lead you into a dead end or a long detour: looking best is not the same as being best.
14. Greedy algorithms · Making change: biggest coin first · Fill the blanksCount the coins greedily. coins is sorted from largest to smallest.1: /, 2: %Dividing one int by another in C++ gives a whole number and drops the remainder: 38 / 25 is 1, how many times the coin fits whole, which is the same as taking it again and again in a loop. amount % coin (the remainder, 13) is what is left to give afterwards. With doubles, 38.0 / 25 would be 1.52, which is not a number of coins.
14. Greedy algorithms · Try it: give change greedily · Code challengeFinish greedyChange(amount, coins): return the coins the greedy rule gives, largest first. Assume there is always a coin of 1.One solution:
coins is taken by value (no &), so the function sorts its own copy and the caller’s vector is left alone. The lambda[](int a, int b) { return a > b; } is a small unnamed function that tells std::sort “a comes first if it is bigger”: largest first (std::greater<>() says the same). The inner while takes one coin as often as it fits before moving down. The last test is a reminder that this is the greedy answer, not always the best one.
14. Greedy algorithms · Choosing meetings: the earliest finish wins · Put in orderPut the earliest-finish rule in order.1. Sort the meetings by when they end 2. Take the first meeting in that order 3. Remember when the room becomes free 4. Skip each meeting that starts before then 5. Take the next one that starts after, and repeatSort once, then a single pass that takes or skips each meeting. Because the meetings come by end time, the first one that fits is always the one that finishes soonest, which leaves the most room for the rest.
14. Greedy algorithms · Why the earliest finish is always right · QuizTry the exchange argument on “shortest meeting first”. Where does it break, with meetings 1–5, 4–7 and 6–10?B. Swapping a best schedule’s meeting for the shortest one, 4–7, can create clashes: 4–7 overlaps both 1–5 and 6–10The best schedule is 1–5 and 6–10. Swapping either for 4–7 leaves a clash with the other, so the swap makes things worse, and the argument fails. That failure points at the counterexample. With earliest finish, the swap is always safe because the greedy meeting ends no later than the one it replaces.
14. Greedy algorithms · Try it: cancel as few meetings as possible · Code challengeFinish fewestToCancel(meetings): return how many meetings must go so that the rest never overlap. A meeting may start exactly when another ends.One solution:
Keep the most meetings with the earliest-finish rule, then cancel everything else: meetings.size() - kept. Sorting by start time is the tempting mistake: it keeps 1–10 first and then has to cancel the three short meetings. Using >= rather than > lets back-to-back meetings both stay.
14. Greedy algorithms · Try it: cancel as few meetings as possible · Find the bugThis compiles and runs, but sometimes keeps too few meetings. Which line is wrong?Line 4: return a.end < b.end;Comparing start sorts by start time, so this is the earliest-start rule, which grabs a long early meeting and loses the short ones inside it. Sorting by end is the rule the exchange argument proves right. Everything else in the function is fine, which is what makes this bug hard to see.
14. Greedy algorithms · Packing a bag: greedy by value per kilo · QuizNow sacks cannot be split: take a whole sack or leave it (the 0/1 knapsack). Same bag of 50, same spices. What does greedy by value per kilo give, and what is the best?B. Greedy 160 (saffron and vanilla), best 220 (vanilla and pepper)Greedy takes saffron and vanilla (30 kilos, 160), and then pepper does not fit, so 20 kilos of space are wasted. Vanilla and pepper fill the bag exactly for 220. Being able to split was what made greedy safe: without it, the problem needs dynamic programming (module 16).
14. Greedy algorithms · Packing a bag: greedy by value per kilo · Code challengeFinish bestLoad(items, capacity): return the most value that fits in the bag, when any part of an item may be taken.One solution:
Solution
1#include<algorithm>2#include<vector>34structItem{int weight;int value;};56doublerate(constItem& item){7returndouble(item.value)/ item.weight;8}910doublebestLoad(std::vector<Item> items,int capacity){11 std::sort(items.begin(), items.end(),12[](constItem& a,constItem& b){13returnrate(a)>rate(b);14});15int room = capacity;16double total =0;17for(constItem& item : items){18int take = std::min(item.weight, room);19 total += take *rate(item);20 room -= take;21}22return total;23}
Sort by value per kilo, highest first, then take std::min(item.weight, room) of each: the whole item while it fits, then the part that does, then 0 for the rest. Sorting by value alone takes pepper (120) first and wastes the bag on cheap spice. Watch the division: item.value / item.weight on two ints drops the fraction, so 15 / 2 would be 7, the same as 14 / 2, and the last test would fail. Turning one side into a double first keeps it. EXPECT_NEAR is used because decimals are not always exact in floating point.
15. Dynamic programming · Count the calls to see the waste · QuizHow many times is fib called, in total, to work out fib(5) with the code above?C. 15Count the calls with the same recursion: calls(0) and calls(1) are 1; calls(n) is 1 plus calls(n - 1) plus calls(n - 2). That gives 1, 1, 3, 5, 9, 15. The count grows like the Fibonacci numbers themselves, about 1.6 times for each step of n: exponential time.
15. Dynamic programming · Memoisation: remember every answer · Fill the blanksMemoise a function that counts paths. Look up first, store before returning.1: contains, 2: n, 3: resultmemo.contains(n) (C++20) asks whether the answer is known, and only then is memo[n] read. That order matters: memo[n] on a key that is not there quietly adds it with the value 0, so using it to look up would find a 0 for every new question. memo[n] = result stores the new answer. Forgetting to store is the classic slip: the code stays correct but is exactly as slow as before.
15. Dynamic programming · Bottom-up: fill a table from the smallest case · Find the bugThis keeps only two values instead of a table, but fib(3) gives 3, not 2. Which line is wrong?Line 9: return prev;The comments say what each variable holds: after the loop has run n times, prev is fib(n) and curr is fib(n + 1), one step ahead. Returning curr is off by one. Writing down what each variable means at every step (an invariant) is how you catch this without guessing.
15. Dynamic programming · Climbing stairs: count the ways · Code challengeFinish ways(n, steps): how many orders of moves climb exactly n stairs, when each move is one of the sizes in steps? Fill a table from 0 up.One solution:
The last move was one of the sizes in steps, so table[i] sums table[i - step] over every size that fits. Without table[0] = 1 every entry stays 0: the base case is what the sums are built from. With sizes 1 and 2 the table is Fibonacci; with 1, 2 and 3, each entry sums the three before it. O(n × steps) time. The count for 80 stairs is about 3.8 × 10¹⁶, which only fits because the table holds long long.
15. Dynamic programming · Coin change: how many ways to pay? · QuizWhat do the two loop orders count, for paying 3 with coins 1 and 2?B. A gives 2 (sets of coins), B gives 3 (orders: 1+2 and 2+1 apart)In A, a coin joins only after the smaller ones are all counted, so each combination is built in one order: 1+1+1 and 1+2. In B, every amount tries every coin as the last one, which is exactly the stairs problem: 1+1+1, 1+2 and 2+1. Same recurrence, different loop order, different question answered.
15. Dynamic programming · Coin change: how many ways to pay? · Code challengeFinish countWays(amount, coins): the number of different sets of coins that pay amount exactly (order does not matter).One solution:
Coins outside, amounts inside: that order is what counts sets rather than orders. Starting a at coin skips amounts the coin cannot help with. Counting upwards inside one coin is right here, because ways[a - coin] may already include this coin, which is how a set gets two or more of it. O(amount × coins) time, O(amount) memory.
15. Dynamic programming · Coin change: the fewest coins, for any coins · Code challengeFinish fewestCoins(amount, coins): the fewest coins that pay amount exactly, or -1 if it cannot be done. INF is ready for “not payable yet”.One solution:
Solution
1#include<algorithm>2#include<vector>34constint INF =1000000000; // more coins than any real answer56intfewestCoins(int amount,const std::vector<int>& coins){7 std::vector<int>fewest(amount +1, INF);8 fewest[0]=0;9for(int a =1; a <= amount; a++)10for(int coin : coins)11if(coin <= a)12 fewest[a]= std::min(fewest[a], fewest[a - coin]+1);13return fewest[amount]== INF ?-1: fewest[amount];14}
fewest[0] = 0 is the base case; every other amount starts at INF (“not payable yet”), so std::min replaces it with the first real answer. INF is chosen with care: bigger than any real count of coins, yet small enough that INF + 1 still fits in an int. std::numeric_limits<int>::max() would overflow on that + 1 (undefined behaviour, which in practice gives a large negative number), and std::min would then pick it. Here loop order does not matter: the minimum over every last coin is the same either way. O(amount × coins).
15. Dynamic programming · Top-down or bottom-up · Put in orderPut the steps of designing a DP solution in order.1. Say in words what one subproblem is: “fewest coins to pay a” 2. Write the recurrence: the answer from smaller answers 3. Fix the base cases: fewest[0] = 0 4. Pick an order (or memoise) so smaller answers are ready first 5. Read the answer from the table, and rebuild the choices if neededEverything follows from the first step: once a subproblem is named precisely, the recurrence is usually “think about the last choice”. Base cases stop it, the order makes the table fillable, and the final entry is the answer. Most DP bugs are a vague first step.
16. Dynamic programming in two dimensions · Counting paths through a grid · QuizAdd a fourth row (row 3) to the table above. What goes in its last cell, the bottom-right corner of a 4 by 4 grid?C. 20Row 3 starts at 1 and each cell adds the cell above and the one on its left: 1, 1 + 3 = 4, 4 + 6 = 10, 10 + 10 = 20. A shortcut for open grids: a route is 3 rights and 3 downs in some order, and there are 6!/(3!·3!) = 20 ways to arrange them. The table still wins as soon as some corners are blocked.
16. Dynamic programming in two dimensions · Filling the grid row by row · Code challengeFinish countPaths(grid): grid[r][c] is 1 for a wall and 0 for open ground. Count the routes from the top-left to the bottom-right moving only right or down, never through a wall. A wall on the start or the end means 0 routes.One solution:
Solution
1#include<vector>23usingGrid= std::vector<std::vector<int>>;45longlongcountPaths(constGrid& grid){6int rows = grid.size(), cols = grid[0].size();7 std::vector<std::vector<longlong>>ways(8 rows, std::vector<longlong>(cols,0));9for(int r =0; r < rows; r++)10for(int c =0; c < cols; c++){11if(grid[r][c]==1)continue;12if(r ==0&& c ==0){13 ways[r][c]=1;14continue;15}16longlong up = r >0? ways[r -1][c]:0;17longlong left = c >0? ways[r][c -1]:0;18 ways[r][c]= up + left;19}20return ways[rows -1][cols -1];21}
A wall keeps its 0, and every cell after it adds that 0, so routes through it simply vanish. Taking up and left as 0 when they are off the grid handles the top row and the left column without a separate rule, which also matters with walls: a top-row cell after a wall must get 0, not the 1 of the open-grid shortcut. The start cell is the only base case.
16. Dynamic programming in two dimensions · The longest common subsequence · Fill the blanksComplete the rule for one cell.1: ==, 2: 1, 3: maxA match extends the best answer for both prefixes without their last letters, by one. Otherwise one of the two last letters is not in the answer, so take the larger of “drop the last letter of a” (dp[i - 1][j]) and “drop the last letter of b” (dp[i][j - 1]). Note a[i - 1]: row i is about the first i letters, and the ith letter sits at index i - 1.
16. Dynamic programming in two dimensions · From exponential to a table · Code challengeFinish lcsLength(a, b) with the table: fill dp[i][j] for every i from 1 and j from 1, using the rule from the slide before, and return the bottom-right cell.One solution:
Solution
1#include<algorithm>2#include<string>3#include<vector>45intlcsLength(const std::string& a,const std::string& b){6int n = a.size(), m = b.size();7 std::vector<std::vector<int>>dp(n +1, std::vector<int>(m +1,0));8for(int i =1; i <= n; i++)9for(int j =1; j <= m; j++){10if(a[i -1]== b[j -1]) dp[i][j]= dp[i -1][j -1]+1;11else dp[i][j]= std::max(dp[i -1][j], dp[i][j -1]);12}13return dp[n][m];14}
Row 0 and column 0 stay 0 (an empty prefix shares nothing), which is why the loops start at 1. Each cell reads only the cell up-left, up, and left, all filled already in row-by-row order. A common slip is comparing a[i] with b[j], which is off by one, and quietly so: a[a.size()] on a std::string is its hidden end marker \0, not a crash, so the code just compares the wrong letters. Time and memory are O(n × m): four million cells for the last test.
16. Dynamic programming in two dimensions · Edit distance: how far apart two words are · Find the bugThis says the distance from abc to an empty word is 0. Which line is wrong?Line 4: for (int i = 0; i <= n; i++) dp[i][0] = i;Turning the first i letters into the empty word takes i deletions, so column 0 must count up 0, 1, 2… just as row 0 does. With zeros there, every word looks free to delete, and the error spreads to every cell built from that column. Base cases are where most table bugs hide: check them on the smallest inputs first. (std::min({x, y, z}), with braces, takes the smallest of a whole list.)
16. Dynamic programming in two dimensions · The 0/1 knapsack: packing a bag · Fill the blanksComplete the knapsack loop.1: capacity, 2: weight, 3: maxTaking the item uses weight of the room, so the rest comes from the row above at c - weight: the row above, because each item can be used only once. When it does not fit, take is 0, which never beats skip, and c - weight is never read, which matters: a negative index would be out of bounds. The answer is best[items.size()][capacity].
16. Dynamic programming in two dimensions · The 0/1 knapsack: packing a bag · QuizA greedy packer takes the most valuable item first, then the next that still fits. With the 4 kg bag above, how does it do?B. It takes the camera and stops at 30The camera (30) goes in first and fills the bag, so nothing else fits. The table tries both “take” and “skip” for every item and room, which is why it finds map plus water for 35. Greedy choices are right for some problems (the greedy module showed when), and the 0/1 knapsack is not one of them.
16. Dynamic programming in two dimensions · Reading the answer back from the table · Code challengeFinish lcs(a, b), which returns the longest common subsequence itself. table(a, b) already fills the LCS table. Walk back from dp[a.size()][b.size()]: on matching letters, collect the letter and go up-left; otherwise move towards the larger of the cell above and the cell on the left.One solution:
Each step undoes the choice that filled the cell: a match came from up-left plus that letter, anything else came from whichever neighbour was larger. Letters are found last to first, so collect them with push_back and reverse once at the end. Putting each one on the front with out.insert(0, …) also works, but moves every letter already there, O(n²) in all. When up and left tie, either way gives a longest answer, though possibly a different one. Reading back costs only O(n + m) steps on top of the table.
16. Dynamic programming in two dimensions · Saving memory with one row · QuizWith one row and only the map (weight 1, value 15), what does the bag of room 4 end up worth if the loop goes up, from c = 1 to 4?C. 60Going up, best[c - 1] already includes the map, so each step packs it again: 15, 30, 45, 60. That is the answer to a different problem, with unlimited copies of each item (like the coins in coin change). Going down from 4, best[c - 1] is still the old 0, and the bag is worth 15.
16. Dynamic programming in two dimensions · Saving memory with one row · Code challengeFinish bestValue(items, capacity) for the 0/1 knapsack using only the one row best, where best[c] is the most value with room c. Each item can be packed at most once.One solution:
Solution
1#include<algorithm>2#include<vector>34structItem{int weight;int value;};56intbestValue(const std::vector<Item>& items,int capacity){7 std::vector<int>best(capacity +1,0);8for(constItem& item : items){9for(int c = capacity; c >= item.weight; c--){10int take = best[c - item.weight]+ item.value;11 best[c]= std::max(best[c], take);12}13}14return best[capacity];15}
The inner loop runs from capacity down to the item’s weight, so best[c - item.weight] is still the value without this item, and stopping at the weight also skips rooms it does not fit. Loop upwards instead and the map is packed again and again: the bag of 4 comes out at 60, not 35. The time is still O(n × capacity).
17. Backtracking · Subsets: take it or leave it · QuizHow many subsets does a list of 10 items have?D. 1024Each item doubles the count, because every subset of the rest comes once with it and once without: 2 × 2 × … ten times is 2¹⁰ = 1024, including the empty one and the whole list. Listing them all takes O(2ⁿ × n) time (each saved copy is up to n long), which is why backtracking over all subsets is only for small n, around 20 to 25 at most.
17. Backtracking · Choosing k of n · Code challengeFinish explore so that choose(items, k) returns every way to pick k of the letters in items, each in the order the letters come, with the picks in that order too. Use chosen with push_back, recurse and pop_back.One solution:
The loop starts at start, and the recursive call passes i + 1, so every pick goes forward through the items and no combination is listed twice in another order. Passing start + 1 instead is the classic slip: the next call may pick item i again, or one before it, so "bb" and "cb" turn up. Forgetting the & on chosen would still give right answers, but every call would copy it.
17. Backtracking · Permutations: every order · Find the bugFor "abc" this finds only {"abc"}, not all six orders. Which line is wrong?Line 13: used[i] = false;Lines 12 and 13 are the undo, and both must put things back as they were. Line 13 seats the guest again instead of un-seating them, so after the first full row every letter stays marked as used and no other branch can place anything. Line 4 is fine in C++: push_back copies current. (In languages where it would store a reference to the one shared partial solution, such as JavaScript or Python, that line is the classic bug: every saved answer ends up empty.)
17. Backtracking · Try it: every arrangement of a word · Code challengeFinish explore so that arrangements(word) returns every ordering of the word's letters, in the order the search tree finds them (first letter first). The letters are all different.One solution:
The undo is current.pop_back() plus used[i] = false: forget the second and only "abc" is found, because every letter stays marked as used. The last test puts every answer into a std::set, which keeps one copy of each, to check none is repeated. There are n! arrangements, so 10 letters already give over 3.6 million.
17. Backtracking · Choices that add up to a target · Fill the blanksComplete the pruning line and the recursive call.1: left, 2: break, 3: i + 1break (not continue) ends the whole loop: the list is sorted, so nothing after a too-big number can fit. Passing i + 1 means each number is used at most once and later numbers only, so [2, 5] and [5, 2] are not both found. Passing i instead would allow the same number again, as in coin change.
17. Backtracking · Choices that add up to a target · Code challengeFinish explore so that sumsTo(nums, target) returns every list of numbers from nums (distinct and positive, each used at most once) that adds up to target, each list in ascending order. The order of the lists does not matter.One solution:
Solution
1#include<algorithm>2#include<vector>34usingNums= std::vector<int>;56voidexplore(constNums& sorted,size_t start,int left,7Nums& chosen, std::vector<Nums>& result){8if(left ==0){9 result.push_back(chosen);10return;11}12for(size_t i = start; i < sorted.size(); i++){13if(sorted[i]> left)break;14 chosen.push_back(sorted[i]);15explore(sorted, i +1, left - sorted[i], chosen, result);16 chosen.pop_back();17}18}1920std::vector<Nums>sumsTo(Nums nums,int target){21 std::sort(nums.begin(), nums.end());22Nums chosen;23 std::vector<Nums> result;24explore(nums,0, target, chosen, result);25return result;26}
Counting down what is left makes both checks cheap: 0 is a success, and a number bigger than left ends the loop. The numbers come from the sorted vector, so each list is already ascending. sumsTo takes numsby value, so it sorts its own copy and the caller’s vector is left alone. The tests sort the lists before comparing (vectors compare item by item, like words in a dictionary), so any order of answers passes.
17. Backtracking · Pruning: the N-queens puzzle · QuizQueens at (row 0, column 1) and (row 2, column 3): std::abs(1 - 3) == 2 - 0 is true. Are they safe from each other?B. No: they are on the same diagonalTwo squares are on one diagonal when they are as many columns apart as rows apart: here 2 and 2. The check is true, so safe returns false and the square is rejected. A queen between them would block the attack in a real game, but it would itself share that diagonal with both, so a board where no two queens attack each other never has one there.
17. Backtracking · Try it: find a word in a letter grid · Code challengeFinish hasWord(grid, word): return true if word can be traced through neighbouring squares, using each square at most once. Mark a square by setting it to '#' while on the path, and put the letter back afterwards, so the grid is unchanged at the end. A helper like from(grid, word, r, c, i), “can the word from letter i on start at this square?”, is a good start.One solution:
Solution
1#include<string>2#include<vector>34usingGrid= std::vector<std::string>;56boolfrom(Grid& g,const std::string& word,int r,int c,size_t i){7if(i == word.size())returntrue;8if(r <0|| r >=int(g.size()))returnfalse;9if(c <0|| c >=int(g[r].size()))returnfalse;10if(g[r][c]!= word[i])returnfalse;11 g[r][c]='#';12bool found =from(g, word, r +1, c, i +1)||13from(g, word, r -1, c, i +1)||14from(g, word, r, c +1, i +1)||15from(g, word, r, c -1, i +1);16 g[r][c]= word[i];17return found;18}1920boolhasWord(Grid& grid,const std::string& word){21for(int r =0; r <int(grid.size()); r++)22for(int c =0; c <int(grid[r].size()); c++)23if(from(grid, word, r, c,0))returntrue;24returnfalse;25}
The bounds checks come first, so a step off the edge returns false before g[r][c] is ever read. After that, one comparison covers wrong letters and squares already marked '#'. The grid is taken by reference without const, because marking changes it; restoring the letter before returning is what makes a square available again to other paths, and what leaves the caller's grid as it was. || stops at the first neighbour that works, so the search ends as soon as the word is found.
17. Backtracking · When to reach for backtracking · Put in orderPut one call of a backtracking search in order.1. If the partial solution is complete, save a copy and return 2. Loop over the choices still allowed here 3. Skip a choice that breaks a rule (prune) 4. Make the choice: add it to the partial solution 5. Recurse to make the next choices 6. Undo the choice before trying the nextThis is the whole pattern, whatever the puzzle. The complete check comes first, so a finished solution is not extended. Pruning goes before the choice, so no work is spent on a dead end. And the undo comes after the recursion returns, so every sibling choice starts from the same partial solution.
17. Backtracking · When to reach for backtracking · QuizWhich of these are best done with backtracking? Pick every one.A. Print every way to seat 6 guests around a table; C. Fill in a SudokuPrinting every seating means visiting every answer, so nothing is faster than generating them. Sudoku is “any answer that meets the rules”, with strong pruning. Counting stairs and edit distance have overlapping subproblems and need only a number: as a backtracking search they take exponential time, as a table only a few steps per cell.
18. Minimum spanning trees · Connect every town with the least cable · QuizA spanning tree connects 5 towns. How many edges does it have?B. 4Start with 5 separate towns. Each edge that does not make a cycle joins two separate groups into one, so it lowers the number of groups by exactly one. Going from 5 groups to 1 takes 4 edges. Any tree on n vertices has n - 1 edges; a fifth edge would always close a loop.
18. Minimum spanning trees · Trace it: a table of decisions · Put in orderPut the steps of Kruskal’s algorithm in order.1. Put every vertex in a group of its own 2. Sort the edges from cheapest to dearest 3. Take the next edge in the list 4. If its ends are in different groups, keep the edge and merge the groups 5. Stop once you have kept n - 1 edgesGroups start as single vertices, and the sorted list is walked once. The “different groups” test is what keeps loops out, and n - 1 kept edges means everything is one group, so the rest of the list can be ignored.
18. Minimum spanning trees · Union-find answers “same group?” fast · Fill the blanksComplete Kruskal’s loop using the UnionFind from above.1: <, 2: unite, 3: costx.cost < y.cost says “x comes first when it is cheaper”: cheapest first. unite merges and returns true only when the ends were in different groups, so the edge is kept and its cost counted; when it returns false the edge would make a loop and is skipped.
18. Minimum spanning trees · Kruskal in C++ · Find the bugThis runs, and always adds up the cost of a spanning tree, but not the cheapest one. Which line is wrong?Line 4: return x.cost < y.cost;x.cost > y.cost sorts dearest first, so the loop builds a maximum spanning tree: still connected and loop-free, which is why it looks right. The sort order is the whole of Kruskal’s greed. (A maximum spanning tree is a real tool too, for finding the most reliable links, but it is not what was asked.)
18. Minimum spanning trees · Try it: the cost of a minimum spanning tree · Code challengeFinish mstCost(n, edges): the total cost of a minimum spanning tree of vertices 0 to n - 1. The graph is connected.One solution:
Solution
1#include<algorithm>2#include<vector>34structEdge{int from;int to;int cost;};56structUnionFind{7 std::vector<int> parent; // parent[i] == i means i is a root89UnionFind(int n):parent(n){10for(int i =0; i < n; i++) parent[i]= i;11}12intfind(int x){13while(parent[x]!= x){14 parent[x]= parent[parent[x]]; // skip a step next time15 x = parent[x];16}17return x;18}19};2021longlongmstCost(int n,const std::vector<Edge>& edges){22UnionFindgroups(n);23 std::vector<Edge> sorted = edges;24 std::sort(sorted.begin(), sorted.end(),25[](constEdge& x,constEdge& y){26return x.cost < y.cost;27});28longlong total =0;29for(auto[a, b, cost]: sorted){30int ra = groups.find(a), rb = groups.find(b);31if(ra == rb)continue;32 groups.parent[ra]= rb;33 total += cost;34}35return total;36}
std::vector<Edge> sorted = edges; makes the copy to sort; trying std::sort(edges.begin(), …) on the const& does not compile, which is const doing its job. The test ra == rb is what stops a loop: in the triangle, the road of cost 3 finds both ends already under one root. Forgetting groups.parent[ra] = rb is the classic slip: every edge then looks like it joins two groups, and the triangle costs 6. The sort is the cost, O(E log E): about 200 000 roads here, done in a blink.
18. Minimum spanning trees · Try it: Prim on a table of costs · Code challengeFinish primCost(cost). Each round, pick the town not in the tree with the smallest best, add it (and its best to total), then lower best[v] to cost[picked][v] for every town outside the tree where that is cheaper.One solution:
Solution
1#include<algorithm>2#include<vector>34constint INF =1000000000; // no road5usingTable= std::vector<std::vector<int>>;67longlongprimCost(constTable& cost){8int n = cost.size();9 std::vector<bool>inTree(n,false);10 std::vector<int>best(n, INF);11 best[0]=0;12longlong total =0;13for(int round =0; round < n; round++){14int pick =-1;15for(int v =0; v < n; v++)16if(!inTree[v]&&(pick ==-1|| best[v]< best[pick])) pick = v;17 inTree[pick]=true;18 total += best[pick];19for(int v =0; v < n; v++)20if(!inTree[v]) best[v]= std::min(best[v], cost[pick][v]);21}22return total;23}
Town 0 starts with best 0, so the first round picks it for free; every later pick adds exactly one edge. The inTree check in the picking loop is what stops a town being added twice; a common slip is to leave it out, and the cheapest town (town 0, at 0) is picked every round. Lowering best only outside the tree just saves work. Two nested loops of n make this O(V²), which beats a heap when nearly every pair is connected. total is a long long and best never has INF added to it, so nothing overflows.
18. Minimum spanning trees · What a spanning tree does and does not promise · QuizRoads: A–B costs 2, B–C costs 2, A–C costs 3. In the minimum spanning tree, how far is it from A to C?B. 4, through BThe MST is A–B and B–C (total 4); the road A–C (3) is left out because it is the dearest edge of the triangle. So travelling A to C inside the tree costs 4, though the shortest path in the full graph is 3. An MST minimises the total cable, not any one trip; for trips you want Dijkstra.
19. Solving problems you have never seen · A method beats a flash of insight · Put in orderYou are given a problem you have never seen. Put the steps in order.1. Restate it and note the input sizes 2. Solve small examples by hand 3. Write a brute-force solution 4. Find the step that repeats work 5. Replace it with a known technique 6. Compare the fast solution with the brute force on many inputsUnderstanding comes first because a fast solution to the wrong problem is worth nothing. Brute force comes before cleverness: it is quick to write, it shows you where the waste is, and it stays useful at the end as the oracle that checks the fast one.
19. Solving problems you have never seen · The input size tells you how fast to be · QuizYou must choose which of up to 18 parcels to pack in a van, and the brute force tries every choice. Is that fast enough?B. Yes: 2¹⁸ is about 262 000 choicesEach parcel is in or out, so there are 2¹⁸ = 262 144 choices; even at a few dozen steps each, that is a few million steps, well under a second. Brute force is a fine answer when the input is small. At 60 parcels, 2⁶⁰ would never finish, and you would need DP or greedy.
19. Solving problems you have never seen · From brute force to fast: the best run of days · QuizWhat does bestRun return?A. 6here goes 2, −3, 3 (starting afresh beats −3 + 3 = 0), 2, 6, 0; the largest is 6, from the run 3, −1, 4. The −1 is worth keeping because 4 follows it. Restarting at each new day whenever the past is a burden is the whole idea.
19. Solving problems you have never seen · Clues that point to a technique · QuizOn a chessboard with some squares blocked, find the fewest moves a knight needs from one square to another. Which technique fits?B. Breadth-first search“Fewest moves” where every move counts the same is the BFS clue: squares are vertices, legal moves are edges, and BFS reaches squares in order of moves. Greedy fails because a knight often has to move away to get closer. Dijkstra works but is overkill when every edge weighs 1. DP struggles because moves can go back and forth, so there is no order of smaller subproblems.
19. Solving problems you have never seen · Check the fast answer against the slow one · Code challengeWrite firstMismatch(fast, slow, inputs): return the first input on which the two functions give different results, or std::nullopt if they agree on all.One solution:
One loop and one comparison: C++’s != on two std::vectors compares their contents, item by item, so sorted lists compare as you would hope with no extra work. The template lets the same function check ints and whole lists. The last test is a real bug an oracle finds: putting values in a std::set sorts them, but also keeps only one of each, so any list with a repeated value comes out short. A thousand random lists with a fixed seed find one at once.
19. Solving problems you have never seen · Edge cases are where solutions break · Find the bugThis passes every test with a profitable day in it. On which line does it fail for a list of all losses?Line 2: int best = std::numeric_limits<int>::min();For {-3, -1} the right answer is −1 (the least bad day), but best starts at 0, a run of no days that the problem does not allow, and nothing negative ever beats it. Starting at the smallest int (or at xs[0]) fixes it. here starting at 0 is harmless, because std::max(x, 0 + x) is just x. An oracle with negative numbers finds this in a few rounds.
19. Solving problems you have never seen · Amortised cost: expensive now and then, cheap on average · Fill the blanksComplete the growth step so pushes are O(1) amortised.1: 2, 2: slotsDoubling (any constant factor above 1 works) makes the copies a geometric series that sums to less than 2n. Growing by a fixed amount instead, say 10 slots each time, copies 10 + 20 + 30 + … which is O(n²) over n pushes. (new int[capacity] asks the heap for a row of capacity ints and delete[] gives it back: the array forms of the new and delete from the Data structures course. std::vector does all of this for you.)
19. Solving problems you have never seen · Try it: days until a warmer day · Code challengeFinish daysUntilWarmer(temps) with a stack of day numbers, so that it handles 100 000 days in time. {30, 31, 29, 33} gives {1, 2, 1, 0}.One solution:
Solution
1#include<vector>23std::vector<int>daysUntilWarmer(const std::vector<int>& temps){4 std::vector<int>wait(temps.size(),0);5 std::vector<int> stack; // days still waiting, as a stack6for(int day =0; day <int(temps.size()); day++){7while(!stack.empty()&& temps[stack.back()]< temps[day]){8int cold = stack.back();9 stack.pop_back();10 wait[cold]= day - cold;11}12 stack.push_back(day);13}14return wait;15}
Each day is pushed once and popped at most once, so all the while loops together run at most n times: O(n) overall, though one day can pop many. The comparison must be strict (<): an equally warm day is not warmer, and the second test catches <=. A std::vector makes a fine stack: push_back, back and pop_back all work at its end, O(1). The brute force on the last test would look ahead about 5 billion times and run out of time; the stack does about 200 000 steps.
19. Solving problems you have never seen · Choosing between correct solutions · QuizCheck a list of up to 10 million IDs for duplicates. The list must not change, and memory is plentiful. Which is the best choice?B. Put them in a std::unordered_set and compare sizes: O(n) time, O(n) memoryTen million squared is far too many steps, so pairs are out. Sorting in place breaks “the list must not change” (sorting a copy works, at O(n log n)). Binary search needs a sorted list in the first place. With memory to spare, std::unordered_set<int>(ids.begin(), ids.end()).size() != ids.size() is linear (on average), short and obviously right.
20. Mastery challenge · How this challenge works · Put in orderPut the method for an unseen problem in order.1. Restate the problem, its inputs and its limits 2. Work two or three small examples by hand 3. Write a brute force you are sure is right 4. Find the step that makes it slow 5. Match a pattern that removes that step 6. Test the fast version against the brute forceUnderstanding comes before code, and a correct slow answer before a fast one: the brute force is both a fallback and the oracle that checks the clever version. The bottleneck tells you which pattern to reach for: a repeated search suggests hashing, repeated subproblems suggest dynamic programming. (Problem solving.)
20. Mastery challenge · How this challenge works · QuizHow many times does the loop body run?A. 9Dividing two ints drops the remainder, so i takes the values 1000, 500, 250, 125, 62, 31, 15, 7 and 3, then becomes 1 and the loop stops: 9 runs. Halving each time gives about log₂ n steps (log₂ 1000 is just under 10), the same count that makes binary search fast. (Counting steps; binary search.)
20. Mastery challenge · Name the technique · QuizFind the length of the longest stretch of a string with no letter repeated. Which approach is O(n)?B. A sliding window with a set of the letters inside it (std::unordered_set<char>)Grow the window on the right; when the new letter is already inside, shrink it from the left until it is not. Each letter enters and leaves the window at most once, so the work is O(n) overall. Trying every stretch is O(n²) stretches, each checked in O(n). Sorting destroys the order the answer depends on. (Two pointers and sliding windows; counting and hashing.)
20. Mastery challenge · Name the technique · QuizCoins are worth 1, 5, 21 and 25. What is the fewest coins for 63, and what finds it reliably?C. 3 coins, by dynamic programming over the amounts 0 to 63Greedy takes 25, 25, then 5, 5, 1, 1, 1: 7 coins. But 21 + 21 + 21 is 3. Greedy is only right for coin systems where an exchange argument holds, and this one is not such a system. Dynamic programming tries every last coin for every amount, so it cannot miss: best[a] = 1 + min(best[a - c]). (Greedy; dynamic programming.)
20. Mastery challenge · Write it: count the islands · Code challengeFinish islands(grid): return how many islands the map has. Each string in grid is one row. The grid is your own copy (it is taken by value), so you may change it.One solution:
Solution
1#include<stack>2#include<string>3#include<utility>4#include<vector>56intislands(std::vector<std::string> grid){7constint dy[]={1,-1,0,0}, dx[] ={0,0,1,-1};8int rows = grid.size(), count =0;9for(int r =0; r < rows; r++)10for(int c =0; c <int(grid[r].size()); c++){11if(grid[r][c]!='#')continue;12 count++;13 grid[r][c]='.'; // sink it: seen14 std::stack<std::pair<int,int>> stack;15 stack.push({r, c});16while(!stack.empty()){17auto[y, x]= stack.top();18 stack.pop();19for(int k =0; k <4; k++){20int ny = y + dy[k], nx = x + dx[k];21if(ny <0|| ny >= rows)continue;22if(nx <0|| nx >=int(grid[ny].size()))continue;23if(grid[ny][nx]!='#')continue;24 grid[ny][nx]='.';25 stack.push({ny, nx});26}27}28}29return count;30}
Scan every square; each land square not yet seen starts a new island, and a flood fill (depth first with a stack here; breadth first works too) marks the whole island so it is not counted again. Here the mark is to sink the land, turning # into .: the grid is a copy, so the caller never sees it. Every square is pushed at most once, so it is O(rows × columns). Marking a square when it is pushed, not when it is popped, keeps it from being pushed twice. The bounds checks come before grid[ny][nx] is read. (Graph search: grids and flood fill.)
20. Mastery challenge · Write it: the shortest word ladder · Code challengeFinish ladder(start, goal, words): return the number of words in the shortest ladder from start to goal, counting both ends, using only words in words after start. Return 0 if there is none. oneApart is written for you.One solution:
Breadth first goes one level at a time, so the first level that contains the goal gives the shortest length. Marking a word queued as soon as it joins the next level is the visited set: without it, words bounce back and forth and the search never ends. Keeping the words in a std::unordered_set and erasing each as it is queued also works, but not inside a range-for over that same set: erasing the element the loop stands on breaks the loop’s iterator, which is undefined behaviour (use the iterator that erase returns instead). Each level compares every queued word with every unseen word, O(n² × L) for n words of length L; for big dictionaries, try each of the 25 other letters in each position instead and look the result up in a std::unordered_set of the words. (Graph search: BFS.)
20. Mastery challenge · Write it: merge overlapping bookings · Code challengeFinish merge(bookings): return the merged bookings sorted by start. Bookings that touch (one ends where the next starts) merge.One solution:
Once sorted by start, a booking can only overlap the one merged just before it, so a single sweep is enough: O(n log n) for the sort, O(n) for the sweep. std::sort needs no comparator here, because pairs compare by first and then by second: by start. std::max matters: {1, 10} then {2, 3} must stay {1, 10}, not shrink to {1, 3}. out.back() is a reference to the last booking kept, so changing its .second changes the booking inside out. Taking bookings by value means sorting never touches the caller’s vector. (Simple sorts; greedy: intervals.)
20. Mastery challenge · Write it: the longest rising run · Code challengeFinish longestRise(nums): return the length of the longest strictly increasing subsequence. An empty vector gives 0.One solution:
Solution
1#include<algorithm>2#include<vector>34intlongestRise(const std::vector<int>& nums){5 std::vector<int>best(nums.size(),1);6int most =0;7for(size_t i =0; i < nums.size(); i++){8for(size_t j =0; j < i; j++)9if(nums[j]< nums[i]) best[i]= std::max(best[i], best[j]+1);10 most = std::max(most, best[i]);11}12return most;13}
Every increasing subsequence ending at i is one ending at some earlier, smaller j, plus nums[i]; so best[i] is 1 more than the best such best[j], or 1 alone. The answer is the largest best[i], since the longest run can end anywhere: returning best.back() is the common slip (and on an empty vector back() has no element to read: here the library’s checks stop the program). Two nested loops: O(n²) time, O(n) memory. Starting most at 0 covers the empty vector. (Dynamic programming.)
20. Mastery challenge · Write it: how many rooms are needed · Code challengeFinish rooms(meetings): return the fewest rooms needed. A meeting may start in a room at the moment another ends. Use a std::priority_queue that keeps the smallest end time on top.One solution:
Sorting by start replays the day in order. Each meeting either takes over the room that frees up soonest, if that room is already free, or needs a new one; reusing the soonest-free room is the greedy choice, and it is safe because any free room is as good as another. At most one room is freed per meeting, so the queue never shrinks, and its final size is the most rooms in use at once. std::priority_queue is a binary heap, so each step is O(log n), O(n log n) in all. (Greedy; shortest paths: priority queues.)
20. Mastery challenge · Which statements are true? · QuizPick every true statement.A. Breadth-first search finds a path with the fewest edges, whatever the edges weigh.; C. Memoising the naive recursive Fibonacci makes it O(n).; E. Kruskal’s algorithm adds edges cheapest first, skipping any that would close a cycle.BFS counts edges and ignores weights, so it does find the fewest edges, just not the cheapest path (graph search; shortest paths). Dijkstra finalises a node the first time it is taken from the queue, and a negative edge found later can make it cheaper: use Bellman–Ford (shortest paths). Memoising computes each fib(k) once (dynamic programming). A random pivot makes O(n log n) the expected time; an unlucky run can still be O(n²) (quicksort). The last one is Kruskal exactly (spanning trees).
20. Mastery challenge · Which statements are true? · Find the bugThis topological sort (Kahn’s algorithm) leaves some tasks out of the order even though there is no cycle. Which line is wrong?Line 9: if (--waiting[next] == 0) ready.push_back(next); // and drop line 10waiting[t] counts how many unfinished tasks task t still depends on. Finishing task removes one, and next is ready when that count reaches 0. Line 9 checks before the decrement, so a task whose last dependency just finished still reads 1 and is never queued. Decrement first, then check. If order ends up shorter than tasks for real, the graph has a cycle. (Graph search: topological sort.)
20. Mastery challenge · Read a recurrence: edit distance · Fill the blanksComplete the step that fills one cell of the table.1: ==, 2: 1, 3: minWhen the last letters match, they cost nothing and the answer is the distance without them. Otherwise one edit is unavoidable, and you pick whichever of the three leaves the cheapest problem behind: 1 + std::min({…}), where the braces let std::min take a list of three. Filling the table row by row is O(len(a) × len(b)), and only the previous row is needed, so memory can be one row. (Dynamic programming in two dimensions.)
20. Mastery challenge · Read a recurrence: edit distance · QuizWhat is the edit distance from kitten to sitting?B. 3Replace k with s (sitten), replace e with i (sittin), insert g at the end (sitting): 3 edits. Filling the table confirms that no shorter sequence exists, because it tries every alternative at every cell. The length difference alone only proves at least 1, so a lower bound by eye is easy to get wrong.