Final Exam Review Session Problems


This page is curated by Jonathan Coronado, Yasmine Alonso, and Sean Szumlanski, drawing upon materials from previous quarters.

📦 Starter Project

These problems will give you more practice with concepts covered on the final exam. The links to the recording and slides from this session will be posted below after the session.

1. Haircuts (Haircuts.cpp)

Topics: Trees, Dynamic Memory, Recursion

Suppose we have the following Node type:

struct Node {   
    int value;  
    Node* left; 
    Node* right;
};    

Given a binary tree (not necessarily a binary search tree), we can number the levels of the tree from top to bottom. The root is in level 0, its children are in level 1, their children are in level 2, etc. An example is shown below:

TODO: Describe this

Write a recursive function

Set<Node*> trimAbove(Node* root, int level);

that works as follows. This function deallocates all nodes above the level given by the level parameter. For example, calling trimAbove(root, 1) on the above tree will delete all nodes above layer 1 (that’s just the root node), calling trimAbove(root, 3) on the above tree will delete everything above layer 3 (nodes 6, 9, 1, 3, 5, 7, and 0), and calling trimAbove(root, 0) on the above tree won’t remove anything, because there are no nodes above layer 0.

In addition to deallocating nodes, calling trimAbove(root, level) returns a Set<Node*> containing pointers to all the nodes at level level of the tree. For example, if root points to the root of the tree shown above, then trimAbove(root, 3) would delete all nodes in the tree except for 8, 2, and 4, and would return a set containing pointers to those three nodes. Calling trimAbove(root, 2) would delete the nodes containing 6, 9, and 1 and return pointers to the nodes containing 3, 5, 7, and 0. Calling trimAbove(root, 137) would delete the entire tree and return an empty set.

Some notes on this problem:

  • Aside from the Set, you should not use any other container types (Vector, Map, Queue, string, etc.) in the course of solving this problem.
  • You must implement this function recursively. If you’d like, you can make it a wrapper function.
  • The Set you return should not include any null pointers.
  • You should not allocate any new Nodes in the course of solving this problem, but you may need to deallocate nodes.
  • You do not need to handle the case where level is negative.
  • Your code should work for all trees, not just the tree shown above.

Here are a few different solutions to this problem, each of which illustrates a different set of insights.

This first solution is based on the insight that if you remove the root of a tree, you form two new trees, each of whom can have their levels numbered by subtracting one from the levels relative to the main tree.

Set<Node*> trimAbove(Node* root, int level) {
    /* Base Case: If the root is null, there's nothing to do. */
    if (root == nullptr) return { };
    
    /* Base Case: If level is 0, then the root node is the only node at
     * level 0 in the tree.
     */
    if (level == 0) return { root };
    
    /* Recursive Case: Delete this node, and possibly trim below us too.
     * To ensure we don't lose track of the children, store pointers to
     * the children before deleting the root.
     */
    Node* left  = root->left;
    Node* right = root->right;
    delete root;
    
    return trimAbove(left, level - 1) + trimAbove(right, level - 1);
}

This second solution uses a wrapper function and recursively counts down which level we're in until we hit the level in question.

/* Trims all nodes above targetLevel. currLevel denotes our current level in
 * the tree.
 */
Set<Node*> trimRec(Node* root, int targetLevel, int currLevel) {
    /* Base Case: If the root is null, there's nothing to do. */
    if (root == nullptr) return { };
    
    /* Base Case: If we hit the target level, then hand back the node. */
    if (targetLevel == currLevel) return { root };
    
    /* Recursive Case: Recursively process the left and right subtree. Having
     * done so, clean up the current node.
     */
    auto left  = trimRec(node->left,  targetLevel, currLevel + 1);
    auto right = trimRec(node->right, targetLevel, currLevel + 1);
    
    delete root;
    return left + right;
}

Set<Node*> trimAbove(Node* root, int level) {
    return trimRec(root, level, 0);
}

This third solution uses a totally different strategy. We begin with a set of all the nodes in layer 0 and repeatedly use the nodes in the current level to work out the nodes in the next level, recursively wiping one level at a time.

