CS 311 Fall 2020  >  Midterm Exam Review Problems, Part 2


CS 311 Fall 2020
Midterm Exam Review Problems, Part 2

This is the second of three sets of review problems for the Midterm Exam. For all three sets, see the class web page, or the following links:

Problems

Review problems are given below. Answers are in the Answers section of this document. Do not turn these in.

  1. Write a function div, declared as follows:
    double div(double a, double b);
    Function div should divide a by b and return the result. If this is impossible, because b is zero, it should throw an exception of type std::overflow_error, with an appropriate message string. Include relevant comments, including preconditions, if there are any. You may assume that all relevant standard header files have been included, but that no using statements have been executed.
     
  2. Write code (not a complete function) that attempts to allocate dynamically an array of 10 ints, using the ordinary array form of new. If allocation is successful, a boolean flag should be set to true. Otherwise, the flag should be set to false and a warning message should be printed. Hint: Catch an exception.
     
  3. The Lucas numbers are defined as follows: \(L_0 = 2\). \(L_1 = 1\). For \(n\ge 2\), \(L_n = L_{n-2}+L_{n-1}\). Thus, the first few Lucas numbers are: 2, 1, 3, 4, 7, 11, 18, 29, 47, 76.
    1. Write a recursive function to compute the \(n\)th Lucas number. Include preconditions.
    2. Write a reasonably efficient iterative function to compute the \(n\)th Lucas number. Include preconditions.

     
    1. Briefly describe the advantages and disadvantages of Binary Search, as compared with Sequential Search.
    2. Despite its advantages, we often do not use Binary Search for the find operation in Insertion Sort, even when we can. Why not?

     
  4. If we want to jump a random-access iterator forward, we can add an integer to it.
    myIter += howFar;
    Or we can do the same thing with std::advance.
    std::advance(myIter, howFar);
    Why is std::advance sometimes preferable?
     
    1. Explain the difference between equality and equivalence.
    2. How does this difference affect the way we do searching in sorted lists?

     
  5. What disadvantages does recursion have, compared with iteration?
     
    1. Which recursive functions can be rewritten as iterative functions that use essentially the same algorithm?
    2. How can we do this conversion? (Describe a method that works for all such rewritable functions.)
    3. Under what circumstances is this conversion very easy to do?

     
    1. What is tail recursion?
    2. Why is tail recursion important?

     
  6. The following function is tail-recursive. Rewrite this function in iterative form, making only minimal changes.
    template <typename FDIter>
    void recursiveSelectionSort(FDIter first, FDIter last)
    {
        // BASE CASE
        // Empty list? Done.
        if (first == last)
            return;
    
        // RECURSIVE CASE
        // Find minimum item
        FDIter minItem = std::min_element(first, last);
        // Put it at the beginning
        std::iter_swap(first, minItem);
        // Find iterator to item after first
        FDIter afterfirst = first;
        ++afterfirst;
        // And recurse
        recursiveSelectionSort(afterfirst, last);
    }
    By the way, this code works. It implements a recursive version of a (lousy) sort called “Selection Sort”. Standard algorithms min_element and iter_swap are defined in the header <algorithm>.
     
  7. We are interested in algorithms that work well when solving large problems on large, fast systems (here, large refers to things like memory and disk capacity).
    1. What word do we use to describe such algorithms?
    2. Why are we interested in algorithms with this property?

     
    1. In general, what is efficiency (in the context of algorithms and programming)?
    2. In practice, when we talk about the efficiency of an algorithm, without qualifying further, we mean something very specific. What do we typically mean?

     
    1. How do we measure the (time) efficiency of an algorithm in a way that is independent of system and implementation details?
    2. What notation do we use to express how efficient an algorithm is?

     
    1. Suppose \(f(n)\) is some function of \(n\). What does it mean for an algorithm to be “\(O(f(n))\)”?
    2. We generally use big-\(O\) to place an algorithm into one of a few well-known categories. List and name at least 5 of these.

     
  8. Rewrite each of the following using big-\(O\).
    1. Linear time.
    2. Quadratic time.
    3. Constant time.
    4. Logarithmic time.
    5. Exponential time.
    6. Log-linear time.

     
  9. Consider the following function:
    // Dereferencing a valid Iterator must return an int
    template<typename Iterator>
    int sum_cubes(Iterator first, Iterator last)
    {
        int sum = 0;
        for (Iterator iter = first; iter != last; ++iter)
        {
            int cube = 1;
            for (int i = 0; i < 3; ++i)
                cube *= *iter;
            sum += cube;
        }
        return sum;
    }
    Break this function into the “obvious” 11 operations (ignore the parameter passing, and count the “*=” and “+=” lines each as a single operation). Let \(n\) be the length of the range [first, last).
    1. How many operations does this function perform?
    2. Express the time efficiency of this function using big-\(O\).
    3. Using the definition of big-\(O\), explain how you can arrive at your answer in part b.

     
  10. Consider the following function:
    void f2(int arr[], int n)  // n is the length of arr
    {
        for (int i = 0; i < n; ++i)
        {
            for (int j = i; j < n-1; ++j)
            {
                for int (k = 0; k < 10; ++k)
                {
                    for int (m = 2; m < j+1; ++m)
                    {
                        cout << arr[m] << endl;
                    }
                }
            }
        }
    }
    Without counting operations, determine the order of this function. Explain how you can arrive at your answer.
     
  11. What does it mean for an algorithm or technique to be scalable?
     
  12. In each part, a formula is given for the approximate worst-case number of operations performed by some function when given input of size \(n\). Write the order of each function using big-\(O\). If there is an associated name for this order, give it.
    1. \(500+30n\).
    2. \(500+30n+2n^3\).
    3. \(500+30n+2\ln n\).
    4. \(500+30n+2n\log_2 n\).
    5. \(500+30n+2n^2+5n\log_{10}n\).
    6. 500.

     
  13. Typically, how slow can an algorithm be, and still be considered scalable?
     

