CS302 Final Exam - Answers and Grading

James S. Plank - December 9, 2015


Question 1

In the problem specification, |V| = n, and unless otherwise specified, |E| = O(n2). For that reason, if an algorithm's running time is O(|V|+|E|), the answer will be O(n2). This is because O(n+n2) is in fact O(n2). Similarly, O(|E|log|V|) is O(n2log n).

Grading

Two points per part. I explain partial credit above.


Question 2

Grading

Parts A, B, D, E, G and J were all worth two points, because in my opinion, they were straightfoward tests of things that were from the lecture notes. I deemed that the others were more difficult, so I only weighted them one point each.


Question 3

Part A: You can draw the graph if you want to see it. I'll draw it below if that helps you visualize the answer.

However, you can solve the problem just by knowing the adjacency list for node 2, because that is the next node that you will process from the multimap. That node has a shortest distance of 5 from node zero. So, let's process each node on node 2's adjacency list:

The final answer is: { [6,3] [7,4] [12,6] [15,7] [17,5] }. If you don't delete entries of the multimap when you replace them, then the answer is: { [6,3] [7,4] [8,4] [12,6] [14,6] [15,3] [15,7] [17,5] [26,7] }. Part B: We perform the same exercise, but with spanning tree rather than distance: The final answer is: { [1,3] [2,4] [7,6] [10,7] [13,5] } If you don't delete entries of the multimap when you replace them, then the answer is: { [1,3] [2,4] [5,4] [7,6] [10,7] [11,6] [13,5] [14,5] [15,3] [26,7] }.

Grading

7 points per part. I gave some partial credit.


Question 4

This is very similar to the IncreasingSubsequences program that we went over in the lecture notes on Topological Sort. Start by setting the number of paths for node from to one, and all of ther numbers of paths to zero. Then perform a topological sort. Since node from is the only one with zero incident edges (this is because you can reach every other node from node from), that's the only one you have to push onto your deque. Then process the nodes using topological sort, adding a node's number of paths to each of the nodes adjacent to it. When you reach node to return its number of paths. Here is an answer:

(BTW, you don't need to use a deque. You can use a vector so long as you push and pop from the back).

long long Graph::Num_Paths(int from, int to)
{
  int i, n, t;
  deque <int> zero_inc;

  /* Set all paths to zero, except for node <b>from</b>, where there is one. */

  for (i = 0; i < Paths.size(); i++) Paths[i] = 0;
  Paths[from] = 1;

  /* You need a deque of nodes which have zero incident edges. 
     The only node on this deque when you start is node from. */

  zero_inc.push_back(from);

  /* Process the list. */

  while (!zero_inc.empty()) {

    /* Pull the first node off, and delete it */

    n = zero_inc[0];
    zero_inc.pop_front();

    /* If this is to, then we're done -- return the number of paths
       to to that is held in the vector Paths. */

    if (n == to) return Paths[to];

    /* Add the current node's number of paths to each node adjacent to it.
       Then "delete" the edge by decrementing that node's indicent edges.
       When that value hits zero, pust the node onto the deque. */

    for (i = 0; i < Adj[n].size(); i++) {
      t = Adj[n][i];
      Paths[t] += Paths[n];
      Incident[t]--;
      if (Incident[t] == 0) zero_inc.push_back(t); 
    }
  }
  return Paths[to];   /* This shouldn't happen because all nodes
                         are reachable from node "from" */
}

I've programmed this up in Graph.cpp, and put a main() that reads the number of nodes, and then the edges. Nodes are numbered from 0 to the number of nodes minus one. I've put examples 0 and 3 from the IncreasingSubsequences problems into the files. G1.txt and G2.txt. I've put a denser graph with 31 nodes into G3.txt. This program works well on all three files:

UNIX> g++ -o Graph Graph.cpp
UNIX> time ./Graph < G1.txt
4
0.000u 0.000s 0:00.00 0.0%	0+0k 0+0io 0pf+0w
UNIX> time ./Graph < G2.txt
41
0.001u 0.000s 0:00.00 0.0%	0+0k 0+0io 0pf+0w
UNIX> time ./Graph < G3.txt
531372800
0.001u 0.000s 0:00.00 0.0%	0+0k 0+0io 0pf+0w
UNIX> 

Other Approaches, Working and Non-Working

Many of you didn't identify this problem as topological sort, and tried other approaches. I'll address these in turn:

DFS

You can structure this recursively, like a DFS -- call Num_Paths() on each of your children, and sum the return values. If you call Num_Paths(to, to), then return 1. I have this solution in Graph2.cpp. You'll note that it doesn't use Paths or Incident:

long long Graph::Num_Paths(int from, int to)
{
  long long rv;
  int i;

  if (from == to) return 1;
  
  rv = 0;
  for (i = 0; i < Adj[from].size(); i++) {
    rv += Num_Paths(Adj[from][i], to);
  }
  return rv;
}

It works on the first two files, but it takes 12 seconds on G3.txt, because it's not a true DFS -- it calls Num_Paths() on the same node multiple times, and these calls blow up exponentially in G3.txt:

UNIX> time ./Graph2 < G1.txt
4
0.000u 0.000s 0:00.00 0.0%	0+0k 0+0io 0pf+0w
UNIX> time ./Graph2 < G2.txt
41
0.001u 0.000s 0:00.00 0.0%	0+0k 0+0io 0pf+0w
UNIX> time ./Graph2 < G3.txt
531372800
12.551u 0.008s 0:12.56 99.9%	0+0k 0+0io 0pf+0w
UNIX>

Memoizing the DFS

You can memoize the DFS like it's a dynamic program -- now the running time is linear again (I'm using Paths as the cache, and setting all entries to -1 at the beginning of the program). This is in Graph3.cpp:

long long Graph::Num_Paths(int from, int to)
{
  long long rv;
  int i;

  if (from == to) return 1;
  if (Paths[from] != -1) return Paths[from];
  
  rv = 0;
  for (i = 0; i < Adj[from].size(); i++) {
    rv += Num_Paths(Adj[from][i], to);
  }
  Paths[from] = rv;
  return rv;
}

UNIX> g++ -o Graph3 Graph3.cpp
UNIX> time ./Graph3 < G1.txt
4
0.000u 0.001s 0:00.00 0.0%	0+0k 0+0io 0pf+0w
UNIX> time ./Graph3 < G2.txt
41
0.001u 0.000s 0:00.00 0.0%	0+0k 0+0io 0pf+0w
UNIX> time ./Graph3 < G3.txt
531372800
0.001u 0.001s 0:00.00 0.0%	0+0k 0+0io 0pf+0w
UNIX> 

Using recursion to do the topological sort

This one is harder to get correct, but you can perform the topological sort using recursion -- when you set a node's Incidence to 0, you call Num_Paths recursively. This assumes that Paths is initialized to zero for every node. Compare this solution with the first solution above -- they are quite similar, but this one doesn't manage a deque or vector explicitly. The other solution is more straightfoward. This is in Graph4.cpp:

long long Graph::Num_Paths(int from, int to)
{
  long long rv;
  int tn;
  int i;

  if (from == to) return Paths[from];

  rv = 0;
  for (i = 0; i < Adj[from].size(); i++) {
    tn = Adj[from][i];
    Paths[tn] += Paths[from];
    Incident[tn]--;
    if (Incident[tn] == 0) rv = Num_Paths(tn, to);
  }
  return rv;
}

UNIX> g++ -o Graph4 Graph4.cpp
UNIX> time ./Graph4 < G1.txt
4
0.000u 0.000s 0:00.00 0.0%	0+0k 0+0io 0pf+0w
UNIX> time ./Graph4 < G2.txt
41
0.000u 0.000s 0:00.00 0.0%	0+0k 0+0io 0pf+0w
UNIX> time ./Graph4 < G3.txt
531372800
0.001u 0.000s 0:00.00 0.0%	0+0k 0+0io 0pf+0w
UNIX> 

Grading

This was worth 15 points, and you started with 15 if you had one of the correct working frameworks. After that, you got deductions. For example, if you used the DFS, but didn't memoize, you lost three points.

If you didn't start off with a working framework, then I simply assigned some points for code that looked like it would at least compile, or maybe do something reasonable. Let me give you an example. Here's an answer that is similar to some of yours:

long long Graph::Num_Paths(int from, int to)
{
  int rv;
  int i, j;
  vector <int> P;
 
  for (i = 0; i < Adj.size(); i++) {
    for (j = 0; j < Adj[i].size(); j++) {
      if (Adj[i][j] == to) {
        P.push_back(Adj[i][j]);
      } else {
        Paths[j]++;
      }
    }
  }

  for (i = 0; i < P.size(); i++) {
    rv += Paths[P[i]];
  }
  
  return rv;
}

This answer is worth about three points to me. It should compile, but it doesn't make much sense. There are small problems that I attribute to time pressure and stress. When I'm grading answers like this one, these small problems don't mean much to me, and they don't factor into my assignment of points, although if there are ten of these, then they do factor in. (And I'll say that they do factor into answers that are more correct. For example, if you give one of the good answers above, and you don't initialize your variables, I will take off a point).

There are other small mistakes that show a lack of attention to the problem: And there are much bigger problems that make this seem as though the student is just putting down code that uses the variables and does something graph-like, but also make it seem like the student doesn't really understand the problem or the solution: So, I would assign this 3 out of 15, for doing something graph-like, but nothing that seems like the student understands the problem or the proper solution. On your exam, I'll say "Please see the answer." That means that your solution falls into this category.


Question 5

This is very similar to (and much easier than) the PartisanGame topcoder problem that I assigned in lab. That problem had different vectors for Alice and Bob, and the constraints were too big that you could simply use dynamic programming for P. Here, the constraints are much smaller, and you can simply use dynamic programming.

The recursion follows my hints -- try each value of s and recursively call Stones(P-s, S). If that returns that the caller loses, then whoever calls Stones(P, S) will win. If there is no value of s that works, then return that the caller loses.

You have to memoize to make it work fast enough.

Here's my answer, in Stones.cpp. I've added a main() which reads the parameters from standard input, so you can test it.

#include <string>
#include <vector>
#include <iostream>
#include <cstdio>
#include <cstdlib>
using namespace std;

class Game {
  public:
    string Stones(int P, vector <int> &S);
    vector <string> Cache;
};

string Game::Stones(int P, vector <int> &S)
{
  int i;

  /* If the cache is empty, create it. */

  if (Cache.size() == 0) Cache.resize(P+1, "");

  /* Get the return value from the cache if it's there. */

  if (Cache[P] != "") return Cache[P];

  /* Otherwise, use recursion to see if there is any value
     of s that will enable the caller to win. */

  for (i = 0; i < S.size(); i++) {
    if (P-S[i] >= 0 && Stones(P-S[i], S) == "Lose") {
      Cache[P] = "Win";
      return "Win";
    }
  }

  /* If there is no such value, return that you are losing. */

  Cache[P] = "Lose";
  return "Lose";
}

/* Here's a main that lets you test it. */

int main()
{
  int P;
  int s;
  vector <int> S;
  Game G;

  cin >> P;
  while (cin >> s) S.push_back(s);
  cout << G.Stones(P, S) << endl;
}

Here we test a few:

UNIX> g++ -o Stones Stones.cpp
UNIX> echo 5 1 | Stones
Win
UNIX> echo 4 1 | Stones
Lose
UNIX> echo 5 2 | Stones
Lose
UNIX> echo 5 2 3 | Stones
Lose
UNIX> echo 5 2 1 | Stones
Win
UNIX> echo 1000 7 3 11 | Stones
Lose
UNIX> echo 1000 7 3 10 | Stones
Win
UNIX> 

Grading

This one is also 15 points, and like the other one, if you have a generally correct framework, you start with 15 and get deductions. If your code isn't close, I gave you 5 points if you had recursion and a cache, and 3 points if you only had recursion.