More Practice Problems (Post-Midterm)


Section materials curated by Sean and Butch, drawing upon materials from previous quarters

⚠️ This page is actively maintained. Check back at the end of each week for additional problems.

🌱 For your convenience, new problem categories are marked with a sprout emoji each week.

↩️ Backtracking exercises were added Thursday, July 23.

🗃️ Classes exercises were added Thursday, July 23.

👉 Pointers and Memory Management exercises were added Thursday, July 23.

🌱 Backtracking

1. Recursive Enumeration and Backtracking

Given a positive integer n, write a function

void printSumsOf(int n)

that finds all ways of writing n as a sum of nonzero natural numbers. For example, given n = 3, you’d list off these options:

3, 2 + 1, 1 + 2, 1 + 1 + 1

Next, write a function

void listKOrderings(Set<string> choices, int k)

that, given a set of strings and a number k, lists all ways of choosing k elements from that list, given that order does matter. For example, given the objects A, B, and C and k = 2, you’d list

A B, A C, B A, B C, C A, C B

Finally, we will solve a problem involving compound words, which are words that can be cut into two smaller pieces, each of which is a word. You can generalize this idea further if you allow the word to be chopped into even more pieces. For example, the word "longshoreman" can be split into "long," "shore," and "man," and "whatsoever" can be split into "what," "so," and "ever."" Write a function

void printMultCompoundWords(Lexicon& dict)

that takes in a Lexicon representing the English dictionary and prints out all words in the dictionary that can be split apart into two or more smaller pieces, each of which is itself an English word.

The key insight for the first problem is that some positive number has to come first in our ordering, so we can just try all possible ways of breaking off some initial bit and see what we find.

void printSumsOf(int n) {
    /* Handle edge cases. */
    if (n < 0) error("Can't make less than nothing from more than nothing.");
    printSumsRec(n, {});
}

/* Print all ways to sum up to n, given that we've already broken off the numbers
 * given in soFar.
 */
void printSumsRec(int n, Vector<int> soFar) {
    /* Base case: Once n is zero, we need no more numbers. */
    if (n == 0) {
        printAsSum(soFar);
    } else {
        /* The next number can be anything between 1 and n, inclusive. */
        for (int i = 1; i <= n; i++) {
            printSumsRec(n - i, soFar + i);
        }
    }
}

/* Prints a Vector<int> nicely as a sum. */
void printAsSum(Vector<int>& sum) {
    /* The empty sum prints as zero. */
    if (sum.isEmpty()) {
        cout << 0 << endl;
    } else {
        /* Print out each term, with plus signs interspersed. */
        for (int i = 0; i < sum.size(); i++) {
            cout << sum[i];
            if (i + 1 != sum.size()) cout << " + ";
        }
        cout << endl;
    }
}

The second problem is half combinations, half permutations. We use the permutations strategy of asking "what is the next term in our ordered list?" at each step, and the combinations strategy of cutting off our search as soon as we have enough terms.

void listKOrderings(Set<string> choices, int k) {
    /* Quick edge case check: if we want more items than there are options, there
    * are no orderings we can use.
    */
    if (k < choices.size()) {
        listOrderingHelper(choices, k, {});
    }
}
void listOrderingHelper(Set<string> choices, int k, Vector<string> soFar) {
    /* Base case: If no more terms are needed, print what we have. */
    if (k == 0) {
        cout << soFar << endl;
    }
    /* Recursive case: What comes next? Try all options. */
    else {
        for (string choice: choices) {
            listOrderingHelper(choices - choice, k - 1, soFar + choice);
        }
    }
}

The main insight for the final problem is that a word can be broken apart into two or more words if it can be split into two pieces such that the first piece is a word, and the second piece is either (1) a word or (2) itself something that can be split apart into two or more words.

void printMultCompoundWords(Lexicon& dict) {
    for (string word : dict) {
        if (isMultCompoundWord(word, dict)) {
            cout << word << endl;
        }
    }
}

bool isMultCompoundWord(string word, Lexicon& dict) {
    /* In an unusual twist, our base case is folded into the recursive step. We
    * will try all possible splits into two pieces, and if one of them happens
    * to be a pair of words, we stop.
    */
 
    /* Try all ways of splitting things. */
    for (int i = 1; i < word.length(); i++) {
        /* Only split if the first part is a word. */
        if (dict.contains(word.substr(0, i))) {
            string remaining = word.substr(i);
            /* We're done if the remainder is either a word or a compound word. */
            if (dict.contains(remaining) || isMultCompoundWord(remaining, dict)) {
                return true;
            }
        }
    }
    /* Nothing works; give up. */
    return false;
}

