CS 450 Homework 4
Carl Offner
Spring 2022


Part 1: Assignment, Local State, and the Environment Model

Due Wednesday, February 23, 5:00 PM

There is a lot of new material here. It will take getting used to. You will find this material in Sections 3.1 and 3.2 of the text, as well as in the recent lectures.

For this first part of the assignment, you will hand in problems 2 and 5 on paper (in class), and the rest of the problems electronically:

  1. Rewrite the make-account procedure on page 223 so that it uses lambda more explicitly. Create several versions, as follows. (Important: please do exactly what each of the three following versions specifies; no more and no less.)
    1. First version: First, replace

          (define (make-account balance)
           ...)
      
      by
          (define make-account-lambda
            (lambda ...
             ...
            )
          )
      

      Then, for the first two internal procedure definitions, replace (define (proc args) ...) with (define proc (lambda (args) ...)).

      Finally, replace the

          (define (dispatch ...)
          ...
          ...)
          dispatch)
      
      construction with a lambda expression which is evaluated and returned (but of course not called). As indicated above, call this procedure make-account-lambda.

    2. Second version: Start with a copy of the first version. Then inline the internal procedures deposit and withdraw. That is, replace references to them by the bodies of the procedures. Then you can eliminate the definitions of those procedures. Call this procedure make-account-inline.
    3. Third version (A little extra credit): Start with a copy of the second version. I don't know how to say this without doing it for you, but you might then notice that a lambda can be factored out of the cond in your last version. (If you don't know what I'm talking about here, just ignore this part of the problem.) If you do this, call this new version make-account-inline-factored.

    Note that none of these three versions of make-account contains a dispatch procedure.

  2. Consider the procedure new-withdraw which we talked about in Lecture 6, and which we discussed again in Lecture 7. The implementation of that procedure is sketched in Figure 10 on page 8 of Lecture 7.

    1. I want you to draw what that picture looks like after

             (new-withdraw 25)
      
      is evaluated.

    2. Then draw a second picture that shows the situation after

             (new-withdraw 30)
      
      is subsequently evaluated.

  3. Exercise 3.2 (page 224). Please read the statement of the problem very carefully. It tells you precisely what you should do. Please do exactly what it says. And do not use any global variables in doing this problem.
  4. Exercise 3.3 (page 225).

    In doing this problem, build on your solution to Problem 1. In fact, see if you can use one of your solutions to Problem 1 as a "black box" -- that is, make the solution to this problem a "wrapper" procedure that just invokes one of the versions of make-account from Problem 1, after handling password checks. In this way, you don't have to copy any of the body of the original make-account procedure. (It isn't necessary that you do it this way -- this is just a suggestion.)

    Call your new function make-pw-account. And yes, I really mean this—note that this is different from what the book says.

  5. Exercise 3.9 (page 243). Let's make it simpler, however: show how to compute (factorial 3) (rather than (factorial 6)).

    Be sure to read the footnote. And follow the construction that I gave in class exactly. You may think the pictures should look different than what you get. And if something bothers you about how the pictures look, you should write about it as a comment in your ASanswers.scm file. But the construction that I specified is actually what happens internally. You will have to understand it for later assignments.

Part 2: Dynamic Programming and the Remarkable Effectiveness of Memoization

Due Monday, February 28, 5:00 PM

You will hand in three files electronically:

Statement of the problem

Here is a problem: we are given a finite weighted DAG—that is, a directed acyclic graph with finitely many nodes and with a weight on each edge. You can assume that all the weights are non-negative. One of the nodes is called start and another is called end. There are paths through the graph from start to end. Each path has a cost—this cost is the sum of the weights on edges that make up the path. The problem is to find a path of minimal cost from start to end.

Note that there might be more than one path of minimal cost. We only have to find one of them. And we are guaranteed that there is at least one path from start to end.

You can think of this as a simplified form of the kind of problem that is solved all the time by Google maps or by a GPS device in your car: What's the best way to get from one point to another?

Discussion and some preliminary ideas

This problem is computationally expensive!

One way to approach this would be to write a recursive routine which walked the graph starting at start and explored all paths, stopping each path when end was reached, and keeping track of the cost of each path.

This would certainly work, because there is only a finite number of paths from start to end.

Question: How do we know that there are only finitely many paths from start to end? This is actually a more sophisticated question than you may at first think. Please be careful about your reasoning.

On the other hand, the number of such paths could easily be so great that the program would take an inordinate amount of time to complete.

Consider, for example, this dag.

