Optimally Playing the Etaoin Game

MCS-178: Introduction to Computer Science II
For this project, you should work in a two-person team, and each team will submit one project report. We ask that you not work on a project with the same partner twice during the course.

Objectives

In this lab, you will write a program that can optimally play a simple game, called etaoin (pronounced "etta oin", to rhyme with "get a coin"). You will use memoization or dynamic programming to make your program efficient. You will have the flexibility to make the program's user interface as simple or fancy as you want. A fancier user interface may make the game, and hence the assignment, more fun. However, the essential part of what you do is programming the core of the game, making it efficient, and measuring the improvement.

Rules of the game

The game is played by two players, who alternate turns taking letters from the ends of a word, receiving points for the letters in accordance with the following table:
LetterPoints
e1
t2
a3
o4
i5
n6
any other7
(The letters "etaoin" are the six most common in English, listed in descending order of frequency.)

Each player's goal is to get as many points as possible. A word is chosen as the starting point. For example, it might be "incomprehensibility". The first player gets to choose whether to take the first letter, i, and receive 5 points, or the last letter, y, and receive 7. Suppose he chooses the i. The second player is now confronted with the remainder of the word, "ncomprehensibility" and can again choose to take either the first letter, n, for 6 points or the last letter y, for 7. This continues until all the letters are taken. The player with the higher point total wins, but we are not going to be satisfied with just winning: the optimal player's goal is to get as high a score as possible.

How to play optimally

Suppose two players both play optimally, starting with the string "then". I claim that the first player will wind up with 13 points and the second player will get only 3 points. In other words, the first player will win by a 10 point margin. We will say that the value of the string "then" is 10.

In order to see why this is true, first play the game with a partner using the string "the" and convince yourselves that its value is -4, meaning that the first player will lose by 4 points in optimal play. Likewise convince yourself that the value of "hen" is 2.

This notion of value lets us figure out how to play optimally. The two choices you have when confronted with "then" are

  1. to take the t (value 2) and give your opponent "hen" (value 2), for a net value of 0
  2. to take the n (value 6) and give your opponent "the" (value -4), for a net value of 10.
The second option has the higher net value, so the optimal player will choose to take the n from the end of "then". Notice that the net value is the value of the character the player takes, minus the value of the string left for the opponent.

Another point to notice in the above example is that the larger of the two net values, 10, is the value of the full string, "then". This will always be the case, because the value of a string is defined in terms of optimal play, and the optimal player will choose the better option. You can use this to calculate the values of strings in a tree-recursive manner. In the above example, you would calculate the value of the string "then" in terms of the values of the strings "hen" and "the".

Re-read the previous paragraph and make sure you understand it. It is the most important part of this whole assignment.

Working with strings