2. Longest Common Subsequence

Topic: Recursive Backtracking

Write a recursive function named longestCommonSubsequence that returns the longest common subsequence of two strings passed as arguments. Some example function calls and return values are shown below.

Recall that if a string is a subsequence of another, each of its letters occurs in the longer string in the same order, but not necessarily consecutively.

Hint: In the recursive case, compare the first character of each string. What one recursive call can you make if they are the same? What two recursive calls do you try if they are different?

longestCommonSubsequence("cs106a", "cs106b") --> "cs106" 
longestCommonSubsequence("nick", "julie") --> "i" 
longestCommonSubsequence("karel", "c++") --> "" 
longestCommonSubsequence("she sells", "seashells") --> "sesells"
string longestCommonSubsequence(string s1, string s2) {
	if (s1.length() == 0 || s2.length() == 0) {
		return "";
 	} else if (s1[0] == s2[0]) {
 		return s1[0] + longestCommonSubsequence(s1.substr(1), 
 							s2.substr(1));
 	} else {
 		string choice1 = longestCommonSubsequence(s1, s2.substr(1));
		string choice2 = longestCommonSubsequence(s1.substr(1), s2);
		if (choice1.length() >= choice2.length()) {
			return choice1;
		} else {
			return choice2;
		}
	}
}

3. Cracking Passwords

Topic: Recursive backtracking

Write a function crack that takes in the maximum length a site allows for a user's password and tries to find the password into an account by using recursive backtracking to attempt all possible passwords up to that length (inclusive). Assume you have access to the function bool login(string password) that returns true if a password is correct. You can also assume that the passwords are entirely alphabetic and case-sensitive. You should return the correct password you find, or the empty string if you cannot find the password. You should return the empty string if the maximum length passed is 0 and raise an error if the length is negative.

Security note: The ease with which computers can brute-force passwords is the reason why login systems usually permit only a certain number of login attempts at a time before timing out. It’s also why long passwords that contain a variety of different characters are better! Try experimenting with how long it takes to crack longer and more complex passwords. See the comic here for more information: https://xkcd.com/936/

string crackHelper(string soFar, int maxLength) {
	if (login(soFar)) {
		return soFar;
	}
	if (soFar.size() == maxLength) {
		return "";
	}
	for (char c = 'a'; c <= 'z'; c++) {
		string password = crackHelper (soFar + c, maxLength);
		if (password != "") {
			return password;
		}
 		// Also check uppercase
 		char upperC = toupper(c);
 		password = crackHelper (soFar + upperC, maxLength);
		if (password != "") {
 			return password;
 		}
 	}
	return "";
}