Set<Node*> trimRec(const Set<Node*>& currLayer, int layer) {
    /* Base Case: Hit the bottom layer? Return what we have. */
    if (layer == 0) {
        return currLayer;
    }
    
    /* Recursive Case: This layer needs to be deleted, and we need to form
     * the layer below us.
     */
    Set<Node*> nextLayer;
    for (Node* node: currLayer) {
        /* Add the children, then clean up this node. */
        if (node->left  != nullptr) nextLayer += node->left;
        if (node->right != nullptr) nextLayer += node->right;
        delete node;
    }
    
    /* Recursively clean up below this point. */
    return trimRec(nextLayer, layer - 1);
}

Set<Node*> trimAbove(Node* root, int layer) {
    /* Edge case: If the root is null, there's nothing to do. */
    if (root == nullptr) return { };
    
    /* Otherwise, recursively wipe the top layer and the ones beneath
     * it.
     */
    return trimRec({ root });
}

2. Domino Chaining (DominoChaining.cpp)

Topic: Recursive Backtracking

(Based on an excellent problem by Eric Roberts)

A domino is a 2 x 1 tile where each square contains some number of pips (dots). Some sample dominoes are shown here:

TODO: Accessible description

A domino chain is a way of arranging a series of dominoes in a line such that, at the points where the dominoes meet, the numbers of pips on each domino at the meeting point are the same. Here's a way of chaining together the above dominoes, for example:

TODO: Accessible descrption

Notice that some of the dominoes have been rotated 180 degrees to form the chain.

Write a function

bool canChainDominoes(Vector<Domino>& dominoes);

that takes in a set of dominoes, then returns whether it's possible to chain those dominoes together in some order. Here, Domino is a struct type defined as follows:

struct Domino {
    int first, second; // Number of dots on the first/second tile, respectively
};

There are two subtleties to watch out for in this problem. The first is that dominoes can be rotated, so when trying to extend a chain of dominoes we have to consider placing the domino in either direction. The second is that the very first domino in the chain behaves differently from the others in that it does not need to match anything that comes before it. There are several ways of handling each of these constraints. One of them is shown here:

/* Can we chain the remaining dominoes together, given that the last domino's
 * exposed number of pips is 'last'.
 */
bool canChainRec(Vector<Domino>& dominoes, int last) {
    /* Base Case: No dominoes left? Then we're done! */
    if (dominoes.isEmpty()) {
        return true;
    }

    /* Otherwise, some domino comes next. Try all options. */
    for (int i = 0; i < dominoes.size(); i++) {
        Domino d = dominoes[i];

        /* If the number of pips on the front of the domino matches the number
         * of pips on the end of the previous domino, we can extend the chain,
         * and the number of pips on the side we didn't use becomes the new
         * number of pips to match.
         */
        auto front = dominoes.subList(0, i);
        auto prev = dominoes.subList(i + 1);
        auto combined = front + prev;

        if ((d.first == last  && canChainRec(combined, d.second)) ||
            (d.second == last && canChainRec(combined, d.first))) {
            return true;
        }
    }

    /* We tried everything and nothing worked. Give up. */
    return false;
}

/* Checks if the dominoes can be chained together. */
bool canChainDominoes(Vector<Domino>& dominoes) {
    /* If there are no dominoes, then we can chain all zero of them
     * together into a length-zero chain.
     */
    if (dominoes.isEmpty()) {
        return true;
    }

    /* Otherwise, some domino comes first. Try each domino, in both
     * orientations, to see whether we can form a chain.
     */
    for (int i = 0; i < dominoes.size(); i++) {
        Domino d = dominoes[i];

        /* If the number of pips on the front of the domino matches the number
         * of pips on the end of the previous domino, we can extend the chain,
         * and the number of pips on the side we didn't use becomes the new
         * number of pips to match.
         */
        auto front = dominoes.subList(0, i);
        auto prev = dominoes.subList(i + 1);
        auto combined = front + prev;

        if (canChainRec(combined, d.second) ||
            canChainRec(combined, d.first)) {
            return true;
        }
    }
    return false;
}