(If it isn't obvious, click on "this dag", which should download a pdf file to your computer. Then look at that file.)

Question: How many paths are there from start to end in that dag?

An idea that can be used to simplify the computation

On the other hand, even though there can be an absolutely huge number of paths from start to end, many of them quite likely overlap. For instance, suppose we have two paths like this:

    start → a → b → … → p → … → t → end
    start → x → y → … → p → … → t → end

which start out differently but from point p on are the same. Then if we know the cost of the sub-path

                        p → … → t → end
then we can compute the cost of each of the original paths with less effort, because we only needed to compute the cost of that sub-path once. We'll come back to this later.

How the data is input

Before we go on, let me explain how the data will be input to the program you are going to write. There will be a file containing the specification of the graph. The name of the file will be dist.dat. That's it. You can't use any other file name. This is just to make things simple (even at the cost of being a bit user-unfriendly). Please don't try to change this.

A typical (but very small) dist.dat file might look like this:

(start p1 3)
(start p2 7)
(p1 p2 1)
(p2 end 11)
(p1 end 22)

Each line in the file looks like a Scheme list. Each line represents an edge in the graph. The first two elements in the list are the source and the target of the edge, in that order. The last element of the list—which is always a non-negative integer—is the weight of that edge.

Before going any farther, you should draw a picture of this graph for yourself. (Don't hand it in.) You should be able to see that the shortest path from start to end has cost 15.

Don't make any assumptions about the order in which these edges are placed in the file. For instance, this would describe exactly the same graph:

(p2 end 11)
(start p2 7)
(p1 p2 1)
(start p1 3)
(p1 end 22)

And for that matter, the nodes (with the exception of start and end) could have any names, in any order. So this would also really be the same graph, with the internal nodes renamed:

(p1 end 11)
(start p1 7)
(p2 p1 1)
(start p2 3)
(p2 end 22)

It also might be that there are other "initial" or "final" nodes in the graph. For instance, you might have something like this:

(start p1 3)
(p0 p1 2)
(start p2 7)
(p1 p2 1)
(p2 end 11)
(p2 p3 1)
(p5 p6 3)
(p1 end 22)

You should draw this graph just to see what I mean here. But in any case, there will always be one or more paths from start to end, and it is only those paths that we care about.

Also note that between any two nodes in the graph, there is either no edge, or there is 1 edge. There is never more than 1 edge.

Reading in the graph

As in the previous assignment, you can use the following code to read in the graph:

  ;; read-file produces a list whose elements are the expressions in the file.

  (define (read-file)
    (let ((expr (read)))
      (if (eof-object? expr)
          '()
          (cons expr (read-file)))))

  ;; Here we go:  read in the file that defines the graph

  (define data (with-input-from-file "dist.dat" read-file))

This will give you a variable named data that holds a list the elements of which are just the lines in the file dist.dat.

Building the main lookup table

The next thing you will need to do is to build a lookup table. This table should enable you to implement a lookup function (in fact, let's call it lookup) that takes the names of two nodes in the graph (like start and p2, for instance). If these two nodes are not the source and the target (in that order) of an edge, this function should evaluate to #f. Otherwise, it should evaluate to the cost of that edge.

We talked in class about how to build lookup tables (and the same thing is in the textbook, in section 3.3.3). In this case, we are talking about a two-dimensional table, right? You should use the method we talked about in class.

Using recursion to solve our problem

A straightforward but naive way to solve our problem would just be via a depth-first walk of the graph, starting at the start node. Pseudo-code for this might look something like this. (Bear in mind that this pseudo-code is not really "pseudo-Scheme"—the syntax is wrong—but it could be easily translated into it.):

procedure naive-cost(node) // returns an integer
  if the node has no children, return "infinity".
  for each child of node
    if child is "end"
      just compute (lookup node child)
    else
      compute (lookup node child) + naive-cost(child)
  return the minimum of those computed values

Here "infinity" just means some number that is so big that it couldn't possibly be the value of any path. You can use the number 1000000 (i.e., 1 million) if you want—I guarantee that I won't try your code out on any graphs with weights that could possibly add up to a million. And of course any graphs that you make up should satisfy the same constraint. That shouldn't be hard.

Also note a couple of things:

Your first task—the file naive-path.scm

So the first program you have to write for this part of the assignment is the Scheme version of naive-cost.

Put this code in the file naive-path.scm

The code should be organized so that it is run like this:

scheme < naive-path.scm
This is entirely similar to how the "real" code is run. (The "real code" is the code that you will develop in the "second task" of this part of the assignment and put in the file path.scm. It's important for you to see below for more details; I don't want to repeat them here. Be careful to get this right. In particular, make sure that the last expression inside your begin expression is
(naive-cost 'start)

Make sure you get it right; we'll be running tests on it.

I believe you will find this to be quite easy, and it will give you some practice in specifying graphs and convincing yourself that you understand what is going on. Of course you want to make sure that what you get back is what you think you should get back.

Why does this method work?

This method, which is quite simple, works for a reason which is actually pretty sophisticated: Suppose that we have a path P from start to end, and suppose P passes at some point through a node p. And suppose the cost of this path is minimal—that is, there is no other path from start to end with lower cost. Then the part of this path (call it Pp-end) starting from p and going all the way to end must also be minimal, in the sense that there is no other path S from p to end whose cost is smaller than the cost of Pp-end.

Why is this? The reason is this: suppose there was a path S from p to end whose cost was less than the cost of Pp-end. Then we could make a new path as follows:

Follow the original path P from start to p. Then, instead of continuing on Pp-end, follow S from p to end This new path from start to end will have a cost strictly less than the cost of P, which is of course a contradiction, since we started out by assuming that the cost of P was minimal.

Thus we have proved that if P is a minimal-cost path from start to end, then the sub-path of P from any node p to end is also a minimal-cost path from p to end.

And that's why the algorithm above works. At each stage we take the minimal cost of any path from the children of node and put it together with the cost to each child. We are thus guaranteed to get the minimal cost from the node itself.

Introducing memoization

However, as we have already mentioned, this algorithm is just too costly to run on large graphs. And the reason is that we can end up computing the same information (for common sub-paths) many times. But based on the discussion above, we can get around this difficulty by making sure that we compute the cost of each path just once. We do this by a process called memoization.

Please be careful. There is another word, "memorization" that you probably know. That's a different word. The word "memoization" (without the "r") is used only in Computer Science. Every computer scientist knows what it means, and almost no one else does. We just have to live with that. The words are clearly related, but they mean different things, so you do have to be careful.

The idea is simple: we keep a table of costs. For each node, when we compute the cost of that node, we enter that node and its cost in the table. Then every time after that, when we want to compute the cost of the node, we first look it up in that table. If it's there, then we know the cost. Otherwise, we compute it and put it and its cost in the table.

In effect we are making a "memorandum" (or "memo") of the cost of each node as we compute it. That's where the term "memoize" comes from.

This use of memoization, which is made possible by the "minimal sub-paths" property we proved above, in conjunction with a naive recursive algorithm, is called dynamic programming. Dynamic programming is one of the most powerful techniques of algorithm construction. It's tremendously useful.

Your second task: the final version, in the file path.scm

You should implement this memoized version. Call it cost (as opposed to naive-cost). In doing this you will want to be very careful that you really are not redoing computations that can be memoized.

You may find that you need (or want) to have several tables. That's fine. Do what you need to do, and please explain your decisions in notes.txt.

Finally, we would like to print out, not only the minimal cost of getting from start to end, but also a path that has that minimal cost. (Remember that there might be more than one minimal path. That's OK; we only care about one, and it doesn't matter which one.)

To do this, you will also need to memoize a shortest path from each node to end. In principle, this is not at all hard to do, but you will need to be careful.

I'm sure you will need some helper functions in doing all this. And will also undoubtedly need some more tables. Be sure to put in comments so that your code is reasonably explanatory. When I did this, I had functions that returned data structures—not just simple numbers or lists. You will want to document that as well.

How should the result be printed out? I would like you to print out the result like this: Suppose you have read in the original graph I gave as an example above. Then your file—which we're calling path.scm—should be able to be executed by simply typing

scheme < path.scm

at the Unix prompt. It should print out

(15 start p1 p2 end)

In other words, you don't call cost or any other Scheme function from the Unix command line. You just read in path.scm, as I indicated above::

scheme < path.scm

This will evaluate every Scheme expression in the file and display the result. Of course, that's not what you want. So package up the file as one big (begin ...) special form, and make it so that the last thing evaluated in the (begin ...) is what you want displayed. That is, the last expression in the (begin ...) expression should be

   (cost 'start)

Please be sure to do this. This is the way I'm going to test your code. If your interface is different in any way, my tests will fail.

Finally, I expect to see a serious discussion in your notes.txt file of the design decisions you made, any difficulties you ran into, how you resolved them, what you would do differently, and so on. This is important, and I plan to take this very seriously. So don't leave it till the end, and don't blow it off.

A moderate-sized test case

Although in developing your program you will certainly want to work with very small graphs that you make yourself, so that you can debug your code easily, you might like to have a somewhat larger graph to test your code on. I have put a graph dist.dat in ~offner/cs450/hw4. You can certainly feel free to use it. You should find that the minimum-length path from start to end in that graph has length 169.

You should also find that your function cost runs much faster than naive-cost on this test case. (And if it doesn't, you have definitely done something wrong.)

Three final warnings: