Skip to the content

Module 1: What a data structure is

Module 2: Arrays

Module 3: Measuring cost

Module 4: Strings

Module 5: Linked lists

Module 6: Stacks

Module 7: Queues

Module 8: Hash tables

Module 9: Maps and sets

Module 10: Trees

Module 11: Binary search trees

Module 12: Heaps and priority queues

Module 13: Graphs

Module 14: Tries: trees of prefixes

Module 15: Balanced trees

Module 16: Disjoint sets: union-find

Module 17: Caches: combining structures

Module 18: Range queries

Module 19: Persistent structures

Module 20: Mastery challenge

Answer key

  1. 1. What a data structure is · Data is values kept in memory · QuizThe computer needs the value in box 7 000. What does it do?B. Goes straight to box 7 000 using its addressMemory is built so any box can be reached by its address in the same short time, whichever box it is. That is why it is called random access memory (RAM). Searching is only needed when you know what you want but not where it is, and that is exactly the problem data structures solve.
  2. 1. What a data structure is · A data structure is a layout plus the operations it makes cheap · Put in orderPut the steps of choosing a data structure in order.1. List the operations the job needs 2. Work out which of them happen most often 3. Look up what each operation costs in each candidate layout 4. Pick the layout that makes the frequent operations cheap 5. Check it with realistic amounts of dataThe operations come first, the structure last: picking a favourite structure and then fitting the job to it is the usual mistake. The final check matters because real data can surprise you, for example by being far larger than you expected.
  3. 1. What a data structure is · Four operations to ask about · QuizA browser checks whether a web address is already in your bookmarks. Which operation is that?B. SearchYou know the value (the address) but not where it is, or whether it is there at all: that is a search. A read is when you already know the position, such as "the first bookmark". The difference matters, because many structures make reading instant but searching slow.
  4. 1. What a data structure is · Every value has a type · Fill the blanksGive each variable the right type.1: int, 2: double, 3: bool, 4: std::stringint holds whole numbers and double numbers with a fraction. bool is only true or false. std::string holds text; it needs #include <string> and the std:: in front, because it comes from the standard library rather than the language itself.
  5. 1. What a data structure is · Decisions, loops and functions · QuizWhat does the program above print?A. 0, one!, 4The loop runs for i = 0, 1 and 2. When i is 0, twice(0) is 0; when it is 1, the if prints one! instead; when it is 2, twice(2) is 4. It stops before 3 because i < 3 is then false.
  6. 1. What a data structure is · Searching a shelf means looking at every book · Find the bugThis search says "not found" for books that are on the shelf. Which line is wrong?Line 5The return -1; inside the loop runs as soon as the first book does not match, so the search gives up after one look. "Not found" can only be decided once the loop has finished, which is why it belongs after it, on line 7.
  7. 1. What a data structure is · Try it: count the looks · Code challengeFinish searchShelf(shelf, title): return a Result whose index is the title’s position (or -1) and whose looks is how many books were compared.One solution:
    Solution
    #include <string>#include <vector>
    struct Result {  int index;  int looks;};
    Result searchShelf(const std::vector<std::string>& shelf,                   const std::string& title) {  int looks = 0;  for (int i = 0; i < shelf.size(); i++) {    looks++;    if (shelf[i] == title) return Result{i, looks};  }  return Result{-1, looks};}
    Count before comparing, so the look that finds the book is counted too. The last test is the important one: a missing book is the worst case, because you cannot stop early. On a shelf of a million books that is a million looks, which is what the next slide fixes.
  8. 1. What a data structure is · Try it: build the card box · Code challengeFinish byIsbn(books): return a map whose keys are the books’ ISBNs and whose values are the books themselves.One solution:
    Solution
    #include <string>#include <unordered_map>#include <vector>
    struct Book {  std::string isbn;  std::string title;};
    std::unordered_map<std::string, Book>byIsbn(const std::vector<Book>& books) {  std::unordered_map<std::string, Book> index;  for (const Book& book : books) {    index[book.isbn] = book;  }  return index;}
    One pass over the books fills the box: index[book.isbn] = book; files a book under its ISBN. In C++ that line copies the book into the map: the map holds its own books, not a link to yours. Copying is C++’s default for every type, which keeps things simple; later modules show how to share one value without copying it. (auto in the tests lets the compiler work out a variable’s type.)
  9. 1. What a data structure is · What a C++ value takes up · QuizWhat does a std::vector<Book> of three books hold, in its own memory?B. The three books themselves, side by sideIn C++ a vector of structs stores the structs themselves, one after another. (Some languages, JavaScript and Java among them, store addresses of objects kept elsewhere instead.) So putting a book in two vectors makes two copies, and changing one does not change the other. When you do want to share one value, C++ has references and pointers, which come later in the course.
  10. 2. Arrays · Reading by index is one sum · QuizAn array of doubles starts at address 2000, and each double is 8 bytes. Where is index 5?B. 20402000 + 5 × 8 = 2040. The tempting 2048 counts six slots, as if indexes started at 1. Whatever the numbers, it is one multiplication and one addition: no searching. The compiler writes that sum for you every time you write a[i].
  11. 2. Arrays · std::array is a C array that knows its size · QuizWhat is sizeof(std::array<double, 10>), if a double is 8 bytes?C. 80Ten slots of 8 bytes, side by side, with nothing else: 80 bytes. size() would say 10, the number of values; sizeof counts bytes. The values never change the size, because the type alone decides how many bytes each slot takes.
  12. 2. Arrays · Passing a vector: a copy, a reference, or a const reference · Fill the blanksOne function reads a playlist, one changes it. Give each the right parameter type, so neither copies it.1: const std::vector<int>&, 2: std::vector<int>&longest only reads, so const std::vector<int>&: no copy, and the compiler stops it from changing the list by mistake. addSong must change the caller’s list, so a plain reference. Passing by value (std::vector<int>) would work for longest but copy every value on each call, and would make addSong change only its own copy.
  13. 2. Arrays · Try it: remove from the middle · Code challengeFinish removeAt(items, index) without erase: shift the later values left, drop the last slot with pop_back(), and return the value removed.One solution:
    Solution
    #include <string>#include <vector>
    std::string removeAt(std::vector<std::string>& items, int index) {  std::string removed = items[index];  for (int i = index; i < items.size() - 1; i++) {    items[i] = items[i + 1];  }  items.pop_back();  return removed;}
    Here you walk left to right, the opposite of inserting: each slot copies its right neighbour, so values are read before they are overwritten. The loop stops one short of the end, because the last slot has no right neighbour; pop_back() drops it instead. Removing at index 0 moves every other value, which is why removing from the front of a big vector is slow. (using List = … in the tests gives a long type a short name.)
  14. 2. Arrays · A dynamic array grows by doubling · Put in orderPut the steps of a push_back into a full vector in order.1. See that size equals capacity 2. Ask the heap for a block with twice the capacity 3. Copy every value into the new block 4. Put the new value in the next free slot 5. Add one to the size, and give the old block backThe new block must exist before anything can go into it, because the old block has no room, and the old block can only be given back once every value has been copied out of it. Only the size changes on an ordinary push; the capacity changes only when the block is replaced.
  15. 2. Arrays · Try it: build a dynamic array · Code challengeFinish push: when size equals capacity, double the capacity, copy the values into a new row (adding to copies), then store the value.One solution:
    Solution
    #include <vector>
    struct DynamicArray {  int capacity = 1;  int size = 0;  int copies = 0;  std::vector<int> slots = std::vector<int>(1);
      void push(int value) {    if (size == capacity) {      capacity *= 2;      std::vector<int> bigger(capacity);      for (int i = 0; i < size; i++) bigger[i] = slots[i];      copies += size;      slots.swap(bigger);   // keep the new row, drop the old    }    slots[size] = value;    size++;  }
      int get(int index) const { return slots[index]; }};
    The check comes first: grow, then store. Copying size values each time the row fills gives 1 + 2 + 4 + … + 512 = 1023 copies for 1000 pushes. Growing by a fixed amount instead (say 10 more slots each time) would copy about 50 000 values for the same pushes, because the copies would no longer get rarer. If you run the starter, the second push stops the program with "vector[] index out of bounds": this page’s C++ checks every [], so writing past the end is caught instead of quietly spoiling other memory. (const after get() promises it changes nothing.)
  16. 2. Arrays · Which std::vector operations are cheap · QuizWhat does this print, and which lines move the most values?B. cook 3; insert and erase at the front each move every valueinsert at begin() puts shop at the front (moving cook and eat right), push_back adds at the end with no moves, and erase takes shop off the front again (moving the rest left), so cook is first and three tasks remain. With three tasks the moves do not matter; with a million they would.
  17. 2. Arrays · A grid is an array of arrays · Find the bugOn a 3 × 4 grid, booking row 1, column 2 marks the wrong seat. Which line is the cause?Line 5: flat[row * cols + col] = 'X';Each row is cols cells long, so row 1 starts cols = 4 cells in: the index is row * cols + col, which is 6. Multiplying by rows (3) gives 5, a seat in the wrong column. On a square grid the bug would hide, because rows and columns are equal.
  18. 3. Measuring cost · Count steps, not seconds · QuizAbout how many times does the inner line run, for n guests?C. n × nFor each of the n guests, the inner loop runs n times: n × n in all. Ten guests is 100 greetings; a thousand guests is a million. A loop inside a loop multiplies; two loops one after the other would only add, giving 2 × n.
  19. 3. Measuring cost · Big O keeps only how the cost grows · Fill the blanksSimplify each count to its Big O.1: n, 2: n², 3: 1Constants and smaller terms go, because Big O describes the shape of growth. 5n + 100 is still a straight line; 2n² + 3n + 1 is still a curve that is quadrupled when n doubles; 42 never changes.
  20. 3. Measuring cost · O(log n): halve the problem each step · QuizWhy does binary search need the array to be sorted?B. So one comparison with the middle tells it which half cannot hold the targetIf the middle value is smaller than the target and the array is sorted, everything to its left is smaller too, so that half can be thrown away. Unsorted, the target could be anywhere, and you are back to linear search.
  21. 3. Measuring cost · Try it: count the halvings · Code challengeFinish binarySearch(sorted, target): return a Result with the target’s index (or -1) and how many middle values were compared, looks.One solution:
    Solution
    #include <vector>
    struct Result {  int index;  int looks;};
    Result binarySearch(const std::vector<int>& sorted, int target) {  int low = 0;  int high = sorted.size() - 1;  int looks = 0;  while (low <= high) {    int mid = low + (high - low) / 2;    looks++;    if (sorted[mid] == target) return Result{mid, looks};    if (sorted[mid] < target) low = mid + 1;    else high = mid - 1;  }  return Result{-1, looks};}
    The two + 1 and - 1 matter: the middle has already been checked, so it is left out of the next half. Writing low = mid instead can loop forever once low and high are next to each other. The last test is the point of the module: 20 looks for a million values, where linear search could take a million.
  22. 3. Measuring cost · Loops inside loops multiply · Code challengeFinish hasDuplicate(nums): return true if any number appears twice. Sort the copy it is given and compare neighbours, so a list of 100 000 numbers is quick.One solution:
    Solution
    #include <algorithm>#include <vector>
    bool hasDuplicate(std::vector<int> nums) {  std::sort(nums.begin(), nums.end());  for (int i = 1; i < nums.size(); i++) {    if (nums[i] == nums[i - 1]) return true;  }  return false;}
    std::sort(nums.begin(), nums.end()) (from <algorithm>) sorts the whole vector, smallest first, in O(n log n). The parameter is a plain std::vector<int>, not a reference, on purpose: the function gets its own copy, so sorting it leaves the caller’s list alone, which is what the third test checks. Try the every-pair version: 100 000 numbers means about 5 billion comparisons, and the test runs out of time.
  23. 3. Measuring cost · Best, worst and average cases · QuizWhat is the worst case for binary search on n sorted values?B. O(log n)Even when the target is missing, each look halves what is left, so it takes about log₂ n looks before nothing remains. O(1) is its best case: the target happens to be in the middle.
  24. 3. Measuring cost · Space cost: the memory it needs · Fill the blanksReverse the array in place: swap the ends, then move both inwards.1: 1, 2: <, 3: ++, 4: --kept holds one value while the other is copied over it: that single variable is all the extra space. The loop stops when the two indexes meet in the middle, so each pair is swapped once: n / 2 swaps, O(n) time, O(1) extra space. The vector comes in by reference (&), so it is the caller’s list that is reversed. (The standard library has this as std::reverse(a.begin(), a.end()), and the three middle lines as std::swap(a[left], a[right]).)
  25. 3. Measuring cost · Amortised cost: the average over a whole run · QuizAn array grows by 10 slots each time it is full, instead of doubling. What does push cost, amortised?C. O(n)A copy happens every 10 pushes, and each copy is as big as the array: 10 + 20 + 30 + … up to n is about n² / 20 copies for n pushes. Divided by n pushes, that is O(n) each. Doubling works because the gaps between copies grow with the array.
  26. 3. Measuring cost · Recap · Put in orderPut these from the slowest-growing to the fastest-growing.1. O(1) 2. O(log n) 3. O(n) 4. O(n log n) 5. O(n²)At n = 1 000 000 they are roughly 1, 20, a million, 20 million and a million million. Moving one row up this list is often the whole difference between code that copes and code that does not.
  27. 4. Strings · A string is a sequence of characters · Quizstd::string s = "bakery"; What is the last character, and how do you read it?B. s[5] or s.back(), which is ybakery has 6 characters, at indexes 0 to 5, so the last is s[5], s[s.size() - 1] or s.back(). s[6] is one past the end: a std::string keeps an invisible null character ('\0', the byte 0) there, a habit from the C language, so you would read that instead of an error. Anything further stops the program here, as with vectors.
  28. 4. Strings · A std::string can change in place · Find the bugshout should turn every letter into a capital, but returns the text unchanged. Which line is wrong?Line 5: for (char& c : text) {for (char c : text) gives c a copy of each character, so line 6 changes the copy and the string stays as it was. char& c makes c a reference to the character inside the string, so the change lands there. (std::toupper, from <cctype>, gives the capital of a letter and leaves other characters alone.)
  29. 4. Strings · Building a long string piece by piece · QuizWhat does this print?B. bread, milk, eggs, The loop adds ", " after every item, the last one included, so the text ends with a stray comma and space. To put a separator only between items, add it before each item except the first (if (!out.empty()) out += ", ";), or cut the last two characters off at the end.
  30. 4. Strings · What common string operations cost · QuizWhat does this print?A. 3 tea 4The first comma is at index 3 (t, e, a are 0 to 2). substr(0, 3) copies three characters, tea, not including the comma. find(',', 4) starts looking at index 4, which is the second comma, right next to the first: the empty text between them is where a CSV has an empty field.
  31. 4. Strings · std::string_view looks at text without copying it · QuizA function takes a 1 MB string and returns its first 10 characters. Which costs the least?C. Take std::string_view, return text.substr(0, 10) as a std::string_viewTaking a std::string by value copies the whole megabyte first. A const std::string& avoids that, and copying 10 characters out is cheap. A string_view in and out copies nothing at all: but the returned view is only good while the caller’s string lives, which is the price of free.
  32. 4. Strings · Try it: reverse a string · Code challengeFinish reverse(s) with a loop from the last index down to 0, adding each character to out with +=.One solution:
    Solution
    #include <string>
    std::string reverse(const std::string& s) {  std::string out;  for (int i = s.size() - 1; i >= 0; i--) {    out += s[i];  }  return out;}
    The loop starts at s.size() - 1, the last index, and runs while i >= 0, so index 0 is included. Keep i an int: s.size() is a size_t, a whole number that is never negative, so size_t i would make i >= 0 always true, and 0 - 1 would wrap round to a huge number. The library version reverses in place: std::reverse(s.begin(), s.end()) from <algorithm>.
  33. 4. Strings · Two pointers walk in from both ends · Code challengeFinish isPalindrome(s), ignoring case, spaces and punctuation. letters already holds only the lower-case letters and digits; compare from both ends.One solution:
    Solution
    #include <cctype>#include <string>
    bool isPalindrome(const std::string& s) {  std::string letters;  for (unsigned char c : s) {    if (std::isalnum(c)) letters += std::tolower(c);  }  int left = 0;  int right = letters.size() - 1;  while (left < right) {    if (letters[left] != letters[right]) return false;    left++;    right--;  }  return true;}
    Return false the moment a pair differs, and true only once the pointers meet, since every pair has then matched. left < right stops at the middle: an odd-length word’s centre character has no partner and cannot spoil it. std::isalnum (is it a letter or digit?) and std::tolower come from <cctype>; they want an unsigned char, which is why the loop reads the bytes that way.
  34. 4. Strings · Counting characters with an array of 256 · Code challengeFinish isAnagram(a, b): true when b uses exactly the same letters as a, as many times each. Count a’s letters up, then count b’s down.One solution:
    Solution
    #include <array>#include <string>
    bool isAnagram(const std::string& a, const std::string& b) {  if (a.size() != b.size()) return false;  std::array<int, 256> counts{};  for (unsigned char c : a) counts[c]++;  for (unsigned char c : b) {    if (counts[c] == 0) return false;    counts[c]--;  }  return true;}
    When b asks for a letter whose count is already 0, it cannot be an anagram. Because the sizes match, if every letter of b found a count to take from, every count ends at 0. Read the bytes as unsigned char: a plain char may be signed (−128 to 127), so a byte above 127, such as part of é, would be a negative index and stop the program. Sorting both strings and comparing also works, but costs O(n log n) instead of O(n).
  35. 4. Strings · Characters, bytes and UTF-8 · QuizWhat goes wrong with reverse("ok👍") from the earlier slide?B. The emoji’s four bytes come out in the wrong order, giving broken textIndexing walks bytes, so the four bytes of 👍 come out backwards and no longer make a character: the text is no longer valid UTF-8, and is shown as �. Reversing text people can read means first splitting it into characters, which the standard library does not do for you.
  36. 5. Linked lists · A pointer holds an address · QuizWhat does this print?B. 2 1b = *p copies the value 1 into b, which is its own box. Then *p = 2 writes through the pointer into a. So a is 2 and b stays 1. A pointer is like a reference you can re-aim and that can be empty: a reference always names one variable, for good.
  37. 5. Linked lists · A node is a small struct · QuizWith the nodes above, what is head->next->next->value?C. "Outro"head points at Intro, head->next at Waves, and its next at Outro. One more ->next would be nullptr, and reading ->value through that would dereference nullptr: undefined behaviour, not an answer. To reach the k-th node you follow k pointers: reading by position is O(n), not O(1) as in an array.
  38. 5. Linked lists · Walking the list from the head · Fill the blanksCount the nodes.1: head, 2: nullptr, 3: ->The same three parts as every traversal: start at the head, stop at nullptr, step with ->next (-> because node is a pointer). That is why list classes usually keep a size member instead: counting is O(n), reading a stored number is O(1).
  39. 5. Linked lists · Nodes live on the heap, until someone frees them · Find the bugThis frees every node, but reads memory it has already given back. Which line is wrong?Line 5: head = next;After delete head, the node is gone, so head->next reads freed memory: undefined behaviour, which may work today and fail tomorrow. Line 3 already saved the next pointer for exactly this reason; line 5 should use it. Bugs like this are why modern C++ rarely writes delete by hand, as the next slides show.
  40. 5. Linked lists · A class looks after its own parts · Put in orderPut what the program above prints in order.1. made Road trip 2. 385 3. made Short 4. gone Short 5. end of main 6. gone Road tripquick lives only inside its own { }, so it is made and destroyed before end of main. trip lives until main ends, after its last line: its destructor runs then. Destructors always run when a variable’s scope ends, which is what makes RAII dependable.
  41. 5. Linked lists · std::unique_ptr owns what it points at · QuizWhich of these does not compile?B. std::unique_ptr<Node> b = a;Copying would leave two owners, and both would delete the same node. So a unique_ptr has no copy: you must say std::move, after which a is empty. A plain Node* from .get() is fine, because a plain pointer never deletes anything; it just must not outlive the owner.
  42. 5. Linked lists · Try it: a list class with a tail · Code challengeFinish append(value) on LinkedList: add a node at the end in O(1) using tail, handling the empty list, and add 1 to size.One solution:
    Solution
    #include <memory>#include <string>
    struct Node {  std::string value;  std::unique_ptr<Node> next;  Node(const std::string& v) : value(v) {}};
    class LinkedList {public:  std::unique_ptr<Node> head;  Node* tail = nullptr;  int size = 0;
      ~LinkedList() { while (head) head = std::move(head->next); }
      void prepend(const std::string& value) {    auto node = std::make_unique<Node>(value);    node->next = std::move(head);    head = std::move(node);    if (tail == nullptr) tail = head.get();    size++;  }
      void append(const std::string& value) {    auto node = std::make_unique<Node>(value);    Node* added = node.get();    if (tail == nullptr) head = std::move(node);    else tail->next = std::move(node);    tail = added;    size++;  }};
    Keep a plain pointer to the new node (added) before moving it: after std::move(node), node is empty. Then link the old tail to it before moving tail, or you lose your only way to reach the old tail. In an empty list, tail is nullptr, so the new node becomes the head instead. prepend shows the mirror case: the first node added is also the tail. The destructor frees the nodes in a loop, as the last slide explained.
  43. 5. Linked lists · Removing a node means changing the one before it · Find the bugIn a list class with a tail, removing the last node leaves things broken. Which line is missing a step?Line 4: if (gone == tail) { tail = previous; } previous->next = std::move(gone->next);If gone was the tail, tail still points at the removed node, which line 4 has just freed: a dangling pointer. The next append writes into freed memory, which is undefined behaviour. Whenever a node is removed, ask whether it was the head or the tail.
  44. 5. Linked lists · Try it: remove every match · Code challengeFinish removeAll(head, value): remove every node holding value. Walk from the sentinel with previous, unlinking as you go.One solution:
    Solution
    #include <memory>#include <string>
    struct Node {  std::string value;  std::unique_ptr<Node> next;  Node(const std::string& v) : value(v) {}};
    void removeAll(std::unique_ptr<Node>& head,               const std::string& value) {  Node sentinel("");  sentinel.next = std::move(head);  Node* previous = &sentinel;  while (previous->next != nullptr) {    if (previous->next->value == value) {      previous->next = std::move(previous->next->next);    } else {      previous = previous->next.get();    }  }  head = std::move(sentinel.next);}
    Only move previous forward when you did not remove: after a removal, its new next has not been checked yet, and it might match too (the "side by side" test). Handing sentinel.next back to head at the end matters when the head itself was removed. The sentinel lives on the stack, so it vanishes when the function returns, with no delete.
  45. 5. Linked lists · A doubly linked list points both ways · QuizYou hold a pointer to a node in the middle of a long list. Removing it is O(1) in which list?B. Doubly linkedRemoving means changing the node before it. A doubly linked node knows it (prev); a singly linked one does not, so you would walk from the head to find it: O(n). This is why the LRU cache later in the course uses a doubly linked list.
  46. 6. Stacks · A vector’s end is a ready-made top · QuizWhat does this print?A. 1 3Push 1, push 2, pop takes the 2, push 3, push 4, pop takes the 4: 1 3 is left, with 3 on top. Each pop takes whatever was pushed most recently, not what was pushed first.
  47. 6. Stacks · A template writes code once, for any type · Fill the blanksMake Box hold one value of any type.1: typename, 2: T, 3: inttemplate <typename T> opens the type; inside, T is used wherever the type goes. Box<int> chooses int for T. (class works in place of typename there too; they mean the same.) Templates are why the standard library’s containers hold any type, and still run as fast as code written by hand for one.
  48. 6. Stacks · Try it: a stack class · Code challengeFinish the class template Stack<T> on a private vector: push, pop and peek (both give std::nullopt when empty), size() and empty().One solution:
    Solution
    #include <optional>#include <vector>
    template <typename T>class Stack {public:  void push(const T& value) { items.push_back(value); }
      std::optional<T> pop() {    if (items.empty()) return std::nullopt;    T top = items.back();    items.pop_back();    return top;  }
      std::optional<T> peek() const {    if (items.empty()) return std::nullopt;    return items.back();  }
      int size() const { return items.size(); }  bool empty() const { return items.empty(); }
    private:  std::vector<T> items;};
    Check items.empty() before back(): on an empty vector, back() has nothing to give, and this page stops the program (elsewhere it is undefined behaviour). Copy the top out before pop_back(), because afterwards it is gone. Using the vector’s front with insert and erase would also make a stack, but every operation would move the whole vector.
  49. 6. Stacks · A stack on a linked list · Fill the blanksPush onto, and pop from, the head of a list.1: top, 2: nullptr, 3: nextThe new node takes over the old top as its next, then becomes the top. Popping copies the value, then moves the top down one node; the old node loses its owner and is deleted there and then. Check for an empty stack first, or top->value follows a null pointer.
  50. 6. Stacks · The call stack keeps track of running functions · Put in ordercheckout calls total, which calls tax. Put what happens to the call stack in order.1. Push a frame for checkout 2. Push a frame for total 3. Push a frame for tax 4. Pop tax’s frame as it returns 5. Pop total’s frame as it returns 6. Pop checkout’s frameThe last function called is the first to finish: LIFO. That is why a debugger’s stack trace (or backtrace) lists the functions innermost first: it is a printout of the frames on the call stack at that moment.
  51. 6. Stacks · Undo and redo are two stacks · Find the bugAfter undo then a new edit, redo brings back text that no longer fits. Which line is missing a step?Line 2: undos.push_back(text); redos.clear();A new edit starts a new history, so the redo stack must be emptied. Otherwise redo would replace the new text with a state from the abandoned branch. redos.clear() empties a vector in place (its capacity stays, ready for reuse).
  52. 6. Stacks · Matching brackets with a stack · Code challengeFinish isBalanced(text): true when every ), ] and } closes the most recent unclosed opener of its kind, and nothing is left open. Other characters are ignored. partnerOf gives a closer’s opener.One solution:
    Solution
    #include <stack>#include <string>
    char partnerOf(char closer) {  if (closer == ')') return '(';  if (closer == ']') return '[';  return '{';}
    bool isBalanced(const std::string& text) {  std::stack<char> open;  for (char ch : text) {    if (ch == '(' || ch == '[' || ch == '{') {      open.push(ch);    } else if (ch == ')' || ch == ']' || ch == '}') {      if (open.empty() || open.top() != partnerOf(ch)) return false;      open.pop();    }  }  return open.empty();}
    open.empty() || must come first: || stops as soon as one side is true, so top() is never called on an empty stack, which would be undefined behaviour. The final open.empty() catches openers never closed. Counting each kind of bracket is the tempting shortcut, and ([]{)} shows why it fails: the counts match, the order does not. Compilers check your code’s brackets this way.
  53. 7. Queues · A queue: first in, first out · QuizEnqueue Ada, Bo and Cy; dequeue once; enqueue Di; dequeue once. Who is at the front now?C. CyThe first dequeue serves Ada, the second serves Bo, so Cy is at the front with Di behind. A stack would have served the most recent arrivals instead: Cy, then Di.
  54. 7. Queues · Try it: a queue with a head index · Code challengeFinish Queue<T>: dequeue() returns the front value (or std::nullopt when empty) by moving head, without erase; size() counts what is left.One solution:
    Solution
    #include <optional>#include <vector>
    template <typename T>class Queue {public:  void enqueue(const T& value) { items.push_back(value); }
      std::optional<T> dequeue() {    if (head == items.size()) return std::nullopt;    T value = items[head];    head++;    return value;  }
      int size() const { return items.size() - head; }
    private:  std::vector<T> items;  int head = 0;};
    The queue is empty when head has caught up with items.size(); checking that first stops head running past the end, which this page would stop with an error. The starter’s erase gives the right answers but moves every value on each call: 200 000 people means about 20 billion moves, and the last test runs out of time. Dropping dead slots, as the previous slide says, is the next step for a queue that runs for ever.
  55. 7. Queues · A ring buffer reuses its slots · Fill the blanksWork out where the next value goes in a ring of capacity 5.1: %, 2: 03 + 2 = 5, and 5 divided by 5 leaves a remainder of 0, so the next value wraps round to slot 0. % keeps any index between 0 and capacity - 1. Advancing the head on dequeue wraps the same way: head = (head + 1) % capacity.
  56. 7. Queues · Try it: a ring buffer · Code challengeFinish enqueue (put the value at (head + count) % capacity; return false if full) and dequeue (take from head, wrap it, or std::nullopt if empty).One solution:
    Solution
    #include <optional>#include <vector>
    template <typename T>class RingBuffer {public:  RingBuffer(int capacity) : slots(capacity) {}  int count = 0;
      bool enqueue(const T& value) {    int capacity = slots.size();    if (count == capacity) return false;    slots[(head + count) % capacity] = value;    count++;    return true;  }
      std::optional<T> dequeue() {    if (count == 0) return std::nullopt;    T value = slots[head];    head = (head + 1) % slots.size();    count--;    return value;  }
    private:  std::vector<T> slots;  int head = 0;};
    Keeping count rather than a second "tail" index avoids the classic ambiguity: with only head and tail, head == tail could mean empty or full. The constructor makes all the slots at once, slots(capacity), and the ring never grows. A fixed size known while writing the code can even be a template parameter, template <typename T, int N> with a std::array<T, N> inside, so the ring needs no heap at all.
  57. 7. Queues · A deque works at both ends · QuizA music app keeps your last 50 songs: each new song goes at the front, and the oldest falls off when there are more than 50. What fits best?B. A std::dequeYou add at one end and drop at the other, while also reading the newest: a deque does all of it in O(1). The vector works, but inserting at the front moves all 50 songs each time. A stack only works at one end, so it cannot drop the oldest.
  58. 7. Queues · Queues everywhere · Put in orderIn a program with an event loop, a button is clicked while a long function is running. Put what happens in order.1. The long function keeps running 2. The click waits in the event queue 3. The function returns to the event loop 4. The loop dequeues the click 5. The click handler runsAn event loop handles one event at a time. A click cannot interrupt running code; it joins the queue and is taken, first in first out, when the loop is free again. That is why a long calculation freezes a window: nothing in the queue can run.
  59. 8. Hash tables · A simple hash function for strings · QuizWhy would std::rand() % size, a random number, be a useless hash function?C. The same key would land in a different bucket each time, so get could not find what set storedRandom numbers spread evenly, but a hash function’s first job is to be deterministic: the look-up must recompute the bucket the value was stored in. Evenness is the second job.
  60. 8. Hash tables · Try it: a hash map with chaining · Code challengeFinish set and get. Both find the key’s bucket with hash(key, buckets.size()) and search only that bucket.One solution:
    Solution
    #include <optional>#include <string>#include <utility>#include <vector>
    int hash(const std::string& key, int size) {  unsigned h = 0;  for (unsigned char ch : key) h = (h * 31 + ch) % size;  return h;}
    template <typename V>class HashMap {public:  using Entry = std::pair<std::string, V>;  std::vector<std::vector<Entry>> buckets =      std::vector<std::vector<Entry>>(8);
      void set(const std::string& key, const V& value) {    auto& bucket = buckets[hash(key, buckets.size())];    for (Entry& entry : bucket) {      if (entry.first == key) {        entry.second = value;        return;      }    }    bucket.push_back({key, value});  }
      std::optional<V> get(const std::string& key) const {    const auto& bucket = buckets[hash(key, buckets.size())];    for (const Entry& entry : bucket) {      if (entry.first == key) return entry.second;    }    return std::nullopt;  }};
    Comparing the key inside the bucket is essential: 100 keys in 8 buckets means about 12 per bucket, so the bucket alone does not say which entry is yours. auto& bucket is a reference to the bucket inside the table, so push_back adds to the table and not to a copy; in get, const auto& reads without copying. Forgetting to check for an existing key in set leaves two entries for tea, which the third test catches.
  61. 8. Hash tables · Deleting an entry · Code challengeFinish erase(key): remove the key’s entry from its bucket (copy the last entry over it, then pop_back()), lower size, and return true; return false if the key was not there.One solution:
    Solution
    #include <functional>#include <string>#include <utility>#include <vector>
    class HashMap {public:  using Entry = std::pair<std::string, double>;  int size = 0;  std::vector<std::vector<Entry>> buckets =      std::vector<std::vector<Entry>>(8);
      std::vector<Entry>& bucketFor(const std::string& key) {    return buckets[std::hash<std::string>{}(key) % buckets.size()];  }
      void set(const std::string& key, double value) {    for (Entry& entry : bucketFor(key)) {      if (entry.first == key) { entry.second = value; return; }    }    bucketFor(key).push_back({key, value});    size++;  }
      bool erase(const std::string& key) {    std::vector<Entry>& bucket = bucketFor(key);    for (int i = 0; i < bucket.size(); i++) {      if (bucket[i].first == key) {        bucket[i] = bucket.back();        bucket.pop_back();        size--;        return true;      }    }    return false;  }};
    This table uses the standard library’s hash function, std::hash<std::string>{}(key) (from <functional>): it makes a hasher with {} and calls it. bucketFor returns a reference (std::vector<Entry>&) to the bucket inside the table, so changes through it change the table. Copying the last entry over the one to delete, then pop_back(), is O(1) after the search; when the entry is the last one, it is copied onto itself and then dropped, which still works. Lowering size only when the key was found keeps it honest: deleting a missing key must change nothing.
  62. 8. Hash tables · Deleting an entry · Find the bugThis table grows when it gets full. Afterwards, get misses keys that are there. Which line is wrong?Line 12: int i = hash(key, buckets.size());After grow, there are 16 buckets and keys were rehashed into them, but get still computes the bucket for 8. Any key whose bucket changed is looked for in the wrong place. Every operation must hash with the current number of buckets.
  63. 8. Hash tables · Load factor and resizing · QuizResizing copies every entry: O(n). Why is set still called O(1)?B. Because the table doubles, resizes get rarer as it grows, so the cost is O(1) amortisedIt is the vector argument again: doubling means n inserts cause at most about 2n rehashes in total. Each one is O(1) amortised. Every key must be rehashed, because its bucket depends on the number of buckets.
  64. 8. Hash tables · Average O(1), worst case O(n) · QuizWith this hash, where do the keys tea, jam and bun go?B. All into bucket 3All three keys are 3 letters long, so all land in bucket 3, and the table is just a slow list. A hash must look at the content of the key, not only its size: that is what multiplying in each byte does.
  65. 9. Maps and sets · Four containers for looking things up · QuizA leaderboard must list players by name, A to Z, and find any player quickly. Which fits?B. std::map<std::string, int>You need a value per player (the score), so a map, and you need names in order, so the sorted one: std::map walks its keys A to Z, and finds any name in O(log n), about 20 steps for a million players. The unordered map finds faster, but its order is a jumble.
  66. 9. Maps and sets · std::unordered_map: a ready-made hash table · Fill the blanksRecord a sale: take one scone out of stock, and drop the entry when none are left.1: find, 2: end, 3: erasefind hashes once and hands back where the entry is, so the rest uses it without looking the key up again. Comparing with end() is how C++ says "found". erase accepts the iterator itself. stock["scone"]-- would also work, but it would create a scone entry (at 0, then −1) if there was none, as the next slide shows.
  67. 9. Maps and sets · m[key] adds the key if it is missing · QuizWhat does this print?B. no pie, no jam, 2prices["pie"] added pie with the price 0, which is why the test said "no pie", and left it in the map. count("jam") only asked. So there are two entries: tea and pie. In a real shop, that stray pie would now appear on the menu at no cost.
  68. 9. Maps and sets · Sets: each value once · Code challengeFinish inBoth(a, b): the titles on both reading lists, in a’s order, each once. Put b in an unordered_set so each check is O(1), and use another set to skip titles already added.One solution:
    Solution
    #include <string>#include <unordered_set>#include <vector>
    std::vector<std::string> inBoth(const std::vector<std::string>& a,                                const std::vector<std::string>& b) {  std::unordered_set<std::string> inB(b.begin(), b.end());  std::unordered_set<std::string> added;  std::vector<std::string> result;  for (const std::string& title : a) {    if (inB.contains(title) && added.insert(title).second) {      result.push_back(title);    }  }  return result;}
    With std::find(b.begin(), b.end(), title) instead of a set, each check would scan all of b: O(n × m) for the whole thing, over a billion steps for the last test. A set makes it O(n + m). added.insert(title).second does two jobs at once: it records the title, and says whether it was new. && only runs it when the title is in b.
  69. 9. Maps and sets · Grouping and counting with a map · Find the bugEvery count comes out one too high. Which line is wrong?Line 5: if (!counts.contains(word)) counts[word] = 0;A new word is set to 1, then line 6 adds another 1, so it starts at 2. Since counts[word] already starts a missing word at 0, the whole if can go: counts[word]++ alone is the usual way to count in C++.
  70. 9. Maps and sets · Grouping and counting with a map · Code challengeFinish byAuthor(books): a std::map from each author to their titles, in the order the books came.One solution:
    Solution
    #include <map>#include <string>#include <vector>
    struct Book {  std::string title;  std::string author;};
    std::map<std::string, std::vector<std::string>>byAuthor(const std::vector<Book>& books) {  std::map<std::string, std::vector<std::string>> groups;  for (const Book& book : books) {    groups[book.author].push_back(book.title);  }  return groups;}
    The first time an author is seen, groups[book.author] makes an empty vector for them; every time, push_back adds the title to the vector inside the map, because [] returns a reference to it. Being a std::map, the authors come out sorted for free. With std::unordered_map the groups would be the same, in a jumbled order.
  71. 9. Maps and sets · A sorted map answers "what comes next?" · QuizWhat does this print?B. 11 BoThere is no booking at 10, and lower_bound does not need one: it finds the first key that is not less than 10, which is 11. lower_bound(15) would give bookings.end(), meaning "none", so check that before reading ->first. An unordered_map has no lower_bound at all: its keys are in no order to search.
  72. 10. Trees · Depth, height and subtrees · QuizA Leek recipe gets two variations as its children: Leek and potato, Leek and pea. What is the cookbook’s height now?B. 3The new nodes sit one edge below Leek, which was at depth 2, so they are at depth 3, and the deepest leaf sets the height: 3. Seven is the number of nodes before the change, a different measure.
  73. 10. Trees · Trees are everywhere · QuizWhich of these is not a tree?C. Roads between towns, where you can drive round in a loopIn a tree, each node has one parent, so there is only one path between any two nodes and never a loop. Roads form loops and have no root: that is a graph, the more general shape covered later in the course. Every tree is a graph; not every graph is a tree.
  74. 10. Trees · A tree node holds a vector of children · Fill the blanksAdd a Pancakes recipe under Baking, and count Baking’s recipes.1: children, 2: push_back, 3: sizeChildren are an ordinary vector, so you index into it and push_back onto it. The & matters: TreeNode baking = … would copy Baking and its whole subtree, and Pancakes would be added to the copy, leaving the cookbook as it was.
  75. 10. Trees · Recursion: solve it for the children, then combine · Find the bugThis always returns 1. Which line is wrong?Line 4: total += countNodes(child);The recursive call works out each child’s count and returns it, and line 4 throws it away. A recursive call is an ordinary function call: its answer has to be used. Each call has its own total in its own frame, so the children cannot add to the parent’s by themselves.
  76. 10. Trees · Try it: leaves and height · Code challengeFinish countLeaves(node): a leaf counts 1; any other node’s count is the sum of its children’s counts.One solution:
    Solution
    #include <string>#include <vector>
    struct TreeNode {  std::string value;  std::vector<TreeNode> children;};
    int countLeaves(const TreeNode& node) {  if (node.children.empty()) return 1;  int total = 0;  for (const TreeNode& child : node.children) {    total += countLeaves(child);  }  return total;}
    Here the base case has to be written out, because a leaf’s answer (1) is not what the loop would give for no children (0). The parent counts nothing for itself, unlike countNodes: it only gathers its children’s answers.
  77. 10. Trees · Try it: leaves and height · Code challengeFinish height(node): the number of edges on the longest path down to a leaf. A leaf has height 0; any other node is 1 more than its tallest child. std::max(a, b) gives the larger of two.One solution:
    Solution
    #include <algorithm>#include <string>#include <vector>
    struct TreeNode {  std::string value;  std::vector<TreeNode> children;};
    int height(const TreeNode& node) {  int tallest = -1;  for (const TreeNode& child : node.children) {    tallest = std::max(tallest, height(child));  }  return tallest + 1;}
    Starting tallest at -1 makes a leaf come out as -1 + 1 = 0 with no special case; if (node.children.empty()) return 0; is just as good. Taking the maximum, not the sum, is the point: height is about the longest single path, not how many nodes there are.
  78. 10. Trees · Depth-first: preorder and postorder · Put in orderPut the cookbook’s nodes in preorder: root first, then each subtree left to right.1. Cookbook 2. Baking 3. Scones 4. Bread 5. Soups 6. Leek 7. IndexPreorder finishes all of Baking’s subtree (Scones, Bread) before moving to Soups. In postorder the root comes last: Scones, Bread, Baking, Leek, Soups, Index, Cookbook.
  79. 10. Trees · Breadth-first: level by level, with a queue · Code challengeFinish levels(root): a vector per depth, each holding that level’s values left to right. Work one level at a time: the next level is all the current level’s children.One solution:
    Solution
    #include <string>#include <vector>
    struct TreeNode {  std::string value;  std::vector<TreeNode> children;};
    std::vector<std::vector<std::string>> levels(const TreeNode& root) {  std::vector<std::vector<std::string>> result;  std::vector<const TreeNode*> level = {&root};  while (!level.empty()) {    std::vector<std::string> values;    std::vector<const TreeNode*> next;    for (const TreeNode* node : level) {      values.push_back(node->value);      for (const TreeNode& child : node->children) {        next.push_back(&child);      }    }    result.push_back(values);    level = next;  }  return result;}
    Each pass of the loop handles one whole level, so the queue is replaced by "this level" and "the next". The inner loop gathers every node’s children into next, in order. When a level has no children at all, next is empty and the loop ends. This is breadth-first search with the levels kept apart, the shape used to find shortest paths in graphs. The pointers stay valid because nothing changes the tree while they are in use.
  80. 10. Trees · When a tree is too deep for recursion · QuizIn the loop above, back() and pop_back() take the most recently added node. Which traversal does that give?B. Depth-firstA stack is last in, first out, so the children just pushed are visited before anything older: the walk dives deep first. A queue is first in, first out, so older nodes (shallower ones) go first: breadth-first. The only difference between the two traversals is the structure holding the waiting nodes.
  81. 11. Binary search trees · Smaller keys go left, larger keys go right · QuizA tree has root 50, whose left child is 30, and 30’s right child is 55. Is it a BST?B. No: 55 is in 50’s left subtree, so it must be smaller than 50The rule is about whole subtrees, not just parent and child. 55 sits below 50 on the left, so it must be smaller than 50, and it is not. Checking only each parent with its children is the classic mistake; you will meet it again at the end of this module.
  82. 11. Binary search trees · A node owns its two children · QuizThe variable root above goes out of scope while holding a tree of 7 nodes. What happens?B. All 7 nodes are freed: each unique_ptr deletes the node it owns, which destroys that node’s childrenDestroying a unique_ptr deletes its node, and deleting a node destroys its members, including its own two unique_ptrs. That chain reaches every node. delete root would not even compile: root is a unique_ptr, not a raw pointer.
  83. 11. Binary search trees · Insert walks down to an empty spot · Fill the blanksComplete the insert so smaller keys go left and larger ones go right.1: &, 2: make_unique, 3: <, 4: left, 5: rightThe & is the whole trick: node is the parent’s left or right, not a copy, so filling it links the new node into the tree. Without it the code does not even compile, because a unique_ptr cannot be copied: there can only be one owner.
  84. 11. Binary search trees · An in-order walk visits the keys in sorted order · Code challengeFinish collect(node, out): push every key of the tree onto out, smallest first. Visit the left subtree, then the node, then the right subtree. Node and insert are ready for you.One solution:
    Solution
    #include <memory>#include <vector>
    struct Node {  int key;  std::unique_ptr<Node> left, right;  explicit Node(int k) : key(k) {}};void insert(std::unique_ptr<Node>& node, int key) {  if (!node) { node = std::make_unique<Node>(key); return; }  if (key < node->key) insert(node->left, key);  else if (key > node->key) insert(node->right, key);}
    void collect(const Node* node, std::vector<int>& out) {  if (!node) return;  collect(node->left.get(), out);  out.push_back(node->key);  collect(node->right.get(), out);}
    The empty node is the base case that stops the recursion. Each call adds its left subtree’s keys, then its own, then its right subtree’s. Pushing node->key first gives a pre-order list instead, which is not sorted. Passing one out vector by reference down every call means no vector is ever copied; returning a fresh vector from each call and joining them would copy keys over and over.
  85. 11. Binary search trees · The smallest key is the leftmost, the largest the rightmost · Code challengeWrite floorKey(node, budget): the largest key less than or equal to budget, or std::nullopt if every key is larger. Walk down like a search, remembering the best key so far.One solution:
    Solution
    #include <memory>#include <optional>
    struct Node {  int key;  std::unique_ptr<Node> left, right;  explicit Node(int k) : key(k) {}};void insert(std::unique_ptr<Node>& node, int key) {  if (!node) { node = std::make_unique<Node>(key); return; }  if (key < node->key) insert(node->left, key);  else if (key > node->key) insert(node->right, key);}
    std::optional<int> floorKey(const Node* node, int budget) {  std::optional<int> best;  while (node) {    if (node->key == budget) return budget;    if (node->key < budget) {      best = node->key;      node = node->right.get();    } else node = node->left.get();  }  return best;}
    When a key fits the budget it is a candidate, and anything better must be larger, so you go right. When a key is too dear, only smaller keys can help, so you go left. The last candidate you saw is the answer. The tempting mistake is to stop at the first key that fits: for a budget of 65 that would be 50, but 60 is further down on the right. std::optional<int> holds an int or nothing, which says "no floor" more honestly than a made-up -1.
  86. 11. Binary search trees · Deleting a key has three cases · Put in orderPut the steps for deleting a node with two children in order.1. Search down the tree to the node with the key 2. Step once into its right subtree 3. Keep going left to find the smallest key there, the successor 4. Copy the successor’s key into the node being deleted 5. Delete the successor from the right subtree (it has at most one child)The successor is "one step right, then all the way left". Copying its key keeps the node and all its links in place, so only the successor’s old node really disappears, and that one is an easy case. The predecessor (largest on the left) works just as well.
  87. 11. Binary search trees · Delete in code · Find the bugThis version of the two-children case breaks the ordering rule. Which line is wrong?Line 2: while (next->right) next = next->right.get();Starting from the left child is fine, but then you want the largest key there, the predecessor, found by going right. Going left finds the smallest key of the whole left side, and copying it into the node leaves larger keys to its left. Either use the predecessor (left, then all the way right) or the successor (right, then all the way left).
  88. 11. Binary search trees · Sorted input makes a tree as slow as a list · QuizYou insert the titles of a bookshop’s catalogue, already in alphabetical order, into a plain BST. How tall is the tree for 1,000 books?C. 1,000Each new title is larger than every title before it, so it always goes right of the last one: a single chain of 1,000 nodes. Random order would give a height in the low 20s (about twice log₂ 1000 ≈ 10, because random trees are not perfectly balanced). Real data is often sorted, which is why balancing matters.
  89. 11. Binary search trees · Check a tree really is a BST · Code challengeFinish isBst(node, low, high): true when every key is strictly between low and high, and both subtrees are valid with tightened limits. tree and leaf build trees by hand for the tests.One solution:
    Solution
    #include <climits>#include <memory>struct Node {  int key;  std::unique_ptr<Node> left, right;};using Ptr = std::unique_ptr<Node>;Ptr tree(Ptr l, int key, Ptr r) {  return std::make_unique<Node>(Node{key, std::move(l), std::move(r)});}Ptr leaf(int key) { return tree(nullptr, key, nullptr); }
    bool isBst(const Node* node, long long low = LLONG_MIN,           long long high = LLONG_MAX) {  if (!node) return true;  if (node->key <= low || node->key >= high) return false;  return isBst(node->left.get(), low, node->key) &&         isBst(node->right.get(), node->key, high);}
    The limits carry what every ancestor demands. 55 under 30 passes a parent-and-child check, but it inherited the upper limit 50 from the root, so it fails here. Strict <= and >= reject duplicates; a tree that allows them must pick one side for equal keys and loosen that test. (using Ptr = … just gives the long type a short name.)
  90. 12. Heaps and priority queues · Each parent is smaller than its children, and that is all · QuizWhich of these is true of the min-heap above?B. The second-smallest item is always a child of the rootThe second smallest must be smaller than everything but the root, so it can only sit directly under the root. Level by level gives 1, 3, 8, 5, 4, 9: not sorted. And the largest item is somewhere among the leaves, but not necessarily the last one. A heap is only "a little sorted", which is exactly why it is cheap to keep.
  91. 12. Heaps and priority queues · A complete tree fits in a vector with no gaps · Fill the blanksWrite the index arithmetic for a heap stored from index 0.1: 1, 2: 1, 3: 2Each level doubles, so the children of i are at 2i + 1 and 2i + 2, and going back up undoes that: subtract one, halve. Dividing two whole numbers in C++ drops the remainder, so / 2 already rounds down. (std::size_t is the unsigned whole-number type the standard library uses for sizes and indexes.) Some books store the heap from index 1, where the formulas become 2i, 2i + 1 and ⌊i / 2⌋; mixing the two conventions is a common off-by-one bug.
  92. 12. Heaps and priority queues · Push adds at the end and sifts up · QuizThe heap is [1, 3, 8, 5]. What is it after heapPush(heap, 2)?C. [1, 2, 8, 5, 3]2 goes in at index 4, whose parent is index 1 (value 3). 2 is smaller, so they swap: [1, 2, 8, 5, 3]. Now at index 1, its parent is the root, 1, which is smaller, so it stops. The heap is not sorted, and it does not need to be.
  93. 12. Heaps and priority queues · Push adds at the end and sifts up · Code challengeFinish heapPush: after adding value at the end, sift it up until its parent is smaller or equal. Use std::swap.One solution:
    Solution
    #include <utility>#include <vector>
    void heapPush(std::vector<int>& heap, int value) {  heap.push_back(value);  std::size_t i = heap.size() - 1;  while (i > 0) {    std::size_t parent = (i - 1) / 2;    if (heap[parent] <= heap[i]) break;    std::swap(heap[parent], heap[i]);    i = parent;  }}
    Each pass compares the value with one parent and either stops or moves up a level, so a heap of a million items needs at most about 20 swaps. Using < instead of <= in the stop test also works, but swaps equal values for nothing. Forgetting i = parent stops after one swap: the next pass compares the same two places, now in order, and breaks, so a new value never climbs more than one level (the first two tests catch it). And watch the std::size_t: it cannot go below 0, so a loop written as i >= 0 would never end.
  94. 12. Heaps and priority queues · Pop takes the root and sifts the last item down · Put in orderPop from the heap [1, 3, 8, 5, 4]. Put what happens in order.1. Remember the root, 1, to return it 2. Take the last item, 4, off the end and put it at the root: [4, 3, 8, 5] 3. Its children are 3 and 8; 3 is smaller and less than 4, so swap: [3, 4, 8, 5] 4. At index 1, its only child is 5, which is larger: stop 5. Return 1The shape stays complete because only the last slot disappears. Swapping with the smaller child matters: swapping 4 with 8 would put 8 above 3, breaking the heap property. Like push, it is one swap per level at most: O(log n).
  95. 12. Heaps and priority queues · Pop takes the root and sifts the last item down · Code challengeFinish siftDown(heap, i): while the item at i is larger than its smaller child, swap them and continue from the child. heapPop uses it.One solution:
    Solution
    #include <optional>#include <utility>#include <vector>
    void siftDown(std::vector<int>& heap, std::size_t i) {  while (true) {    std::size_t l = 2 * i + 1, r = l + 1, small = i;    if (l < heap.size() && heap[l] < heap[small]) small = l;    if (r < heap.size() && heap[r] < heap[small]) small = r;    if (small == i) return;    std::swap(heap[i], heap[small]);    i = small;  }}std::optional<int> heapPop(std::vector<int>& heap) {  if (heap.empty()) return std::nullopt;  int top = heap[0];  heap[0] = heap.back();  heap.pop_back();  if (!heap.empty()) siftDown(heap, 0);  return top;}
    small starts as the node itself and becomes whichever child is smaller still; when neither child beats it, the item has found its level. The l < heap.size() checks matter in C++: reading heap[l] past the end is not a harmless "nothing", it is a bug that this runner stops with "index out of bounds" (and that an unchecked build would silently read garbage for). Popping every item in turn gives them sorted: that is heap sort.
  96. 12. Heaps and priority queues · Heaps schedule work and find the top k · QuizTo keep the 10 largest values of a long stream, which heap do you keep, and of what size?B. A min-heap of size 10The root of a size-10 min-heap is the weakest of your current top 10, the one to throw out when something better comes. Each new value is compared with it in O(1) and, if larger, replaces it in O(log 10). A max-heap of everything works but costs O(n) memory; a max-heap of size 10 puts the wrong item at the root.
  97. 12. Heaps and priority queues · The standard library has a heap: std::priority_queue · QuizWhat does this print?C. 7With no comparison given it is a max-heap: the top was 9, pop threw it away, and the next largest is 7. Answering 4 is reading it as a min-heap (pop the 2, then 4 is smallest); for that you need std::greater<>. And pop returns nothing in C++, so you read top() first if you want the item.
  98. 12. Heaps and priority queues · Top k with the standard heap · Code challengeFinish topK(values, k): the k largest values, largest first. Keep a min-heap best of at most k items: push each value, and pop whenever it holds more than k.One solution:
    Solution
    #include <algorithm>#include <functional>#include <queue>#include <vector>
    std::vector<int> topK(const std::vector<int>& values, int k) {  std::priority_queue<int, std::vector<int>, std::greater<>> best;  for (int v : values) {    best.push(v);    if (static_cast<int>(best.size()) > k) best.pop();  }  std::vector<int> out;  while (!best.empty()) { out.push_back(best.top()); best.pop(); }  std::reverse(out.begin(), out.end());  return out;}
    The heap never holds more than k + 1 items, so each push and pop costs O(log k), and the whole thing O(n log k). The item popped is always the smallest of those kept, the one that just lost its place. A max-heap here would pop the largest and keep the worst values. Emptying a min-heap gives smallest first, so the result is reversed at the end. (static_cast<int> turns the unsigned size() into an int before comparing it with k.)
  99. 13. Graphs · Edges can have a direction and a weight · QuizWeb pages link to other pages. What kind of graph is that?B. Directed and unweightedA link goes from one page to another; the other page does not link back unless someone added a second link. So edges are directed. A plain link has no number on it, so the graph is unweighted (a search engine may add weights, such as how important a link is).
  100. 13. Graphs · An adjacency list stores each vertex’s neighbours · QuizIn an undirected graph, the edges are Ada–Ben, Ada–Cleo, Ben–Cleo and Cleo–Dev. What is the degree of Cleo, and how many entries are in all the adjacency lists together?A. Degree 3; 8 entriesCleo touches three edges (Ada, Ben, Dev), so her degree is 3. Each of the 4 edges is stored from both ends: 8 entries. That is a handy check: the degrees always add up to twice the number of edges.
  101. 13. Graphs · An adjacency matrix is a grid of yes and no · QuizA social network has 10 million people, each with about 200 friends. Roughly how many cells would a matrix need, compared with entries in adjacency lists?B. Matrix: 10¹⁴ cells; lists: about 2 × 10⁹ entriesThe matrix has (10⁷)² = 10¹⁴ cells, almost all zero: even at one bit per cell that is over 12 terabytes. The lists hold 10⁷ × 200 = 2 × 10⁹ ints, about 8 GB. Most real graphs are sparse (few edges compared with all possible pairs), and adjacency lists win. Matrices suit small or dense graphs.
  102. 13. Graphs · Try it: a friends network · Code challengeFinish Friends: addFriendship(a, b) joins two people both ways (adding them if new), and degree(p) is how many friends p has (0 for someone unknown, without adding them).One solution:
    Solution
    #include <string>#include <unordered_map>#include <unordered_set>
    struct Friends {  std::unordered_map<std::string,                     std::unordered_set<std::string>> adj;
      void addFriendship(const std::string& a, const std::string& b) {    adj[a].insert(b);    adj[b].insert(a);  }  int degree(const std::string& p) const {    auto it = adj.find(p);    return it == adj.end() ? 0 : it->second.size();  }};
    adj[a] creates an empty set for a new person, then insert adds the friend; a set ignores one that is already there, so Ben–Ada after Ada–Ben changes nothing. In degree, adj[p] would be the trap: operator[] inserts a missing key, so asking about Zoe would add her, and it does not even compile in a const function. find asks without changing anything; it->second is the set it found.
  103. 13. Graphs · Directed and weighted graphs in code · Fill the blanksStore a one-way flight with its price, and read a price back.1: Edge, 2: from, 3: to, 4: priceThe outer vector goes from a city to its outgoing flights; each Edge holds a destination and a price, and {to, price} builds one in place. Only from gets the edge, because the edge is directed: flights[1] is still empty. For an undirected weighted graph, you would add both directions with the same weight.
  104. 13. Graphs · Directed and weighted graphs in code · Find the bugThis friends graph says Ben has no friends after befriend(0, 1) (Ada, Ben). Which line is the bug?Line 5: adj[b].push_back(a);Line 5 adds Ada to her own list (a self-loop) instead of adding Ada to Ben’s. The graph has quietly become directed. A good test for any undirected graph: after adding a–b, both hasEdge(a, b) and hasEdge(b, a) are true, and no vertex is its own neighbour.
  105. 13. Graphs · Who can I reach? A first graph search · Code challengeFinish reachable(graph, start): mark in seen every vertex reachable from start, including start. Use the std::queue (from the queues module): take a vertex from the front, and push each unseen neighbour, marking it as you push it.One solution:
    Solution
    #include <queue>#include <vector>using Graph = std::vector<std::vector<int>>;
    std::vector<int> reachable(const Graph& graph, int start) {  std::vector<bool> seen(graph.size(), false);  std::queue<int> queue;  seen[start] = true;  queue.push(start);  while (!queue.empty()) {    int v = queue.front();    queue.pop();    for (int next : graph[v]) {      if (seen[next]) continue;      seen[next] = true;      queue.push(next);    }  }  std::vector<int> out;  for (std::size_t v = 0; v < graph.size(); v++)    if (seen[v]) out.push_back(v);  return out;}
    Marking a vertex seen when it is queued, not when it is taken out, means it can never be queued twice. Every vertex and every edge is handled once: O(V + E). The groups found this way are the graph’s connected components; the disjoint sets module finds them another way. (using Graph = … gives the long type a short name.)
  106. 14. Tries: trees of prefixes · A node is 26 child slots and an end flag · QuizStarting from an empty trie, you insert "car", "cart" and "cat". How many nodes are there, not counting the root?B. 5"car" makes c, a, r (3). "cart" reuses c, a, r and adds t (4). "cat" reuses c, a and adds a second t under a (5). Ten letters in all, but only five nodes: the shared prefix "ca" is stored once.
  107. 14. Tries: trees of prefixes · has and startsWith differ only at the last step · Code challengeFinish find(root, text) (the node at the end of text, or nullptr), then use it in has (a whole word) and startsWith (any word begins with it). The tests build a trie with the insert from the last slide.One solution:
    Solution
    #include <array>#include <memory>#include <string>struct Node {  std::array<std::unique_ptr<Node>, 26> children;  bool end = false;};
    const Node* find(const Node& root, const std::string& text) {  const Node* node = &root;  for (char ch : text) {    node = node->children[ch - 'a'].get();    if (!node) return nullptr;  }  return node;}bool has(const Node& root, const std::string& word) {  const Node* node = find(root, word);  return node && node->end;}bool startsWith(const Node& root, const std::string& prefix) {  return find(root, prefix) != nullptr;}
    The early return nullptr matters in C++: once a letter is missing, node is nullptr, and reading node->children on the next letter would dereference it: undefined behaviour. startsWith(t, "") is true, since the root is the empty prefix. In has, node && node->end checks the pointer first, and && stops there if it is nullptr. Forgetting the end check in has is the classic trie bug: every prefix would count as a word.
  108. 14. Tries: trees of prefixes · Collect every word below a prefix · Put in orderPut the steps of wordsWithPrefix(root, "ca") in order.1. Walk from the root down "c" then "a" 2. If a letter was missing, return an empty list 3. Start a depth-first visit at the "a" node, with the text "ca" 4. At each node, if its end flag is set, add its text to the results 5. For each child, visit it with the text plus the child’s letterFinding the prefix costs its length; the visit then costs the size of that part of the trie, which is about the size of the answer. The rest of the dictionary is never touched. A search box usually stops after the first 10 words found.
  109. 14. Tries: trees of prefixes · Collect every word below a prefix · Code challengeFinish collect(node, text, out): push text onto out if the node ends a word, then collect from each child that exists, with its letter (char('a' + i)) added to text. The tests’ wordsWithPrefix walks to the prefix and calls it.One solution:
    Solution
    #include <array>#include <memory>#include <string>#include <vector>struct Node {  std::array<std::unique_ptr<Node>, 26> children;  bool end = false;};void collect(const Node* node, std::string text,             std::vector<std::string>& out) {  if (node->end) out.push_back(text);  for (int i = 0; i < 26; i++)    if (node->children[i])      collect(node->children[i].get(), text + char('a' + i), out);}
    The text grows by one letter per level, so each word is built exactly when its last node is reached. Because the slots go from a to z, suggestions come out in alphabetical order with no sorting: a gift of the array layout. Passing text by value copies it at every node; the faster habit is one shared std::string& that you push_back a letter onto before the call and pop_back after it.
  110. 14. Tries: trees of prefixes · The price is memory · QuizWhich job does a trie do clearly better than a std::unordered_set of words?B. Suggesting completions for what someone has typed so farExact lookups and counting distinct words are what a hash set is for: O(L) on average and less memory. The trie earns its memory only when you ask about prefixes. Choosing a structure means asking which operations you need.
  111. 14. Tries: trees of prefixes · Counting through a trie · Find the bugAfter inserting car, cart and cat, countPrefix(root, "car") should be 2, but it says 1. Which line is the bug?Line 6: next->count++;Line 6 counts the node being left, not the one being entered. The root gets counted for every word (harmless), but the last letter of each word never is: the "r" of car is only counted when cart passes through it on the way to "t", so it reads 1. Counting next counts every node on the path, including the last. An off-by-one level is the usual trie bug; test with a word that is a prefix of another.
  112. 15. Balanced trees · Height and the balance factor · Code challengeWrite height(node) (0 for nullptr, otherwise 1 plus the taller child’s height) and isBalanced(node): true when every node’s subtrees differ in height by at most 1. std::max and std::abs help.One solution:
    Solution
    #include <algorithm>#include <cstdlib>#include <memory>struct Node {  int key;  std::unique_ptr<Node> left, right;};using Ptr = std::unique_ptr<Node>;Ptr t(Ptr l, int key, Ptr r) {  return std::make_unique<Node>(Node{key, std::move(l), std::move(r)});}
    int height(const Node* node) {  if (!node) return 0;  return 1 + std::max(height(node->left.get()),                      height(node->right.get()));}bool isBalanced(const Node* node) {  if (!node) return true;  int diff = height(node->left.get()) - height(node->right.get());  return std::abs(diff) <= 1 && isBalanced(node->left.get()) &&         isBalanced(node->right.get());}
    Checking only the root is the tempting shortcut, and the last test catches it: the root’s sides are 3 and 2 tall, but node 3 has a left side two taller than its empty right. This version recomputes heights again and again, O(n²) in the worst case. Returning the height and the verdict together from one traversal makes it O(n), and AVL trees go further: each node stores its height, updated as the tree changes.
  113. 15. Balanced trees · A rotation lifts one side and lowers the other · QuizAn in-order traversal of the tree before the rotation gives 20, 30, 40, 50, 60. What does it give after?B. 20, 30, 40, 50, 60A rotation never changes the sorted order, only the shape. 40 was right of 30 and left of 50 before, and it still is: that is why it moves across. This is what lets a search tree reshape itself without breaking the ordering rule.
  114. 15. Balanced trees · Try it: rotate left · Code challengeWrite rotateLeft(top): lift top->right, move its left subtree across to top->right, put the old top under it on the left, and make top own the lifted node.One solution:
    Solution
    #include <memory>struct Node {  int key;  std::unique_ptr<Node> left, right;};using Ptr = std::unique_ptr<Node>;Ptr t(Ptr l, int key, Ptr r) {  return std::make_unique<Node>(Node{key, std::move(l), std::move(r)});}Ptr leaf(int k) { return t(nullptr, k, nullptr); }
    void rotateLeft(Ptr& top) {  Ptr lifted = std::move(top->right);  top->right = std::move(lifted->left);  lifted->left = std::move(top);  top = std::move(lifted);}
    The order of the moves matters: hand lifted->left to top->right before overwriting lifted->left, or assigning over it deletes 40 and everything under it. Moving top into lifted->left empties top for a moment, so the last line refills it with the new top. Get the order wrong in the other direction and you read through an empty pointer: undefined behaviour. The nodes themselves never move in memory: only who owns them changes.
  115. 15. Balanced trees · AVL insertion: insert, then rotate on the way up · QuizYou insert 10, 20 and 30, in that order, into an empty AVL tree. What happens?B. A left rotation at 10, leaving 20 at the root with 10 and 30 under itAfter 30 goes in, 10 has an empty left and a right side of height 2: balance factor −2, a right-right case. One left rotation at 10 lifts 20. A plain BST would have stayed a chain; an AVL tree never lets it get that far.
  116. 15. Balanced trees · Red-black trees balance more loosely · QuizA map is changed constantly and read about as often. Why do standard libraries often pick a red-black tree over an AVL tree for it?B. Red-black trees do less restructuring per change, for lookups that are only a little slowerBoth are O(log n) for everything. Red-black trees accept a somewhat taller tree in exchange for fewer rotations per update, a good deal for general-purpose maps. AVL trees are shorter, which pays off when reads dominate. Neither is "better"; they make different trade-offs.
  117. 15. Balanced trees · std::set and std::map are balanced trees you can use today · Code challengeWrite floorKey(prices, budget) with the set’s own search: the largest price ≤ budget, or std::nullopt. Find the first price above the budget with upper_bound; the floor, if any, is just before it.One solution:
    Solution
    #include <iterator>#include <optional>#include <set>
    std::optional<int> floorKey(const std::set<int>& prices,                            int budget) {  auto above = prices.upper_bound(budget);  if (above == prices.begin()) return std::nullopt;  return *std::prev(above);}
    This is the floor from the binary search trees module, done by a tree that cannot go skewed. upper_bound skips equal keys, so the step back lands on the budget itself when it is there. The begin() check is the edge case that matters: stepping back from the first position is undefined, a crash or garbage rather than an error. lower_bound is the tempting choice, but it finds 60 for a budget of 60 and 70 for 65, so you would need two different cases.
  118. 15. Balanced trees · B-trees: wide and shallow, for disks · QuizWhy does a database not use a binary tree for its index on disk?A. A binary tree of a billion keys is about 30 levels deep, and each level could be a separate slow disk readThe cost that matters on disk is the number of pages read, not comparisons. Thirty levels means up to thirty reads; a B-tree needs three or four, each read bringing in hundreds of keys to compare in memory. The same idea, shallow and wide, helps in memory too: a node of a std::set is a separate allocation, so each level can be a cache miss, which is why in-memory B-tree maps (with a few dozen keys per node, side by side) often beat std::map.
  119. 16. Disjoint sets: union-find · Which group is this in, and are these two together? · QuizWhich of these can a plain union-find do?A. Merge two groups, and tell whether two items share a groupUnion-find only merges and compares. It cannot undo a union (splitting needs other structures), and it knows nothing about the route between two items, only that one exists. That narrow job is what makes it so fast.
  120. 16. Disjoint sets: union-find · Find climbs to the root; union links two roots · QuizWith the struct above and 4 items, what are parent[3] and find(3) after these calls?B. parent[3] is 2; find(3) is 0The first two unions make 1 a child of 0 and 3 a child of 2. The third finds the roots of 1 and 3 (0 and 2) and hangs 2 under 0. Only roots move, so parent[3] is still 2, and find(3) climbs 3 → 2 → 0. That climb is what the next slides make short.
  121. 16. Disjoint sets: union-find · Try it: count friend circles · Code challengeFinish countCircles(n, friendships): for each pair, find both roots; if they differ, link one under the other and count one group fewer. Return how many groups are left.One solution:
    Solution
    #include <numeric>#include <utility>#include <vector>
    int find(const std::vector<int>& parent, int x) {  while (parent[x] != x) x = parent[x];  return x;}int countCircles(int n,                 const std::vector<std::pair<int, int>>& friendships) {  std::vector<int> parent(n);  std::iota(parent.begin(), parent.end(), 0);  int groups = n;  for (auto [a, b] : friendships) {    int ra = find(parent, a), rb = find(parent, b);    if (ra == rb) continue;    parent[rb] = ra;    groups--;  }  return groups;}
    The ra == rb check is the heart of it: the third test closes a loop (2 is already with 0), and counting it would give 0 circles. Linking parent[b] = a instead of the roots is the classic slip: it can cut b away from its old group. Always link root to root. (auto [a, b] is a structured binding: it unpacks each std::pair into two names.)
  122. 16. Disjoint sets: union-find · Path compression: point straight at the root · Fill the blanksMake find compress the path: each item on the way gets the root as its parent.1: find, 2: parent, 3: parentThe recursive call returns the root, and on the way back each item stores it as its parent. return parent[x] then returns the root for roots and non-roots alike. A loop version does two passes: one to find the root, one to repoint everything. The recursion goes as deep as the path is long, which is fine once paths are kept short: the next slide shows how to keep every path under log₂ n.
  123. 16. Disjoint sets: union-find · Union by size: hang the small tree under the big one · Find the bugThis union by size builds tall trees. Which line is wrong?Line 4: if (size[ra] < size[rb]) std::swap(ra, rb);Line 5 hangs rb under ra, so ra must be the larger root. Line 4 swaps when ra is larger, putting the big tree under the small one: the worst choice. With <, the swap makes ra the larger, and line 6 adds the absorbed group’s size to it. Only roots’ sizes need to be right.
  124. 16. Disjoint sets: union-find · Union by size: hang the small tree under the big one · Put in orderPut the steps of a union by size with path compression in order.1. Find the root of a, compressing its path 2. Find the root of b, compressing its path 3. If the roots are the same, stop: already together 4. Make the root of the larger group the one that stays on top 5. Set the smaller root’s parent to the larger root 6. Add the smaller group’s size to the larger root’s sizeBoth finds come first because only roots may be linked. The size is updated last, on the root that stays: a root that has been absorbed never needs its size again.
  125. 16. Disjoint sets: union-find · Union-find for pixels, networks and cycles · Code challengeWrite firstCycle(n, cables): cables join computers one at a time. Return the index of the first cable that joins two computers already connected, or -1 if none does. UnionFind is ready, with both tricks.One solution:
    Solution
    #include <numeric>#include <utility>#include <vector>struct UnionFind {  std::vector<int> parent, size;  explicit UnionFind(int n) : parent(n), size(n, 1) {    std::iota(parent.begin(), parent.end(), 0);  }  int find(int x) {    if (parent[x] != x) parent[x] = find(parent[x]);    return parent[x];  }  bool unite(int a, int b) {    int ra = find(a), rb = find(b);    if (ra == rb) return false;    if (size[ra] < size[rb]) std::swap(ra, rb);    parent[rb] = ra;    size[ra] += size[rb];    return true;  }};using Cables = std::vector<std::pair<int, int>>;
    int firstCycle(int n, const Cables& cables) {  UnionFind uf(n);  for (std::size_t i = 0; i < cables.size(); i++) {    auto [a, b] = cables[i];    if (!uf.unite(a, b)) return i;  }  return -1;}
    unite already does the work: it returns false exactly when both ends share a root, which means a path between them existed before this cable. This only works for undirected connections; cycles in a directed graph (a → b → c → a) need a graph search instead. The loop counts with an index, rather than a range-for, because the answer is the index.
  126. 17. Caches: combining structures · A cache keeps answers you may need again · QuizA cache answers 900 of 1,000 requests itself. A hit takes 1 ms and a miss 100 ms. What is the average?B. About 11 ms900 × 1 + 100 × 100 = 10,900 ms over 1,000 requests: 10.9 ms each. The misses dominate: raising the hit rate from 90% to 99% would bring it down to about 2 ms. That is why the choice of what to evict matters so much.
  127. 17. Caches: combining structures · Which entry goes when the cache is full? · QuizA music app caches songs on the phone. People replay the songs they have been listening to lately. Which policy fits best?B. LRURecent listening predicts what comes next, which is exactly LRU’s bet. FIFO would delete a song you play every day because you first downloaded it long ago. LRU is the default choice for most caches, and the rest of this module builds it.
  128. 17. Caches: combining structures · The classic LRU: a hash map plus a doubly linked list · Put in orderPut the steps of get(key) in the map-plus-list LRU in order.1. Look the key up in the map 2. If it is missing, return nothing (std::nullopt) 3. Unlink its node from where it is in the list 4. Link the node in at the front of the list 5. Return the node’s valueEach step is O(1): the map gives the node directly, and a doubly linked node knows both neighbours, so unlinking needs no search. A singly linked list could not do the third step without walking from the front to find the previous node.
  129. 17. Caches: combining structures · std::list moves a node in O(1) with splice · QuizWhy does the classic C++ LRU keep its entries in a std::list and store list iterators in the map, rather than keeping entries in a std::vector and storing indexes?B. Moving an entry to the front of a vector shifts all the others, O(n), and changes their indexes; a list splices in O(1) and its iterators stay validAn LRU moves entries on every hit. In a vector that means shifting everything in between, and every shifted entry’s index in the map is now wrong. A list node never moves in memory, so an iterator to it stays good however the list is reordered. Lists use more memory (two pointers per node), the price of that guarantee.
  130. 17. Caches: combining structures · Try it: the map-plus-list LRU · Code challengeFinish get and put. get splices a hit to the front. put updates and splices an existing key; otherwise it adds the entry at the front, stores its iterator in index, and, over capacity, evicts order.back() from both structures.One solution:
    Solution
    #include <list>#include <optional>#include <string>#include <unordered_map>struct Entry {  std::string key;  int value;};struct Lru {  std::size_t capacity;  std::list<Entry> order;  std::unordered_map<std::string,                     std::list<Entry>::iterator> index;
      std::optional<int> get(const std::string& key) {    auto found = index.find(key);    if (found == index.end()) return std::nullopt;    order.splice(order.begin(), order, found->second);    return found->second->value;  }  void put(const std::string& key, int value) {    auto found = index.find(key);    if (found != index.end()) {      found->second->value = value;      order.splice(order.begin(), order, found->second);      return;    }    order.push_front({key, value});    index[key] = order.begin();    if (index.size() > capacity) {      index.erase(order.back().key);      order.pop_back();    }  }};
    The two structures must always agree: every key in index has exactly one node in order. On eviction the node goes from both, which is why each entry stores its own key: read order.back().key before pop_back frees the node, or you read freed memory. Forgetting the update branch in put adds a second node for the key and overwrites the map’s iterator, leaving a ghost node that is later "evicted" and erases the new key from the map. Lru c{2} sets capacity to 2 and leaves the list and map empty.
  131. 17. Caches: combining structures · Memoising: a cache in front of a function · Find the bugThis memoised cost keeps recomputing for some arguments, slowly. Which line is the bug?Line 4: if (cache.contains(n)) return cache[n];cache[n] on a new key inserts a 0, so "0" has to mean "not computed yet". When cost(n) really is 0, every call looks like a miss and computes it again. Ask whether the key is there (contains, or find), not whether its value looks empty. The same trap hides in any cache that uses a special value, like -1, to mean "missing".
  132. 17. Caches: combining structures · Expiry: entries that go stale · Code challengeFinish TtlCache: set stores the value with expires = now + ttl; get returns it only while now is before expires, and erases it once it has expired.One solution:
    Solution
    #include <optional>#include <string>#include <unordered_map>struct Fresh {  double value;  long long expires;};struct TtlCache {  long long ttl;  std::unordered_map<std::string, Fresh> map;
      void set(const std::string& key, double value, long long now) {    map[key] = {value, now + ttl};  }  std::optional<double> get(const std::string& key, long long now) {    auto found = map.find(key);    if (found == map.end()) return std::nullopt;    if (now >= found->second.expires) {      map.erase(found);      return std::nullopt;    }    return found->second.value;  }};
    Storing the expiry time, not the time it was stored, makes the check one comparison. map.erase(found) erases through the iterator you already have, with no second lookup; do not use found after it, since erasing invalidates it. Lazy expiry only frees memory when an expired key is read again; real caches combine it with an LRU limit or an occasional sweep. Taking now as an argument, instead of reading the clock inside, is what lets the tests jump an hour ahead in a microsecond.
  133. 18. Range queries · Totals over a range, asked again and again · QuizA dashboard shows 10,000 range totals over 1,000,000 data points, each range about half the data. About how many additions does the loop above do?C. About 5,000,000,00010,000 questions × 500,000 additions each is 5 × 10⁹: seconds of work even for compiled C++, every time the page loads. The rest of this module answers each question in one subtraction, after one pass to prepare.
  134. 18. Range queries · Prefix sums: subtract two running totals · QuizWith the same prefix, what is sum(0, 5), and what is sum(3, 3)?A. 23 and 1sum(0, 5) is prefix[6] - prefix[0], 23 − 0, the whole list. sum(3, 3) is prefix[4] - prefix[3], 9 − 8 = 1: a range of one day is just that day’s value. The leading 0 is what makes from = 0 work without an if.
  135. 18. Range queries · Try it: a range-sum struct · Find the bugsum(1, 3) should be 1 + 4 + 1 = 6 but gives 5. Which line is the bug?Line 4: return prefix[to + 1] - prefix[from];prefix[i] holds the first i values, so the total through index to is prefix[to + 1]. prefix[to] stops one short and drops the last day of the range. Mixing "sum of the first i" with "sum through index i" is the classic off-by-one with prefix sums; pick one meaning and test a range of length one.
  136. 18. Range queries · Try it: a range-sum struct · Code challengeFinish RangeSum: fill prefix in the constructor, then make sum(from, to) return the total of values[from] through values[to] in O(1).One solution:
    Solution
    #include <vector>
    struct RangeSum {  std::vector<long long> prefix;
      explicit RangeSum(const std::vector<int>& values)      : prefix(values.size() + 1, 0) {    for (std::size_t i = 0; i < values.size(); i++)      prefix[i + 1] = prefix[i] + values[i];  }  long long sum(int from, int to) const {    return prefix[to + 1] - prefix[from];  }};
    The constructor does O(n) work once, and each sum is two reads and a subtraction. The last test is a trillion: an int total would overflow long before, which is why prefix is long long and why prefix[i] + values[i] adds in long long (the int is widened first). The same trick works for anything you can "subtract": counts, and even XOR. It does not work for a minimum, because knowing the minimum of the first 10 and of the first 4 tells you nothing about positions 4 to 9. The standard library’s std::partial_sum (in <numeric>) builds running totals too.
  137. 18. Range queries · Prefix sums in two dimensions · Fill the blanksComplete the build, and the query for rows r1–r2 and columns c1–c2, both inclusive.1: -, 2: -, 3: -, 4: +Adding "above" and "left" counts the overlapping corner twice, so it is subtracted once. The query is the mirror: take the big rectangle to the bottom-right corner, cut off the strip above and the strip to the left, and add back the corner piece that was cut off twice. Adding and removing overlaps like this is called inclusion-exclusion.
  138. 18. Range queries · Try it: trees in a rectangle · Code challengeThe constructor builds P. Write count(r1, c1, r2, c2): the total of the rectangle from row r1, column c1 to row r2, column c2, both corners included.One solution:
    Solution
    #include <vector>using Grid = std::vector<std::vector<int>>;
    struct GridSum {  std::vector<std::vector<long long>> P;  explicit GridSum(const Grid& grid)      : P(grid.size() + 1,          std::vector<long long>(grid[0].size() + 1, 0)) {    for (std::size_t r = 0; r < grid.size(); r++)      for (std::size_t c = 0; c < grid[0].size(); c++)        P[r + 1][c + 1] = grid[r][c] + P[r][c + 1] + P[r + 1][c]                          - P[r][c];  }  long long count(int r1, int c1, int r2, int c2) const {    return P[r2 + 1][c2 + 1] - P[r1][c2 + 1] - P[r2 + 1][c1] +           P[r1][c1];  }};
    Four reads and three additions, whatever the rectangle’s size. The extra row and column of zeros make the edges work: a rectangle starting in row 0 subtracts P[0][…], which is 0. Forgetting to add the corner back is the usual mistake; the middle-rectangle test catches it. Image software uses the same table (an "integral image") to blur and detect faces quickly.
  139. 18. Range queries · When values change, prefix sums fall behind · QuizA live scoreboard changes a score 5,000 times a second and is asked for range totals 5,000 times a second, over 100,000 players. Which fits?C. A Fenwick treeRebuilding prefix sums costs 100,000 steps per change; summing a range costs up to 100,000 steps per question. A Fenwick tree does each in about 17 steps (log₂ 100,000). When both operations are frequent, pick the structure that is never slow at either.
  140. 18. Range queries · A Fenwick tree stores sums of power-of-two blocks · QuizWhich cells does the sum of values 1 to 6 read?A. 6 and 46 is 0110, so cell 6 holds 2 values (5–6); 6 − 2 = 4. 4 is 0100, so cell 4 holds values 1–4; 4 − 4 = 0, stop. Two cells cover six values. Each jump removes the lowest 1 bit, so a number with b bits takes at most b jumps.
  141. 18. Range queries · A Fenwick tree in code · Code challengeadd is written. Finish prefix(i) (the sum of values 1 to i), then range(from, to): the sum of values from to to, both included.One solution:
    Solution
    #include <vector>
    struct Fenwick {  std::vector<long long> tree;  explicit Fenwick(int n) : tree(n + 1, 0) {}
      void add(int i, long long delta) {    for (; i < static_cast<int>(tree.size()); i += i & -i)      tree[i] += delta;  }  long long prefix(int i) const {    long long sum = 0;    for (; i > 0; i -= i & -i) sum += tree[i];    return sum;  }  long long range(int from, int to) const {    return prefix(to) - prefix(from - 1);  }};
    range is the prefix-sums trick again, one block down: everything up to to, minus everything before from. Because indexes start at 1, prefix(0) is 0 and needs no special case. To set a value rather than change it by an amount, add the difference: add(i, newValue - oldValue), keeping the old values in a plain vector alongside.
  142. 19. Persistent structures · Changing data in place forgets the past · QuizWhat does this print?A. 1C++ copies values: push_back(shelf) stores a whole copy of the vector, so the first saved version keeps its one book. History works, but every save copies every book: O(n) time and memory per version. Persistent structures keep the history without the copies.
  143. 19. Persistent structures · Shared cells need shared owners: std::shared_ptr · QuizVersion 1 is a list of three cells. Versions 2 and 3 each put one new cell in front of version 1. What is the reference count of version 1’s first cell?C. 3Three shared_ptrs point at it: the variable holding version 1, and the tail of each of the two new cells. Drop the version 1 variable and the cell stays alive, count 2, because versions 2 and 3 still need it. That is ownership following sharing, with no one deciding who frees what.
  144. 19. Persistent structures · Try it: change one item, share the rest · Code challengeWrite update(list, index, value): a new list with value at index, sharing every cell after it with the old list. If the list is too short, return the very same list. Recurse: at index 0 make one cell in front of the old tail; otherwise update the tail, and copy this cell only if the tail came back different.One solution:
    Solution
    #include <memory>#include <string>struct Cell;using List = std::shared_ptr<const Cell>;struct Cell {  std::string head;  List tail;};List cell(std::string head, List tail) {  return std::make_shared<Cell>(Cell{std::move(head), std::move(tail)});}
    List update(const List& list, int index, const std::string& value) {  if (!list) return list;  if (index == 0) return cell(value, list->tail);  List rest = update(list->tail, index - 1, value);  if (rest == list->tail) return list;  return cell(list->head, rest);}
    Each call copies one cell on the way back up, until index 0, where the new cell points straight at the old tail: from there on, everything is shared. == on shared_ptrs checks identity, the same cell, which is how you test sharing; copying the whole list would print the same titles but fail that test. Without the rest == list->tail check, a too-short list would come back as a full copy instead of the same list.
  145. 19. Persistent structures · Path copying: a new tree in O(log n) · Put in orderIn what order are the new nodes finished when a recursive persistent insert adds 40?1. The new leaf 40 is made 2. A copy of 30 is made, with right child 40 3. A copy of 50 is made, with left child the new 30 4. The new root is returned as the new versionThe recursion goes down first, then builds on the way back up: each level can only make its copy once it has the new child from below. So the leaf is finished first and the root last. Nothing old is ever changed, so a reader holding the old root sees a complete, valid tree the whole time.
  146. 19. Persistent structures · Try it: a persistent insert · Code challengeWrite insert(tree, key) for an immutable BST: return a new tree with key added, copying only the nodes on the path (node(left, key, right) makes one). If the key is already there, return the very same tree, not a copy.One solution:
    Solution
    #include <memory>struct Node;using Tree = std::shared_ptr<const Node>;struct Node {  int key;  Tree left, right;};Tree node(Tree left, int key, Tree right) {  return std::make_shared<Node>(Node{key, left, right});}
    Tree insert(const Tree& t, int key) {  if (!t) return node(nullptr, key, nullptr);  if (key < t->key) {    Tree left = insert(t->left, key);    return left == t->left ? t : node(left, t->key, t->right);  }  if (key > t->key) {    Tree right = insert(t->right, key);    return right == t->right ? t : node(t->left, t->key, right);  }  return t;}
    Each copied node takes the one new child and the same shared_ptr for the other side, so that whole subtree is shared (its count goes up by one). The last test is the subtle one: copying on the way back up even when nothing below changed would make a new root for a key that was already there. Checking left == t->left returns the old node when its child came back unchanged, so callers can notice "nothing changed" with one pointer comparison.
  147. 19. Persistent structures · Same pointer, same contents: change detection for free · Find the bugHere the cart is held by std::shared_ptr<Cart>, not const. After addToCart, the old state has the new book too. Which line is the bug?Line 2: auto cart = std::make_shared<Cart>(*state.cart);Copying a shared_ptr copies the pointer, not the cart: both states now own the same cart, and the push changes it for both. Undo is broken, and next.cart == state.cart is true, so anything checking for changes skips the cart. Copy the object (*state.cart) into a new one. Holding shared data through shared_ptr<const Cart> makes the compiler refuse this code (line 2 cannot drop the const, and the cart cannot be changed through it), which is why persistent code uses const everywhere.
  148. 19. Persistent structures · What persistence costs · QuizWhich job gains most from a persistent structure?B. A drawing app that keeps every step so the user can undo any number of timesUndo history is the textbook case: each step is a version, sharing almost everything with the last. The other two never look back and need raw speed, where a mutable vector is the right choice. As always, the operations you need choose the structure.
  149. 19. Persistent structures · Bonus: a Bloom filter trades certainty for space · QuizA sign-up form checks a Bloom filter of taken usernames before asking the database. What can the filter’s answer tell you for sure?B. A "no" means the name is certainly freeA Bloom filter has false positives but never false negatives: a name that was added always finds its bits set. So "no" is final and skips the database; only a "maybe" needs the real check. Databases such as Cassandra and RocksDB use them the same way, to skip disk reads for keys that are not there. Items cannot be removed, since clearing a bit could erase another item.
  150. 20. Mastery challenge · How this challenge works · QuizWhat does this print?C. out of dune, 2stock["dune"] finds no "dune", so it inserts one with the value 0 and returns that: the test is true and the map now has two keys. Asking with [] changes the map; find or contains only look. The same trap breaks caches and memo tables. (Hash tables; caches.)
  151. 20. Mastery challenge · Choose a structure by the operations you need · QuizA calendar app receives reminders in any order, each with a time. It must always fire the earliest pending reminder next, and new reminders keep arriving. Which structure?B. A min-heap ordered by timeThe operations are "add anywhere in time" and "take the earliest": a priority queue. A heap does both in O(log n); in C++ that is a std::priority_queue with std::greater<>, since the default puts the largest on top. A sorted vector makes each add O(n); a queue would fire in arrival order, not time order; a hash map has no notion of "earliest". (Heaps.)
  152. 20. Mastery challenge · Choose a structure by the operations you need · Put in orderPut the steps of choosing a structure for a new feature in order.1. List the operations the feature needs 2. Estimate how often each runs, and how big the data gets 3. Compare what each candidate structure costs for those operations 4. Pick the simplest one that is fast enough 5. Measure with realistic data, and change only if it is too slowThe operations decide, not the structure’s fame. "How often" and "how big" separate an O(n) that is fine (a few hundred items) from one that is not (a million, every keystroke). Simple first: a std::vector or a std::unordered_map often wins until measurements say otherwise, because contiguous memory is fast in ways Big O does not show.
  153. 20. Mastery challenge · Build it: a stack that knows its minimum · Code challengeFinish MinStack: keep mins in step with items, so min() returns the smallest value on the stack (or std::nullopt when empty) in O(1), including after pops.One solution:
    Solution
    #include <algorithm>#include <optional>#include <vector>
    struct MinStack {  std::vector<int> items, mins;
      void push(int x) {    items.push_back(x);    mins.push_back(mins.empty() ? x : std::min(x, mins.back()));  }  void pop() {    items.pop_back();    mins.pop_back();  }  std::optional<int> min() const {    if (mins.empty()) return std::nullopt;    return mins.back();  }};
    Each level of mins answers "what is the smallest at or below here?", which never changes while that level exists. So a pop just discards one level of each. Storing only a single min number fails the second test: after popping 3, nothing remembers 5. mins.back() on an empty vector is undefined (this runner stops the program), which is why push checks empty() first. A version that only pushes onto mins when the value is smaller saves memory but must handle equal values, which the third test checks. (Stacks.)
  154. 20. Mastery challenge · Build it: the k most frequent words · Code challengeWrite topWords(words, k): count each word, keep heap (a min-heap of {count, word} pairs) at most k entries big, and return the words most frequent first.One solution:
    Solution
    #include <algorithm>#include <functional>#include <queue>#include <string>#include <unordered_map>#include <utility>#include <vector>using Pair = std::pair<int, std::string>;
    std::vector<std::string> topWords(    const std::vector<std::string>& words, int k) {  std::priority_queue<Pair, std::vector<Pair>, std::greater<>> heap;  std::unordered_map<std::string, int> counts;  for (const auto& w : words) counts[w]++;  for (const auto& [word, count] : counts) {    heap.push({count, word});    if (static_cast<int>(heap.size()) > k) heap.pop();  }  std::vector<std::string> out;  for (; !heap.empty(); heap.pop()) out.push_back(heap.top().second);  std::reverse(out.begin(), out.end());  return out;}
    Here counts[w]++ is the right use of []: a new word starts at 0 and becomes 1. Counting is O(n); the heap work is O(m log k) for m distinct words, better than sorting all m when k is small. The heap pops the least frequent first, so the drained list is reversed at the end. The mistakes to avoid: the default std::priority_queue (a max-heap) would pop the most frequent and keep the worst, and a heap of everything is correct but O(m) memory. (Hash tables; heaps.)
  155. 20. Mastery challenge · Build it: an LRU cache with a size budget · Code challengeFinish put(key, value, size): remove any old entry for key; skip anything bigger than budget; otherwise add it at the front, store its iterator, add its size to used, and evict from the back until used fits the budget.One solution:
    Solution
    #include <list>#include <string>#include <unordered_map>struct Entry { std::string key, value; std::size_t size; };struct SizedLru {  std::size_t budget, used = 0;  std::list<Entry> order;  std::unordered_map<std::string, std::list<Entry>::iterator> index;
      void remove(const std::string& key) {    auto found = index.find(key);    if (found == index.end()) return;    used -= found->second->size;    order.erase(found->second);    index.erase(found);  }  void put(const std::string& key, const std::string& value,           std::size_t size) {    remove(key);    if (size > budget) return;    order.push_front({key, value, size});    index[key] = order.begin();    used += size;    while (used > budget) {      std::string oldest = order.back().key;      remove(oldest);    }  }};
    Routing every removal through remove keeps used honest, which is the real difficulty: the second test fails if replacing a adds 6 without taking away 4. The size check must come before inserting, or one huge image would empty the cache on its way through. The loop copies the oldest key before removing it: order.back().key is a string inside the node that remove frees, and passing a reference to it would leave key pointing at freed memory midway through. The while loop always ends, because once only the new entry is left, used is its size, which fits. (Caches.)
  156. 20. Mastery challenge · Build it: is this network a tree? · Code challengeWrite isTree(n, pipes): false unless there are exactly n - 1 pipes; then unite each pipe’s ends, returning false if a pipe joins two houses already connected.One solution:
    Solution
    #include <numeric>#include <utility>#include <vector>using Pipes = std::vector<std::pair<int, int>>;
    int find(const std::vector<int>& parent, int x) {  while (parent[x] != x) x = parent[x];  return x;}bool isTree(int n, const Pipes& pipes) {  std::vector<int> parent(n);  std::iota(parent.begin(), parent.end(), 0);  if (static_cast<int>(pipes.size()) != n - 1) return false;  for (auto [a, b] : pipes) {    int ra = find(parent, a), rb = find(parent, b);    if (ra == rb) return false;    parent[ra] = rb;  }  return true;}
    Counting alone is not enough: the third test has three pipes for four houses, but they form a loop and house 3 is left out. Why are the two checks together enough? Each pipe that closes no cycle merges two groups, so n − 1 such pipes merge n groups into one: everything is connected. The cast keeps the comparison between two ints: pipes.size() is unsigned, and comparing it with a signed n - 1 would turn that into an unsigned number too, so for n = 0 its −1 would become a huge number. Here the answer happens to come out the same either way, but mixing signed and unsigned in a comparison is a classic source of bugs, and compilers warn about it. (Graphs; disjoint sets.)
  157. 20. Mastery challenge · Review it: a heap that is almost right · Find the bugThis sift-down passes most tests but sometimes breaks the heap. Which line is wrong?Line 5: if (r < heap.size() && heap[r] < heap[small]) small = r;Line 5 compares the right child with the left child instead of with the best so far. Take a parent of 2 with children 9 and 5: the parent is already the smallest, but line 5 sees 5 < 9 and picks the right child anyway, so 2 and 5 swap and 5 ends up above 2. Comparing with heap[small] means "smaller than everything checked so far", which includes the parent. (Heaps.)
  158. 20. Mastery challenge · Review it: a heap that is almost right · Code challengeWrite isMinHeap(a): true when every element is greater than or equal to its parent, at index (i - 1) / 2.One solution:
    Solution
    #include <vector>
    bool isMinHeap(const std::vector<int>& a) {  for (std::size_t i = 1; i < a.size(); i++)    if (a[(i - 1) / 2] > a[i]) return false;  return true;}
    Checking each element against its one parent covers every parent-child pair exactly once: O(n). A check like this is worth keeping in a heap’s tests, run after every push and pop, to catch bugs like the one above; the standard library has one too, std::is_heap (for max-heaps, unless given std::greater<>()). Starting the loop at 1 skips the root, which has no parent, and keeps i - 1 from wrapping around below zero. (Heaps.)
  159. 20. Mastery challenge · Predict what the structures do · QuizInsert 5, 3, 8, 1, 4 into an empty binary search tree, then delete 3 using its successor. What is 5’s left child now?B. 43 has two children, 1 and 4. Its successor is the smallest key in its right subtree: 4, a leaf. 4 is copied into 3’s place and the old 4 removed, so 5’s left child is 4, with 1 below it on the left. The ordering rule still holds. (Binary search trees.)
  160. 20. Mastery challenge · Predict what the structures do · QuizA trie holds "tea", "ten" and "to". Separately, a union-find with 5 items runs unite(0, 1), unite(2, 3), unite(1, 3). How many trie nodes (not counting the root) and how many groups?B. 5 nodes and 2 groupsThe trie shares "t" and "te": t, e, a, n, o, five nodes for eight letters. The unions merge {0, 1} and {2, 3}, then join those two; item 4 is still alone, so 2 groups. (Tries; disjoint sets.)
  161. 20. Mastery challenge · Predict what the structures do · QuizA persistent tree’s version 1 has 1,000 nodes. Version 2 is version 1 with one key inserted by path copying, along a path of 10 nodes. Then the variable holding version 1 is destroyed. How many nodes are freed?B. 10Version 2 shares 990 of version 1’s nodes, so their shared_ptr counts stay above 0. Only the 10 old nodes on the copied path were owned by version 1 alone: their counts reach 0 and they are deleted. Reference counting frees exactly what no version uses any more. (Persistent structures.)
  162. 20. Mastery challenge · Which statements are true? · QuizPick every true statement.B. An in-order traversal of any binary search tree visits the keys in sorted order.; D. A trie’s lookup cost depends on the length of the word, not on how many words it holds.A heap is only ordered parent over child, not sorted (heaps). Union-find only knows whether two items are connected, not how (disjoint sets). A Bloom filter has false positives but never false negatives (persistent structures, bonus). The true ones are the defining property of BSTs and the reason tries exist.
  163. 20. Mastery challenge · Which statements are true? · QuizWhich pairing of structure and cost is wrong?B. Plain binary search tree built from sorted input: search O(log n)Sorted input makes a plain BST a chain, so search is O(n). A balanced tree, like the red-black tree inside std::set, guarantees O(log n) whatever the input order. The other three are exactly what their modules showed. (Binary search trees; balanced trees.)