Fun fact: have you ever done those puzzles as a kid where you have a diagram of a figure and need to trace the figure without picking up your pencil or retracing a line? This problem turns out to be equivalent to that, but in a really subtle way. Specifically, draw a dot for each different number of pips that appears on the domino. Then, for each domino, draw a curve connecting the dots corresponding to its two sides. Tracing out a path that never retraces a line or picks up your pencil then gives you a solution to this dominoes problem! This is called an Euler trail. For more on this, take Math 108 or CS261!


3. Slow and Steady Copying (BackupVector.cpp/.h)

Topics: Dynamic Memory, Arrays, Classes

This problem explores an alternate way to implement a vector called a backup vector. In Lecture 15, when we implemented the stack, we used the following strategy:

  • Store all the elements in an array.
  • When we run out of space in the array, double the array size, copying over all the old elements.

This is the same way that the Vector type is implemented, as well. (Recall that we actually used a slight variation of this approach in lecture, though: instead of doubling the array length, we used (2 * oldLength + 1) for the length of our new array. If we simply double the length of an array, then we need to catch the special case where oldLength == 0 if we want to ensure our array always actually expands.)

This approach works very well in practice, but has one slight drawback. Even though most push operations are fast, every now and then we have to do a huge amount of work to copy over all the elements from the old array to the new one. This then raises a question – is there a way to grow the array without moving everything over all at once?

The backup vector is one possible way to do this. The basic idea is the following: let’s imagine that we have a full array of elements, as shown here, and we want to append one more element to the vector:

A yellow pointer points to a full array of size 4. The elements in the array are: a, b, c, d

As before, we’ll get a new array that’s twice as big as our previous one:

The previous array of size 4 with elements a, b, c, d is now labeled small. There is a new pointer, large, which points to an empty array of size 8.

At this point, we have two arrays, the older, smaller array (called the small array) and a newer, larger array called the large array that’s twice as big as the small array.

However, here’s where things diverge from what we did in class. Rather than copying over all the elements, we’ll just copy over the last element from the small array into the new array, as shown here:

At the top, we have our small array, with size 4 and elements a, b, c, d. At the bottom, we have our large array of size 8. Now, the element at index 3 is d (so the array is like this: empty,empty,empty,d,empty,empty,empty,empty)

We’ll then append our new value to the vector by placing it at the next free slot in the large array, as shown here:

At the top, we have our small array, with size 4 and elements a, b, c, d. At the bottom, we have our large array of size 8. Now, e is appended to the array (so the array is like this: empty,empty,empty,d,e,empty,empty,empty)

More generally, whenever we append an element to the vector, we’ll place it at the next free slot in the large array, then copy over one more element from the small array. For example, here’s what happens if we append f to the vector:

At the top, we have our small array, with size 4 and elements a, b, c, d. At the bottom, we have our large array of size 8. Now, f is appended to the array after d and c from the small array is copied over (so the array is like this: empty,empty,c,d,e,f,empty,empty)

Notice that we placed f in the next spot in the large array, then copied the last uncopied element (c) from the small array into the large array. If we append g, we get this result:

At the top, we have our small array, with size 4 and elements a, b, c, d. At the bottom, we have our large array of size 8. Now, g is appended to the array after f and b from the small array is copied over (so the array is like this: empty,b,c,d,e,f,g,empty)

That is, g goes in the next free slot in the large array, and we copy the next uncopied element over from the small array (namely, b). Finally, if we append h, we get this result:

At the top, we have our small array, with size 4 and elements a, b, c, d. At the bottom, we have our large array of size 8. Now, h is appended to the array after g and a from the small array is copied over (so the array is like this: a,b,c,d,e,f,g,h)

Notice that our large array is now full and has all the elements that used to be in the small array. We’ve copied everything over, just not all at once.

At this last point, all the elements from the small array have been copied. If we then append one more value to the vector, we can discard it and begin this process again: get a new large array and move a single element over. For example, here’s what it would look like if we now appended i:

Our small array is now the array we created previously: a,b,c,d,e,f,g,h. We now have a new array that is double the size of the small array, and it contains the elements h and i, like this: empty,empty,empty,empty,empty,empty,empty,h,i,empty,empty,empty,empty,empty,empty,empty

Notice that the old large array is now the small array, and the current large array is a new array twice as big as the previous one.

