Big O and Recursion

Week 3


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

This week’s section exercises provide practice with Big-O and begin our exploration of recursion with some interesting problems!

Remember that every week we will also be releasing a Qt Creator project containing starter code and testing infrastructure for that week's section problems. When a problem name is followed by the name of a .cpp file, that means you can practice writing the code for that problem in the named file of the Qt Creator project. Here is the zip of the section starter code:

📦 Starter project

1. Big O, basics

Topics: Big-O, code analysis

What is the Big O runtime of the following functions, in terms of the variable N?

Code Snippet A

int sum = 0;
for (int i = 1; i <= N + 2; i++) {
    sum++;
}
for (int j = 1; j <= N * 2; j++) {
    sum++;
}

Code Snippet B

int sum = 0;
for (int i = 1; i <= N - 5; i++) {
    for (int j = 1; j <= N - 5; j += 2) {
        sum++;
    }
}

Code Snippet C

int sum = 0;
for (int i = 1; i <= 10000000; i++) {
    sum ++;
}

Code Snippet D

int sum = 0;
for (int i = 0; i < 1000000; i++) {
    for (int j = 1; j <= i; j++) {
        sum += N;
    }
    for (int j = 1; j <= i; j++) {
        sum += N;
    }
    for (int j = 1; j <= i; j++) {
        sum += N;
    }
}

Code Snippet E

int sum = 0;
for (int i = 1; i <= N; i *= 2) {
    sum ++;
}
Code Snippet A has a runtime complexity of O(N): The first for loop runs in O(N),
the second also runs in O(N). This makes O(N) + O(N) = O(2N) = O(N), because we 
throw away constants

Code Snippet B has a runtime complexity of O(N^2): The code in the innermost for
loop is just a sum so that runs in constant time, O(1). The j for loop iterates 
a total of (N - 5)/2 times which is equal to O(N) since we ignore constants and 
scalars. The outermost for loop also runs in O(N). In total we get 
O(1) * O(N) * O(N) = O(N^2)

Code Snippet C has a runtime complexity of O(1): This loop doesn't depend on N 
at all, and loops up to a constant 10000000. Therefore runtime is O(1)

Code Snippet D has a runtime complexity of O(1): This is also constant time. The 
outermost for loop loops up until 1000000, which is constant. The 3 inner loops 
all loop until i, which will be always less than 1000000. Therefore total 
runtime is O(1)

Code Snippet E has a runtime complexity of O(logN): This runs in logarithmic 
time, O(logN). You can see this by counting the number of times it takes i to get to
n. i starts at 1, then jumps to 2, then 4, ..., n. It takes logN steps to get
to N. This is similar to logarithmic runtime analysis in class that starts from 
n and drops down to 1.

2. Big O: best of times, worst of times

Topics: Big-O, code analysis

What are the best- and worst-case Big O runtimes of the following functions, in terms of the vector length, n?

Function A

// Returns the first even integer in the vector, or -1 if no such integer
// is found.
int firstEven(Vector<int>& v) {
  for (int i = 0; i < v.size(); i++) {
    if (v[i] % 2 == 0) {
      return v[i];
    }
  }
  return -1;
}

Function B

// Returns the first even integer in the vector, or -1 if no such integer
// is found. This is very similar to Function A. Can you spot the minor
// change that has a potentially significant impact on the runtime?
int firstEven(Vector<int> v) {
  for (int i = 0; i < v.size(); i++) {
    if (v[i] % 2 == 0) {
      return v[i];
    }
  }
  return -1;
}

Function C

// Returns whether the given vector has the same number of even integers
// as it has odd integers.
bool evenOddOnPar(Vector<int>& v) {
  int evenCount = 0;
  int oddCount = 0;
  for (int i = 0; i < v.size(); i++) {
    if (v[i] % 2 == 0) {
      evenCount++;
    } else {
      oddCount++;
    }
  }
  return evenCount == oddCount;
}

Function D

