Analyzing Procedures
Summary:
Once you develop procedures, it becomes useful to have some sense
as to how efficient the procedure is. For example, when working
a list of values, some procedures take a constant number of steps
(e.g., car), some take a number of steps proportional
to the length of the list (e.g., spots-leftmost), and
some take a number of steps proportional to the square of the length
of the list (e.g., finding the closest pair of colors in a list).
In this reading, we consider ways in which you might analyze how slow
or fast a procedure is.
Introduction
At this point in your career, you know the basic tools to
build algorithms, including conditionals, recursion, variables,
and subroutines. You've also found that you can often write
several different procedures that all solve the same problem and
even produce the same results. How do you then decide which one
to use? There are many criteria we use. One important one is
readability - can we easily understand the way
the algorithm works? A more readable algorithm is also easier to
correct if we ever notice an error or to modify if we want to expand
its capabilities.
However, most programmers care as much about
efficiency - how many computing resources does the
algorithm use? (Pointy-haired bosses care even more about such things.)
Resources include memory and processing time. Most analyses of
efficiency focus on running time, the amount of time the program takes
to run, which, in turn depends on both how many steps the procedure
executes and how long each step takes. Since almost every step in
Scheme involves a procedure call, to get a sense of the approximate
running time of Scheme algorithms, we can usually count procedure calls.
In this reading and the corresponding lab, we will consider some
techniques for figuring out how many procedure calls are done.
Some Examples to Explore
As we explore analysis, we'll start with a few basic examples. First,
consider two versions of the rgb-brightest procedure,
which you should recall from earlier in the semester.
You can find the helpers for this procedure in an appendix.
The two versions are fairly similar. Does it really matter which we use?
We'll see in a bit.
As a second example, consider how we might write the famous
reverse procedure ourselves, rather than using
the built-in version. In this example, we'll use two very different
versions.
You'll note that we've used list-append rather
than append. Why? Because we know that the
append procedure is recursive, so we want to make
sure that we can count the calls that happen there, too. We've used
the standard implementation strategy for append
in defining list-append.
Strategy One: Counting Steps Through Output
How do we figure out how many steps these procedures take?
One obvious way is to
add a bit of code to output something for each procedure call.
While you may not know the details of output yet, all you need to
know right now is that write prints its
parameter, and newline prints a carriage
return. To annotate the procedures for output, we simply add
appropriate calls. For example, here are the first few lines of
the annotated versions of rgb-brightest-1 and
rgb-brightest-2.
So, what happens when we call the two procedures on a simple list of colors?
> (define grey (lambda (n) (rgb-new n n n)))
> (define light-to-dark (map grey (list 255 192 128 64 0)))
> (rgb-brightest-1 light-to-dark)
(rgb-brightest-1 (255/255/255 192/192/192 128/128/128 64/64/64 0/0/0))
(rgb-brightest-1 (192/192/192 128/128/128 64/64/64 0/0/0))
(rgb-brightest-1 (128/128/128 64/64/64 0/0/0))
(rgb-brightest-1 (64/64/64 0/0/0))
(rgb-brightest-1 (0/0/0))
16777215
> (rgb-brightest-2 light-to-dark)
(rgb-brightest-2 (255/255/255 192/192/192 128/128/128 64/64/64 0/0/0))
(rgb-brightest-2 (192/192/192 128/128/128 64/64/64 0/0/0))
(rgb-brightest-2 (128/128/128 64/64/64 0/0/0))
(rgb-brightest-2 (64/64/64 0/0/0))
(rgb-brightest-2 (0/0/0))
16777215
So far, so good. Each has about five calls for a list of length five.
Let's try a slightly different list.
> (define dark-to-light (reverse light-to-dark))
> (rgb-brightest-1 dark-to-light)
(rgb-brightest-1 (0/0/0 64/64/64 128/128/128 192/192/192 255/255/255))
(rgb-brightest-1 (64/64/64 128/128/128 192/192/192 255/255/255))
(rgb-brightest-1 (128/128/128 192/192/192 255/255/255))
(rgb-brightest-1 (192/192/192 255/255/255))
(rgb-brightest-1 (255/255/255))
(rgb-brightest-1 (255/255/255))
(rgb-brightest-1 (192/192/192 255/255/255))
(rgb-brightest-1 (255/255/255))
(rgb-brightest-1 (255/255/255))
(rgb-brightest-1 (128/128/128 192/192/192 255/255/255))
(rgb-brightest-1 (192/192/192 255/255/255))
(rgb-brightest-1 (255/255/255))
(rgb-brightest-1 (255/255/255))
(rgb-brightest-1 (192/192/192 255/255/255))
(rgb-brightest-1 (255/255/255))
(rgb-brightest-1 (255/255/255))
(rgb-brightest-1 (64/64/64 128/128/128 192/192/192 255/255/255))
(rgb-brightest-1 (128/128/128 192/192/192 255/255/255))
(rgb-brightest-1 (192/192/192 255/255/255))
(rgb-brightest-1 (255/255/255))
(rgb-brightest-1 (255/255/255))
(rgb-brightest-1 (192/192/192 255/255/255))
(rgb-brightest-1 (255/255/255))
(rgb-brightest-1 (255/255/255))
(rgb-brightest-1 (128/128/128 192/192/192 255/255/255))
(rgb-brightest-1 (192/192/192 255/255/255))
(rgb-brightest-1 (255/255/255))
(rgb-brightest-1 (255/255/255))
(rgb-brightest-1 (192/192/192 255/255/255))
(rgb-brightest-1 (255/255/255))
(rgb-brightest-1 (255/255/255))
16777215
Wow! That's a lot of lines to count, 31 if we've counted correctly. That
seems like a few more than one might expect.
That may be a problem. So, how many does the other version
take?
> (rgb-brightest-2 dark-to-light)
(rgb-brightest-2 (0/0/0 64/64/64 128/128/128 192/192/192 255/255/255))
(rgb-brightest-2 (64/64/64 128/128/128 192/192/192 255/255/255))
(rgb-brightest-2 (128/128/128 192/192/192 255/255/255))
(rgb-brightest-2 (192/192/192 255/255/255))
(rgb-brightest-2 (255/255/255))
16777215
Still five calls for a list of length five. Clearly, the second one is better
on this input. Why and how much? That's an issue for a bit later.
In case you haven't noticed, we've now tried two special cases, one
in which the list is organized brightest to darkest and one in which
the list is organized darkest to brightest. In the first case, the
two versions are equivalent in terms of the number of recursive calls.
In the second case, the second implementation is significantly faster.
We should certainly try some others. Clearly, the cases you test for
efficiency matter a lot.
Now, let's try the other example, reversing lists. We'll start by reversing
a list of length five.
> (reverse-1 (list 1 2 3 4 5))
(reverse-1 (1 2 3 4 5))
(reverse-1 (2 3 4 5))
(reverse-1 (3 4 5))
(reverse-1 (4 5))
(reverse-1 (5))
(reverse-1 ())
(5 4 3 2 1)
> (reverse-2 (list 1 2 3 4 5))
(reverse-2 (1 2 3 4 5))
(reverse-2-kernel (1 2 3 4 5) ())
(reverse-2-kernel (2 3 4 5) (1))
(reverse-2-kernel (3 4 5) (2 1))
(reverse-2-kernel (4 5) (3 2 1))
(reverse-2-kernel (5) (4 3 2 1))
(reverse-2-kernel () (5 4 3 2 1))
(5 4 3 2 1)
So far, so good. The two seem about equivalent. But what about the
other procedures that each calls? The kernel
of reverse-2 calls cdr,
cons, and car once
for each recursive call. Hence, there are probably five times
as many procedure calls as we just counted. On the other hand,
reverse-1 calls list-append
and list. The list procedure
is not recursive, so we don't need to worry about it. But what about
list-append? It is recursive, so let's add an
output annotation to that procedure, too. Now, what happens?
> (reverse-1 (list 1 2 3 4 5))
(reverse-1 (1 2 3 4 5))
(reverse-1 (2 3 4 5))
(reverse-1 (3 4 5))
(reverse-1 (4 5))
(reverse-1 (5))
(reverse-1 ())
(list-append () (5))
(list-append (5) (4))
(list-append () (4))
(list-append (5 4) (3))
(list-append (4) (3))
(list-append () (3))
(list-append (5 4 3) (2))
(list-append (4 3) (2))
(list-append (3) (2))
(list-append () (2))
(list-append (5 4 3 2) (1))
(list-append (4 3 2) (1))
(list-append (3 2) (1))
(list-append (2) (1))
(list-append () (1))
(5 4 3 2 1)
Hmmm, that's a few calls to list-append. Not the 31
we saw for the brightest element in a list, but still a lot.
Let's see ... fifteen, if we count correctly. Now, let's see
what happens when we add one element to the list.
> (reverse-1 (list 1 2 3 4 5 6))
(reverse-1 (1 2 3 4 5 6))
(reverse-1 (2 3 4 5 6))
(reverse-1 (3 4 5 6))
(reverse-1 (4 5 6))
(reverse-1 (5 6))
(reverse-1 (6))
(reverse-1 ())
(list-append () (6))
(list-append (6) (5))
(list-append () (5))
(list-append (6 5) (4))
(list-append (5) (4))
(list-append () (4))
(list-append (6 5 4) (3))
(list-append (5 4) (3))
(list-append (4) (3))
(list-append () (3))
(list-append (6 5 4 3) (2))
(list-append (5 4 3) (2))
(list-append (4 3) (2))
(list-append (3) (2))
(list-append () (2))
(list-append (6 5 4 3 2) (1))
(list-append (5 4 3 2) (1))
(list-append (4 3 2) (1))
(list-append (3 2) (1))
(list-append (2) (1))
(list-append () (1))
(6 5 4 3 2 1)
> (reverse-2 (list 1 2 3 4 5 6))
(reverse-2 (1 2 3 4 5 6))(kernel (1 2 3 4 5 6) ())
(kernel (2 3 4 5 6) (1))
(kernel (3 4 5 6) (2 1))
(kernel (4 5 6) (3 2 1))
(kernel (5 6) (4 3 2 1))
(kernel (6) (5 4 3 2 1))
(kernel () (6 5 4 3 2 1))
(6 5 4 3 2 1)
Hmmm ... we added one element to the list, and we added six calls to
list-append (we're now up to 21). In the case
of reverse-2, we seem to have added only one call.
What if there are ten elements? You probably don't want to count that
high. However,
we're pretty sure we now have 55 total calls to
list-append, and only ten recursive calls to the kernel.
Strategy Two: Automating the Counting of Steps
We've seen a few problems in the previous strategy for analyzing the
running time of procedures. First, it's a bit of a pain to add the
output annotations to our code. Second, it's even more of a pain to
count the number of lines those annotations produce. Finally, there
are some procedure calls that we didn't count. So, what should we do?
We want to transition from manual to
automatic counting.
Some of the Grinnell faculty have built a simple library that helps
you make that transition. How do you use that library? It's relatively
straightforward, but still requires you to modify your code a bit.
In particular, for any procedure of interest, replace define with
define$. In this case, we might write
(define$ rgb-brightest-1
(lambda (colors)
...))
To report on all the counted procedure calls made during
the evaluation of a particular expression, we write
> (analyze expression proc)
For example, we can redo our first analyses as follows:
> (analyze (rgb-brightest-1 light-to-dark) rgb-brightest-1)
rgb-brightest-1: 4
Total: 4
16777215
> (analyze (rgb-brightest-2 light-to-dark) rgb-brightest-2)
rgb-brightest-2: 4
Total: 4
16777215
Now, we can try the second analysis of the rgb-brightest
procedures and even do a bit less counting.
> (analyze (rgb-brightest-1 dark-to-light) rgb-brightest-1)
rgb-brightest-1: 30
Total: 30
16777215
> (analyze (rgb-brightest-2 dark-to-light) rgb-brightest-2)
rgb-brightest-2: 4
Total: 4
16777215
What if we try a slightly bigger list? We didn't really want to do
so when we were counting by hand, but now the computer can count for
us.
> (define lots-of-greys (map grey (list 0 16 32 48 64 96 112 128 144 160 176 192 208 224 240 255)))
> (length lots-of-greys)
16
> (analyze (rgb-brightest-1 lots-of-greys) rgb-brightest-1)
rgb-brightest-1: 65534
Total: 65534
16777215
What about the other procedures involved (null?,
cdr, etc)? The analysis library provides a variant
of the analyze routine in which you do not list
the procedures to count. In this case, it counts as many as it can.
(It will count the procedures you define with define$
and the procedures they call. However, it will not count many of the
calls from those procedures to other procedures.)
> (analyze (rgb-brightest-1 lots-of-greys))
car: 65535
cdr: 131069
null?: 65535
rgb-brighter?: 32767
rgb-brightest-1: 6553
Total: 360440
16777215
> (analyze (rgb-brightest-2 lots-of-greys))
car: 16
cdr: 31
null?: 16
rgb-brighter: 15
rgb-brightest-2: 15
Total: 93
16777215
Which one would you prefer to use?
You may be waiting for us to analyze the two forms of reverse,
but we'll leave that as a task for the lab.
Interpreting Results
You now have a variety of ways to count steps. But what should you do
with those results in comparing algorithms? The strategy most computer
scientists use is to look for patterns that describe the relationship
between the size of the input and the number of procedure calls.
I find it useful to look at what happens when you add one to the input
size (e.g., go from a list of length 4 to a list of length 5) or when
you double the input
size (e.g., go from a list of length 4 to a list of length 8, or go
from the number 8 to the number 16).
The most efficient algorithms generally use a number of procedure
calls that does not depend on the size of the input. For example,
car always takes one step, whether the list of length
1, 2, 4, or even 1024.
At times, we'll find algorithms that add a constant number of steps
when you double the input size (we haven't seen any of those so far).
Those are also very good.
However, the best we normally do are the algorithms that take about
twice as many steps when we double the size of the input. For example,
in processing a list of length n, in a situation in which we expect
to look at every element of the list, we'll probably do a few steps
for each element. If we double the length of the list, we double the
number of steps. Most of the list problems you face in this class
should have this form.
A few times, you'll notice that when you double the input, the number of
steps seems to go up by about a factor of four. Such algorithms
are sometimes necessary, but get slow.
At times, you'll find that when you double the input size, the number
of steps goes up much more than a factor of four. (For example, that
happens in the first version of rgb-brightest-1.) If
you find your code exhibiting that behavior, it's probably time to
write a new algorithm.
Note: While we generally don't like to use
algorithms that exhibit worse behavior than quadrupling steps for
doubled input, there are cases in which these slow algorithms are
the best that any computer scientist has developed. There are even
some problems for which we have slow algorithms but theorize that there
cannot be a fast algorithm.
To make life even more
confusing, there are some problems for which it has been proven that
no algorithm can exist. If you continue your study of computer science,
you'll have the opportunity to explore these puzzling but powerful ideas.
What Went Wrong?
So, why are the slower versions of the two algorithms above so slow?
In the case of rgb-brightest-1, there is a case in which
one call spans two identical recursive calls.
That doubling gets painful fairly quickly. From 1 to 3 to 7 to 15 to
31 to 63 to ....
If you notice that you are doing multiple identical recursive
calls, see if you can apply a helper to the recursive call, as we did
in rgb-brightest-2. Rewriting your procedure using the
primary tail recursion strategy (that is, accumulating a result as you go) may
also help.
In the case of reverse-1, the difficulty is only
obvious when we include list-append. And what's
the problem? The problem is that list-append
is not a constant-time algorithm, but one that needs to visit every
element in the first list. Since reverse
keeps calling it with larger and larger lists, we spend a lot of
time appending.
Appendix: Utilities for Computing with Brightness
At the beginning of this reading, we considered two techniques for computing
the brightest element of a list. Each of those techniques relied on some
additional procedures, such as rgb-brighter? and
rgb-brighter. Those procedures, and other related
procedures, may be found here.