string crack(int maxLength) {
	if (maxLength < 0) {
 		error("max length cannot be negative!);";
	}
 	return crackHelper("", maxLength);
}

🌱 Classes

1. Rational Decision Making (Fraction.h/.cpp)

📦 Starter Code | Topic: Classes

Consider the following partially-implemented Fraction class which can be used to model rational numbers in C++, functionality that is not built-in to the language. If you're interested in the actual source code of the public and private helper methods, feel free to refer to the starter code attached above.

class Fraction {
public:
    /* Creates the fraction num / denom. If neither is specified,
     * the fraction defaults to 0. If only the numerator is specified,
     * the fraction will be equal to that value exactly (the
     * denominator will be 1). For example:
     *
     * Fraction f;          // f == 0      (0/1)
     * Fraction f = 137;    // f == 137    (137/1)
     * Fraction f(137, 42); // f == 137/42
     */
    Fraction(int num = 0, int denom = 1);
    
    /* Access numerator and denominator. */
    int numerator();
    int denominator();
    
    /* Adds the given value to this fraction. For example, if this
     * fraction is 1/2 and the other fraction is 1/4, this fraction
     * ends up equal to 3/4.
     */
    void add(Fraction f);
    
    /* Multiplies this fraction by the other fraction. For example,
     * if this fraction is 1/2 and the other fraction is 1/4, this
     * fraction ends up equal to 1/8.
     */
    void multiply(Fraction f);
    
    /* Evaluates the ratio represented by this fraction. */
    double asDecimal();
    
private:
    /* The actual numerator and denominator. */
    int num;
    int den;
    
    /* Reduces the fraction to its simplest form. */
    void reduce();
};

i. Fraction(int num = 0, int denom = 1) Analysis

The constructor for the Fraction type uses two default arguments. Read the comments on the constructor. Given how the constructor is defined and given its default arguments, why does writing Fraction f = 137 produce the fraction 137 / 1?

The constructor takes in two arguments, but has the first default to 0 and the second to 1. Therefore, if only a single argument is provided, C++ will assume the second is 1. This means that Fraction f = 137 is treated as if the first argument is 137 and the second argument is 1, so we get the fraction 137 / 1 = 137


ii. add(Fraction f) Analysis

The function named add could mean one of two different things. First, it could take two fractions and add them together, producing a third fraction. Second, it could take two fractions and modify the first fraction by adding in the second. In the case of our Fraction class, it's the latter. There are two ways you can infer this just by looking at the signature of the function add, without even needing to read the comments. What are they?

The first "tell" is that add has a void return type and thus doesn't return anything. Therefore, it couldn't return a new Fraction.

The second "tell" is that add is not passed by reference. This means that the function cannot modify the Fraction object it's called on.


iii. Fraction reciprocol()

Add a function named reciprocal to the Fraction type that returns the reciprocal of the fraction. If the fraction is zero, call error to report that the reciprocal can't be taken.

First, we'll add this to the .h file in the public section of the class.

/* Returns the reciprocal of this fraction. If the fraction is zero, this
 * operation is not defined, and the function calls error().
 */
Fraction reciprocal();

Next, we'll add this to the .cpp file:

Fraction Fraction::reciprocal() {
    if (numerator() == 0) {
        error("I can't divide by zero. (Maybe you can, though?)");
    }

    return Fraction(denominator(), numerator());
}

iv. void divide()

Add a function divide to the Fraction type. Analogously to multiply, the divide function should accept as input another Fraction and modify the receiver by dividing its value by that of the other Fraction.

If the other fraction is equal to zero, call error().

First, let's add this to our .h file:

/* Divides this fraction by the fraction given in the parameter. If that
 * fraction is zero, calls error().
 */
void divide(Fraction rhs);

Next, the implementation, which goes in the .cpp file:

void Fraction::divide(Fraction rhs) {
    multiply(rhs.reciprocal());
}

2. Dynamic Arrays and Classes

Topics: Classes, dynamic memory management

The int type in C++ can only support integers in a limited range (typically, -2^31 to 2^31 1). If you want to work with integers that are larger than that, you’ll need to use a type often called a big number type (or “bignum” for short). Those types usually work internally by storing a dynamic array that holds the digits of that number. For example, the number 78979871 might be stored as the array [7, 8, 9, 7, 9, 8, 7, 1] (or, sometimes, in reverse as [1, 7, 8, 9, 7, 9, 8, 7]). Implement a bignum type layered on top of a dynamic array. Your implementation should provide member functions that let you add together two bignums or produce a string representation of a bignum, and a constructor that lets you initialize the bignum to some integer value. For simplicity, you don’t need to worry about negative numbers.

Let’s begin with the interface for our class, which will look like this:

class BigNum {
public:
    BigNum(int value = 0); // Default to zero unless specified otherwise.
    ~BigNum();

    std::string toString() const; // Get a string representation
    void add(const BigNum& value);
private:
    int* digits; // Stored in reverse order
    int allocatedSize; // In # of digits
    int numDigits; // In # of digits.
    void reserve(int space); // Ensure we have space to hold numDigits digits
    int numDigitsOf(int value) const; // How many digits are in value?
};

Here, the constructor takes in the value we’ll store. The toString function produces a string representation of the number, and the add function takes in another BigNum and adds its value to the total.

Interally, we’ll represent the BigNum as an array of integers, each of which represents a single digit. The allocatedSize and numDigits variables track how many digits we have space for and how many digits we actually have, respectively. (A note: it’s actually not a good use of space to store each digit as an integer because the int type can hold much, much larger values than a single digit, but for simplicity we’ll opt for that approach. Take CS107 to see some alternatives!)

We’ll also write a function named reserve(), which takes as input a number of digits and then does whatever needs to be done to ensure that we have space for at least that many digits. Think of it as a more cautious version of the grow() function we wrote for our stack type: it’ll make the array bigger, but only if it needs to.

To make the implementation simpler, we’ll store the digits of our number in reverse order, so the number 137 would be stored as 7, 3, 1. This makes the math easier and makes it easier to add digits in, since it’s usually easier to add new items further in an array rather than earlier. The implementation itself is presented below.

BigNum::BigNum(int value) {
    /* Set up an initial array of elements. */
    digits = new int[kDefaultSize]; // Some reasonable default
    allocatedSize = kDefaultSize;

    /* Count how many digits are in our number. 0 is an edge case, so we'll make
    * this work by reducing the number of digits down to the last one.
    */
    numDigits = numDigitsOf(value);

    if(numDigits >= allocatedSize) error("Input too big!");

    /* Copy the number into the array, one digit at a time. */
    for (int i = 0; i < numDigits; i++) {
        digits[i] = value % 10;
        value /= 10;
    }
}

int BigNum::numDigitsOf(int value) const {
    /* Pro C++ tip: Since this function doesn't read or write any member
    * variables, this should either be a free function or a static member
    * function. We didn't discuss those sorts of concerns in CS106B, though.
    */
    int result = 1; // All numbers have at least one digit.

    /* We've already counted one digit. Now count the rest. */
    while (value >= 10) {
        result++;
        value /= 10;
    }
    return result;
}

BigNum::~BigNum() {
    delete[] digits;
}

string BigNum::toString() const {
    /* Because characters are stored in reverse order, we have to scan them
    * backwards to make our number.
    */
    string result;
    for (int i = numDigits - 1; i >= 0; i--) {
        result += to_string(digits[i]);
    }
    return result;
}

void BigNum::add(const BigNum& value) {
    /* First, ensure we have space to hold the result. Adding two numbers produces
    * a result whose size is at most one digit bigger than either input number.
    */
    int digitsToVisit = max(numDigits, value.numDigits);
    reserve(1 + digitsToVisit);

    /* Use the grade school algorithm to add the numbers. */
    int carry = 0;
    for (int i = 0; i < digitsToVisit; i++) {

        int sum;
        if (i < numDigits && i < value.numDigits) sum = digits[i] + value.digits[i] + carry;
        else if (i < numDigits) sum = digits[i] + carry;
        else sum = value.digits[i] + carry;

        /* Write the one's place. */
        digits[i] = sum % 10;
        /* Store the carry. */
        carry = sum / 10;
    }

    /* We need at least as many digits as before, plus one if there is a final
    * carry.
    */
    numDigits = digitsToVisit;
    if (carry != 0) {
        numDigits ++;
    }
    if (carry == 1) digits[digitsToVisit] = 1;
}

/* Reserving space uses the regular "double in size" trick. */
void BigNum::reserve(int space) {
    /* If we have the space, then we don't need to do anything. */
    if (space <= allocatedSize) return;

    /* Double and copy. We deliberately don't just grow to the size requested,
    * since that may not be efficient.
    */
    allocatedSize *= 2;
    int* newDigits = new int[allocatedSize];
    for (int i = 0; i < numDigits; i++) {
        newDigits[i] = digits[i];
    }
    delete[] digits;
    digits = newDigits;
}

3. Growing a Ring Buffer Queue (RingBufferQueue.h/.cpp)

Remember our good friend the `RingBufferQueue from earlier? Check out the problem definition from last week if you want a quick refresher. Last time we visited the RBQ it had fixed capacity – that is, it couldn't grow in size after it was initially created. How limiting! With our newfound pointer and dynamic allocation skills, we can remove this limitation on the RBQ and make it fully functional!

Add functionality to the RingBufferQueue from the previous section problem so that the queue resizes to an array twice as large when it runs out of space. In other words, if asked to enqueue when the queue is full, it will enlarge the array to give it enough capacity.

For example, say our queue can hold 5 elements and we enqueue the five values 10, 20, 30, 40, and 50. Our queue would look like this:

index    0    1    2    3    4
      +----+----+----+----+----+
value | 10 | 20 | 30 | 40 | 50 |
      +----+----+----+----+----+
         ^                   ^
         |                   |
       head                tail

If the client tries to enqueue a sixth element of 60, your improved queue class should grow to an array twice as large:

index    0    1    2    3    4    5    6    7    8    9
      +----+----+----+----+----+----+----+----+----+----+
value | 10 | 20 | 30 | 40 | 50 | 60 |    |    |    |    |
      +----+----+----+----+----+----+----+----+----+----+
         ^                        ^
         |                        |
       head                     tail

The preceding is the simpler case to handle. But what about if the queue has wrapped around via a series of enqueues and dequeues? For example, if the queue stores the following five elements:

index    0    1    2    3    4
      +----+----+----+----+----+
value | 40 | 50 | 10 | 20 | 30 |
      +----+----+----+----+----+
              ^    ^
              |    |
            tail head

If the client tries to add a sixth element of 60, you cannot simply copy the array as it is shown. If you do so, the head and wrapping will be broken. Instead, copy into the new array so that index 0 always becomes the new head of the queue. The picture will look the same as the previous one with the value 60 at index 5.

Write up the implementation of the new and improved RingBufferQueue. It should have the same members as in the previous problem, but with the new resizing behavior added. You may add new member functions to your class, but you should make them private.

First, the header file.

#pragma once

class RingBufferQueue {
public:
	// Same as before!
private:
	// Same as before! Except with the following new variable:
    // NEW
    void grow();        // Resize if we need more space  
};

Now, our .cpp file.

// Same as before! Except with the following new method:

// NEW
void RingBufferQueue::grow() {
    /* Get twice as much space as we used to have. */
    int* newElems = new int[allocatedSize * 2];
    
    /* Copy the elements over. Start at the position given by the head, and
     * use modular arithmetic to walk forward, wrapping around if necessary.
     */
    for (int i = 0; i < size(); i++) {
        newElems[i] = elems[(head + i) % allocatedSize];
    }
    
    /* Replace the old array. */
    delete[] elems;
    elems = newElems;
    
    /* Our copy operation pushed everything to the front of the ring buffer
     * array. Update the head to look there as well.
     */
    head = 0;
    
    /* Remember that we just increased our size. */
    allocatedSize *= 2;
}

🌱 Pointers and Memory Management

1. Pointers Worksheet

Topics: pointers

Work through Sean's CS106B Pointers Worksheet (PDF).


2. Cleaning Up Your Messes

Topics: pointers, dynamic memory management

Whenever you allocate an array with new[], you need to deallocate it using delete[]. It's important when you do so that you only deallocate the array exactly once – deallocating an array zero times causes a memory leak, and deallocating an array multiple times usually causes the program to crash. (Fun fact – deallocating memory twice is called a double free and can lead to security vulnerabilities in your code! Take CS155 for details.)

Below are three code snippets. Trace through each snippet and determine whether all memory allocated with new[] is correctly deallocated exactly once. If there are any other errors in the program, make sure to report them as well.

int main() {
    int* baratheon = new int[3];
    int* targaryen = new int[5];

    baratheon = targaryen;
    targaryen = baratheon;

    delete[] baratheon;
    delete[] targaryen;

    return 0;
}
int main() {
    int* stark     = new int[6];
    int* lannister = new int[3];

    delete[] stark;
    stark = lannister;

    delete[] stark;

    return 0;
}
int main() {
    int* tyrell = new int[137];
    int* arryn  = tyrell;

    delete[] tyrell;
    delete[] arryn;

    return 0;
}

The first piece of code has two errors in it. First, the line

baratheon = targaryen;

causes a memory leak, because there is no longer a way to deallocate the array of three elements allocated in the first line. Second, since both baratheon and targaryen point to the same array, the last two lines will cause an error. The second piece of code is perfectly fine. Even though we execute

delete[] stark;

twice, the array referred to each time is different. Remember that you delete arrays, not pointers.

Finally, the last piece of code has a double-delete in it, because the pointers referred to in the last two lines point to the same array.


3. The Notorious RBQ, Revisited

Topics: Classes, dynamic memory allocation, pointers

Note: This problem was included in the Section 5 handout, but we anticipate most sections will not have time to get to this one. It's important enough of an exercise that we wanted to reiterate it here.

Remember our good friend the RingBufferQueue from section? Check out the problem definition from Section 5 if you want a quick refresher. Last time we visited the RBQ it had fixed capacity – that is, it couldn't grow in size after it was initially created. How limiting! With our newfound pointer and dynamic allocation skills, we can remove this limitation on the RBQ and make it fully functional!

Add functionality to the class RingBufferQueue from section so that the queue resizes to an array twice as large when it runs out of space. In other words, if asked to enqueue when the queue is full, it will enlarge the array to give it enough capacity.

For example, say our queue can hold 5 elements and we enqueue the five values 10, 20, 30, 40, and 50. Our queue would look like this:

index    0    1    2    3    4
      +----+----+----+----+----+
value | 10 | 20 | 30 | 40 | 50 |
      +----+----+----+----+----+
         ^                   ^
         |                   |
       head                tail

If the client tries to enqueue a sixth element of 60, your improved queue class should grow to an array twice as large:

index    0    1    2    3    4    5    6    7    8    9
      +----+----+----+----+----+----+----+----+----+----+
value | 10 | 20 | 30 | 40 | 50 | 60 |    |    |    |    |
      +----+----+----+----+----+----+----+----+----+----+
         ^                        ^
         |                        |
       head                     tail

The preceding is the simpler case to handle. But what about if the queue has wrapped around via a series of enqueues and dequeues? For example, if the queue stores the following five elements:

index    0    1    2    3    4
      +----+----+----+----+----+
value | 40 | 50 | 10 | 20 | 30 |
      +----+----+----+----+----+
              ^    ^
              |    |
            tail head

If the client tries to add a sixth element of 60, you cannot simply copy the array as it is shown. If you do so, the head and wrapping will be broken. Instead, copy into the new array so that index 0 always becomes the new head of the queue. The picture will look the same as the previous one with the value 60 at index 5.

Write up the implementation of the new and improved RingBufferQueue. It should have the same members as in the previous problem, but with the new resizing behavior added. You may add new member functions to your class, but you should make them private.

RingBufferQueue.h

#pragma once

#include <iostream>
using namespace std;
class RingBufferQueue {
public:
    RingBufferQueue();
    ~RingBufferQueue();
    bool isEmpty() const;
    bool isFull() const;
    int size() const;
    void enqueue(int elem);
    int dequeue();
    int peek() const;
private:
    int* _elements;
    int _capacity;
    int _size;
    int _head;
    friend ostream& operator <<(ostream& out, const RingBufferQueue& queue);
    void enlarge();
};

RingBufferQueue.cpp

#include "RingBufferQueue.h"

static int kDefaultCapacity = 5;
RingBufferQueue::RingBufferQueue() {
    _capacity = kDefaultCapacity;
    _elements = new int[_capacity];
    _head = 0;
    _size = 0;
}
RingBufferQueue::~RingBufferQueue() {
    delete[] _elements;
}
void RingBufferQueue::enqueue(int elem) {
    if (isFull()) {
        enlarge();
    }
    int tail = (_head + _size) % _capacity;
    _elements[tail] = elem;
    _size++;
}
int RingBufferQueue::dequeue() {
    int front = peek();
    _head = (_head + 1) % _capacity;
    _size--;
    return front;
}
int RingBufferQueue::peek() const {
    if (isEmpty()) {
        error("Can't peek at an empty queue!");
    }
    return _elements[_head];
}
bool RingBufferQueue::isEmpty() const {
    return _size == 0;
}
bool RingBufferQueue::isFull() const {
    return _size == _capacity;
}
int RingBufferQueue::size() const {
    return _size;
}
void RingBufferQueue::enlarge() {
    int *larger = new int[_capacity * 2];
    for (int i = 0; i < _size; i++) {
        larger[i] = _elements[(_head + i) % _capacity];
    }
    delete[] _elements;
    _elements = larger;
    _capacity *= 2;
    _head = 0;
}
ostream& operator <<(ostream& out, const RingBufferQueue& queue) {
    out << "{";
    if (!queue.isEmpty()) {
        // We can access the inner ‘_elements’ member variables because
        // this operator is declared as a friend of the RingBufferQueue class
        out << queue._elements[queue._head];
        for (int i = 1; i < queue.size(); i++) {
            int index = (queue._head + i) % queue._capacity;
            out << ", " << queue._elements[index];
        }
    }
    out << "}";
    return out;
}