// Returns whether the given vector has the same number of even integers
// as it has odd integers. This is very similar to Function C. Can you
// spot the minor change that has a potentially significant impact on the
// runtime?
bool evenOddOnPar(Vector<int> v) {
  int evenCount = 0;
  int oddCount = 0;
  for (int i = 0; i < v.size(); i++) {
    if (v[i] % 2 == 0) {
      evenCount++;
    } else {
      oddCount++;
    }
  }
  return evenCount == oddCount;
}

Function E

int foozle(Vector<int>& v) {
  int result = 0;
  for (int i = 0; i < v.size(); i++) {
    // Assume both of the firstEven calls below are to Function A above
    // (NOT Function B).
    if (firstEven(v) != -1) {
       result += firstEven(v);
    }
  }
  return result;
}
Function A has a best-case runtime of O(1) and a worst-case runtime of
O(n): In the best case, the first element in the vector is even, and the
function return that integer in the first iteration of the loop. In the
worst case, the vector contains only odd integers, and so we perform n
loop iterations, only to return -1 at the bottom of the function.

Function B has a best- and worst-case runtime of O(n): Because the vector is
passed by value, it takes O(n) time to copy all those values into the new
vector, v, in memory. In addition to that, the loop has a runtime of O(1)
in the best case and O(n) in the worst case. Either way, the function has
an overall runtime of O(n). 

For Function C, the best- and worst-case runtimes are both O(n): No matter
what specific values we find in the vector, the function goes through each
cell, performing a few O(1) operations for each iteration. The total time
this function takes is O(n).

For Function D, the best- and worst-case runtimes are both O(n): No matter
what specific values we find in the vector, the loop takes O(n) time. Passing
the vector by value also takes O(n) time. When we add those runtimes together,
we have a total runtime of O(n). Although passing by value vs. passing by
reference does not affect the best- or worst-case Big-O runtimes for Functions
C and D, it does impact the *actual* runtime. Passing by value is a slower
operation.

Function E has a best-case runtime of O(n) and a worst-case runtime of
O(n^2). Our loop always performs n iterations, but each iteration calls
firstEven. In the best case, every firstEven function call is O(1) (as
described above for Function A), and our total runtime for foozle becomes
O(n). In the worst case, every firstEven call is O(n), and our overall runtime
for foozle becomes O(n^2).

3. Big O, Recursive Functions - Fibonacci

Topics: Big-O, code analysis, recursion

What is the Big O runtime of the following function, in terms of the variable n?

int fibonacci(int n) {
    if (n <= 1) {
        return n;
    }
    return fibonacci(n - 1) + fibonacci(n - 2);
}
The time complexity for fibonacci is exponential, O(2^n). This is tricky! 
We can speculate that it's exponential because 1 recursive call makes 2 
others which in turn make 4 others and so on. In general this is how you 
can analyze Big O of recursive functions. First, find what the runtime 
is in 1 recursive call. Next, count how many recursive calls we make in 
terms of N.

In a single recursive call to fibonacci, we just make other calls to Fibonacci.
So outside of the recursive calls, we do constant time amount of work. Next,
let's analyze how many calls we make in terms of n. To do that, it helps to
try a few examples. Fibonacci(3) calls Fibonacci(2) and Fibonacci(1). 
Fibonacci(2) then calls Fibonacci(1) and Fibonacci(0). In total, for N = 3, 
we make 4 recursive calls. Let's try another example for Fibonacci(4). 
Fibonacci(4) calls Fibonacci(3) and Fibonacci(2). From the above, we know 
that Fibonacci(3) results in 4 recursive calls. Fibonacci(2) then calls 
Fibonacci(1) and Fibonacci(0). In total, we make 8 recursive calls.