Until now, all we have done with strings is to display them. Now we need to do more. The procedure string-ref can tell you what character is in a particular position within the string. The positions are numbered from 0, just like with vector-ref. For example, (string-ref "then" 0) would evaluate to the character t, and (string-ref "then" 3) would evaluate to the character n. These two characters are written in Scheme as #\t and #\n respectively. Characters are a new kind of data, which you have not previously seen. All you will need to do with them is pass them into the following char-value procedure, which embodies the table of point values:
(define char-value
  (lambda (char)
    (let ((c (char-downcase char))) ; in case it is upper case
      (cond ((equal? c #\e) 1)
            ((equal? c #\t) 2)
            ((equal? c #\a) 3)
            ((equal? c #\o) 4)
            ((equal? c #\i) 5)
            ((equal? c #\n) 6)
            (else 7)))))

The user interface will also make use of string-length, which tells how many characters are in a string. For example, (string-length "then") evaluates to 4.

Focusing on part of a string

As the game progresses, and the players claim characters from the beginning and end of the string, it is necessary to focus attention on just the remaining characters. Rather than actually making shorter and shorter strings, it is easier to just keep track of what part of the string is still relevant. For example, rather than writing a procedure that computes the value of a string, you can write a procedure that computes the value of only part of the string. Consider the following three examples:
(substring-value "then" 4 0)

(substring-value "then" 3 0)

(substring-value "then" 3 1)
The first means to find the value of 4 characters starting at position 0, the second means 3 characters starting at position 0, and the third would be 3 characters starting at position 1. In other words, these are inquiring about our old friends "then", "the", and "hen", respectively, and so the three expressions above should evaluate to 10, -4, and 2, respectively.

Of course, if you want to have a procedure, string-value, that returns the value of a whole string, it is very easy to write in terms of substring-value. (You might find this convenient for your testing.)

A crude user interface

You could play the game on paper, crossing out the letters as they are claimed, and manually totaling up each player's score as you go. All you absolutely need if you are going to play against the computer, rather than against another human player, is a way to ask the computer to choose its next move. Suppose we had a procedure, which we can call cm as an abbreviation for "choose move", that we pass the current string to. If we evaluate (cm "then"), we would like to get back the symbol last, indicating that the computer chooses to take the last character. We could define cm in terms of a more general procedure, choose-move-from-substring, which limits its attention to only some of the string, in the same manner as substring-value. Here are the definitions:
(define cm  ; cm is short for "choose move"
  (lambda (s)
    (choose-move-from-substring s (string-length s) 0)))

(define choose-move-from-substring
  (lambda (s len low)
    (if (= len 1)
        'either
        ;; We let f be the net value if we choose the first letter,
        ;;  and l be the net value if we choose the last letter.
        (let ((f (- (char-value (string-ref s low))
                    (substring-value s (- len 1) (+ low 1))))
              (l (- (char-value (string-ref s (+ low (- len 1))))
                    (substring-value s (- len 1) low))))
          (cond ((> f l) 'first)
                ((> l f) 'last)
                (else 'either))))))

What you need to do

Find a good test word

Before programming, come up with at least one good test word, so you can gain confidence that each procedure you will write works properly. A good test word is both short (so you can work out all the possible plays), but not too simple (so a working program actually has to do work). In the section entitled how to play optimally, we used the example word "then". This was a poor choice for testing since the following trivial strategy would play optimally on the example: "Always chooses the end of the word with the higher point letter." But with other words, it's sometimes wise to choose the end with a lower point letter. Find an example of such a word. It need not be a real word in English; you'll just need it for testing.

Write procedures to find the value under optimal play

You need to write the tree-recursive procedure, substring-value, and test that it gives correct values. Then you should be able to play the game against the computer, using the crude user interface, cm. You should notice that with longer strings, the program gets rather slow. For example, "incomprehensibility" will probably strain your patience. You can try progressively longer strings in order to get some sense of how rapidly the time grows; you'll do more precise timings later.

Now use either memoization or dynamic programming to make substring-value more efficient, test that it still works, and again, see how time grows as you move to longer strings.

Perform run-time analysis of your procedures

To get a more precise sense of how poorly the tree-recursive process scales up, and how much better your new version is, you should time how long cm takes with strings of varying lengths, and graph your results, using each of the two versions of substring-value. You should do the timings using the timing procedure in the file ~mc27/public/time.scm; this is the one you used in the MCS-177 lab concerning perfect numbers.

You should use semi-log, log-log and regular graph paper found in http://www.gac.edu/~wolfe/courses/graph-paper/. Recall that

In any case, be sure that to choose axes so that the straightest curve has a nice slope heading northeast on the paper.

Try to experimentally predict, by looking at your graphs, the running times of your two versions. Solve for b in anb or abn by looking at the graphs. Predict how long a word can you solve in a day's time with the two programs.

If you are able to analytically deduce the running time of your program (Theta-notation), by analyzing the program rather than the data, that's a bonus.

If you want to improve the user interface

If you would like, please feel free to write a better user interface. For example, you could adapt the Nim program from chapter 6 (which was the basis for an MCS-177 project). This program displays the game state at each turn, before reporting the computer's move or asking for the human's. In order to do this, you may want to display the relevant substring of the original string. There is a procedure built into Scheme that will help with this. If you want len characters of the string s starting at position low, you could evaluate
(substring s low (+ low len))
For example, (substring "then" 1 (+ 1 3)) would evaluate to "hen".

Postlab

Write up a scientific project report that explains the data you collected and any conclusions you have drawn from your data. In your report, your primary goal is to compare the performance of the two versions of the program, and the details of the game itself are secondary. (You could try to summarize the rules in two sentences or so just to make the report self contained.)

To make your quantitative data easy to interpret, present it both graphically and tabularly. You should attempt to compare the performance of the two versions of your program using your experimental results. To facilitate the comparison, the data from both versions should appear on each graph or table.

The gradesheet for the project is available in PostScript or PDF format. (If you print a copy out, you can staple it to the front of your project report to save paper.)