Answers

  1. // div
    // Divides its parameters and returns the result: a/b.
    // Pre: None.
    // Throws std::overflow_error if b == 0.
    double div(double a, double b)
    {
        if (b == 0.)
            throw std::overflow_error("Division by zero");
            // Must #include <stdexcept>
        return a/b;
    }
    Notes:
    • The code should not include any “try” or “catch” statements. You are signalling errors, not handling them.
    • You should not have a precondition that b != 0., since the function handles that case.
  2. // Must have #include'd <stdexcept> and <iostream>
    bool allocSucceeded; // true if alloc succeeds
    try
    {
        int * myArray = new int[10];
        allocSucceeded = true;
    }
    catch (std::bad_alloc & e)
    {
        allocSucceeded = false;
        std::cout << "Could not allocate" << std::endl;
    }
    Notes:
    • The catch block executes if the allocation fails. However, the only place where there is code that executes only if the allocation succeeds is that just after the allocation. Thus, I set allocSucceeded to true there. Alternatively, I could have set allocSucceeded to true in its declaration.
    • I must declare allocSucceeded outside the try-catch code. Otherwise, it will not exist when that code finishes.
    • I caught the exception by type. You could also catch all exceptions with “catch (...)”.
    Here is another version, which includes the possible differences mentioned above.
    // Must have #include'd <stdexcept> and <iostream>
    bool allocSucceeded = true; // set to false if alloc fails
    try
    {
        int * myArray = new int[10];
    }
    catch (...)
    {
        allocSucceeded = false;
        std::cout << "Could not allocate" << std::endl;
    }
    1. // lucas1
      // Given n, returns L(n) (the nth Lucas number).
      // L(0) = 2. L(1) = 1. For n >= 2, L(n) = L(n-2) + L(n-1).
      // Recursive.
      // Pre:
      //     n >= 0.
      //     L(n) is a valid int value.
      // Does not throw
      int lucas1(int n)
      {
          // Base case
          if (n <= 1)
              return 2 - n;
      
          // Recursive case
          return lucas1(n-2) + lucas1(n-1);
      }
    2. // lucas2
      // Given n, returns L(n) (the nth Lucas number).
      // L(0) = 2. L(1) = 1. For n >= 2, L(n) = L(n-2) + L(n-1).
      // Pre:
      //     n >= 0.
      //     L(n) is a valid int value.
      // Does not throw
      int lucas2(int n)
      {
          int luc1 = -1;  // Previous Lucas number [L(-1) = -1]
          int luc2 = 2;   // Current Lucas number
      
          for (int i = 0; i < n; ++i)
          {
              // Invariants:
              //     luc1 == L(i-1)
              //     luc2 == L(i)
      
              int next = luc1 + luc2;
              luc1 = luc2;
              luc2 = next;
          }
          return luc2;
      }
      • Binary Search is much faster than Sequential Search [\(O(\log n)\) versus \(O(n)\)].
      • Binary Search requires sorted data. If data are not already sorted, and only one search (or only a few searches) are to be done, it is probably not worthwhile to sort the data for Binary Search; use Sequential Search instead.
      • To be efficient, Binary Search requires random-access data. It is not a good algorithm for sequential-access data structures, such as Linked Lists.
    1. Insertion Sort is used in situations in which the data are nearly sorted. If each item in a list is very close to its proper position, then a backwards Sequential Search is likely to find this position faster than a Binary Search. Thus, we usually implement Insertion Sort using Sequential Search, not Binary Search. Furthermore, for an array we have to iterate backward through the list anyway, to move up items when we insert. Thus, using Binary Search will not speed up the algorithm.
  3. std::advance may be a better choice, because, for a random-access iterator, it is just as fast as addition, but it also works with more general categories of iterators. Using std::advance results in more generic code.
    1. Equality of two objects means that they compare equal [a == b]. Equivalence of two objects means that neither is less than the other [!(a < b) && !(b < a)].
    2. Equality and equivalence may be different concepts for some types. Thus, in any given algorithm, we should use one or the other, not both. It may seem natural to search in a sorted list using “<” and then check whether we have found the right thing using “==”. However, this means we are using both criteria (equality and equivalence), which may result in problems. Conclusion: Since we search in a sorted list using “<” (thus, equivalence), we should also use “<” (and equivalence) to check whether we have found what we want. In short, a generic Binary Search should not use “==” at all.
  4. Recursion has two main disadvantages:
    • Function-call overhead. Recursive calls require time and stack space that iteration may not.
    • Poor memory management. Stack overflows do not signal errors in useful ways. Dynamic allocation signals errors in ways that allow us to recover.
    An additional factor, which can become a disadvantage in some situations, is that many recursive algorithms are inherently inefficient.
    1. Every recursive function can be rewritten as an iterative function that uses essentially the same algorithm,
    2. One way to do this conversion involves mimicking the system Stack. We declare our own Stack, which holds local variables and an indication of where we came from (return address). We then use it much as the system uses a Stack to handle function calls. Instead of various local variables, we use the top of our Stack. We replace a recursive call with a push, setting parameters, and restarting the function. We replace a return with a pop and a check of the return address. If the outside world called us, we really return; otherwise, we go back to where we were in the function.
    3. This conversion is very easy to do when the function is tail recursive. In such a case, we do not need the Stack.
    1. Tail recursion means that a recursive call is the last operation a function performs.
    2. Tail recursion is important because it is easy to convert to iteration (either manually or automatically) without introducing a Stack data structure, thus increasing efficiency and reliability (no Stack overflows).
  5. Eliminating tail recursion is simple. Just put a “while (true)” loop around the function body, comment out the recursive call, and set up the parameters at the end of the loop.
    template <typename FDIter>
    void recursiveSelectionSort(FDIter first, FDIter last)
    {
        while (true)
        {
            // BASE CASE
            // Empty list? Done.
            if (first == last)
                return;
    
            // RECURSIVE CASE
            // Find minimum item
            FDIter minItem = std::min_element(first, last);
            // Put it at the beginning
            std::iter_swap(first, minItem);
            // Find iterator to item after first
            FDIter afterfirst = first;
            ++afterfirst;
            // And recurse
            //recursiveSelectionSort(afterfirst, last);
            first = afterfirst;
        }
    }
    We could improve this a little bit: get rid of the recursion comments, and eliminate the variable afterfirst, simply incrementing first instead.
    template <typename FDIter>
    void recursiveSelectionSort(FDIter first, FDIter last)
    {
        while (true)
        {
            // Empty list? Done.
            if (first == last)
                return;
    
            // Find minimum item
            FDIter minItem = std::min_element(first, last);
            // Put it at the beginning
            std::iter_swap(first, minItem);
            // Find iterator to item after first
            ++first;
        }
    }
    1. Algorithms that work well when solving large problems on large/fast systems are said to be scalable (or, they “scale well”).
    2. We want scalable algorithms because they are good at doing what people want done. In the real world, people solve ever larger problems on ever larger and faster systems. Scalable algorithms allow them to accomplish their goals in a reasonable amount of time.
    1. Efficiency means that an algorithm or function uses few resources.
    2. We are typically interested in time efficiency. Specifically, we want the worst-case number of operations performed by an algorithm to grow slowly as the size of its input increases.
    1. We measure time efficiency of an algorithm by dividing its work into “steps”. We count the maximum number of steps required to execute the algorithm for input of a given size (usually “\(n\)”).
    2. We express how efficient an algorithm is using “big-\(O\)” notation.
    1. An algorithm is \(O(f(n))\) if there exist numbers \(k\) and \(n_0\) such that the algorithm requires no more than \(k\times f(n)\) steps to solve a problem of size \(n\ge n_0\).
    2. The major categories we use are as follows. Any five of these form a correct answer to this part.
      • \(O(1)\). Constant time.
      • \(O(\log n)\). Logarithmic time.
      • \(O(n)\). Linear time.
      • \(O(n\log n)\). Log-linear time.
      • \(O(n^2)\). Quadratic time.
      • \(O(b^2)\), for some number \(b\). Exponential time.
  6. I use the variable “\(n\)” below, but any variable is acceptable.
    1. Linear time: \(O(n)\).
    2. Quadratic time: \(O(n^2)\).
    3. Constant time: \(O(1)\).
    4. Logarithmic time: \(O(\log n)\). The base of the logarithm does not matter.
    5. Exponential time: \(O(b^n)\), for some \(b>1\). [For example, \(O(2^n)\), \(O(10^n)\), \(O(e^n)\), etc.]
    6. Log-linear time: \(O(n\log n)\). The base of the logarithm does not matter.
    1. The code inside the inner braces does \(13\) operations, regardless of \(n\). Total number of operations for each part:
      • int sum = 0. \(1\).
      • Iterator iter = first. \(1\).
      • iter != last. \(n+1\).
      • ++iter. \(n\)
      • (Code inside inner braces). \(13\) operations, executed \(n\) times. Total: \(13n\).
      • return sum. \(1\).
      The total is \(15n+4\).
    2. This function is \(O(n)\).
    3. For large values of \(n\) (specifically, for \(n\ge 4\)), we have \(15n+4\le 16n\). Thus, with \(k=16\) (and \(n_0=4\), we see, by the definition of big-\(O\), that this function is \(O(n)\).

      Note. Making \(k\) large does not hurt anything. We could just as well have used \(k=100\) or \(1000000\). However, \(k\) must be greater than \(15\).

  7. This function is \(O(n^3)\), We can use the Rule of Thumb discussed in class. There are three nested loops that go up to \(n\) or something like \(n\) or something that goes up to something like \(n\): the i, j, and m loops. The k loop is only executed a constant number of times. The order of this function is \(n\) raised to the power of the number of significant loops: \(3\).
  8. An algorithm or technique is scalable if it works well with (increasingly) large problems.
    1. \(O(n)\). Linear time.
    2. \(O(n^3)\). We would call this “cubic time”.
    3. \(O(n)\). Linear time.
    4. \(O(n\log n)\). Log-linear time.
    5. \(O(n^2)\). Quadratic time.
    6. \(O(1)\). Constant time.
  9. The dividing point between scalable and non-scalable algorithms is typically placed somewhere just a bit slower than log-linear time. Algorithms that are much slower then log-linear time will not be considered to scale well, in most situations.