Do you see some kind of pattern here? From our simple examples, it appears 
for each n, we make approximately 2^(n - 1) recursive calls (It's approximate 
because this formula doesn't fit for all n). We can write 2^(n - 1) as 
2^n / 2. Therefore we can write the Big O as O(1)[for amount of work in a 
single recursive call] * O(2^n / 2)[the total number of recursive calls we
make]. Remember that in Big O, we discard constant multipliers, so the Big O
for fibonacci simplifies to O(2^n).

----------------------------------------

Interesting note (beyond the scope of what we cover in this class):

Note that if fibonacci(n) called fibonacci(n-1) twice, the number of calls
would be approximately 2^(n-1). However, this formula is not super precise
for Fibonacci -- and is actually an overestimate of the runtime -- because
the function is making two different function calls: one to fibonacci(n-1)
and one to fibonacci(n-2). If we diagram out these calls with a decision
tree, rather than seeing a nice, symmetrical tree with approximately
2^(n-1) calls, we see a lopsided tree in which the right-hand side is
smaller than the left-hand side.

A more precise indication of the runtime is actually O(Φ^n) ≈ O(1.618^n).
A careful analysis of the runtime is beyond the scope of this class but
can be found if you google "Fibonacci runtime analysis," if you're curious!


4. Recursion Tracing

Topics: Recursion, strings, recursion tracing

Below is a recursive function to reverse a string.

string reverseOf(string s) {
    if (s.empty()) {
        return "";
    } else {
        return reverseOf(s.substr(1)) + s[0];
    }
}

Trace through the execution of reverseOf("stop") along the lines of what we did in lecture, showing recursive call information for each call that’s made and how the final value gets computed.

Our initial call to reverseOf("stop") fires off a call to reverseOf("top"). This call fires off a call to reverseOf("op"). This in turn calls reverseOf("p"). This in turn calls reverseOf(""). This triggers the base case and returns the empty string. (Notice that the reverse of the empty string "" is indeed the empty string ""). We now append p to return "p". We now append o to return "po". We append t to return "pot". And finally we append s to return "pots" back to whoever called us. Yay!


5. Count Escape Routes (escape.cpp)

Topics: Recursive counting

Given an input maze and a start GridLocation, write a recursive function that counts the number of ways we can escape the maze. In this maze, we can only take one unit steps in two directions: south and east. Just like the maze in assignment 2, the only exit out of the maze is at the bottom right corner.

int countWaysToEscape(Grid<bool>& maze, GridLocation location) {
    // if we are out of bounds or this location is blocked, stop searching
    if (!maze.inBounds(location) || !maze[location]) {
        return 0;
    }

    // if we are the end of the maze, we've found one way to escape
    if (location == GridLocation{maze.numRows() - 1, maze.numCols() - 1}) {
        return 1;
    }

    int waysSouth = countWaysToEscape(maze, {location.row + 1, location.col});
    int waysEast = countWaysToEscape(maze, {location.row, location.col + 1});

    return waysSouth + waysEast;
}

6. Random Shuffling (shuffle.cpp)

How might the computer shuffle a deck of cards? This problem is a bit more complex than it might seem, and while it's easy to come up with algorithms that randomize the order of the cards, only a few algorithms will do so in a way that ends up generating a uniformly-random reordering of the cards.

One simple algorithm for shuffling a deck of cards is based on the following idea:

  • Choose a random card from the deck and remove it.
  • Shuffle the rest of the deck.
  • Place the randomly-chosen card on top of the deck. Assuming that we choose the card that we put on top uniformly at random from the deck, this ends up producing a random shuffle of the deck.

Write a function

	   		   string randomShuffle(string input)

that accepts as input a string, then returns a random permutation of the characters of the string using the above algorithm. Your algorithm should be recursive and not use any loops (for, while, etc.).

The header file "random.h" includes a function

                           int randomInteger(int low, int high);

that takes as input a pair of integers low and high, then returns an integer greater than or equal to low and less than or equal to high. Feel free to use that here.

Interesting note: This shuffling algorithm is a variant of the Fisher-Yates Shuffle. To learn how to prove it works correctly, take CS109!

Here is one possible solution:

string randomShuffle(string input) {
    /* Base case: There is only one possible permutation of a string
    * with no characters in it.
    */
    if (input.empty()) {
        return input;
    } else {
        /* Choose a random index in the string. */
        int i = randomInteger(0, input.length() - 1);
        /* shuffle the rest of the string, add the removed character on the front
        * the string.
        */
        return input[i] + randomShuffle(input.substr(0, i) + input.substr(i + 1));
    }
}

This function is based on the recursive observation that there is only one possible random shuffle of the empty string (namely, itself), and then using the algorithm specified in the handout for the recursive step.