To summarize:

  • At each point in time, we maintain two arrays, a small array and a large array, such that the large array is twice as big as the small array.
  • Whenever we append a value, if there’s room left in the large array, we place the item at the next free slot in the large array, then copy an element over from the small array. If the large array is full, we deallocate the small array, then allocate a new large array that’s twice as big as the old one.

There is one exception to the above rules. Specifically, when the backup vector is first created, we will only have a large array, and there will be no small array. Until that first array fills up, we basically proceed as usual, as though we were working with a regular dynamic array.

As an example, here’s what it looks like if we start with an empty backup vector, the insert the a, b, c, d, and e, in that order. This diagram assumes that the initial size of the large vector is two.

The diagram models how a,b,c,d,e is inserted into an empty backup vector. Step one: there is no small array, and the large array is empty with size 2. Step two: Insert a. There is no small array, and the large array contains a at the 0 index, like this: a,empty. Step 3: Insert b. There is no small array, and the large array is like this: a,b. Step 4: Insert c. The small array is now size 2, and is like this: a,b. The large array is size 4, and is like this: empty, b, c, empty. Step 5: Insert d. The small array is like this: a,b. The large array is like this: a,b,c,d. Step 6: Insert e. The small array is size 4, and is like this: a,b,c,d. The large array is size 8, and has the elements d and e, like this: empty,empty,empty,d,e,empty,empty,empty

That leaves the question of how to do lookups. To access the item at some index, you’ll need to determine which of the two arrays to look at. Some elements will only be in the large array. Others will be located only in the small array. See if you can find a general pattern you can use to determine where to look.

Implement the following BackupVector type:

class BackupVector {
public:
    BackupVector();
    ~BackupVector();

    /* Appends a new value to the vector. */
    void append(int toAdd);

    /* Returns the value at the given index. Calls error() if the index is
     * out of bounds.
     */
    int get(int index) const;

    /* Size of the vector, and whether it's empty. */
    int  size() const;
    bool isEmpty() const;

private:
    /* The two arrays. */
    int* small;
    int* large;
    
    /* The rest is up to you! */
};

A few things to notice here. First, there’s no way to remove elements from this vector. In practice, we’d have some way to remove things, but to keep things simple you just need to support adding elements. Second, there’s no way to change an element after it’s been added. This is, again, a simplification.

Here's our header file:

class BackupVector {
public:
    BackupVector();
    ~BackupVector();

    /* Appends a new value to the vector. */
    void append(int toAdd);

    /* Returns the value at the given index. Calls error() if the index is
     * out of bounds.
     */
    int get(int index) const;

    /* Size of the vector, and whether it's empty. */
    int  size() const;
    bool isEmpty() const;

private:
    /* The two arrays. */
    int* small;
    int* large;
    
    /* The rest is up to you! */
    int logicalSize;   // Number of stored elements
    int allocatedSize; // Of the large array

    /* Cleans up the previous small array and makes a new large array. */
    void grow();
};

Now, our .cpp file:

#include "BackupVector.h"
using namespace std;

/* Initial size of the large array. (The small array is not allocated until
 * the large array is full.)
 */
const int kInitialSize = 2;

BackupVector::BackupVector() {
    /* We begin with just a large array and no small array. */
    small = nullptr;
    large = new int[kInitialSize];
    
    logicalSize = 0;              // Logically, no elements are stored
    allocatedSize = kInitialSize; // Size of the large array
}

/* Need to clean up both the large and small array. */
BackupVector::~BackupVector() {
    delete[] large;
    
    /* There might not be a small array. However, C++ guarantees that calling
     * delete[] on a null pointer has no effect.
     */
    delete[] small;
}

void BackupVector::append(int toAdd) {
    /* Get more space if we need it. */
    if (logicalSize == allocatedSize) {
        grow();
    }

    /* Write the element to the large array. */
    large[logicalSize] = toAdd;
    
    /* Now, move an element from the small array to the large array. */
    if (small != nullptr) {
        large[allocatedSize - logicalSize - 1] = small[allocatedSize - logicalSize - 1];
    }
    logicalSize++;
}

