| 11/20/09 | Lecture 34. Merge sort and a lower bound for sorting.
Please also see the notes Sorting and A Lower Bound for Sorting. We showed that the worst case number of comparisons for merge sort to sort n elements is O(n log n). We also showed that the worst case number of comparisons for ANY comparison algorithm to sort n elements is Omega(n log n). (A comparison algorithm accesses its input only by means of pairwise comparisons: (xi <= xj)?.) |
|---|---|
| 11/18/09 | Lecture 33. More efficiency of algorithms: sorting.
Please also see the notes Running time and Sorting. We covered a procedure (insert item lst) to insert an item into the correct position in an already sorted list.
(define insert
(lambda (item lst)
(cond
((null? lst) (cons item '()))
((<= item (car lst)) (cons item lst))
(else (cons (car lst) (insert item (cdr lst)))))))
The time used by insert big-Oh of the number of comparisons
it does, that is, the number of times the <= procedure is
called. For n elements in the list, the best case number
of comparisons is 1 (for n at least 1) and the worst case
number of comparisons is n.
Being pessimists, we focus on the worst-case number of comparisons.
We used the insert procedure to implement insertion sort as follows:
(define i-sort
(lambda (lst)
(cond
((null? lst) lst)
(else (insert (car lst) (i-sort (cdr lst)))))))
We analyzed the worst-case number of comparisons used by
i-sort in sorting a list of n elements.
n worst-case number of comparisons
0 0
1 0
2 1
3 3
4 6
To see that this is right for 3, consider (i-sort '(3 2 1)),
which calls i-sort on (2 1), which requires one comparison.
and then calls insert on 3 and (1 2), which requires two comparisons.
Similarly, for (i-sort '(4 3 2 1)) calls i-sort on (3 2 1),
which requires 3 comparisons, and then insert on 4 and (1 2 3),
which requires 3 more comparisons, for a total of 6.
In general, for n elements the worst case number of comparisons is
1 + 2 + 3 + ... + (n-1)which we can see (using Gauss's trick) is w(n) = n(n-1)/2. We saw that w(n) = O(n^2) and w(n) = Omega(n^2), and concluded that w(n) = Theta(n^2). Because (we claim) the running time of i-sort is big-Theta of the number of comparisons, and the number of comparisons is Theta(n^2), we conclude that the worst-case running time of i-sort is Theta(n^2). We looked at the problem of merging two sorted lists, for example: (merge '(3 5 12 17) '(4 6 19 23)) => (3 4 5 6 12 17 19 23)The idea is to compare the first elements of the two lists, and move the smaller one to an output list, repeating until one of the lists becomes empty, and then moving the rest of the elements in the other list to the end of the output list. If we are merging a list of m elements with a list of n elements, the best-case number of compares for this process is min(m,n). (This case is achieved when all of the elements of the shorter list are smaller than all of the elements of the larger list, so that after min(m,n) comparisons, the shorter list is empty and the longer list is just copied to the end of the output.) The worst-case number of comparisons for merging a list of length m with a list of length n is (m+n-1). (Think about what inputs would achieve this, and why it is an upper bound.) |
| 11/16/09 | Lecture 32. Another implementation of a queue; efficiency of algorithms.
Please see also the notes List representation and mutation, queues, circular lists. We looked in detail at the implementation that the text gives (p. 52) for the queue operations make-queue, putq!, getq and delq!, and verified that each one involves at most some constant number of instructions regardless of how many elements are in the queue. We also considered the following Scheme procedure to find the maximum of a list of numbers.
(define largest
(lambda (lst)
(largest-helper (cdr lst) (car lst))))
(define largest-helper
(lambda (lst val)
(cond
((null? lst) val)
((> (car lst) val) (largest-helper (cdr lst) (car lst)))
(else (largest-helper (cdr lst) val)))))
The helper procedure works by having a candidate largest value, val,
which is initially set to the first element of the list, and the
list lst of remaining elements to consider.
If the list of remaining elements is empty, then the candidate
largest value is the largest value, and is returned.
Otherwise, the candidate largest value is compared with the
first element of lst.
If the first element of the list is larger, then it becomes the
new candidate largest element in a recursive call to largest-helper
with the rest of the list elements (cdr lst).
Otherwise, val remains the candidate largest value and the recursive
call is made with the rest of the list elements (cdr lst) and val.
Analyzing this algorithm, we see that at most a constant number of instructions are executed for each element of the list that is input to the largest procedure. If we let n denote the number of elements of the list input to the largest procedure, then the time until it returns the answer is described as O(n), read "big-Oh of n.'' This means that there is some (unspecified) constant c, independent of n, such that the time that the procedure runs on any list of length n is at most cn. For our purposes, "time" is the number of machine language instructions executed while the program is running. |
| 11/13/09 | Lecture 31. A queue object (1st version); representation of lists in
memory and costs of null?, car, cdr, cons; set-car! and set-cdr!
Perlis epigram #55: A LISP programmer knows the value of everything and the cost of nothing. Please see also the notes List representation and mutation, queues, circular lists. We looked at a first implementation of a queue object using a list to represent the elements in the queue and *append* to add elements to the end of the list. A queue is a list in which elements may be added to the end of the list and removed from the beginning of the list. It displays "First In, First Out" (FIFO) behavior, in contrast to a stack, which displays "Last In, First Out" (LIFO) behavior. We implement the operations: empty? (to test if the queue is empty), getq (to return the element at the beginning of queue, without changing the queue), delq! (to remove the element at the front of the queue and return it), putq! (to add a new element at the end of the queue.) The code is as follows.
(define make-queue
(lambda (queue)
(lambda (command . args)
(case command
((empty?) (null? queue))
((getq) (car queue))
((delq!) (let ((value (car queue)))
(set! queue (cdr queue))
value))
((putq!) (set! queue (append queue args)))))))
|
| 11/11/09 | Lecture 30. Environments, objects, mutators; a stack object.
Please see the notes Mutators, environments and objects and the notes A stack object. We covered a detailed explanation of how the make-counter procedure from the last lecture succeeds in creating counter objects with private state, in terms of the global environment and local environments. We considered an analogous implementation of a make-stack procedure that stores the stack as a list (also covered in the text). |
| 11/9/09 | Lecture 29. Random generation of an element of L((a)*); a counter
object in Scheme.
We discussed issues related to randomly generating any string from the language of the regular expression (a)*. Such a method should be able (in principle) to generate any of the strings in the language, including the empty string, a, aa, aaa, aaaa, and so on, while not generating any other strings. One method of doing this would be to use a sequence of coin flips as follows. (1) Flip a coin: if it comes up heads, then output the empty string and halt. If it comes up tails, generate one a and continue. (2) Flip the coin again: if it comes up heads, then output the string a and halt. If it comes up tails, generate another a and continue. (3) Flip the coin again: if it comes up heads, then output the string aa and halt. If it comes up tails, generate another a and continue. And so on. We can picture this process as follows:
*--1/2---> output the empty string
|
1/2
|
V
*--1/2---> output the string a
|
1/2
|
V
*--1/2---> output the string aa
|
1/2
|
V
*--1/2---> output the string aaa
|
1/2
|
V
etc.
Each coin flip is symbolized by *, and the two possible results
(each with probability 1/2) are shown at the ends of arrows leaving
the *.
This process generates the empty string with probability 1/2, the string a
with probability 1/4, the string aa with probability 1/8, the
string aaa with probability 1/16, and so on, with the string of n a's
generated with probability 1/2^(n+1).
This process clearly generates all the strings in the language of (a)*.
Does it generate any other string?
It seems that there is a logical possibility that the coin flips *never* result in heads, so that we just keep piling up more and more a's without ever outputting a finite string and halting. That is, there is the logical possibility that this process might never halt. However, the probability of that event is smaller than 1/2^n for every n, which means it must be 0. Here we have an event (non-halting) that is logically possible but has probability 0. Because it has probability 0, we feel free to ignore it. Thus, this process generates all and only the strings in the language of (a)* with positive probabilities. Although this process satisfies the technical conditions we set, the distribution of probabilities is not very "even." Could we get a *uniform* probability distribution on the strings in the language of (a)*? No, we cannot, as we now see. Let p_n be the probability assigned to the string of n a's; we'd like these to satisfy two conditions (1) all the p_n's are equal, and (2) the sum of p_0 + p_1 + p_2 + ... = 1. The first condition is our desired uniformity condition, and the second one is required to have a probability distribution. But if we take p_n = p > 0 for all n, then the sum of p_0 + p_1 + ... grows without bound, and is not equal to 1. On the other hand, if we take p_n = 0 for all n, then the sum of p_0 + p_1 + p_2 + ... is 0, not 1. Thinking about this a little further, we see that for any probability distribution on the strings in the language of (a)*, and for any positive number epsilon, there will be a *finite* set of the strings whose probabilities add up to at least (1 - epsilon), leaving probability epsilon or less to be distributed among the (infinitely many) remaining strings. Hence, any such distribution is bound to be quite "uneven." (This phenomenon occurs whenever the range of objects to be generated is an infinite countable set.) On the other hand, if we imagine drawing a real number x uniformly from the interval [0,1], then the particular number x we draw has probability 0. Nonetheless, measure theory makes logical sense of this situation. (We had a lively discussion of whether we could in fact draw a real number from [0,1] uniformly at random.) We discussed an implementation of a counter object in Scheme using the mutator set! and the properties of procedures and environments. Please also see the notes: Mutators, environments and objects. |
| 11/6/09 | Lecture 28. Code generation, continued.
We discussed how we might write a procedure to generate TC-201 assembly language instructions for the following statement in a Pascal-like language, given its parse tree.
begin
x = 1;
while x <= 100 do
x = x + x + 6
end
Suppose we are given the following parse tree for this statement.
(statement)
-------------------------------------------
/ / | \ \
begin (statement) ; (compound statement) end
| |
(assignment) (statement)
/ | \ |
(var) = (exp) (while statement)
| | / / \ \
x (constant) while (condition) do (statement)
| / | \ |
1 (exp) <= (exp) (assignment)
| | / | \
(var) (constant) (var) = (exp)
| | | / | \
x 100 x (exp) + (term)
/ | \ |
(exp) + (term) (constant)
| | |
(var) (var) 6
| |
x x
(For conciseness we've simplified the grammar from the lecture a bit.)
Then we can begin to see the outlines of a recursive procedure to
generate TC-201 assembly language statements to execute these operations.
Initially we could scan the whole parse tree and collect up the
variables that occur (in this case, just x) and the constants that occur
(in this case: 1,100,6) and allocate storage for them with data statements,
for example,
x: data 0 c::1: data 1 c::100: data 100 c::6 data 6This gives "unlikely" symbolic names to the constants so that we can refer to them in the assembly language program we generate. Proceeding top-down from the root of the parse tree, we realize that we should recursively generate assembly language instructions for the subtree rooted at (statement) and follow them with the assembly language instructions generated from the subtree rooted at (compound statement). Generating instructions for an (assignment), we recursively generate instructions to load the value of the (exp) on the right hand side into the accumulator, and we follow this with a store instruction that stores into the variable (in this case, x) on the left hand side of the =. Recursively generating the code for the right hand side, we realize a "load c::1" instruction will do the trick. Hence the first assignment statement generates the following code.
load c::1
store x
This has the desired effect of assigning the value 1 to x.
The (compound statement) is a (while statement), which involves
generating code to test the condition and jump beyond the code
in the "body" of the while if it is false, and execute the
code in the body of the while if it is true.
At the end of the code for the body of the while is a jump
back to test the condition again.
So schematically we have
test: (code to test condition)
jump body
jump next
body: (code for the body of the while)
jump test
next: (any code following the while statement would start here)
As we saw last lecture, we can implement the condition test
for <= by generating code to put the value of the left hand expression
in a temporary variable, generating code to put the value of the right hand
expression in another temporary variable, then subtracting the second
temporary variable from the first and doing a skippos instruction
to test whether the first value is > the second value (ie, the
while condition is false.)
So we expand the scheme above to
test: (code to get value of left hand expression in accumulator)
store temp::1
(code to get value of right hand expression in accumulator)
store temp::2
load temp::1
sub temp::2
skippos
jump body
jump next
body: (code for the body of the while)
jump test
next: (code following the while, if any)
The left hand expression is just the variable x, so the code
to get its value into the accumulator is just "load x".
The right hand expression is just the constant 100, so the
code to get its value into the accumulator is just "load c::100".
(Note that the expressions being compared could be more complex,
like (x + x + 6) <= (y - 17 - x).)
So now we have
test: load x
store temp::1
load c::100
store temp::2
load temp::1
sub temp::2
skippos
jump body
jump next
body: (code for the body of the while)
jump test
next: (code following the while, if any)
To figure out what code to generate for the body of the while,
we look at the (statement) part, which is an (assignment).
This we know generates code to get the value of the (exp) on the
right hand side of the = into the accumulator, and then
stores the value into the variable on the left hand side.
In this case the (exp) is resolved to (exp) + (term), so
we recursively generate code for the expression and add the
term (which in our case is the constant 6) to it.
Continuing, we finally resolve the body of the while
to the following instructions.
load x
add x
add c::6
store x
Thus the full translation we arrive at is the following.
test: load x
store temp::1
load c::100
store temp::2
load temp::1
sub temp::2
skippos
jump body
jump next
body: load x
add x
add c::6
store x
jump test
next: (code following the while, if any)
halt
x: data 0
c::1 data 1
c::100 data 100
c::6 data 6
temp::1 data 0
temp::2 data 0
It is evident that our (imagined) translation process did not generate
code as efficient as we might generate by hand (avoiding the use
of the temporary variables temp::1 and temp::2 altogether.)
However, we can also imagine that the code generator could
try to improve the code that it generated by analyzing it to
find redundant stores and replacing small blocks of instructions
with equivalent but more efficient choices.
This would be a kind of local optimization of the code.
Global optimization might attempt to find whole blocks of the
program that would never be executed, in order to remove them.
(Which in its full generality involves the halting problem,
although there are useful global optimizations are both possible
and safe.)
|
| 11/4/09 | Lecture 27. Compilers.
A compiler takes a program in a higher-level language (Java, Pascal, C, C++, Fortran, and so on) and translates it into an assembly-language (or machine-language) program that "does the same thing." As an example of the kind of thing a compiler does, we translated by hand a program in a Pascal-like language into an "equivalent" TC-201 assembly-language program.
The pseudo-Pascal program The TC-201 assembly-language program
------------------------- ------------------------------------
sum = 0; load zero
store sum
n = 1; load one
store n
while n <= 10 do test: load n
sub ten
skippos
jump body
jump next
begin
sum = sum + n; body: load sum
add n
store sum
n = n + 1 load n
add one
store n
jump test
next: halt
end zero: data 0
sum: data 0
one: data 1
n: data 0
ten: data 10
|
| 11/2/09 | Lecture 26. A Context-free grammar for Pascal.
We considered the context-free grammar (given in Backus-Naur Form in Jensen and Wirth's "Pascal: User Manual and Report, corrected edition, 1978) for the computer language Pascal, and eventually convinced ourselves that the following is a legal statement in Pascal:
begin
a := 1;
x := 1;
while (a <= 10) do
begin
x := a * x + 1;
a := a + 1
end
end
|
| 10/30/09 | Lecture 25. Context free grammars and languages.
Please see the notes Context free languages. A sketch of the proof that the set of palindromes is not a regular set appears at the end of the preceding set of lecture notes: Deterministic finite state acceptors. I am happy to expand on that proof if anyone is interested. We covered some Scheme constructs to deal with input from and output to the user at the terminal (useful for implementing the read and write instructions in the TC-201 for assignment #6.) To output the value of an expression exp to the user at the terminal, you can use (display exp). To move the cursor to the start of the next line, you can use (newline). To read in a value from the user, you may use the Scheme procedure (read), which reads in an expression from the user at the terminal and returns its value as the value of the expression (read). To write a message to the user, you may use strings. A constant string may be quoted, for example, "input = ". There are other library procedures for dealing with strings, such as string-length, string-append and string-ref. Please explore the reference manual for any other information you may need about strings. As an example of prompting the user for an input and then echoing it back to the user, we have the following expression.
(begin
(display "input = ")
(let ((x (read)))
(display "you typed = ")
(display x)
(newline)))
When this expression is evaluated, the user is prompted for
an input with
input =The user then types in a value, say the number 17, terminated with "enter". input = 17 (user types the 17 followed by enter)The value of the (read) is returned as 17, which is then assigned to the variable x by the let. Then the body of the let is evaluated, which causes the string "you typed = " to be output to the terminal, followed by the value of x (which is 17), followed by a newline. So the whole encounter is as follows. input = 17 you typed = 17The keyword "begin" signals a special form (begin exp1 exp2 ... expn), which evaluates each of the expressions exp1, exp2, ..., expn *in that order* and returns the value of the last one (expn). This is useful to ensure that the prompt message is printed out before the program attempts to collect input from the user, and so on. The reason that there is no additional "begin" expression in the body of the let is that there is already an implicit begin in the body of let and lambda (and a few other places.) That is, the sequence of expressions that form the body of the let above will be evaluated in order, ensuring that the "you typed = " will come before the 17, which will come before the newline. The procedures read, display, newline, write can also be used (by supplying an additional argument) to do input/output to a file. Experiment with these and read about them in a reference manual if you are interested. |
| 10/28/09 | Lecture 24. Deterministic finite state acceptors.
Please see the notes Deterministic finite state acceptors. |
| 10/26/09 | Lecture 23. Strings, languages, regular expressions.
Please see the notes Regular expressions. We argued that no method of describing sets of strings over a finite alphabet can succeed in describing them all. This is true because of cardinalities: the set of descriptions is assumed to be a countable set, and the set of all sets of strings over a finite alphabet is an uncountable set. We can also see it more directly via a diagonalization. Assume the descriptions are d1, d2, d3, ... (this assumes the set of descriptions is countable, eg, consist of finite strings over a finite alphabet.) Then we make a list of all the strings and all the descriptions, for example:
lambda a b aa ab ba bb aaa aab aba abb baa bab ..
d1 0 0 0 0 0 0 0 0 0 0 0 0 0
d2 1 1 1 1 1 1 1 1 1 1 1 1 1
d3 1 0 0 1 1 1 1 0 0 0 0 0 0
d4 0 1 1 0 0 0 0 1 1 1 1 1 1
d5 1 0 1 1 0 0 1 0 1 1 0 1 0
d6 0 1 0 0 1 1 0 1 0 0 1 0 1
d7 1 1 0 1 0 0 1 1 0 0 1 0 1
d8 0 0 1 0 1 1 0 0 1 1 0 1 0
.
.
In this table, the entry for description di and string sj is 1 if
sj is in the language of di and 0 otherwise.
Thus, the language of d1 does not contain any of the strings:
lambda, a, b, aa, ab, ba, bb, aaa, aab, aba abb, baa, bab,
while the language of d2 contains all of these.
The language of d7 contains lambda, a, aa, bb, aaa, abb, bab and
does not contain b, ab, ba, aab, aba, baa.
Given this table, we can use the diagonalization trick to construct
a set S of strings that is not listed anywhere in the table.
The diagonal elements of the table are marked below.
lambda a b aa ab ba bb aaa aab aba abb baa bab ..
d1 *0 0 0 0 0 0 0 0 0 0 0 0 0
d2 1 *1 1 1 1 1 1 1 1 1 1 1 1
d3 1 0 *0 1 1 1 1 0 0 0 0 0 0
d4 0 1 1 *0 0 0 0 1 1 1 1 1 1
d5 1 0 1 1 *0 0 1 0 1 1 0 1 0
d6 0 1 0 0 1 *1 0 1 0 0 1 0 1
d7 1 1 0 1 0 0 *1 1 0 0 1 0 1
d8 0 0 1 0 1 1 0 *0 1 1 0 1 0
.
.
We construct the set S by putting string sj in S
if and only if sj is not in the language of description dj.
In terms of the table above, this means that the set S
contains lambda, b, aa, ab, aaa but not a, ba, or bb.
This means that S is not the language of d1 (because S contains
lambda but the language of d1 does not), it is not the language
of d2 (because S does not contain the string a, but the language
of d2 does), and so on.
Continuing in this way, we define a set S that is not described
by any description on the list.
|
| 10/23/09 | Lecture 22. The TC-201 indirect instructions and a simple assembler.
Consider again the task of reading in from the user a zero-terminated sequence of numbers and storing them in consecutive memory locations. To avoid a self-modifying program, we add two more instructions to the TC-201. loadi opcode = 11 binary = 1011 storei opcode = 12 binary = 1100This now leaves opcodes 13, 14 and 15 unused; they should have the same effect as a halt (opcode = 0). A loadi (load indirect) instruction looks at the contents of the addressed memory location and uses the low-order 12 bits to specify another memory address, whose contents are copied to the accumulator. In terms of micro-operations, the instruction (loadi address) copies the address to the MAR and issues a "read" to the memory to get the contents of the addressed memory location into the MDR. It then copies the low-order 12 bits of the MDR into the MAR and issues ANOTHER "read" to the memory, to get the contents of the memory location addressed by the MAR into the MDR. Finally, it copies the contents of the MDR to the accumulator. Thus, it doesn't use the address "directly", but performs one level of "indirection" on it. The instruction (storei address) is analogous: address is copied to the MAR and a "read" is issued to the memory, copying the contents of the memory location at address to the MDR. Then the low-order 12 bits of the MDR are copied to the MAR, and finally a "write" is issued to the memory, copying the contents of the accumulator to the memory location addressed by the MAR. As an example, consider the following: 0: loadi 4 1: storei 5 2: halt 3: 16 4: 61 5: 137 6: 118 ... 61: 144 ... 137: 77If this program is executed starting with the program counter = 0, then the (loadi 4) instruction takes the (low-order 12 bits) of the contents of location 4, namely 61, as the address to load the accumulator from. After that instruction, the accumulator contains the number 144. Then the (storei 5) instruction takes the (low-order 12 bits) of the contents of location 5, namely 137, as the address to store the contents of the accumulator to. When the program halts, the memory contents are as follows: 0: loadi 4 1: storei 5 2: halt 3: 16 4: 61 5: 137 6: 118 ... 61: 144 ... 137: 144With these instructions in hand, we can write a program to read in a zero-terminated sequence of numbers and store them in consecutive locations in memory as follows.
loop: read number (read a number into memory)
load number (load it into the accumulator)
skipzero (test to see if it is zero)
jump next (non-zero case, jump over the halt)
halt (number read was zero, halt)
next: storei pointer (store number indirect through pointer)
load pointer (increment where pointer points)
add one
store pointer
jump loop (read in next number)
number: data 0 (stores most recent number read)
one: data 1 (constant 1)
pointer: data table (pointer to next location to store a number)
table: data 0 (first location to store a number)
To check your understanding of these concepts, figure out
how to change this program so that instead of halting when it
gets a zero, it writes out the numbers it read in, in reverse
order.
That is, if it read in 13,43,77,0, it should print out 77,43,13.
The next topic was the function of a simple two-pass assembler to translate the assembly language that we have been using for the TC-201 to the correct sequence of machine instructions, represented as a sequence of 16-bit quantities. As an example, consider the task of translating the program just above, assuming that it will be loaded starting at location 0 in memory. The first pass of the assembly process constructs a "symbol table" that translates each label in the program into the corresponding address, by numbering the instructions starting with the starting address (in this case, 0). 0: loop: read number (read a number into memory) 1: load number (load it into the accumulator) 2: skipzero (test to see if it is zero) 3: jump next (non-zero case, jump over the halt) 4: halt (number read was zero, halt) 5: next: storei pointer (store number indirect through pointer) 6: load pointer (increment where pointer points) 7: add one 8: store pointer 9: jump loop (read in next number) 10: number: data 0 (stores most recent number read) 11: one: data 1 (constant 1) 12: pointer: data table (pointer to next location to store a number) 13: table: data 0 (first location to store a number)Then the symbol table is just the correspondence: symbol address ------ ------- loop 0 next 5 number 10 one 11 pointer 12 table 13In the second pass, the assembler uses the symbol table to translate each instruction into its 16-bit representation. For example, the instruction (loop: read number) is translated by looking up the opcode "read" (in another table, that gives the correspondence of symbolic opcodes and their values) to find the opcode field should be 0101, and then looking up the symbol "number" in the symbol table to find that its value is 10, or, as a 12-bit address field, 000000001010. Putting these two together gives 0101000000001010 for the first instruction. Other opcodes are handled similarly. The "data" statements are treated somewhat differently. If a number follows "data", then it is converted to a 16-bit two's complement representation that is the translation of the data statement. Thus, the statement (data 1) is translated as 0000000000000001. If a symbol follows "data", it is translated to a number by looking it up in the symbol table, and then the number is converted to a 16-bit two's complement representation that is the translation of the data statement. Thus, the statement (data table) is first translated (using the symbol table) into the statement (data 13) and then into the bit pattern 0000000000001101. Note that sometimes we want to be able to load a program into memory starting at a location other than 0. For example, if the starting address for the above program were 5 (instead of 0), then the instructions would be numbered starting with 5 (instead of 0), giving: 5: loop: read number (read a number into memory) 6: load number (load it into the accumulator) 7: skipzero (test to see if it is zero) 8: jump next (non-zero case, jump over the halt) 9: halt (number read was zero, halt) 10: next: storei pointer (store number indirect through pointer) 11: load pointer (increment where pointer points) 12: add one 13: store pointer 14: jump loop (read in next number) 15: number: data 0 (stores most recent number read) 16: one: data 1 (constant 1) 17: pointer: data table (pointer to next location to store a number) 18: table: data 0 (first location to store a number)In this case, the symbol table is as follows: symbol address ------ ------- loop 5 next 10 number 15 one 16 pointer 17 table 18The rest of the translation process continues as before, so that the instruction (loop: read number) now translates to 0101000000001111. |
| 10/21/09 | Lecture 21. The TC-201, two's complement and a self-modifying program.
Two's complement arithmetic with k bits can be thought of as arithmetic modulo 2^k. For example, for k = 4, we consider arithmetic modulo 2^4 = 16. For arithmetic modulo 16, we have the numbers 0 through 15. To add two numbers x and y from this range, we add them as usual and then subtract 16 if the sum is over 16. For example (7 + 7) mod 16 = 14, and (8 + 10) mod 16 = 2. Subtraction of two numbers is similar, except we may have to add 16 to get a number in the range 0 to 15 again. Thus (10 - 8) mod 16 = 2, while (8 - 10) mod 16 = 14. We can also consider multiplication; we may have to subtract 16 several times to get a number in the right range. Equivalently, we can divide by 16 and take the remainder. Thus (3 * 7) mod 16 = 5 and (4 * 12) mod 16 = 0. In this last case, the product of two numbers that are not zero modulo 16 can be zero modulo 16. (This doesn't happen in the rational, real, or complex numbers: the product of two nonzero numbers will always be nonzero.) The great thing about these definitions is that we can reduce modulo 16 as we go along and still get the right answer. So if we want (21 * 3) mod 16, we can multiply to get 63 and then reduce modulo 16 to get 15, or we can first reduce 21 modulo 16 to get 5 and multiply 5 by 3 to get 15. With these definitions, (-3) mod 16 is 13. To check, we see that (3 + 13) mod 16 = 0. Thus, 13 has the property we want for (-3), namely, that (3 + (-3)) = 0. Now if we look at our 4-bit binary representations of the numbers 0 to 15, we get 0000, 0001, and so on, up to 1111. In terms of positive and negative numbers, we have:
number binary negative of number binary
0 0000
1 0001 (-1) mod 16 = 15 1111
2 0010 (-2) mod 16 = 14 1110
3 0011 (-3) mod 16 = 13 1101
4 0100 (-4) mod 16 = 12 1100
5 0101 (-5) mod 16 = 11 1011
6 0110 (-6) mod 16 = 10 1010
7 0111 (-7) mod 16 = 9 1001
Note that we've left 8 off this list because (-8) mod 16 = 8,
so 8 is its own negative modulo 16. By convention, we think of
the bit pattern 1000 as representing -8, because the binary
representations starting with 1xxx are exactly the negative numbers.
How do we find the negative of a number in this system? To find the negative of 3, we subtract 3 from 16. In binary, we have:
10000
- 0011
-----
1101
which involves a lot of borrowing because of all the 0's.
Instead, we could subtract 3 from 15, which in binary is:
1111
- 0011
-----
1100
This subtraction is a lot easier.
In fact, we don't even have to subtract -- we just complement
each bit (change 0 to 1 and vice versa.)
However, it isn't quite the right answer -- we just need to add 1 to
it, to get 1101.
The general rule to find the negative, then, is to complement the
bits and add 1.
To motivate the introduction of the load and store indirect instructions, we first write a self-modifying TC-201 program to solve the following problem: read in a zero-terminated sequence of numbers and store them in consecutive locations in memory. Written in assembly language, one such program is as follows.
loop: read number reads a number from the user into memory
load number copies the number to the accumulator
skipzero skips if the number in the accumulator is zero
jump next non-zero case, jump over the halt
halt stop if the number in the accumulator is zero
next: store table store the number in memory
load next accumulator will have the store instruction in it
add one add one (to the address field)
store next copies the store instruction back into next
jump loop go read in the next number
number: data 0 holds the most recent number read in
one: data 1 the constant 1
table: data 0 the start of the table to hold the numbers read in
If this program is assembled and loaded with its first instruction in
memory location 0, then the successive memory locations have the following
bit patterns:
0: 0101000000001010 (read 10) 1: 0001000000001010 (load 10) 2: 1000000000000000 (skipzero) 3: 0111000000000101 (jump 5) 4: 0000000000000000 (halt) 5: 0010000000001100 (store 12) 6: 0001000000000101 (load 5) 7: 0011000000001011 (add 11) 8: 0010000000000101 (store 5) 9: 0111000000000000 (jump 0) 10: 0000000000000000 (0) 11: 0000000000000001 (1) 12: 0000000000000000 (0)Suppose this program is started with the program counter (PC) = 0. A number is read from the user and placed in location 10. Suppose the number is 15, or 0000000000001111 as a 16-bit binary number. After this happens, the memory contents is as follows: 0: 0101000000001010 (read 10) 1: 0001000000001010 (load 10) 2: 1000000000000000 (skipzero) 3: 0111000000000101 (jump 5) 4: 0000000000000000 (halt) 5: 0010000000001100 (store 12) 6: 0001000000000101 (load 5) 7: 0011000000001011 (add 11) 8: 0010000000000101 (store 5) 9: 0111000000000000 (jump 0) 10: 0000000000001111 (15) 11: 0000000000000001 (1) 12: 0000000000000000 (0)Now that value is loaded into the accumulator, so the accumulator also contains 0000000000001111. The skipzero does not skip, because the accumulator does not contain 0, so the program jumps to instruction 5 and the contents of the accumulator are stored in location 12. Now the memory contents are as follows: 0: 0101000000001010 (read 10) 1: 0001000000001010 (load 10) 2: 1000000000000000 (skipzero) 3: 0111000000000101 (jump 5) 4: 0000000000000000 (halt) 5: 0010000000001100 (store 12) 6: 0001000000000101 (load 5) 7: 0011000000001011 (add 11) 8: 0010000000000101 (store 5) 9: 0111000000000000 (jump 0) 10: 0000000000001111 (15) 11: 0000000000000001 (1) 12: 0000000000001111 (15)The next instruction is the (load 5), which copies the contents of location 5 into the accumulator, so that the accumulator now contains the value 0010000000001100. The (add 11) adds the contents of location 11 (which has value 1) to the value in the accumulator, to yield 0010000000001101 in the accumulator. Now the (store 5) instruction copies the contents of the accumulator into memory location 5. Now the memory contents are as follows: 0: 0101000000001010 (read 10) 1: 0001000000001010 (load 10) 2: 1000000000000000 (skipzero) 3: 0111000000000101 (jump 5) 4: 0000000000000000 (halt) 5: 0010000000001101 (store 13) 6: 0001000000000101 (load 5) 7: 0011000000001011 (add 11) 8: 0010000000000101 (store 5) 9: 0111000000000000 (jump 0) 10: 0000000000001111 (15) 11: 0000000000000001 (1) 12: 0000000000001111 (15)Notice that we have in effect changed the (store 12) instruction into a (store 13) instruction. After this, the program jumps back to location 0 to execute the next instruction. This reads another number, say 43, from the user, and stores it in location 10. The memory contents are now as follows: 0: 0101000000001010 (read 10) 1: 0001000000001010 (load 10) 2: 1000000000000000 (skipzero) 3: 0111000000000101 (jump 5) 4: 0000000000000000 (halt) 5: 0010000000001101 (store 13) 6: 0001000000000101 (load 5) 7: 0011000000001011 (add 11) 8: 0010000000000101 (store 5) 9: 0111000000000000 (jump 0) 10: 0000000000101011 (43) 11: 0000000000000001 (1) 12: 0000000000001111 (15)Then the value in location 10 is copied to the accumulator, so that the accumulator contains 0000000000101011. The skipzero does not skip, because this value is not zero, and the program jumps to location 5. The (store 13) instruction stores the contents of the accumulator in memory location 13, so that the memory contents are as follows: 0: 0101000000001010 (read 10) 1: 0001000000001010 (load 10) 2: 1000000000000000 (skipzero) 3: 0111000000000101 (jump 5) 4: 0000000000000000 (halt) 5: 0010000000001101 (store 13) 6: 0001000000000101 (load 5) 7: 0011000000001011 (add 11) 8: 0010000000000101 (store 5) 9: 0111000000000000 (jump 0) 10: 0000000000101011 (43) 11: 0000000000000001 (1) 12: 0000000000001111 (15) 13: 0000000000101011 (43)Now the (load 5), (add 11), (store 5) load the contents of memory location 5 into the accumulator, namely 0010000000001101, add one to it to get 0010000000001110, and store it back into memory location 5, at which point the memory contents are as follows. 0: 0101000000001010 (read 10) 1: 0001000000001010 (load 10) 2: 1000000000000000 (skipzero) 3: 0111000000000101 (jump 5) 4: 0000000000000000 (halt) 5: 0010000000001110 (store 14) 6: 0001000000000101 (load 5) 7: 0011000000001011 (add 11) 8: 0010000000000101 (store 5) 9: 0111000000000000 (jump 0) 10: 0000000000101011 (43) 11: 0000000000000001 (1) 12: 0000000000001111 (15) 13: 0000000000101011 (43)Then the program jumps back to location 0 to read in another number from the user. This continues, storing the successive numbers read in from the user in locations 12, 13, 14, and so on, until the user inputs 0, at which point the program halts. (Note that if "too many" numbers are read in, the carry from the addition will finally reach the opcode field and change the instruction from a store (opcode 0010) to an add (opcode 0011), and the behavior of the program will be considerably changed!) Self-modifying programs are generally frowned upon as confusing and very error-prone. More desirable is a situation where the variables and other data structures may change while the program is running, but the program itself does not. Next lecture we see two more instructions, loadi (load indirect) and storei (store indirect), that allow a non-self-modifying TC-201 program to accomplish this task. |
| 10/19/09 | Lecture 20. The TC-201, continued.
(Exam 1 was 10/16/09.) We went over the first 11 of the instructions of the TC-201 computer (HALT through SKIPERR) and wrote a program to read in and sum up a zero-terminated sequence of numbers from the user. Here are some examples of programs: TC-201 programs. We discussed number representations of unsigned, sign/magnitude, one's complement and two's complement, and voted on the representation to be used in the TC-201 this year. Two's complement was the overwhelming preference of the class. The following notes give the TC-201 specification. |
| 10/14/09 | Lecture 19. Computer organization and the TC-201.
For a description of TC-201 instructions, please see TC-201 instructions. In our simplified model of machine architecture, we consider the machine as divided into three major parts: the random access memory (or RAM), the central processing unit (or CPU), and the input/output system, each of which communicates directly with each of the others. We discussed a possible random access memory design in the last lecture, and this lecture we discussed the CPU. The CPU consists of three main parts: the arithmetic logic unit (or ALU), the control unit (or CU), and the registers. The ALU is the part of the CPU that carries out addition, subtraction and comparison of numbers, as well as other arithmetic and logical operations on data. Part of the ALU will contain a circuit to add binary numbers, for example. The registers are like the registers of the random access memory, only faster (and more expensive) -- ideally they hold the data that is currently being operated on, avoiding as much as possible the necessity of accessing the slower RAM memory, or the even slower disk memory. In our toy model of a computer, the TC-201, there is only one register in the CPU, called the accumulator. In actual modern computers, the CPU will contain numerous fast registers. The control unit (CU) contains both combinational circuits and registers, and is responsible for performing the fetch-execute cycle to access and execute the instructions that make up a program running on the machine. The registers it contains include the program counter (PC), which has the address in memory of the next instruction to be executed, and the instruction register (IR) which holds the instruction being executed while it is decoded and executed by the CU. Perlis aphorism #44: Sometimes I think the only universal in the computing field is the fetch-execute cycle. In the fetch-execute cycle, the CU reads the current instruction from memory into the instruction register (IR) and decodes the instruction (figures out what operation needs to be done, and what data it needs to be done to) and then executes the instruction. Most instructions do not modify the program counter -- it is just incremented to move on to the next instruction in memory. Some instructions (like jumps) change the value of the program counter, causing the next instruction to be taken from a specified place in memory. We can understand what the control unit does during the fetch-execute cycle in terms of "microinstructions". Suppose the program counter (PC) has value 1 and the instruction in memory location 1 is STORE 5, which is an instruction to copy the value in the accumulator into memory location 5. The CU copies the PC into the memory address register (MAR) and then issues a "read" request to the memory. This causes the value representing the instruction STORE 5 to be copied into the memory data register (MDR). The CU then copies this value from the MDR into the instruction register (IR) and decodes the opcode, which specifies a store operation. To execute this instruction, the CU copies the address portion of the instruction, which is 5, into the MAR and copies the value in the accumulator into the MDR, and issues a "write" request to the memory. This causes the value in the MDR (which is equal to the value in the accumulator at this point) to be copied into memory register 5. Because a store instruction does not explicitly set the program counter, the CU just increments the value of the PC by 1, to get 2. The CU then performs another fetch-execute cycle for the instruction at memory location 2. This is more detail than you need if all you want to do is write machine language or assembly language programs for a machine, but it helps illustrate how the CU works and how the hardware concepts we have covered are connected to the machine language level (and on up.) |
| 10/12/09 | Lecture 18. Memory: registers and RAM.
A collection of 1-bit memories with a common set input can be organized into a register to store several bits in parallel. (Registers are described in Memory.) A collection of registers can be organized into a random access memory (RAM). In lecture we worked out a possible design for a random access memory with 4 registers of 4 bits each. In addition, it had a memory data register (MDR) of 4 bits and a memory address register (MAR) of 2 bits, which determines which of the 4 registers should participate in a read or write operation. (To be continued.) |
| 10/9/09 | Lecture 17. Sequential circuits and a 1-bit memory.
Please see the notes: Memory. We went ahead and elaborated the two NAND gate circuit described in the notes to a 1-bit memory of the desired kind, as follows.
x -*----------|
| |NAND>----|
| *-----| |NAND>---*------ q
| | *---| |
| | | |
| | *-------* |
s ------* *-------|----*
| | | *----*
| | | |
| | *---| |
| *-----| |NAND>---*------ q'
| |NAND>----|
*--|NOT>---|
To understand this circuit, note that when the set line s = 0,
then both the lefthand NAND gates will have outputs of 1,
which preserves the values of q and q' "latched" in the righthand
NAND gates.
When the set line s = 1, then by analyzing the cases we can
see that q will take on the value of x and q' will be its
negation -- if x = 1, then q = 1 and q' = 0, and if x = 0, then
q = 0 and q' = 1.
This will continue to be true until the set line goes back to s = 0,
at which time, the value of q will remain "latched" at the
value of x at the time when s was last 1.
We can then abstract this 1-bit memory element into
a circuit with two inputs, x and s, and two outputs, q and q'
(of which we will generally only consider q, the value of the
bit stored in the memory.)
|
| 10/7/09 | Lecture 16. A circuit for addition of binary numbers.
Please see the notes: Addition circuit. A ripple-carry adder adds two n-bit numbers in time proportional to n, because the carry from the low-order bit may "ripple" all the way to the high-order bit. The design actually used in modern computers is a carry-lookahead adder, which can add two n-bit numbers in time proportional to (log n), an exponential improvement. We will not cover the carry-lookahead adder in this course -- instead we describe how we could speed up (by almost a factor of 2) a ripple-carry adder by using parallelism. We can construct a ripple-carry adder for two 2n-bit numbers x and y using three ripple-carry adders for two n-bit numbers in parallel. The first adder just adds the low-order n bits of x and the low-order n bits of y, to give the low-order n bits v of (x+y) and the carry out of the 32nd bit. The second adder adds the high-order n bits of x and the high-order n bits of y, to give 32 bits we'll call u0. The third adder adds the high-order n bits of x and the high-order n bits of y, PLUS 1, to give 32 bits we'll call u1. Note that z, u0 and u1 can all be computed in parallel in the time it takes to add two n-bit numbers. The answer (x+y) is either (u0,v) (if the carry out of the 32nd bit of z is 0) or (u1,v) (if the carry out of the 32nd bit of z is 1). Then by using the carry out of the 32nd bit of z as a selector, the whole sum (x+y) can be computed in 3 more gate delays -- each high-order bit of (x+y) is either the corresponding bit of u0 (if the carry out is 0) or the corresponding bit of u1 (if the carry out is 1), and they can all be selected in parallel. Thus, we can reduce the time to compute the sum of two 2n-bit numbers to the time to compute the sum of two n-bit numbers, plus 3 more gate delays. If we consider gates of at most 2 inputs, then because all 2n bits of two n-bit inputs can affect the high-order bit of the output, there must be some path of wires of length at least (log n) from an input to the high-order bit of the output, which means that the full output cannot be available sooner than (log n) gate delays after the inputs are presented. (To see this, note that the high-order bit of the output is the output wire of a gate with at most two inputs, which themselves are outputs of two gates that together have at most four inputs, which themselves are outputs of gates that together have at most eight inputs, and so on. At least (log n) doublings are required before the final output can depend on n inputs.) Thus, the time of a carry-lookahead adder (proportional to (log n) gate delays) is optimal up to a constant multiplicative factor. |
| 10/5/09 | Lecture 15. Gates and circuits, continued.
We've seen that {AND, OR, NOT} is a complete Boolean basis, as are {AND, NOT} and {OR, NOT}. In addition, {NAND} is a complete basis by itself, as is {NOR}. NAND is the NOT-AND function, which can be defined by (x NAND y) = (x * y)'. NOR is the NOT-OR function, which can be defined by (x NOR y) = (x + y)'. Please see the notes: Boolean bases. Note that we sketched arguments that neither {XOR, NOT} nor {AND,OR} is a complete Boolean basis, as follows. (1) Try an inductive proof that the set of all two-argument Boolean functions that can be computed with XOR and NOT have an even number of 1's in their truth tables. (2) Try an inductive proof that the set of all Boolean functions that can computed with AND and OR are "monotonic" (that is, changing an argument from 0 to 1 never changes the function value from 1 to 0.); because NOT is not monotonic, this means that we cannot achieve NOT with AND and OR. For any combinational (that is, loop free) Boolean circuit using the gates AND, OR, and NOT, we can write down a Boolean formula that represents the same function as follows. Starting with a gate whose only inputs are input wires, write down an expression for the value of its output wire. (That is, if the gate is an AND of input wires x and y, we write (x * y) on its output wire, and analogously for OR and NOT.) Once we have written down expressions for all of the input wires to a gate, we can write down an expression for its output wire; for example, if the gate is an OR and its input wires have expressions (x * y') and (y + z)', then the output wire gets the expression ((x * y') + (y + z)'). Eventually (because the circuit is loop-free) we will write down an expression for the output wire. Another way to view this algorithm is as a recursive, top-down method. That is, look at the output wire. If it is an input wire or a constant 0 or 1, return the appropriate variable or constant (these are the base cases.) If it is the output wire of a gate, recursively compute Boolean expressions for the input wires of the gate, and then combine them with + (for an OR gate), * (for an AND gate) or ' (for a NOT gate.) In general, a Boolean circuit may be exponentially more concise than any equivalent Boolean expression, because in a circuit we can split a wire to "copy" an expression, avoiding the problem of writing it down twice. Suppose we want a circuit to compute z = (x1 * x2 * x3 * x4 * x5 * x6), where we have only 2-input AND gates. One way is to feed x1 and x2 into an AND gate, feed the output of the first AND gate and x3 into a second AND gate, feed the output of the second AND gate and x4 into a third AND gate, feed the output of the third AND gate and x5 into a fourth AND gate, and finally feed the output of the fourth AND gate and x6 into a fifth AND gate. This method uses 5 AND gates, and time equal to 5 gate delays. Another design feeds x1 and x2 into a first AND gate, x3 and x4 into a second AND gate, x5 and x6 into a third AND gate, then the outputs of the first and second AND gate into a fourth AND gate, and finally the output of the third and fourth AND gate into a fifth AND gate. This method also uses 5 AND gates, but time equal to only 3 gate delays. (For a better view, draw both circuits.) In general, if we are trying to AND n different inputs together, the first method uses (n-1) gates and time equal to (n-1) gate delays, and the second method uses (n-1) gates and time equal to the ceiling of (log_2 n) gate delays. Thus, the first method uses exponentially more time than the second. Please also see the notes: Equality testing. A very useful element of hardware design is a selector (or multiplexer) circuit. In the simplest case, the circuit has three inputs: s, x, and y and one output z. If s = 0, then the output z is equal to the input x, while if s = 1, then the output z is equal to the input y. Thus, depending on whether s is 0 or 1, the output z copies x or y. Please see the notes: Multiplexer circuits. |
| 10/2/09 | Lecture 14. Boolean expressions concluded; Gates and circuits begun.
We reviewed the sum-of-products algorithm; please see the notes: Sum of products algorithm. As a result of the sum-of-products algorithm, we know that the Boolean functions {AND, OR, NOT} are a complete basis for all Boolean functions. That is, using constants and variables with just these three operations, we can express any possible Boolean function by converting its truth table to a Boolean expression in sum of products (or disjunctive normal) form. We also have the following equivalences:
(x * y) = (x' + y')'
(x + y) = (x' * y')'
which can be checked using truth tables, or derived using our
axioms for Boolean equivalences.
Thus, every time we have an AND, it can be replaced by an expression
using just OR and NOT.
Hence, {OR, NOT} is also a complete basis for the Boolean functions
(as is {AND, NOT}).
What about {AND} or {OR}? What about {XOR, NOT} or {XOR}?
We started the topic of gates and circuits; please see the notes: Gates. |
| 9/30/09 | Lecture 13. Boolean Functions and Expressions, continued.
(See also the notes: Boolean Functions and Expressions.) We covered syntax of Boolean expressions in the last lecture. To specify the semantics of a Boolean expression, we give an inductive definition based on the inductive structure of the expression. A Boolean expression has a value in an environment, where an environment assigns a Boolean value (0 or 1) to every variable (or, at least every variable that occurs in the expression.) (1) The value of the constant 0 is 0 in every environment, and the value of the constant 1 is 1 in every environment. (2) The value of a variable x is the value that the environment assigns to x (which is either 0 or 1). (3) If the expression E has value v in an environment, then the expression (E)' has the value NOT(v) in that environment. (4) If the expressions E_1 and E_2 have values v_1 and v_2, respectively, in an environment, then (E_1 + E_2) has the value OR(v_1,v_2) and (E_1 * E_2) has the value AND(v_1,v_2) in this environment. This gives an idea of how to compute (recursively, of course) the value of a given Boolean expression in a given environment, where (1) and (2) are the base cases of the recursion, and (3) and (4) are the recursive cases; e.g., to figure out the value of (E_1 + E_2), we recursively figure out the value of E_1 and the value of E_2, and then combine their values using a procedure to implement the Boolean function OR. This somewhat pedantic definition is equivalent to the usual method of truth tables for giving the meaning of a Boolean expression. In particular, if we consider the expression E = (x + y')', we can build a truth table for it by extracting all the variables (namely, x and y) and giving all possible assignments of Boolean values to the variables in the rows of the table. Then, for each row we substitute the value of the variable (in that row) for every occurrence of the variable in the expression and simplify the result. To help figure out the final values of the expression, we can have additional columns corresponding to the subexpressions of the final expression, as in the following example. x y | y' x+y' (x+y')' -------------------------------- 0 0 | 1 1 0 0 1 | 0 0 1 1 0 | 1 1 0 1 1 | 0 1 0Or we can dispense with the intermediate columns and just calculate the final column in our heads: x y | (x+y')' -------------------------------- 0 0 | 0 0 1 | 1 1 0 | 0 1 1 | 0Note that each row of the truth table corresponds to an environment giving the values of x and y, and the value for (x+y')' is the value of that expression in that environment. Two Boolean expressions are equivalent if for every environment, their values in that environment are equal. Thus, it suffices to construct truth tables for both expressions (using the set of variables that occur in either one of them) and check to see that their truth tables are the same. For example, to see that (x * y)' is equivalent to (x' + y') we construct their truth tables as follows x y | (x * y)' x y | (x' + y') -------------------- ------------------- 0 0 | 1 0 0 | 1 0 1 | 1 0 1 | 1 1 0 | 1 1 0 | 1 1 1 | 0 1 1 | 0Because their truth tables are equal, we conclude that the expressions are equivalent. A Boolean expression is satisfiable if there is at least one environment that makes it true, or equivalently, if there is at least one row of its truth table where it is 1. A Boolean expression is unsatisfiable if there is NO environment that makes it true, that is, if its value is 0 for every row of its truth table. A Boolean expression is a tautology if it is true in every environment, that is, if its value is 1 on every row of its truth table. A Boolean expression that is neither unsatisfiable nor a tautology is called contingent, that is, there is at least one environment in which it is true and at least one environment in which it is false. The equivalence of Boolean expressions can be completely characterized by a set of axioms; the set discussed in class is available from axioms.txt. In these axioms, the symbol "=" signifies that the Boolean expressions are equivalent. The axioms may be used to prove other Boolean expressions equivalent. If we substitute any Boolean expressions for the variables of the axioms, we get new equivalences. For example, from axiom A4, a+b = b+a, we can derive the equivalence (x + y)' + (x * y) = (x * y) + (x + y)' by substituting (x + y)' for a and (x * y) for b. In addition, if we have an equivalence E_1 = E_2 and an equivalence E_3 = E_4, we can substitute E_2 for occurrences of E_1 in the equivalence E_3 = E_4 and derive a new equivalence. As an example, we show that (A12), 0 + a = a, can be derived from the other axioms as follows.
0 + a = a + 0 (by A4, substituting 0 for a and a for b)
= a + a*a' (by A15, substituting a*a' for 0)
= (a + a)*(a + a') (by A10, substituting a for a, a for b, and a'\
for c)
= a * (a + a') (by A2, substituting a for a + a)
= a * 1 (by A16, substituting 1 for a + a')
= 1 * a (by A4, substituting a for a and 1 for b)
= a (by A13, substituting a for 1 * a)
Another shorter proof is the following.
0 + a = a + 0 (by A4, as above)
= a + a*a' (by A15, as above)
= a (by A8, substituting a for a and a' for b)
We can define the "dual" of a Boolean expression as the expression
obtained by replacing + by *, * by +, 0 by 1, and 1 by 0 throughout.
Note that the list of axioms comes in "dual" pairs -- if we
take the dual of both sides of A16, a + a' = 1, we get A15, a * a' = 0.
This allows us to claim that if we have an equivalence, we can
take the dual of both sides and get a new equivalence (because
where we used an axiom in the proof, we could use the dual of the
axiom in the proof of the dual.)
This axiom system, though somewhat redundant, is sound and complete. "Sound" means that every equivalence we can prove using this system is in fact true, and "complete" means that every true equivalence of Boolean expressions can be proved in this system. Axiom systems should be sound (we don't want to be able to prove false things) but they are often incomplete (some true things are unprovable.) Boolean equivalence is one of those fortunate places where we can get an axiom system that is both sound and complete. If we don't want to fool around with axioms, we can always just check equivalence directly using truth tables. However, because there are 2^n rows in a truth table for an expression containing n variables, the straightforward algorithm (generate and check every row) runs in exponential time. In fact, we don't know ANY polynomial time algorithm for this task, and most computer scientists suspect no polynomial time for this task exists. The famous P = NP? problem is equivalent to the question of whether there exists a polynomial time algorithm to check whether a given Boolean expression is satisfiable or not. Are Boolean expressions enough? That is, given any Boolean function and variables representing its arguments, can we write down a Boolean expression that has the same truth table? The answer is yes, and the sum-of-products algorithm can be used to establish it. Suppose we are given the truth table of a Boolean function:
x y z | f(x,y,z)
---------------------------
0 0 0 | 1
0 0 1 | 0
0 1 0 | 1
0 1 1 | 0
1 0 0 | 1
1 0 1 | 0
1 1 0 | 0
1 1 1 | 1
What we do is look at each row with a 1 in it, and create an AND
of variables or their negations that is 1 on that row and 0 everywhere
else.
Thus, for the row x = 0, y = 0, z = 0, we take the following term:
(x' * y' * z').
This expression is certainly 1 when x = 0, y = 0 and z = 0, because
it is 1 * 1 * 1 = 1.
For every other row, it is 0, because at least one of the variables
is 1, so there will be a 0 in the AND, returning 0.
This takes care of the first row with a 1 in it.
The second row with a 1 in it is x = 0, y = 1, z = 0.
For this row, we consider the expression (x' * y * z'), which is
1 when x = 0, y = 1, and z = 0, and is 0 on every other row.
Similarly, for the third row with a 1, we create (x * y' * z'),
and for the fourth row with a 1, we create (x * y * z).
Thus far we have 4 expressions, and the truth table for them
is as follows.
x y z | f(x,y,z) x'*y'*z' x'*y*z' x*y'*z' x*y*z
----------------------------------------------------------------
0 0 0 | 1 1 0 0 0
0 0 1 | 0 0 0 0 0
0 1 0 | 1 0 1 0 0
0 1 1 | 0 0 0 0 0
1 0 0 | 1 0 0 1 0
1 0 1 | 0 0 0 0 0
1 1 0 | 0 0 0 0 0
1 1 1 | 1 0 0 0 1
Now, we just OR together our 4 expressions to get
x'*y'*z' + x'*y*z' + x*y'*z' + x*y*z
which will be 1 on just the 4 desired rows, that is:
x y z | f(x,y,z) x'*y'*z'+ x'*y*z'+ x*y'*z'+ x*y*z
----------------------------------------------------------------
0 0 0 | 1 1
0 0 1 | 0 0
0 1 0 | 1 1
0 1 1 | 0 0
1 0 0 | 1 1
1 0 1 | 0 0
1 1 0 | 0 0
1 1 1 | 1 1
Thus, this expression correctly represents the given Boolean function.
At this point we might be able to simplify the expression to get
a simpler equivalent expression, but we have shown that there
is SOME Boolean function that represents this function.
This is the sum-of-products algorithm, which can be used to
find a Boolean expression that represents any Boolean function.
Thus, Boolean expressions are "enough."
|
| 9/28/09 | Lecture 12. The Halting Problem concluded; Boolean functions
and Expressions.
We went over the halting problem for Scheme programs from the end of last lecture (see Lecture 11.) To see the proof of the uncomputability of the Halting Problem for Turing machines, see the notes: The Halting Problem. (We did not cover this in lecture.) Running programs on themselves is not so unreasonable -- a Java compiler written in Java might well be run on its own code, as might a C syntax checker written in C. One of the advantages of having a very simple model of computation like a Turing machine is that it can be used to prove other apparently simple systems have uncomputable problems associated with them. For example, Conway's Game of Life is a system with very simple rules, which turn out to be sufficient to implement a Turing machine simulator (by creating the appropriate initial configuration), thus showing that certain questions about the system are algorithmically unsolvable. The next topic is Boolean functions and Boolean expressions. Please see the notes: Boolean Functions and Expressions. |
| 9/25/09 | Lecture 11. Uncomputable Functions and the Halting Problem.
Turing machines were just one of several proposed formal models of computation; others included Church's lambda calculus (ancestor of Lisp and Scheme), the general recursive functions, Markov algorithms and Post rewriting systems. Despite the apparent differences in these systems of computation, they were all proved to compute the same set of functions. These proofs were by means of simulation: showing that there is a simulator in system A that can simulate running any program in system B. The Church-Turing thesis asserts that the computable functions are exactly the set of functions computable by any of these equivalent systems. It is a "thesis" rather than a "theorem" because it relates our informal intuitive concept of computability to the formal models. As he states, Turing gives three kinds of arguments in favor of the thesis: (1) direct appeals to intuition, (2) proofs of the equivalence of different notions of computability, and (3) proofs that the formal models can compute a wide variety of different functions. We have seen examples of arguments of types (1) and (3), and by the time you finish the current homework, you will have a concrete idea of simulation, having implemented a Turing machine simulator in Scheme. We restrict our attention to functions from the natural numbers to the natural numbers, where the natural numbers are 0, 1, 2, 3, ... . Examples of such functions are f(n) = 3n+1, g(n) = n^2 and h(n) = 1 if n is prime and 0 otherwise. A Turing machine M computes such a function f if for every natural number n, M started in state q1 with its head on the lefthand end of a string of n 1's, runs for a finite number of steps and halts with its head on the lefthand end of a string of f(n) 1's. We show that there are functions from the natural numbers to the natural numbers that are not computable by any Turing machine. We'll give two types of arguments for this: (1) a cardinality argument and (2) an explicit construction: the halting problem. The cardinality argument shows that the set of Turing machines is countable, while the set of all functions from the natural numbers to the natural numbers is uncountable. Thus there are (uncountably many) functions from the natural numbers to the natural numbers that are not computed by any Turing machine. A set is defined to be countable if and only if it is finite or it can be put into one-to-one correspondence with the natural numbers. Thus, clearly the natural numbers themselves are a countable set. So also are the nonnegative even numbers: 0, 2, 4, 6, ..., because we can put them into one-to-one correspondence with the natural numbers via the correspondence n <-> 2n. Another countable set is the set of all ordered pairs (m,n) such that m and n are natural numbers. To enumerate this set, we can list all the pairs (m,n) such that m + n = 0, then all the pairs such that m + n = 1, then all the pairs such that m + n = 2, and so on: (0,0),(0,1),(1,0),(0,2),(1,1),(2,0),(0,3),(1,2),(2,1),(3,0), ...This amounts to walking along the successive counter-diagonals of the two-way infinite matrix with (m,n) in row m and column n. What about the set of all finite nonempty strings of 0's and 1's? This is a countable set, because we can enumerate all the strings of length 1, then all the strings of length 2, all the strings of length 3, and so on: 0, 1, 00, 01, 10, 11, 000, 001, 010, 011, 100, 101, 110, 111, ...(Another way to see this is to take all the binary numbers from 2 onwards and remove the first digit.) What about the set of all Turing machines over the tape alphabet blank, 0 and 1? We can represent all such Turing machines as a string over an alphabet containing decimal digits, b, q, R, L, left and right parentheses and comma. Then the set of all finite strings over this alphabet is a countable set (first enumerate the strings of length 1, then the strings of length 2, and so on.) Only some of these strings will have the correct syntax to represent Turing machines, but if we cross out the ones that don't represent Turing machines, we still have a countable set. (A subset of a countable set is countable.) Thus the set of all such Turing machines is a countable set. On the other hand, we can use Cantor's diagonal argument to show that the set of all functions from the natural numbers to the natural numbers is an uncountable set. Suppose to the contrary that the set of such functions is countable. Then there must be a sequence f_0, f_1, f_2, ... containing them all. We can construct a 2-dimensional infinite matrix listing all the functions and their values on all natural numbers, for example:
n = 0 1 2 3 4 5 6 7 8 ...
---------------------------------------------
f_0 6 99 43 2 17 29 0 0 8
f_1 0 1 2 0 1 2 0 1 2
f_2 4 4 3 3 2 2 1 1 0
f_3 11 12 13 12 11 10 9 8 7
. .
. .
. .
Here I have shown some hypothetical values for the
functions in the table, f_0(0) = 6, f_0(1) = 99, f_0(2) = 43,
and so on.
We may construct a function g from the natural numbers
to the natural numbers that differs from every function in
the table as follows: for each natural number n, let g(n) = f_n(n) + 1.
Thus, in our example,
g(0) = f_0(0) + 1 = 6 + 1 = 7 g(1) = f_1(1) + 1 = 1 + 1 = 2 g(2) = f_2(2) + 1 = 3 + 1 = 4 g(3) = f_3(3) + 1 = 12 + 1 = 13 ...Note that g is defined by going down the main diagonal of the table and choosing a value different from the value on the diagonal. Thus, for every n, g(n) is not equal to f_n(n), and so g is not equal to f_n. Hence, g is a function from the natural numbers to the natural numbers that is NOT in the table (because it is not equal to any element of the table), contradicting our assumption that the table contains EVERY function from the natural numbers to the natural numbers. Hence, the set of such functions is uncountable. Because the set of Turing machines (over the alphabet b, 0, 1) is countable, and the set of all functions from the natural numbers to the natural numbers is uncountable, there must be (uncountably many) such functions that are not computed by any Turing machine. However, this argument is an existence proof that doesn't actually exhibit a particular uncomputable function. For this, we turn to the Halting Problem, which we define for Scheme programs. We shall prove that there can be no Scheme procedure (halts? proc exp) with the following behavior: if (proc exp) halts, then (halts? proc exp) returns #t, and if (proc exp) doesn't halt, then (halts? proc exp) returns #f. To see that this is impossible, we argue by contradiction; assume that such a procedure halts? exists. Then we may define another procedure (q proc exp) as follows.
> (define q
(lambda (proc exp)
(if (halts? proc exp)
(infinite)
'non-halting)))
where infinite is defined as (define infinite (lambda () (infinite))).
The behavior of this procedure is that if (proc exp) halts, then
(q proc exp) doesn't halt (because it evaluates (infinite)), and
if (proc exp) doesn't halt, then (q proc exp) halts and returns
the symbol non-halting.
Using q, we can define another procedure (s exp) as follows.
> (define s
(lambda (exp)
(q exp exp)))
Now the question is whether (s s) halts; it must either halt or not halt.
If it halts, then (s s) calls (q s s) which calls (halts? s s),
which returns #t, so (q s s) doesn't halt, and (s s) doesn't halt.
On the other hand, if (s s) doesn't halt, then (s s) calls (q s s)
which calls (halts? s s), which returns #f, so (q s s) halts and
returns the symbol non-halting, so (s s) halts and returns the
symbol non-halting.
Now we have a contradiction: (s s) halts if and only if it doesn't
halt.
Hence, no such procedure (halts? proc exp) can exist.
This is a specific well-defined function that cannot be correctly
computed by any Scheme program: an uncomputable function.
It's clear that people (and programs) can detect some instances of non-halting behavior -- it is clear to all of us that evaluating (infinite) leads to non-halting behavior. What this uncomputability result says is that we cannot hope to write a program that always halts and correctly answers the question of whether (proc exp) halts for any procedure proc and expression exp. |
| 9/23/09 | Lecture 10. Turing machines and computability.
Handout: excerpts from Turing's 1936 paper "On Computable Numbers with an Application to the Entscheidungsproblem." We further explore the capabilities of Turing machines. Consider the problem of incrementing a binary number by 1. The binary number 101011 is 32+8+2+1 = 43 in decimal. Consider the process of adding 1 to it in binary.
101011
+1
----
101100
If the rightmost digit is 1, then we change it to a 0 and move
one digit to the left, adding 1 to that digit, and so on.
If the rightmost digit is 0, then we change it to a 1 and
copy the rest of the digits on the left.
We can accomplish this with a 3 state Turing machine, as follows.
The state q1 will just move the head down to the righthand
end of the input and then change to a new state q2.
The state q2 will move to the left, changing 1 to 0 (and
staying in state q2) or 0 to 1 and changing to a new state q3.
The state q3 will move to the lefthand end of the string
and halt by changing to state q4 (the halt state.)
That is, the instructions for this machine are as follows.
(q1, 0, q1, 0, R) q1 copies 0's and 1's, moving right
(q1, 1, q1, 1, R)
(q1, b, q2, b, L) until it finds a blank, then it moves
left and changes into state q2
(q2, 1, q2, 0, L) q2 changes 1's to 0's moving left
(q2, 0, q3, 1, L) q2 changes a 0 or b to a 1 and moves left into q3
(q2, b, q3, 1, L)
(q3, 1, q3, 1, L) q3 copies 0's and 1's, moving left
(q3, 0, q3, 0, L)
(q3, b, q4, b, R) until it finds a blank, when it moves right
and halts in state q4 with the head on
the first symbol of the output
We illustrate the operation of this machine on the input 1011.
To save space, the current state of the machine is shown to the
left of the tape instead of directly under the read/write head
position.
q1 1 0 1 1
^
q1 1 0 1 1
^
q1 1 0 1 1
^
q1 1 0 1 1
^
q1 1 0 1 1
^
q2 1 0 1 1
^
q2 1 0 1 0
^
q2 1 0 0 0
^
q3 1 1 0 0
^
q3 1 1 0 0
^
q4 1 1 0 0
^
If we consider adding two numbers in binary, the process becomes somewhat more complex -- we have to look at a digit in each of two numbers and then write down the output digit and remember the carry, repeatedly, until we exhaust the digits in both numbers. As an example, consider adding the following two binary numbers.
1 1 0 1 1
+ 1 0 0 1 1
-----------
1 0 1 1 1 0
We start from the righthand ends of the numbers and note that
the two rightmost digits are 1, which gives a sum of 2 (or 10 in binary).
Thus, we want to write down the digit 0 and remember a carry of 1
for the next column.
We read the next digits of both numbers and find that they are both 1,
which, with a carry of 1 gives a sum of 3 (or 11 in binary).
Thus, we want to write down the digit 1 and remember a carry of 1
for the next column.
We read the next digits of both numbers and find that they are both 0,
which, with the carry of 1, gives a sum of 1 (or just 1 in binary).
Thus, we want to write down the digit 1 and remember a carry of 0
for the next column.
Reading the next two digits of the numbers, we find that they are
1 and 0, which, with the carry of 0, gives a sum of 1.
Thus, we want to write down the digit 1 and remember a carry of 0
for the next column.
Reading the next two digits of the numbers, we find that they
are both 1, which, with the carry of 0, gives a sum of 2 (or 10 in binary).
Thus, we want to write down the digit 0 and remember a carry
of 1 for the next column.
Attempting to read the next two digits of the numbers, we find
that there are no more digits in either number, so we want
to write down the carry, 1, and halt.
To implement this method as a Turing machine program, we could imagine first marking the lefthand end of the input with the symbol d and the right hand end with the symbol e. We will produce the digits of the sum to the left of the symbol d, because we will be working from the righthand end of the the answer towards the lefthand end. To find the last digit of the first number, we scan right until the symbol c and then scan left until the first 0 or 1 encountered; we remember this digit (in the state of the machine) and erase it. To find the last digit of the second number, we scan right until the symbol e and then scan left until the first 0 or 1 encountered; we also remember this digit (in the state of the machine) and erase it. Knowing both digits, we scan left to the symbol d and then to the first blank, where we record the low order binary digit of the sum and remember (in the state of the machine) the carry digit. We then repeat this loop to find the next digit of the first number and the next digit of the second number, erasing them, and then scan to the left to d and then to the first blank, to record the low order binary digit of the sum (of the carry and the two digits) and remember the carry. We have to detect when we have finished with the digits of one (or both) numbers. Finally, when all the digits of the result have been produced, we send the machine off to find the symbol e and erase it, scanning left and erasing symbols through the symbol d. The machine then scans left over the 0's and 1's of the answer to move the head to the first digit of the answer and halt. To illustrate the plan we consider tape contents at various points for the input 1011c11.
1011c11 (initially)
d1011c11e (after putting d at the start and e at the end)
d101 c1 e (after reading and erasing the last digits of the numbers)
0d101 c1 e (after recording the 0 -- the carry is 1)
0d10 c e (after reading and erasing the next-to-last digits)
10d10 c e (after recording the 1 -- the carry is 1)
10d1 c e (after reading and erasing the next-to-next-to-last digit
of the first number, and noting that the second
number has no more digits)
110d1 c e (after recording the 1 -- the carry is 0)
110d c e (after reading and erasing the first digit of the first
number, and noting that the second number has no more
digits)
1110d c e (after recording the 1 -- the carry is 0)
1110d c e (after detecting that both numbers are exhausted)
1110 (after erasing e through d and moving to the first symbol)
Though we didn't finish up the design of this machine, this sketch should suggest that we could devise Turing machine programs for arithmetic operations like plus, minus, times, divide, as well as for more complex operations like finding the (integer) square root, testing a number for primality, and factoring a number. So do Turing machines, as Turing argues in his paper, really capture all the computable functions? This is a question about whether a formal model (Turing machines) captures an intuitive notion (computable functions), and, as Turing says, it not itself susceptible to mathematical proof. However, he offers three kinds of arguments in favor of the idea that Turing machines capture computability: (1) "A direct appeal to intuition." (This covers his arguments about analyzing and breaking down what we think of as computation into very elementary parts.) (2) "A proof of the equivalence of two definitions (in case the new definition has a greater intuitive appeal)." And finally, (3) "Giving examples of large classes of numbers which are computable." In this lecture, we have offered some evidence of type (3), showing the computability of more complex functions by Turing machines. One question that arose was whether a Turing machine could actually be implemented by us. In homework #3, you will be asked to implement a simulator for a Turing machine, which suggests that we can implement a Turing machine. But, strictly speaking, a Turing machine must be able to access arbitrarily large numbers of tape squares. For example, if we modified our machine to increment a binary number (above) to repeat its calculation (going back to state q1 instead of to the halt state q4), then starting with input 0 the machine would successively compute every positive integer, with no upper bound on the numbers computed. If we believe that the universe is made out of a finite amount of stuff, then there is no way it can represent arbitrarily large distinct integers. A different theory of physics (perhaps involving a countable number of different universes) might permit actual implementation of a Turing machine. Why do we consider the theoretical model of Turing machines? The reason is the same as the reason that we consider the set of all positive integers instead of some finite set of "realizable" numbers -- the theory is a lot simpler and more elegant if we allow ourselves the luxury of infinity. Similarly, when we think about scheme programs, or algorithms in general, we do not limit ourselves to a finite set of inputs, but imagine that the programs or algorithms can be run on arbitrarily large inputs. But, technically, all the computers on all the networks in the world represent a finite (if staggering) amount of memory, and are thus not capable of fully simulating even a simple Turing machine. Another question concerned irrational numbers like pi and limiting processes like lim (n -> infinity) 1/n = 0. If we want to write down all the decimal digits in the number pi, we are stuck, because there are infinitely many of them. However, if we only require a rational approximation of pi to within some positive tolerance epsilon, then we can compute such an approximation, for any fixed positive value of epsilon. Similarly, if we consider limiting processes, we typically consider them computable if we can compute a rational approximation to the final value to within some given positive tolerance epsilon. The real numbers in general cause problems for Turing machines and other discrete models of computation, because there are uncountably many real numbers, but only countably many different Turing machine programs (or scheme programs, or Java programs, etc.) There are models of computation that directly incorporate real numbers, but we won't be considering them further in this course. |
| 9/21/09 | Lecture 9. Turing machines.
Introduction and description of Turing machines; an example of a machine to copy its input. Please see the notes in Turing machines. |
| 9/18/09 | Lecture 8. Scheme, continued; computability begun.
Topics: deep recursion, tail recursion and iterative processes, the Collatz (or 3n+1) problem. In top-level or flat recursion on lists we do something for (or to) every top-level element of a list without regard to whether those elements are themselves lists. An example is our implementation of length. In deep recursion, we call the procedure recursively on elements of the list that are themselves lists. As an example, we write a procedure, add-all, that takes a list and adds up all the numbers it finds (at any level of nesting). Thus, we should have (add-all '(x 3 (first 4 then 5) ((a)) ((6)))) => 12.
> (define add-all
(lambda (lst)
(cond
((null? lst) 0)
((list? (car lst)) (+ (add-all (car lst)) (add-all (cdr lst))))
((number? (car lst)) (+ (car lst) (add-all (cdr lst))))
(else (all-all (cdr lst))))))
The base case is when the list is empty -- the answer is 0 because
when we are adding up no numbers, a useful response is the identity
of the + operation, that is 0.
(If we'd been multiplying the numbers instead, a useful response
for the empty list is the identity for the * operation, namely, 1.)
If the list is not empty, then we have recursive cases based
on what the first element (car) of the list is.
If the first element is itself a list, we call add-all recursively
on the car and cdr of the list and return the sum of the results.
If the first element is a number, then we call add-all recursively
on the rest (cdr) of the list and return the sum of the value
of the first element and the value found by the recursive call.
Finally, if the list is nonempty but its first element is neither
a list nor a number, we just ignore the first element, and return
the result of a recursive call of add-all on the rest (cdr) of the list.
We draw the structure of the recursive calls for a slightly less
complex input as follows.
(add-all (x 3 (first 4 then 5) 6)) => 18
|
(add-all (3 (first 4 then 5) 6) => 18
|
(+ 3 (add-all ((first 4 then 5) 6))) => 18
/ \
(+ (add-all (first 4 then 5)) (add-all (6))) => 15
| |
(add-all (4 then 5)) => 9 (+ 6 (add-all ())) => 6
|
(+ 4 (add-all (then 5))) => 9
|
(add-all (5)) => 5
|
(+ 5 (add-all ())) => 5
Note that the specification of scheme does not determine in what
order the two recursive calls (add-all (first 4 then 5)) and (add-all (6))
will be evaluated.
As another example of deep recursion, we write a procedure add-one-to-all that takes a list and adds 1 to every number it finds, regardless of the level of nesting. That is, we would like (add-one-to-all '(x 3 (first 4 then 5) 6)) => (x 4 (first 5 then 6) 7). We can just modify the structure of the procedure given above replacing 0 by the empty list (), + with cons to combine the results of the recursive calls, and keeping (rather than throwing away) the first element of a list when it is neither a number or a list.
> (define add-one-to-all
(lambda (lst)
(cond
((null? lst) '())
((list? (car lst)) (cons (add-one-to-all (car lst))
(add-one-to-all (cdr lst))))
((number? (car lst)) (cons (+ 1 (car lst))
(add-one-to-all (cdr lst))))
(else (cons (car lst)
(add-one-to-all (cdr lst)))))))
We illustrate the call structure on an example.
(add-one-to-all (x 3 (first 4 then 5) 6))
|
(cons x (add-one-to-all (3 (first 4 then 5) 6)))
|
(cons 4 (add-one-to-all ((first 4 then 5) 6)))
/ \
(cons (add-one-to-all (first 4 then 5)) (add-one-to-all (6)))
| |
(cons first (add-one-to-all (4 then 5))) (cons 7 (add-one-to-all ()))
|
(cons 5 (add-one-to-all (then 5)))
|
(cons then (add-one-to-all (5)))
|
(cons 6 (add-one-to-all ()))
The resulting values are omitted for lack of space, but you can
see that the result is
(cons x (cons 4 (cons (cons first (cons 5 (cons then (cons 6 ()))))
(cons 7 ())))) => (x 4 (first 5 then 6) 7)
as desired.
Tail recursion and iterative processes. Recall the procedure our-length that we wrote previously.
> (define our-length
(lambda (lst)
(if (null? lst)
0
(+ 1 (our-length (cdr lst))))))
The diagram of recursive calls of this procedure on the argument (a b c)
is as follows.
(our-length (a b c)) => 3
|
(+ 1 (our-length (b c))) => 3
|
(+ 1 (our-length (c))) => 2
|
(+ 1 (our-length ())) => 1
Note that there is an operation "waiting to be done" after the
each recursive call returns, namely adding 1 to its result.
The interpreter needs to keep track of these pending operations,
which it does by storing them on a stack (a first in, last out
data structure.)
Thus, after the base case returns, the interpreter can add 1 to 0
and return 1 from the previous recursive call, and then add 1 to
that, and so on.
Because there is stack space consumed by each pending operation,
if there are sufficiently many recursive calls, the interpreter
runs out of stack space.
By contrast, consider the procedure we wrote for first-digit.
> (define first-digit
(lambda (n)
(if (< n 10)
n
(first-digit (quotient n 10)))))
A diagram of the recursive calls for this procedure on input 458 looks
as follows.
(first-digit 458) => 4
|
(first digit 45) => 4
|
(first digit 4) => 4
This procedure is "tail recursive" in the sense that there is nothing
further to be done to the result of the recursive call before it is
returned as the value of the higher-level call.
Scheme recognizes this situation, and, instead of storing the recursive
calls on a stack, simply "branches" to the recursive call and returns
its value as the value of the whole computation.
This means that a tail-recursive procedure is essentially as efficient
as an iterative loop, requiring neither stack space or operations
associated with returning for a recursive call.
If you are in a situation where you are concerned about the efficiency
of your scheme programs
(and for most of this course we won't be asking you to worry about
efficiency) then you may want to write your procedures to be tail
recursive.
A tail recursive program generates an "iterative process", in contrast
to a "recursive process."
Thus our procedure first-digit generates an iterative process, and
the procedures add-all, add-one-to-all and our-length generate
recursive processes.
Here is a technique for transforming a non tail recursive procedure into one that uses tail recursion and generates an iterative process. The idea of the technique is to introduce an auxiliary procedure with one or more additional arguments that are used to "accumulate" the partial results as the computation proceeds. As an example, we may write an iterative version, it-length, of a procedure to find the length of a list.
> (define it-length
(lambda (lst)
(it-length-help lst 0)))
> (define it-length-help
(lambda (lst n)
(if (null? lst)
n
(it-length-help (cdr lst) (+ n 1)))))
The required procedure, it-length, still has just one argument,
the list whose length is to be computed, but it calls a "helper" or
auxiliary procedure, it-length-help, that takes two arguments,
a list and a number.
The procedure it-length-help is tail recursive -- it returns
(unmodified) the value returned by the recursive call.
The extra argument, n, to it-length-help, accumulates the
partial results of the length computation as we remove elements
of the list.
The procedure it-length-help
works by maintaining the invariant that the sum of n and
the length of lst is equal to the length of the original list
that was input to it-length.
Thus, when the base case of an empty list lst is reached,
n must contain the correct answer for the original call.
To illustrate, we diagram the procedure calls for (it-length '(a b c)).
(it-length (a b c)) => 3
|
(it-length-help (a b c) 0) => 3
|
(it-length-help (b c) 1) => 3
|
(it-length-help (c) 2) => 3
|
(it-length-help () 3) => 3
Thus, the answer is built up in the accumulator argument as the
computation proceeds.
As another example of a tail recursive procedure, we write a version of reverse (which is a built in procedure to reverse a list).
> (define it-reverse
(lambda (lst)
(it-reverse-help lst '())))
> (define it-reverse-help
(lambda (lst rlst)
(if (null? lst)
rlst
(it-reverse-help (cdr lst) (cons (car st) rlst)))))
Note that in this case the accumulator argument is a list.
In effect we are removing one element after another from the
front of lst, and adding the to the front of rlst.
To see how this works, we diagram the procedure calls for (it-reverse '(a b
c)).
(it-reverse (a b c)) => (c b a)
|
(it-reverse-help (a b c) ()) => (c b a)
|
(it-reverse-help (b c) (a)) => (c b a)
|
(it-reverse-help (c) (b a)) => (c b a)
|
(it-reverse-help () (c b a)) => (c b a)
Note that rlst accumulates the reverse of longer and longer
initial prefixes of the original value of lst, until finally
it has the reverse of the original list.
In terms of invariants, the invariant preserved is that
(append (reverse lst) rlst) is the reverse of the original list
input to it-reverse.
Thus, when lst is finally empty, rlst is the desired answer.
Because the procedure it-reverse-help returns unchanged the
value returned by its recursive call, it is tail recursive,
and the process generated is iterative.
Note: you do NOT have to try to make your procedures iterative unless requested to do so in a problem. As you can see from the above discussion, compared to the tail recursive version, the non tail recursive version of a procedure may be easier to understand and check the correctness of. Next: the topic of computability. It would be very helpful to have a utility that would check a scheme program to see whether it will always halt. As an example, consider the following scheme procedure, named f.
> (define f
(lambda (n)
(cond
((= n 1) 'yes!)
((even? n) (f (quotient n 2)))
(else (f (+ 1 (* 3 n)))))))
This is a tail recursive procedure that takes a positive integer n
as input.
When n is 1, it just returns the symbol yes!, and when n is greater
than 1, it makes a recursive call to itelf.
If n is even, the recursive call is with n/2, and if n is odd, the
recursive call is with (3n+1).
We consider the example of (f 3):
(f 3) => (f 10) => (f 5) => (f 16) => (f 8) => (f 4) => (f 2) => (f 1) => yes!It is clear that the only base case is n = 1, when the result is yes! So either this procedure is a somewhat complicated way of returning yes! for every positive integer, or there are positive integers for which it does not halt. If we had a general purpose utility to check such procedures, it could tell us which situation is the case. Unfortunately, there is no such general purpose utility. In fact, the conjecture that this procedure will halt on every positive integer n was made in 1937 by Lothar Collatz; the conjecture remains an open problem, despite considerable effort by mathematicians, computer scientists and others. The problem is known as the Collatz problem, the Ulam problem, the Syracuse problem and (most mnemonically) the 3n+1 problem. The next topic of these lectures will concern questions of how we define computability and whether there are uncomputable functions. |
| 9/16/09 | Lecture 7. Scheme, continued.
The problems on hw #2 were briefly reviewed, and the game of "Shut the Box" demonstrated. More procedures on lists. We now write the predicate (member? item lst) that returns #t if item is equal? to a top-level element of the list lst and #f otherwise.
> (define member?
(lambda (item lst)
(cond
((null? lst) #f)
((equal? item (car lst)) #t)
(else (member? item (cdr lst))))))
> (member? 'e '(a e i o u))
#t
> (member? 'y '(a e i o u))
#f
> (member? '(a b) '(a b c))
#f
> (member? '(a b) '((a b) c))
#t
>
The base case is when lst is the empty list, which has no
elements at all, and therefore the answer is #f because no item
is an element of the empty list.
We noted that the desired behavior is (member? '() '()) => #f,
while (member? '() '(())) => #t.
This is because there are no elements in the empty list (), but
the list containing the empty list has one element, namely ().
If lst is not null, then we check to see if its first element
is equal to item.
If so, then we can immediately return #t without checking the
rest of the elements of lst because we know that item is equal?
to at least one top-level element of lst.
Otherwise, we call member? recursively with item and the
rest (cdr) of the elements of list, to see if item might
be equal? to one of the elements on the rest of the list.
There is a built-in procedure member which has a slightly different behavior. In particular, (member item lst) returns #f if item is not a top-level element of lst, and returns the rest of the list (starting with the first occurrence of item) if it is. Note that it is not a predicate (because it returns values other than #t and #f) and its name does not end with ?. For example, > (member 'e '(a e i i e a)) (e i i e a) > (member 'o '(a e i i e a)) #f Next we turn to writing the procedure countdown, whose behavior is exemplified by (countdown 5) => (5 4 3 2 1 blast-off). That is, given a nonnegative integer n, the procedure should return a list containing the integers n down to 1 followed by the symbol blast-off.
> (define countdown
(lambda (n)
(if (= n 0)
'(blast-off)
(cons n (countdown (- n 1))))))
> (countdown 3)
(3 2 1 blast-off)
>
Here we are doing recursion on numbers again instead of lists,
and our base case is the number 0.
For this (after some deliberation) we choose to return a list
containing one element, the symbol blast-off.
We made this choice because our plan is to cons numbers onto
the front of this list to get the desired output.
The recursive case is to cons the number n onto the result
of the recursive call of countdown with n-1.
For example, when n is 5, the recursive call will be with 4,
returning the list (4 3 2 1 blast-off), and we will cons 5 to
that list to get the list (5 4 3 2 1 blast-off), as desired.
We next write a procedure (our-map proc lst) to take a procedure proc and a list lst and return a list of the values obtained by applying the procedure proc to each element of lst in turn. An example of the desired behavior of our-map is as follows. > (define square (lambda (n) (* n n))) square > (our-map square '(2 3 6)) (4 9 36) >To write our-map, we essentially just generalize the procedure square-each that we wrote previously, adding as an argument the procedure that we want applied to each element.
> (define our-map
(lambda (proc lst)
(if (null? lst)
'()
(cons (proc (car lst)) (our-map proc (cdr lst))))))
As previously, the base case is the empty list ().
Applying a procedure to every element of an empty list and making
a list of the results produces the empty list.
If the list lst is not empty, then we apply the procedure proc
to the first element (car) of the list, and cons the resulting
value onto the list returned by a recursive call of our-map with
the procedure proc and the rest (cdr) of the list lst.
To illustrate the recursive calls involved in evaluating
(our-map square '(2 3 6)), we have the following.
(our-map square (2 3 6)) => (4 9 36)
\
(cons 4 (our-map square (3 6)) => (4 9 36)
\
(cons 9 (our-map square (6)) => (9 36)
\
(cons 36 (our-map square ())) => (36)
There is a built-in procedure map that has this behavior for a procedure of one argument and a list of elements to apply it to. In addition, map can take a procedure of two arguments and two equal length lists of elements to apply it to, or a procedure of three arguments and three equal length lists, and so on. For example, for the built-in procedure map, we have the following. > (map (lambda (n) (* 2 n)) '(2 3 6)) (4 6 12) > (map + '(2 3 6) '(17 9 1)) (19 12 7) > (map cons '(a b c) '((d e) (f g h) ())) ((a d e) (b f g h) (c))The procedure map can be quite useful -- see the problem on matrix transpose in homework #2. Comparing ways of constructing lists. The basic list constructor is cons, for example (cons 'a '(b c)) => (a b c). In addition there is the procedure list, which evaluates its arguments and makes a list of them, for example, (list 'a (+ 2 3) 'c) => (a 5 c). Also, to construct a list containing all the elements of one list followed by all the elements of another list, there is the procedure append, for example (append '(a b c) '(d e)) => (a b c d e). It is a common error to use one of these procedures when you really wanted another one of them. Here are some examples to help you distinguish them; it might help to draw the box and pointer diagrams for each one. > (cons '(a b) '(c d e)) ((a b) c d e) > (list '(a b) '(c d e)) ((a b) (c d e)) > (append '(a b) '(c d e)) (a b c d e) > We write our own append procedure for two lists as follows.
> (define our-append
(lambda (lst1 lst2)
(cond
((null? lst1) lst2)
(else (cons (car lst1) (append (cdr lst1) lst2))))))
Here the base case is when the list lst1 is null; appending
a null list and any list returns that list.
If lst1 is not null then we cons its first element (car lst1)
onto the result of appending the rest of its elements (cdr lst1)
to lst2.
(Note that at the end of the lecture we had not resolved
whether it should be cons or list -- try both to see why
it is cons.)
We can look at the recursive calls to illustrate how this works.
(our-append (a b c) (d e)) => (a b c d e)
|
(cons a (our-append (b c) (d e))) => (a b c d e)
|
(cons b (our-append (c) (d e))) => (b c d e)
|
(cons c (our-append () (d e))) => (c d e)
The built-in procedure append takes an arbitrary number of arguments
and concatenates them in order.
> (append '(3) '(1 4 1) '(5 9)) (3 1 4 1 5 9) |
| 9/14/09 | Lecture 6. Scheme, continued.
Perlis epigram #3: Syntactic sugar causes cancer of the semicolon. We begin with some syntactic sugar. Instead of nesting if's inside of if's inside of if's, you can use the special form cond, that evaluates conditions and returns the value of the result associated with the first non-#f condition. As it is a special form, although it looks like an application, its evaluation rules are different. In particular, the form of a cond is (cond (c1 r1) (c2 r2) ... (else rn)), where each of ci and ri is a scheme expression. The rule of evaluation is that scheme evaluates c1, c2, ... until the first one, say ci, that evaluates to a value that is not #f, and then it evaluates the corresponding result, ri, and returns that value as the value of the cond. Note that else evaluates to not #f. As an example, we write a procedure that takes a number as input and returns one of the symbols positive, zero, or negative after testing the number. With cond, this can be written as follows.
(define sign
(lambda (n)
(cond
((> n 0) 'positive)
((= n 0) 'zero)
(else 'negative))))
Choosing to do this with nested if's, we could also write it
as follows.
(define sign
(lambda (n)
(if (> n 0)
'positive
(if (= n 0)
'zero
'negative))))
There is a clear difference in readability, which would grow more
pronounced if the number of conditions were increased.
Note that the else clause is not required, but if there is
no else clause and evaluation "runs off the end" without finding
a non-#f condition, the value returned will be an "unspecified value."
More syntactic sugar. The special form let is a way of creating a temporary local environment with symbols bound to particular values. As an example consider the following.
> (let ((x 1) (y 2))
(* (+ x y) (+ x y)))
9
>
After the keyword let there is a list of items, each item being
a list containing a symbol and a scheme expression (in the example,
the list ((x 1) (y 2)).)
After this list is the body of the let, a scheme expression
that is to be evaluated in the local environment in which the
symbols are bound to the values of their corresponding expressions.
The value of the body in this environment is then returned as
the value of the let expression.
In the example, a local environment is created with x bound
to 1 and y bound to 2.
In this environment, the body (* (+ x y) (+ x y)) is evaluated,
returning the value 9, which becomes the value of the whole
let expression.
Why is this syntactic sugar?
There is a completely equivalent expression using lambda to
create a procedure and an application to apply it immediately.
For the example, the corresponding lambda expression is the
following.
> ((lambda (x y) (* (+ x y) (+ x y))) 1 2) 9 >Evaluating this expression creates a procedure with formal arguments x and y and procedure body (* (+ x y) (+ x y)) and then immediately applies it to the actual arguments 1 and 2. This creates a local environment with symbol x bound to 1 and symbol y bound to 2, and then evaluates the body of the procedure (* (+ x y) (+ x y)) in this environment to get the value 3, which is returned as the value of the procedure application. Though these two expressions are equivalent, in this usage the let form is a lot more readable. We discussed proper lists, like (2 3), and improper lists, like (2 . 3), and tried to sort out whether the list ((2 . 3) 4) should be considered proper or improper. On p. 19 of the text there is the following inductive definition: "More formally, the empty list is a proper list, and any pair whose cdr is a proper list is a proper list." If we take that literally, (cdr '((2 . 3) 4)) => (4) and (cdr '(4)) => (), so we have that (4) is a proper list, and therefore that (cdr '((2 . 3) 4)) is a proper list. After thorough debate, this literal interpretation of the inductive definition was approved by an overwhelming majority of those present. Recursion on lists. Now that we have lists, we'd like to write procedures to deal with them. There is a built-in procedure, length, that takes a proper list and returns the number of top-level elements in the list. For example, (length '(a o u)) => 3. We can write our own version of length as follows.
> (define our-length
(lambda (lst)
(cond
((null? lst) 0)
(else (+ 1 (our-length (cdr lst)))))))
> (our-length '(a o u))
3
This is one pattern for a procedure that deals with lists: do
something for each element of a list by doing something for the
first element, and recursively doing something for each of the
elements in the rest (cdr) of the list.
The base case in this instance is the empty list (), and we know
what length that has, namely 0.
If the list is not empty, then we recursively find the length
of the rest (the cdr) of the list and add 1 to the result to
find the length of the whole list.
Another pattern of operating on lists is to do something to each element of a list and return the list of results. Suppose we want to write a procedure to square each element of a list of numbers, for example, (square-each '(2 3 5)) => (4 9 25). Again the base case is an empty list () -- in that case, we return an empty list of results, (). (Sometimes it is hard to figure out what the results for the base case(s) should be, and it may be helpful to work out the recursive case(s) first.) For a non-empty list, we want to square the first element (car) of the list, recursively find the list of squares of the rest (cdr) of the list, and then put the square of the first element on the resulting list with cons.
> (define square-each
(lambda (lst)
(if (null? lst)
()
(cons (* (car lst) (car lst)) (square-each (cdr lst))))))
> (square-each '(2 3 5))
(4 9 25)
|
| 9/11/09 | Lecture 5. Scheme, continued.
The first (and main) composite data structure we'll consider in scheme is the list. A list is a finite sequence of scheme values (numbers, symbols, booleans, procedures, lists, or other values we'll see later.) The order of elements matters, elements may be repeated, and there are a finite number of elements (possibly zero elements.) The empty list is a list of zero elements. Scheme prints the empty list as (). To input the empty list, it may be quoted as '(). A list consisting of one element, the number 17, is printed out by scheme as (17), and can be input as a quoted expression as '(17). A list consisting of two elements, the number 17 followed by the number 21, is printed out by scheme as (17 21) and may be input as a quoted expression as '(17 21). A list consisting of two elements, the symbol ages followed by the list containing 17 and 21, is printed out by scheme as (ages (17 21)), and can by input as a quoted expression as '(ages (17 21)). Note that scheme permits elements of different types in a list. The lecture covered how these lists are represented as box and pointer diagrams: see the text for details of box and pointer representation. To test a scheme value to determine whether it is the empty list, use the built in procedure null?. That is, (null? exp) => #t if the value of exp is the empty list, and #f otherwise. The built in procedure list? tests a scheme value to tell whether it is a proper list. Thus for example, (list? '()) => #t, (list? '(17 21)) => #t, (list? 33) => #f. Selectors and constructors for lists. A selector is a procedure that returns a valuue from a data structure. The built in procedure car is a selector that returns the first element of a list. For example, (car '(a o u)) => a. The built in procedure cdr is a selector that returns a list equal to its argument with the first element removed. For example, (cdr '(a o u)) => (o u). A constructor is a procedure that makes a new data structure containing some given values. The built in procedure cons is a constructor that creates a new box (cons cell), puts its first argument in the left part of the box and its second argument in the right part of the box, and returns a pointer to the box it just created. For example, (cons 'a '(o u)) => (a o u). Thus, (cons exp lst) returns a new list equal to lst with exp added as the new first element. Though we usually use (cons exp lst) to add a new element to the beginning of a list, cons does not require that its second argument is a list. Thus, (cons 2 3) is a legal scheme expression, whose value is a cons cell with 2 in the left part and 3 in the right part. When scheme has to print out this value, it prints (2 . 3), a "dotted pair." This is also called an "improper list," in contrast to a proper list, in which the right part of the last cons cell contains the empty list, (). We can use cons to create the list (a o u) as follows: > (cons 'a (cons 'o (cons 'u '()))) (a o u) >The box and pointer representation of this value has 3 boxes (cons cells), one created by each application of cons. The first has the symbol a in its left part and a pointer to the second in its right part; the second has the symbol o in its left part and a pointer to the third in its right part; the third has the symbol u in its left part and and the empty list () in its right part. What happens if we evaluate > (car (a o u)) ???Because the argument to car, (a o u), is NOT quoted, scheme goes ahead and tries to evaluate it -- if the symbols a, o and u are not in the top-level environment, then there will be an error message about undefined symbols. |
| 9/9/09 | Lecture 4. Scheme, continued.
Perlis epigram #12: Recursion is the root of computation since it trades description for time. (Explanation?) Perlis epigram #15: Everything should be built top-down, except the first time. We now have the materials to write a program that doesn't halt. We define a procedure infinite with no arguments as follows.
> (define infinite
(lambda ()
(infinite)))
>
A procedure with no arguments has an empty formal argument list, (),
and is invoked in an application with no arguments, eg, (infinite).
Note that the definition above adds the symbol infinite to the
top-level environment with a value that is a procedure of no arguments
and body expression (infinite).
The definition does not invoke the procedure infinite.
However, if we now invoke the procedure infinite as follows:
> (infinite)No value is printed out, and the interpreter does not return to the prompt, because calling infinite causes infinite to be called again, which causes infinite to be called again, and so on ad infinitum, or, until you interrupt the process. In Linux (the Zoo machines), you interrupt the process by typing control C, which causes the message "^C user break" to be printed out, at which point the interpreter returns to the prompt symbol. Why do we care about writing a program that doesn't halt? (1) If we cannot, then we don't have a general-purpose programming language, and (2) some useful programs run "forever", for example, your favorite operating system -- when it halts, you tend to use pejorative language like "crashed." The special form quote and boolean values. To keep a scheme expression from being evaluated, there is the special form quote, which returns the expression unevaluated. For example, > (quote age) age >Rather than attempt to evaluate the symbol age (that is, try to look it up in the relevant environment), quote simply returns the symbol unevaluated. An abbreviation for (quote age) is 'age -- yes a single unmatched quote. An example using this special form is the following procedure (parity n), that returns the symbol odd if n is odd and the symbol even if n is even.
> (define parity
(lambda (n)
(if (even? n)
'even
'odd)))
> (parity 13)
odd
> (parity 28)
even
>
This procedure makes use of the special form if and the procedure
even?, which tests whether a number is even (divisible by 2) and
returns the boolean value #t (true) if so and #f (false) if not.
The values #t and #f are boolean constants.
There is also a procedure odd? to test whether a number is odd.
Procedures that return boolean values are called predicates, and
the convention is that their names end in ?.
Note that the ? is a character of the symbol odd?.
The special form if. The basic form of the special form if is (if exp1 exp2 exp3), where exp1, exp2 and exp3 are arbitrary scheme expressions. The evaluation rule is that first exp1 is evaluated; if the resulting value is not #f, then exp2 is evaluated and its value is returned as the value of the if expression (and exp3 is not evaluated.) However, if the value of exp1 is #f, then exp3 is evaluated and its value is returned as the value of the if expression (and exp2 is not evaluated.) The value of exp1 need not be a boolean -- everything except #f is treated as though it were #t. The procedure not negates boolean values -- that is, (not exp) is #t if the value of exp is #f, and is #f if the value of exp is not #f. In particular, (not #f) => #t, (not #t) => #f and (not 1) => #f. Comparison predicates. To compare the values of numbers we have equality and inequality tests: <, <=, =, >=, >. To compare general scheme values we have the predicate equal?, which can be used on numbers and many other types of values. Your text talks about equality tests eqv? and eq?, but we'll get to those later. For now, just use equal? for comparing general scheme values. We now have materials enough to define the classic (halting) recursive function factorial, as follows.
> (define factorial
(lambda (n)
(if (= n 1)
1
(* n (factorial (- n 1))))))
> (factorial 3)
6
>
To understand how (factorial 3) is computed, we can draw a tree
of procedure calls as follows:
(factorial 3) => 6
|
(* 3 (factorial 2)) => 6
|
(* 2 (factorial 1)) => 2
|
1
The call of (factorial 3) causes a recursive call to (factorial 2),
which causes a recursive call to (factorial 1).
Because 1 is a base case, there is no further recursive call, and
(factorial 1) returns 1, which is then multiplied by 2 to return
2 as the value of (factorial 2), which is then multiplied by 3
to return 6 as the value of (factorial 3).
Recursive procedures that halt generally consist of one or
more base cases (in the case of factorial, when n = 1) and
one or more recursive calls to the procedure, which "make progress"
towards one or more base cases.
Recursion is "upside down" induction; in recursion we work our
way down toward the base cases by means of recursive calls,
while in induction we start at the base cases and work our way
up by means of the inductive step(s).
The procedures quotient and remainder. For your homework it will be helpful to have the procedures quotient and remainder. The value of (quotient m n) is the integer quotient on dividing m by n, and the value of (remainder m n) is the integer remainder on dividing m by n. For example > (quotient 15 4) 3 > (remainder 17 4) 1 >We observe that the last decimal digit of a number n is just the remainder on dividing n by 10, so we can write a procedure (last-digit n) to return the last decimal digit of n as follows.
> (define last-digit
(lambda (n)
(remainder n 10)))
> (last-digit 356)
6
>
Using recursion we can write a procedure to return the first
decimal digit of n as follows.
> (define first-digit
(lambda (n)
(if (< n 10)
n
(first-digit (quotient n 10)))))
> (first-digit 356)
3
>
To get a better idea of how this works, we can draw its tree of
calls.
(first-digit 356) => 3
|
(first-digit 35) => 3
|
(first-digit 3) => 3
Here the progress towards the base case is in terms of reducing
the number of decimal digits in the number, rather than subtracting
one from the number (as in the case of factorial).
|
| 9/7/09 | Lecture 3. Scheme, continued.
Perlis epigram #23: To understand a program you must become both the machine and the program. Perlis epigram #17: If a listener nods his head when you're explaining your program, wake him up. We reviewed the scheme interpreter rules so far: Constants evaluate to themselves. Symbols are looked up in the relevant environment. Applications are evaluated by evaluating each expression in the application and calling the first value (a procedure) on the other values as arguments. The rules apply recursively.Thus, for example, 17 => 17 (+ 17 4) => 21 (+ 17 (* 2 3)) => 23We use the arrow symbol to indicate that a scheme expression evaluates to a value. Environments. When you first call scheme, there is a top-level environment that has certain symbols, like +, * predefined. An environment is a table containing symbols and the values they are bound to. What the "relevant" environment is will become clearer as we talk more about evaluation. For example, when the interpreter evaluates (+ 17 4), it evaluates + (a symbol) by looking it up in the top-level environment, and finding that it is bound to the "primitive" or built-in procedure to add numbers. Thus, after evaluating all three expressions in (+ 17 4), the interpreter has a procedure (the primitive addition procedure) and the numbers 17 and 4, and it calls the addition procedure with the actual arguments 17 and 4. The addition procedure returns the value 21, which is the value that the interpreter returns for the application (+ 17 4). The special form define. We can add a new symbol and value to the top-level environment using the special form define. (A "special form" is an expression that does not follow the rules of evaluation for an application.) For example, suppose we evaluate the following. > (define age 17) > age 17 >The define causes the symbol age to be added to the top-level environment with a value of 17. Its syntax is the keyword define followed by a symbol followed by an arbitrary scheme expression. The expression is evaluated, and the value becomes the value bound to the symbol. Subsequent uses of the symbol age will cause it to be looked up in the top-level environment, where its value will be found to be 17. For example, > (+ age 4) 21 > The special form lambda. To write our own procedures, we must be able to write expressions that evaluate to procedures. Evaluating the special form lambda does precisely that: create a new procedure. For example, we may create a procedure of one argument that squares its argument by evaluating the lambda expression: (lambda (n) (* n n)). Creating a procedure value is all very well and good, but we'd also like to be able to apply the procedure. Here is an example of doing that: > ((lambda (n) (* n n)) 8) 64 >What happened here? The interpreter evaluated the application by evaluating the expression (lambda (n) (* n n)), whose value is a procedure to square a number, and also evaluating 8, whose value is 8. It then applies the procedure to the argument 8; the procedure returns 64, which is the value of the application. Sometimes we want "nameless" procedures like this, but often we want to give them names so that we can use them repeatedly For this we can use define, for example: > (define square (lambda (n) (* n n))) > (square 8) 64 > (square 14) 196 >What happened here? The special form define was evaluated, which put the symbol square into the top-level environment with a value obtained by evaluating the lambda expression (lambda (n) (* n n)). The value of the lambda expression is a procedure of one formal argument n, which has a "body" of (* n n), and this procedure becomes the value of square in the top-level environment. To evaluate the application (square 8), the interpreter looks up the symbol square in the top-level environment and finds its value is a procedure of one formal argument n with body expression (* n n); it also evaluates 8 to 8. It proceeds to create a new "local" environment containing one symbol, n, bound to the value 8, and pointing back to the top-level environment, and in this new local environment it evaluates the body expression (* n n). This evaluation attempts to look up * in the local environment, and doesn't find it, but it follows the pointer back to the top-level environment and finds * there, bound to the primitive multiplication procedure. It also attempts (twice) to look up n in the local environment, where it finds n is bound to 8. Then it calls the primitive multiplication procedure with arguments 8 and 8; the procedure returns the value 64, which is returned as the value of the application (* n n), and in turn returned as the value of the application (square 8). The local environment, because nothing is pointing to it, essentially disappears (and its space will be reclaimed in a garbage collection.) To evaluate (square 14), the interpreter goes through this sequence of operations again to get a value of 196. We discussed the question of what happens if n already has a value in the top-level environment. Because the local environment is searched first, the occurrence of n in the top-level environment will have no effect. For example: > (define n 99) > (define square (lambda (n) (* n n))) > (square 8) 64 > |
| 9/4/09 | Lecture 2. Ancient Egyptian multiplication; Scheme begins; ancient
Egyptian fractions.
Please begin the reading assignment in [Assignments]. We revisited the ancient Egyptian multiplication algorithm to calculate another product: 23 * 21. 23 21 46 10 92 5 184 2 368 1Adding up the numbers from the first column corresponding to odd numbers in the second column, we get 23 + 92 + 368 = 483. Tong gave us a way of thinking about this in the last lecture:
23 * 21 = 46 * 10 + 23
46 * 10 = 92 * 5
92 * 5 = 184 * 2 + 92
184 * 2 = 368 * 1
386 * 1 = 368 * 0 + 368
(OK, maybe the last line is a little pedantic.)
Thus
23 * 21 = 23 + (46 * 10)
= 23 + (92 * 5)
= 23 + 92 + (368 * 1)
= 23 + 92 + 368 + (368 * 0)
= 23 + 92 + 368
This explanation gives an idea of how to prove
the method correct.
Another way to understand the method is to think of the
standard school algorithm for multiplication, but with
23 and 21 written out in base 2 notation
(or, more entertainingly, 23 in decimal notation and 21
in binary notation.)
We noted that we could save space by keeping only the
last two rows of the partial results, along with a running
sum of the first column entries (so far) that correspond to
odd second column entries.
We discussed the question of whether the standard school
multiplication algorithm or this ancient Egyptian method
is more efficient in terms of the number of "basic steps"
required.
We noted that the log base 10 of a number is within 1 of the
number of decimal digits of the number.
Scheme begins. The implementation of scheme that we will use this term is mzscheme on the Zoo machines. This is an interpreter that is part of the PLT Scheme project. It is a "Read-Evaluate-Print-Loop" interpreter: the user (you) type in a scheme expression, the interpreter reads it in, evaluates it and prints out its value. You may choose to install Dr. Scheme on you computer, but to submit assignments after the first one, you must either prepare or upload your programs on the Zoo machines and use the "submit" command there. Your programs will be tested using mzscheme, so you must make sure that they run in the mzscheme environment. Once you have opened a terminal window on a Zoo machine, you type mzscheme at the command-line prompt, and scheme starts up, prints out a greeting message and then prompts you with > to enter an expression. dca3@termite:~> mzscheme Welcome to MzScheme v370 [3m], Copyright (c) 2004-2007 PLT Scheme Inc. >The goal of our coverage of scheme is to install in your head a very compact set of rules for evaluating scheme expressions in the core language. One type of expression is a constant; the rule is that constants evaluate to themselves. One type of constant is a number: for the time being, we'll stick to integers: positive, negative and zero. Thus, if at the prompt you type "17" and then return, scheme evaluates the expression, prints its value and prompts you for another expression. > 17 17 > Another type of expression is an application, or procedure call. It is a finite sequence of expressions enclosed in parentheses, denoting a procedure followed by its arguments. Scheme evaluates the expressions (in some unspecified order) and then calls the denoted procedure on the arguments in the given order. An example of evaluating an application is the following. > (+ 17 4) 21 >Here the three expressions are +, 17 and 4. 17 and 4 are numbers, and evaluate to themselves. But + is a symbol, and another rule is required to evaluate symbols: evaluate a symbol by looking it up in the relevant environment. What is an "environment"? It is essentially a table containing symbols and the values they are bound to. When you first enter scheme, there is a predefined global or top-level environment, which contains the symbol + bound to a value that is a built-in addition procedure. Similarly, the top-level environment contains the symbol *, bound to a value that is a built-in multiplication procedure. Thus, to evaluate (+ 17 4), scheme looks up + in the top-level environment and finds that its value is a built-in addition procedure, and evaluates 17 to 17 and 4 to 4, and calls the built-in addition procedure on the two arguments 17 and 4 (in that order.) The built-in addition procedure returns the sum, 21, which scheme then prints out. Applications may be nested inside applications, and we apply the rules recursively, to find the values of inner applications before outer ones. Thus, for example: > (+ 17 (* 2 3)) 23 >In this case scheme evaluates + by looking it up in the top-level environment, and finding a built-in addition procedure, it evaluates 17 to 17, and it evaluates (* 2 3) recursively by looking up * in the top-level environment, and finding that its value is a built-in multiplication procedure, and evaluating 2 to 2 and 3 to 3, and calling the multiplication procedure on 2 and 3. The multiplication procedure returns 6 as the value of (* 2 3), and then scheme can go ahead and call the built-in addition procedure on 17 and 6. The built-in addition procedure returns the value 23, which scheme prints out. More on ancient Egyptian numbers. The Rhind papyrus treats fractions, but the notation for fractions is somewhat unusual, in that all fractions (with the exception of 2/3, which had a special symbol) had to be written as a sum of "unit fractions", that is 1/a or 1/a + 1/b or 1/a + 1/b + 1/c, etc. Moreover, all the denominators had to be different numbers. Thus, 1/7 is okay, but 2/7 isn't, and we can't even write it as 1/7 + 1/7 because the denominators are the same. Wendy extracted us from this problem as follows: 1 = 1/2 + 1/3 + 1/6, so 1/7 = 1/14 + 1/21 + 1/42. Thus, we can write 2/7 = 1/7 + 1/14 + 1/21 + 1/42. Your challenge is to figure out whether it is possible to represent all proper fractions in this form. |
| 9/2/09 | Lecture 1. Introductory Lecture.
Discussion of the syllabus for CPSC 201a [Syllabus] and the list of Introductory Computer Science Courses. Description of the overall structure of the course and why it is taught in Scheme. The algorithm for multiplying numbers from the Rhind (or Ahmose) Papyrus (about 3659 years before the present) works as follows. Assume we are multiplying 70 by 13. Put 70 at the head of one column and 13 at the head of the second column. In the first column, double 70 to get 140 and in the second column, halve 13 (after subtracting 1 because it is odd) to get 6. Repeat until the number in the second column is 1. The results are:
70 13
140 6
280 3
560 1
Now add up the numbers in the first column that correspond to odd
numbers in the second column, namely, 70 + 280 + 560 = 910.
Since 70 * 13 = 910, the algorithm seems to work in this case;
the challenge is to come up with a good explanation of why.
|