int BackupVector::get(int index) const {
    /* Bounds-checking. */
    if (index < 0 || index >= size()) error("Out of range.");

    /* If there's no small array, we definitely look in the large array. */
    if (small == nullptr) return large[index];
    
    /* Otherwise, where to look depends on whether we're in the first or
     * second half of the array.
     *
     * In case you haven't seen this before, the syntax
     *
     *    test? ifTrue : ifFalse
     *
     * means "see if test is true. If it is, evaluate to ifTrue. If it's not,
     * evaluate to ifFalse."
     */
    return (index < allocatedSize / 2? small : large)[index];
}

int BackupVector::size() const {
    return logicalSize;
}

bool BackupVector::isEmpty() const {
    return size() == 0;
}

void BackupVector::grow() {
    /* Clean up the previous small array. If there isn't one, then small is
     * nullptr, and delete[]-ing nullptr has no effect.
     */
    delete[] small;
    
    /* The small array is now what used to be the large array. */
    small = large;

    /* Get some more space to work with. */
    allocatedSize *= 2;
    large = new int[allocatedSize];
}

4. Deques (Deques.cpp/.h)

Topics: Classes, Linked Lists, Dynamic Memory, Pointers

This problem concerns a data structure called a double-ended queue, or deque for short (it’s pronounced “deck,” as in a deck of cards). A deque is similar to a stack or queue in that it represents a sequence of elements, except that elements can be added or removed from both ends of the deque. Here is one possible interface for a Deque class:

class Deque {
public:
    Deque();
    ~Deque();

    /* Seems like all containers have the next two functions. :-) */
    bool isEmpty() const;
    int  size() const;

    /* Adds a value to the front or the back of the deque. */
    void pushFront(int value);
    void pushBack(int value);

    /* Looks at, but does not remove, the first/last element of the deque. */
    int peekFront() const;
    int peekBack() const;

    /* Returns and removes the first or last element of the deque. */
    int popFront();
    int popBack();
};

One efficient way of implementing a deque is as a doubly-linked list. The deque stores pointers to the head and the tail of the list to support fast access to both ends. Design the private section of the Deque class, then implement the above member functions using a doubly-linked list. As a hint, this is way easier to do using dummy nodes!

If you think about it, the logic from the previous problem really nicely lets us build a deque – we have the ability to splice things in anywhere we want and splice things out anywhere we want, which is precisely what we’d need to do here.

This solution uses a doubly-linked list with a dummy head and tail node, as described in Problem Eight.

class Deque {
public:
    Deque();
    ~Deque();

    /* Seems like all containers have the next two functions. :-) */
    bool isEmpty() const;
    int  size() const;

    /* Adds a value to the front or the back of the deque. */
    void pushFront(int value);
    void pushBack(int value);

    /* Looks at, but does not remove, the first/last element of the deque. */
    int peekFront() const;
    int peekBack() const;

    /* Returns and removes the first or last element of the deque. */
    int popFront();
    int popBack();

private:
    struct Cell {
        int value;
        Cell* next;
        Cell* prev;
    };
    Cell* _head;
    Cell* _tail;
    int  _ numElems; // Cache for efficiency; makes size() run in time O(1).

    /* Creates a new cell initialized to a given value, but whose next and prev
     * pointers are uninitialized. This cell is intended to be used inside the
     * linked list, and therefore the size field is adjusted appropriately.
     */
    Cell* makeCell(int value);

    /* Destroys the given cell, which is presumed to be in the linked list. */
    void destroy(Cell* toRemove);
};

Now, the .cpp file:

#include "error.h" // Because bad things happen.

Deque::Deque() {
    /* Set up the empty dummied, doubly-linked list. */
    _head = new Cell;
    _tail = new Cell;
    _head->next = _tail;
    _tail->prev = _head;
    _head->prev = _tail->next = nullptr;

    _numElems = 0;
}

Deque::~Deque() {
    /* Delete all cells in the list, including the head and tail. */
    while (_head != nullptr) {
        Cell* next = _head->next;
        delete _head;
        _head = next;
    }
}

/* Because we’ve cached the size, we don’t need to scan through the list when
 * we want to determine how many elements there are.
 */
int Deque::size() const {
    return _numElems;
}

/* Good programming exercise: suppose that we didn’t cache the number of elements
 * in the list and that size() had to scan over the entire list in time O(n). How
 * would you implement isEmpty() given that we have a head and tail pointer?
 */
bool Deque::isEmpty() const {
    return size() == 0;
}

/* Our helper function that makes a cell. We could have alternatively defined a
 * constructor on the Cell type (yes, you can do that!), but I chose to do things
 * this way to show off Yet Another Memorable Piece of C++ Syntax. Since the
 * Cell type is nested inside Deque, the return type of this function has to be
 * Deque::Cell*, indicating that Cell is defined inside of Deque.
 */
Deque::Cell* Deque::makeCell(int value) {
    Cell* result = new Cell;
    result->value = value;

    _numElems++;
    return result;
}

/* pushFront is essentially insertAfter with the head pointer. */
void Deque::pushFront(int value) {
    Cell* cell = makeCell(value);
    cell->prev = _head;
    cell->next = _head->next;
    cell->prev->next = cell;
    cell->next->prev = cell;
}

/* pushBack is essentially insertBefore with the tail pointer. */
void Deque::pushBack(int value) {
    Cell* cell = makeCell(value);
    cell->next = _tail;
    cell->prev = tail->prev;
    cell->prev->next = cell;
    cell->next->prev = cell;
}

/* To look at the front or back, we have to skip over the head or tail nodes,
 * since they’re dummies and don’t actually have any data in them.
 */
int Deque::peekFront() const {
    if (isEmpty()) error("This is why we can’t have nice things.");
    return _head->next->value;
}

int Deque::peekBack() const {
    if (isEmpty()) error("This is why we can’t have nice things.");
    return _tail->prev->value;
}

/* The destroy operation is essentially our remove function from earlier.
 * Notice that we do not need to use the full name Deque::Cell here because Cell
 * is an argument to a member function that’s part of the Deque type.
 */
void Deque::destroy(Cell* toRemove) {
    toRemove->next->prev = toRemove->prev;
    toRemove->prev->next = toRemove->next;

    /* At this point it’s spliced out of the list. */
    delete toRemove;
    _numElems--;
}

/* popFront and popBack are essentially just wrapped splice-outs. */
int Deque::popFront() {
    int result = peekFront();
    destroy(_head->next);
    return result;
}

int Deque::popBack() {
    int result = peekBack();
    destroy(_tail->prev);
    return result;
}

5. The Great Tree List Recursion Problem (GreatTreeListRecursion.cpp)

Topics: Binary Trees, Linked Lists, Recursion

(This excellent problem comes from Nick Parlante.)

A node in a binary tree has the same fields as a node in a doubly-linked list: one field for some data and two pointers. The difference is what those pointers mean: in a binary tree, those fields point to a left and right subtree, and in a doubly-linked list they point to the next and previous elements of the list.

Write a function

Node* toList(Node* root);

that, given a pointer to the root of a binary search tree, flattens the tree into a doubly-linked list, with the values in sorted order, without allocating any new cells, and returns a pointer to the first cell in the list. You’ll end up with a list where the pointer left functions like the prev pointer in a doubly-linked list and where the pointer right functions like the next pointer in a doubly-linked list. ​

​ This is a beautiful recursion problem. Essentially, what we want to do is the following:

  • The empty tree is already indistinguishable from the empty list.
  • Otherwise, flatten the left subtree and right subtree, then concatenate everything together.

To implement that last step efficiently, we’ll have our recursive function hand back two pointers: one to the front of the flattened list and one to the back.

struct Range {
    Node* first;
    Node* last;
};

Range flatten(Node* root) {
    /* If the tree is empty, it's already flattened. */
    if (root == nullptr) return { nullptr, nullptr };

    /* Flatten the left and right subtrees. */
    Range left  = flatten(root->left);
    Range right = flatten(root->right);

    /* Glue things together. */
    root->left = left.last;
    if (left.last != nullptr) left.last->right = root;

    root->right = right.first;
    if (right.first != nullptr) right.first->left = root;

    /* Return the full range. */
    return {
        left.first == nullptr? root : left.first,
        right.last == nullptr? root : right.last
    };
}

Node* toList(Node* root) {
    return flatten(root).first;
}