Problem Solving for

Small Scale Software Development

CSCI 2610 Supplement

Karl Abrahamson

 

 

0. Introduction

 

This describes a step-by-step methodology for development of computer programs of small to medium size.  The methodology is a combination of approaches called top-down design and successive refinement.  With practice, you will find that you can go through the steps fairly quickly and end up with a working program.

 

During the process of writing a program, you will encounter subproblems that need to be solved.  Each subproblem should be treated as a problem in its own right, and the entire methodology should be applied to it.  Therefore, the methodology is concerned not just with the overall problem to be solved, but with smaller problems as well.

 

The methodology described here is based on the use of procedures and functions.  A procedure or function is a basic unit of computation.  It is used, or called, by some other procedure or function.  It gets information from the caller, computes, and passes information back to the caller.  Computation typically involves nothing but computing the values to pass back to the caller, but it can also involve actions such as getting information from the keyboard or from a file, printing information on the screen or in a file, or altering the contents of variables.

 

A procedure or function has a list of parameters that are used for communicating with the caller.  Each parameter is either an in-parameter (used to pass information from the caller to the procedure or function), an out-parameter (used to return information from the procedure or function to the caller), or an in-out-parameter (used to pass information both directions).  When a procedure or function is run, the values of its in-parameters and in-out-parameters have already been set by the caller. 

 

A procedure puts values in the out-parameters and the in-out-parameters,  and possibly does some other actions. 

 

A function has just one out-parameter, called the return-value of the function, and otherwise has only in-parameters.   The only thing that a function does is to set the return-value; it does not perform other actions, such as communicating with the keyboard or the screen, except in unusual circumstances.

 

 

1. Understanding the Problem

 


The first step in solving a problem is gaining a clear understanding of the problem.  Keep in mind that the problem to be solved might be the full problem that your program is supposed to solve, or might be only part of the full problem.  Consider, for example, a full problem that calls for reading a positive integer from the keyboard and saying whether or not that positive integer is prime.  (A positive integer n is prime if n > 1 and the only ways to express n as the product of two numbers are n = 1xn and n = nx1.  For example, 5 is prime but 6 is not.)  We will consider a subproblem that is to determine whether a given positive integer is prime.  For the subproblem, we don't care where the integer comes from.

 

A. Get an Initial Description

 

First think of what you need the procedure or function to do.  For example, for the prime number checking program, you need a procedure or function that tells whether or not the number that was typed on the keyboard is prime.  The initial description is

 

"Tell whether the number typed on the keyboard is prime."

 

B. Detach the Problem from its Context

 

In order to be able to solve the subproblem as a problem in its own right, you must think of it by itself, out of the context of the program as a whole.  The example calls for determining whether the number typed on the keyboard is prime.  The fact that the number was typed on the keyboard is only relevant to another part of the program; it is irrelevant here.  So we adjust the description of the subproblem as follows. 

 

"Given an integer n, tell whether or not n is prime." 

 

In general, try to remove from the initial description anything that is irrelevant to this part of the program.  Try to make the procedure or function general, so that it might conceivably be used in some other program as well as this one.

 

C. Carefully Describe the Problem

 

The final step in understanding the problem is to make your description precise.  Identify and name the parameters.  Decide which parameters are in-parameters, which are out-parameters and which are in-out parameters.  Then decide whether to use a procedure or function.  A rule of thumb is to use a function if all of the following are true.  Otherwise, use a procedure.

 


 

Use a function when:

 

  1. There are no in-out parameters, and there is just one out-parameter.

  2. There are no actions, such as getting information from the keyboard or printing   

      information on the screen.  (Sometimes, functions are used to get information from

      the keyboard.  So this rule can be relaxed.)

  3. The out-parameter is not an array or structure.  (Ignore this if you don't know what

      an array or structure is.)

 

 

Write down the description clearly, indicating the purpose of each parameter, and any actions that are to be performed.  One way to be sure that each parameter is described is to include a line for each parameter.  It is not absolutely necessary to have a line for each parameter, but the purpose of each must be described. If there are any restrictions on the values of the parameters, state them.  If you are defining a function, then say what the return value is, in terms of the input parameters.  This  description is a contract between the procedure or function and its caller.  The resposibilities of each party should be clear.

 

As part of the description, write a heading for the procedure or function, showing the return type, the name of the function and the parameters.

 

In the example, we are working with the rough description "Given an integer n, tell whether or not n is prime."  There is one in-parameter, called n.  There is one out-parameter, namely the result (true or false) telling whether n is prime.  Since there is just one out-parameter, we will use a function.  Let the function be called prime.  The description and heading are

 

// prime(n) is true if n is prime.

//   in: n                        The number to check for primality.  Require n > 0.

//   return-value:          True if n is prime, false if n is not prime.

 

bool prime(int n)

 

 

Be sure that you have written down a clear description and a function heading before proceeding to stage 2.  Check that the purpose of each parameter is described, and that the return value is described for a function.

 


 

Pitfall: Avoid pronouns, such as "it", since it will be often be unclear what "it" refers to.  Similarly, avoid pronoun phrases, such as "the number", "the thing", "the input" and "the value", when they stand alone as the name of something.  Instead, give each parameter a name, and refer to it by name.  Avoid cases where a noun has been omitted, and the reader must insert it.  An example of a bad use of pronouns is the following rewrite of the description of function prime.  The following is far less clear than the original above.

 

// prime(n) is true if the number is prime.

//    in: n                       The number to check for primality.

//    return-value:         True if it is prime, false if not prime.

 

bool prime(int n);

 

In this description, line 1 uses "the number" as a pronoun phrase that presumably refers to n.  Line 3 uses "it" to refer to n, and omits "n is" between "false if" and "not".  (In line 2, phrase "the number" is describing n, not naming n, so "the number" is not a pronoun phrase there.)

 

 

Pitfall: If you jump ahead to stage 3 too soon, you will start thinking about the procedures and functions that this procedure or function will use as part of its solution.  You might be tempted to mention in the description of the current procedure that it uses those other procedures.  But that is irrelevant, and should not be mentioned.  Your description should not concern itself with how the procedure or function works, but rather with what the procedure or function does.  Each procedure or function should take credit (or blame) for anything done by any procedure or function that it calls.

 

 

2. Simplifying the Problem

 

If the problem is fairly simple, proceed directly to stage 3.  But if the problem is even moderately complex, try finding easier "approximations" to the problem.  Write down, or at least think about, a sequence of problems that are closer and closer to the desired problem.  The idea is to design the approximate problems so that their solutions will incorporate parts that will be useful in solving the real problem.  After each approximate problem is solved, its solution is modified to solve the next approximate problem.  Each solution to an approximate problem is tested and made to work before proceeding to the next (better) approximation.

 


As an example, consider a program that is supposed to read in the contents of a file that contains names and telephone numbers.  It then reads a name from the keyboard.  Then it looks up the name in the file, and prints the corresponding telephone number.  This is a fairly involved program.  Here is a sequence of approximations, each closer to the full problem.

 

  1. Read in the file, and print out all the data that was read in.  (When this problem is

      solved, you will have a procedure that reads in the file, and of whose correctness you

      are confident, since you see  what was read in.)

 

  2. Read in the file, and search for "John Smith".  Print out the entire line found.  (Here,

      you add  a searching procedure and test it.)

 

  3. Read in the file, and read a name from the keyboard.  Print the name, and search for it.

      Print the entire name found.  (Here, you add reading a name from the keyboard.)

 

  4. The original problem.  (Extraneous output is removed.)

 

This method of producing solutions to successively better approximations to the desired problem has a name.  It is called successive refinement.

 

 

3. Understanding the Method

 

Stages 1 and 2 are concerned solely with understanding the problem to be solved.  Stage 3 is to get a firm understanding of the method to be used to solve the problem or an approximation to the problem.  In this section, we will write solutions in a pseudo-programming language that looks quite a bit like C++, except that we are not concerned at all with the details of C++ syntax.  Those details are dealt with at a later stage.  So don't be suprised that our programs are not strictly C++ here.

 

A. Solve the Problem by Hand

 

Try an example or two by hand.  Try to understand how you are solving the problem.  Imagine explaining the solution to someone else.  For example, to test whether the number 9 is prime, you search for a factor, starting with 2.  2 is not a factor of 9, but  3 is a factor of 9, so 9 is not prime.   To test 5, you again search for a factor.  2 is not a factor of 5, 3 is not a factor of 5 and 4 is not a factor of 5.  Since all of the numbers that are strictly between 1 and 5 are not factors, 5 must be prime.  In general, to decide whether a number n is prime, try each number 2, 3, ..., n-1, testing to see if each one is a factor of n.  If a factor is found, then n is not prime.  If no factor is found, then n is prime.

 


B. Identify Subproblems.

 

If the solution is fairly difficult to explain, then try to make the explanation simpler by identifying subproblems, and imagining that the person to whom you are explaining the solution already knows how to solve the subproblems.  We have already done that above for the prime checking function.  Notice that the explanation assumes that the listener knows how to decide whether one number is a factor of another, though we have not said how to do that.  The subproblems can be as simple or as complex as you like.  If there are many subproblems, consider introducing more approximate problems, so that you can deal with the subproblems one at a time.  This method of breaking a problem into subproblems is called top-down design.

 

For each subproblem, go through stage 1, getting a precise description of the subproblem, and a heading for a procedure or function that solves the subproblem.  Only do stage 1 for the subproblems now.  The remaining stages will be done later.  For the prime number tester, stage 1 for the subproblem might go like this.

 

We need to decide if a some value (the counter) is a factor of the number n that we are testing.  Removing context, why not have a function that tells whether or not one given number is a factor of another.  Call the function factor.  Precisely,

 

// factor(k,n) is true if k is a factor of n.

//    in: n                       The number being tested.

//    in: k                       The possible factor of n.

//    return-value:         True if k is a factor of n, false otherwise.

 

bool factor(int k, int n)

 

C. Identify the form of the method.

 

The next step is to examine the form of the method, to see how the solution will look.  Here are some common forms.

 

a. Sequencing

 

Does the method call for doing a fixed number of steps, one after the other?  If so, just write the steps.  For example, part or the full prime number program involves getting a number from the user.  There are three steps: print a prompt, read the number, and place the number in the output parameter.  Similarly, the factor function can be solved in just one step.  Number k is a factor of n if n % k = 0. 

 


b. Conditional

 

Does the method call for solving some kinds of inputs one way, other kinds of inputs another way?  If so, use an if statement.  (An if statement is called a conditional statement.) For example, suppose the problem is to compute the maximum of two numbers.  The description might be

 

// max(a,b) is the maximum of a and b.

//    in: a, b                   Integers

//    return-value:         The larger of a and b.

 

int max(int a, int b)

 

The solution involves testing to see which is larger, a or b.  A rough description of the solution is

 

            if a > b

  then return-value = a

  else return-value = b

 

so this function requires an if-statement.

 

c. General Looping

 

When the description of the solution involves words or phrases such as "and so on", "etcetera" or "...",  a loop is suggested.  There are different kinds of loops.  We start with a general kind.  To design a general loop solution, go through the following seven steps.  (Seven steps might seem like a lot, but they go quickly with experience.)

 

1. Go through at least one example (preferably more) by hand, writing down the intermediate values that you compute, one per line, and in columns if there are two or more values at each line.  (Sometimes, you can go through the general case as your example.) Give a name to each column.  It helps if you indicate, with each line, what you did to arrive at that line.  For example, suppose that the problem is to compute 0 + 1 + 2 + ... + n.  Specifically, the description is

 

// sum(n) is the value 1 + 2 + ... + n.

//    in n:                       The upper limit of the sum.  Require n > 0.

//    return-value          The sum 1 + 2 + ... + n.

 

int sum(int n)

 


As an example, try n = 5.  Then the hand solution computes the following intermediate values.

 

Sum

 0

 1         (added 1)

             3         (added 2)

 6         (added 3)

10        (added 4)

15        (added 5)

 

2. Identify values that you are keeping in your head.  Add a column or columns to the table for them.  Give a name to each new column.  In the example of 0 + 1 + ... + n, it is apparent that the value to add next is being kept in your head.  Adding a column for it gives

 

Sum     k

 0         0

 1         1

             3         2

 6         3

10        4

15        5

 

3.  Determine how to initialize the variables.  In the case of the summation problem, it is evident from the first line of the table that setting Sum = 0 and k = 0 will initialize variables Sum and k correctly.  Be careful that you are not initializing only for one example; your  initialization should set up the first line for all examples.

 

4. Determine when to stop the loop.  Look at the last line of the table.  What is special about it?  Select a condition that can be tested.  In the example, what is special about the last line is that k = n.  (Again, be careful not to select a condition that only works for this example.  In the table, k = 5, but we only stopped at that value because we were doing the example n = 5.  In general, the loop should stop when k = n.)

 

5. Determine how to update the variables in order to change from one line to the next.  Be sure that the update method is general, so that it works for every line of every example.  For the summation, variables Sum and k can be updated by adding one to k, and then adding the updated value of k to Sum.  So the update can be accomplished by

 

k = k + 1;

Sum = Sum + k;

 


For example, in line 4 of the table, Sum = 6 and k = 3.  Executing the update instructions yields Sum = 10 and k = 4, which are the values of Sum and k at line 5 of the table.  Your update method must work for every example, but certainly you should be sure that your method works for your example.

 

6. Determine what needs to be done to get the answer from the values of the variables in the last line of your table.  In the summation example, the sum 0 + 1 + ... + n is in variable Sum.  That sum is exactly what should be returned by the function.  So extracting the result from the loop is just a matter of setting the return-value of the function to Sum.

 

7. Put the parts together.  The general form is

 

Initialization;

while (!(termination condition)) {

    update instructions

 }

      extract result of loop

 

 The summation example yields

 

Sum = 0;

k = 0;

while (!(k = = n)) {

    k = k + 1;

    Sum = Sum + k

 }

return-value = Sum

 

Note that this is not strictly C++.  Coding is dealt with in stage 5.  Also note that the condition in the while loop has a not sign (!).  Instead of writing !(k = = n), you can write k != n.

 

This method of loop design has you start by thinking about the values that the variables take on as the loop progresses.  The actions, such as adding one to k, come out of the design from looking at what happens to the values.  This method of loop design is called data-oriented loop design.

 

The example of the prime-number checking function also suggests a loop, and can be solved as follows.  The steps go fairly quickly.

 

  1,2. When checking whether n is prime, we try each value from 2 to n-1 as a possible factor.


        Let's call the value currently being tried j.  So the values taken on by j are 2,3,...,n-1. 

        (Write them in a column.)

 

   3. Initially, j = 2. 

 

   4. You keep trying more possible factors until either j = n (in which case no factor was

     found) or j is a factor of n (in which case a factor was found).  So the termination

     condition is "(j = n) or  (j is a factor of n)".

 

   5. To get the next value of j, you just add one to j. 

 

   6. n is prime if j = n at the end of the loop.  (That is, all of the possible factors 2,3, ...,n-1

      were  tried, and none was found to be a factor.)  So the return value (true or false) is the

      same as the value of expression (j = = n).

 

   7. The completed method is

 

            j = 2

while (!(j = = n or j is a factor of n)) {

     j = j + 1

}

return-value = (j = = n)

 

There is more on how to design loops in Section 7.

 

 

Pitfall: Be careful not to let your examples mislead you.  Keep in mind that your procedure or function must work for any input, not just for one or two examples. 

 

 

d. Action-Oriented Loops

 

Action-oriented loop design relies on thinking about the actions to be performed, rather than on the desired data values.  For example, consider again the problem of computing the sum 1 + 2 + ... + n.  To do this, you can start with a variable r that holds 0, and you can add 1, then add 2, then add 3, etc. until finally you add n.  The actions to be performed are adding values to r.  Since you want to add each of 1, 2, ..., n to r, you want something like this.

 

r = 0

for i = 1, 2, ..., n


   add i to r

end for 

 

Now the result is in variable r.  As another example of an action-oriented loop design, consider the problem of determining whether a number n is prime.  The actions are to test each of 2,3,...,n-1, to see if any are factors of n.  If a factor is found, then the function should immediately quit, and it should return 0 as its value.  If none of those values are found to be factors, then the function should return 1.  So the prime number testing function can be written as follows.

 

for i = 2,3,...,n-1

  If i is a factor of n then return from the function now, with return-value 0.

end for

return-value = 1.

 

Any time you find a loop easy to understand and write in this action-oriented way, then use this approach.  If you do not clearly see how to solve the problem using the action-oriented approach, fall back on the data-oriented approach.  That approach is more general.

 

e. Recursion

 

Recursion is used to solve the same kinds of problems that loops solve.  It is an alternative to loops.  Recursion is discussed in section 7.

 

 

4. Check Your Solution

 

Hopefully, you have written your solution in enough detail that you can see what it will do on particular inputs.  The next thing to do is to try it by hand, to see what it does.  For example, trying n = 4 in the data-oriented prime number loop, we find that j is set to 2, and that the while-loop exits immediately since 2 is a factor of 4.  The return value is set to the value (2 = = 4), which is false.  That is good, since 4 is not prime.  It is good to try a few inputs.  Be sure to try "special" inputs.  For example, what if n = 1?  Does the solution do the right thing?  Our loop gets into trouble; it will go forever when n = 1.  We could add a requirement that n > 1 to the description, but that is unpleasant.  It is usually a bad idea to change your description to make it correctly describe a bad algorithm.  Instead, fix the algorithm.  One way to fix the solution is to replace condition j = = n in the while loop termination condition by j >= n.  This is still fine for n > 1.  What about n = 1?  Since j = 2, and 2 > 1, the loop will terminate immediately.  The return-value will be the value of the expression (2 = 1), which is false.  Since 1 is not prime, the answer is correct.  So the solution is


 

            j = 2

while (!(j >= n or j is a factor of n) {

     j = j + 1

}

return-value = (j = = n)

 

 

5. Coding

 

Once the solution is understood, it is necessary to write the solution in the programming language that you are using (C++ here).  Software designers call that coding the solution.  If your solution from stage 4 is detailed enough, the coding should be very easy.  You just need to be sure to use the correct syntax.  Here is a C++ version of the prime-number testing function.  Notice that semicolons have been inserted where C++ needs them.  The "or" operator has been replaced by ||, since that is what C++ uses.  Phrase "j is a factor of n” has been replaced by "factor(j,n)".  Variable j has been declared.

 

// prime(n) is true if n is prime.

//    in: n         The number to check for primality.

//                  Require n > 0.

//    return-value: True if n is prime, false if n is not

//                  prime.

 

bool prime(int n)

{

  int j;

  j = 2;

  while (!( j >= n || factor(j,n) )) { 

     j = j + 1;

  }

  return  (j == n);

}

 

Be sure to include the contract in your program, as is done for function prime.  You will find the contract useful later, particularly if you ask for help, and the person helping you asks, "What is this procedure or function supposed to do?"  Also, be sure to indent your solution nicely.  Doing those things now will save you time in the long run.  Do not put them off.

 

When you code an action-oriented loop, you often use a for-loop.  Here is the result of coding the action-oriented prime number checker.

 


// prime(n) is true if n is prime.

//    in: n         The number to check for primality.

//                  Require n > 0.

//    return-value: True if n is prime, false if n is not
//                  prime.

 

bool prime(int n)

{

  int i;

  for(i = 2; i < n; i++) {

    if(factor(i,n)) return 0;

  }

  return 1;

}

 

 

6. Getting it to Work

 

You will, unfortunately, find that you make mistakes.  Even expert programmers make mistakes.  You need to test you procedure or function.  If it works, great.  If not, then you need to find out what the problem is, and fix the problem.

 

A. Testing

 

One way to test your procedure or function is to write a short testing program.  For example, to test the prime-number testing function, the following might be used.

 

  cout << "  1 " <<  prime(1) << endl;

  cout << "  2 " <<  prime(2) << endl;

  cout << "  3 " <<  prime(3) << endl;

  cout << "  4 " <<  prime(4) << endl;

  cout << "  9 " <<  prime(9) << endl;

  cout << " 17 " <<  prime(17) << endl;

 

This test program should print

 

 1  0

 2  1

 3  1

 4  0

 9  0

17  1

 


Sometimes, the procedure or function does not need additional testing parts, since it is part of a larger program that was already written as part of an earlier approximation to the overall problem.  Simply running the program will test the new procedure or function.  But don't be stingy with testing.  If you fail to test a function, and your function does not work, you will pay for it later.  What happens is that another part of your program does not work because it is using a faulty tool.  Instead of realizing that the tool is faulty, you will spend a great deal of time trying to fix that other part.  You will never get it to work, since the tool is the problem.

 

One thing to keep in mind when testing a larger program is that you should be very sure that the part of the program that you are trying to test is actually being used.  It can happen that simple tests simply avoid the new parts altogether.  One way to ensure that the new parts are being performed is to add some prints to the new parts, so that you can see that they are being reached.

 

B. Dealing With Subproblems

 

Our prime-number checker uses function factor as a subproblem.  When your solution has subproblems, there are two things that you can do.   Choose one of them.

 

1. Complete the solution to the subproblems first; do all of the steps, including testing them.  This is usually the most straightforward approach.

 

2. Write a solution to an approximate problem that does not need the solutions to those subproblems.  You write short (but wrong) implementations of the subproblems.  The short implementations, called stubs, do not really do anything, except possibly print a message that they are being used.  Occasionally, the short implementations give the correct answer only for a few special cases.  When a solution is being checked with stubs for its subproblems, you must understand that you are only checking what is written, and that the results will reflect the existence of the stubs.

 

C. Diagonosing a Problem

 

If your procedure or function does not work, you will need to fix the problem.  You cannot fix the problem until you know what the problem is.  More often than not, you will find that once you understand the source of the problem, fixing the problem is quite easy.

 


 

Pitfall: You might be tempted to try to fix a problem in your program by trying a few random changes, and seeing if they work.  The temptation is particularly strong if your are tired, and don't want to think about what the program is doing.  Resist the temptation to try some random changes to your program.  With overwhelming likelihood, you will only ruin something that is correct rather than fix something that is wrong.  If you are too tired to find out the problem methodically, take a break, and come back to the program.

 

There are two common approaches to diagnosing a problem in a procedure or function.  One is to run the procedure or function under the debugger that comes with your system.  Turbo C++, for example, comes with a debugger that will allow you to run your program one line at a time. That can be useful, and it is worth familiarizing yourself with your debugger.

 

A second approach, called tracing, is the one we will discuss here.  Tracing involves inserting additional prints into your program.  At a minimum, print the input and output of a procedure or function.  You will probably want to print some intermediate results as well, so that you can see what is going on.  Here are some recommendations.

 

1. In each print statement, print a string that identifies the procedure or function that is doing the trace.  That way, your output will be less confusing.

 

2. Print the trace to a file instead of to the screen.  The trace might be long, and you might not want to see it on the terminal directly.  (It might scroll by before you can read it.)  You can view the trace file using a text editor after you run your program.

 

3. I like to place print statements that are for tracing, and that I intend to delete later, at the left margin, so that they can easily be found.

 

Here is an example of a traced version of the prime number checking program, at the stage where function prime is being tested.  It puts the output in file traceout.  You can use this as an example of using files for tracing.

 

#include <iostream.h>

#include <fstream.h>

 

ofstream trace;

 

//*********************** factor ************************

//*******************************************************

// factor(k,n) is true if k is a factor of n.

//  in: k           The possible factor.

//  in: n           The number being tested.


//  return-value:   True if k is a factor of n, false if k

//                  is not a factor of n.

//********************************************************

 

bool factor(int k, int n)

{

  return ((n % k) == 0)

}

 

//*********************** prime *************************

//*******************************************************

//prime(n) is true if n is prime.

//  in: n           The number to check for primality.

//                  Require n > 0.

// 

//  return-value:   True if n is prime, false if n is not

//                  prime.

//*******************************************************

 

bool prime(int n)

{

  int j;

trace << "prime: n = " << n << endl;

  j = 2;

  while (!( j <= n || factor(j,n))) {

trace << "prime: " << j << " is not a factor of n" << endl;

    j = j + 1;

  }

trace << "prime end: n =" << n << " j = " << j;

trace << " result = " << j==n << endl;

  return (j == n);

}

 

//*********************** main ***************************

//********************************************************

 

int main()

{

  trace.open("traceout", ios::out);

  cout <<  " 1  " << prime(1) << endl;

  cout <<  " 2  " << prime(2) << endl;

  cout <<  " 3  " << prime(3) << endl;

  cout <<  " 4  " << prime(4) << endl;

  cout <<  " 9  " << prime(9) << endl;

  return 0;

}


 

Run your program with the trace lines in it.  Use a text editor to examine the output.  If your function is incorrect, you will probably be surprised by the output at some point.  Try to identify the source of the surprise.  Sometimes, you will need to insert another trace statement to get more information.  Once you understand the problem, do what is needed to fix it.

 

 

7. Some Design Techniques

 

A. Characteristics of Loops

 

One very useful tool for designing loops is a loop characteristic, also called a loop invariant.

A characteristic is some assertion about a loop's variables that is true each time the loop reaches its top.  For example, return to the problem of computing 0 + 1 + 2 + ... + n.  Here is a tabular form of a loop to solve that problem.

 

SUM                K

  0                    0

  1                    1

  3                    2

  6                    3

 10                   4

 15                   5

 

This loop has a characteristic that SUM = 0 + 1 + ... + K.  That is, in each line, SUM = 0 + 1 + ... + K.  For example, when K = 3, SUM = 6 = 0 + 1 + 2 + 3.

 

Here is a worked out example using a loop characteristic.  We would like a function to compute factorials.  Working out the description yields

 

//factorial(n) is n factorial (n!).

//   in: n                        Number to compute factorial of.  Require n >= 0.

//  return-value:           n!

 

int factorial(int n)

 

Since n! = 1x2x…xn, , and this involves ..., a loop is suggested.  We will use variable p to hold intermediate results.  As a characteristic, we use p = k! = 1x2x…xk..  The characteristic tells us how to write a tabular form of the loop.  Here is an example where n = 5.  Notice that, at each line, we have put p = k!.

 


   p                   k

   1                   0

   1                   1

   2                   2

   6                   3

  24                  4

 120                 5

 

Now that the loop characteristic has given the tabular form, we extract the four parts of a loop.  Initialization of the loop is accomplished by setting p = 1 and k = 0.  The loop stops when k = n.  (We know that p = k! at each line.  When k = n, it must therefore be the case that p = n!.  That is just what we want.)  Updating is accomplished by adding one to k and then multiplying the new value of k into p.  So the method is

 

p = 1;

k = 0;

while (k != n) {

    k = k + 1;

    p = p * k;

}

return-value = p

 

The coding stage yields the following C++ version.

 

//******************** factorial ********************

//***************************************************

// factorial(n) is n factorial (n!).

//   in: n          Number to compute factorial of. 

//                  Require n >= 0.

//   return-value:  n!

//***************************************************

 

int factorial(int n)

{

  int p,k;

  p = 1;

  k = 0;

  while (k != n) {

    k = k + 1;

    p = p * k;

  }

  return p;

}

 


B. Recursion

 

Recursion is an alternative to loops.  Recursion is generally more powerful than loops, and often is easier to use, but it takes some getting used to.  If you are willing to put in the effort to understand recursion, you will benefit tremendously from its power.  To use recursion effectively, you will need to make some changes in perspective.

 

1. When you use loops, you strive to grasp the entire solution.  For example, the tabular form of a loop that we have used shows how the loop progresses from the beginning to the end.  When you use recursion, you try NOT to grasp the entire solution.  This requires something of a leap of faith, and will be discussed more below.  As you might imagine, it is partly the ability to solve a problem without needing to see the whole solution in front of you that gives recursion its power.

 

2. When using loops, you tend to change the values of variables frequently.  When using recursion, you generally try to avoid changing the value of any variable that already has a value.  You only give values to variables that are uninitialized.  This is another strength of recursion.  If you become accustomed to changing the values of variables, you will inevitably find that one part of your program changes the value of a variable that another part of your program assumes does not get changed.  That kind of thing can lead to chaos, which shows up as errors that are severe and that are very hard to diagnose.  Recursion discourages the kind of programming that leads to such errors.

 

To use recursion, imagine trying to solve an example of your problem by hand.  Ask yourself,

 

If I had a friend who knew how to solve this kind of problem, but who was only willing to solve a smaller example than the example that I am working on, how could I make my job easy by asking my friend to help?"

 

For example, suppose that the problem is to compute 0 + 1 + 2 + ... + n.  Suppose n = 5, so you want to compute 0 + 1 + 2 + 3 + 4 + 5.   You could ask your friend to compute 0 + 1 + 2 + 3 + 4, and to tell you the result (call it s).  (It will turn out that s = 10.)  Now you know that 0 + 1 + 2 + 3 + 4 + 5 = s + 5, so all you need to do is add 5 to s, getting 15.  That is very easy (for you). 

 

Notice that the job you have asked your friend to perform, computing 0 + 1 + 2 + 3 + 4, is the same kind of problem as you are solving, but with n = 4.  You are asking your friend to solve a smaller example than you are solving. You should not try to think about how your friend arrives at the answer for 0 + 1 + 2 + 3 + 4.  The great leap of faith in recursion is that you believe your friend will get the correct answer.

 


There is another critical component to recursion.  Very small examples must be solved without asking for help.  For the problem of computing 0 + 1 + 2 + ... + n, an easy case is when n = 0.  Then you are just asked to compute 0, and don't need any help.

 

To write down the solution when you use recursion, you must use a procedure or function.  Here is a function to compute 0 + 1 + 2 + ... + n.  It does a test to see if the input is the special case (n = 0),  or the general case (n > 0).  When n > 0, function sum itself plays the role of the friend who computes 0 + 1 + ... + (n-1).  What actually happens is a new copy of function sum is created, and that copy computes 0 + 1 + ... + (n-1).

 

//****************** sum ****************************

//***************************************************

//sum(n) is 0 + 1 + ... + n.

//

//   in: n           The upper limit of the sum. 

//                   Require n >= 0

//

//   return-value:  0 + 1 + ... + n.

//***************************************************

 

int sum(int n)

{

  if (n == 0) return 0;

  else return sum(n-1) + n;

}

 

As another example of recursion, consider a procedure to read a list of nonnegative integers from the keyboard, and to print them in reverse order.  A negative number signifies the end of the input.  To solve this, read a number k from the keyboard.  If k is negative, then there is nothing to do; there are no nonnegative numbers in the input.  If k is nonnegative, ask a friend to read the remaining numbers and print them in reverse order.  After the friend has done that job, print k (at the end if the list printed by the friend).  For example, if the input is 2  7  34  19  3  -1, then you would read number 2, and store it in k.  Now your friend would read 7  34  10  3  -1, and would print 3  10  34  7.  (Recall the leap of faith.  Don't ask how the friend does that; it just happens.)  Now you print k (= 2), so that the output looks like 3 10 34 7 2.  That is just what you want.  Here is the solution, written in C++.

 

//******************* PrintReverse ********************

//*****************************************************

// PrintReverse() reads a list of nonnegative integers, 

// terminated by a negative integer.  It prints the

// nonnegative integers in the opposite order from the order

// in which they were read. The output is all on one line.

//*****************************************************

 


void PrintReverse()

{

  int k;

 

  cin >> k;

  if (k >= 0) {

    PrintReverse();

    cout << k << " ";

  }

}

 

Notice that asking the friend to read the remaining numbers and print them in reverse order is accomplished simply by calling PrintReverse.  A new copy of procedure PrintReverse, with its own private variable k, is created, and that copy runs.

 

Occasionally, a problem is not stated in a way that is suitable for recursion.  For example, suppose that the problem is to print a triangle shape with n rows, where n is given, and the bottom row of the triangle is flush with the left margin. Here is what would be printed for n = 4.

 

   *

  ***

 *****

*******

 

Notice that there is a triangle of 3 lines just above the last line of the triangle of 4 lines.  So you might try to print the triangle with 4 lines as follows.  The * characters are those printed by your friend, and the + characters are printed by you.

 

   *

  ***

 *****

+++++++

 

Unfortunately, the triangle that your friend is asked to print does not have a bottom row that is flush with the left margin, so your friend is not solving exactly the same kind of problem that you are solving!   What you can do is generalize the problem.  Your goal now is to print a triangle with n rows where the bottom row is indented k spaces.  If n > 0, then you solve that problem by first asking a friend to print a triangle with n-1 rows, indented k+1 spaces, and then printing the bottom row yourself.  Try writing out a solution.  You will find it convenient to introduce a subproblem of printing a given number of copies of a given character.  Just use a loop for that.

 

 


C. Accumulation

 

Quite a few problems can be solved by loops that accumulate values, getting more and more of a solution.  Examples of such problems are computing 0 + 1 + ... + n, or computing a power (by accumulating more and more of the power).  To compute , you need to compute (n)(n)...(n).  The power can be accumulated in a variable r.  Use loop characteristic , where j gets larger and larger.  Starting with j = 0, we have .  Each time around the loop, we multiply n into r.  So the loop can be written like this.

 

//************************ power *************************

//********************************************************

// power(n,k) is n to the k-the power.

//

//  in: n           the number being raised to a power.

//  in: k           the power

//  return value    n to the k-th power.

 

int power(int n, int k)

{

  int j,r;

  j = 0;

  r = 1;

  while(j < k) {

    r = r * n;

    j = j + 1;

  }

  return r;

}

 

D. Approximation

 

Sometimes, you program by starting with an initial approximation to a value, and moving toward better and better approximations, until your approximation is either the exact answer or is close enough to the exact answer for what you need.  You can view accumulation as a special case of programming by approximations. An initial (and admittedly poor) approximation to  is .  A better approximation is .  You keep getting better and better approximations (by making the exponent closer and closer to k) until finally the exponent is exactly k, and you have the desired answer.

 


Here is another example of programming by approximation.  In this example, the exact answer will never be reached, but we stop when the answer is good enough.  The problem is to compute the square root of a number (approximately).   As an initial (and poor) approximation to the square root of n, we use 1.  If we have a current approximation a, then a better approximation is .  One thing to notice is that, if a happens to be exactly the square root of n, then the new approximation will also be exactly the square root of n.  For n = 16, the approximations are 1, 8.5, 5.19, 4.14, ...  It gets closer and closer to 4 very swiftly at that point.  Here is a square-root function based on this scheme.

This square root function has two parameters, the number to take the square root of, and the tolerance that tells how close an approximation is required.

 

//******************** squareRoot ************************

//********************************************************

// squareRoot(n,t) returns an approximation to the

// square root of n.  Precisely, it returns a number

// s such that |s*s - n| <= t.

//

// Requirement: t > 0, so that some error will be tolerated.

 

double squareRoot(double n, double t)

{

 

  // This function works by finding better and better

  // approximations to the square root of n.  The initial

  // approximation is 1.  If the current approximation is a,

  // then the next (better) approximation is the average of

  // a and n/a.  If a is close to the square root of n,

  // then this converges very rapidly to the square root of 

  // n.

 

  double a = 1;

  while(fabs(a*a - n) > t) {

    a = (a + n/a) / 2.0

  }

  return a;    

}

 

E. Searching for Evidence

 

Some function implementations work by searching for evidence that something is either true or false.  The prime number testing function is an example.  It searches for evidence that a number is not prime.  As soon as the evidence is found, the function returns.  It knows that the number is not prime.  If no evidence is found, then the function knows the number must be prime.

 


Here is another example that works by searching for evidence.  The problem is to decide whether an array is in ascending order.  The idea is to search for evidence that the array is not in ascending order.  If you see two consecutive values that are in the wrong order, then surely the entire array is not in ascending order.  But if there is not pair of adjacent values that are in the wrong order, then the array must be in ascending order.  Here is the function implementation.  Note that the loop only goes while i < n-1, since the last pair to test is the pair A[n-2] and A[n-1].

 

//****************** ascending ***************************

//********************************************************

//

// ascending(A,n) returns 1 if A[0...n-1] is in

// ascending order, and returns 0 otherwise.

 

bool ascending(int A[], int n)

{

  int i;

  for (i = 0; i < n-1; i++) {

    if(A[i] >= A[i+1]) {

      return 0;

    }

  }

  return 1;

}

 

When searching for evidence, ask what evidence is being sought (in the example, that two consecutive values are out of order) and what values should be checked (in the example, all pairs (A[0], A[1]), ..., (A[n-2], A[n-1]) ).

 

 

8. Example

 

We will employ the methodology described in these notes to the following problem, which involves strings.  If s is a string, then s[k] is the k-th character of s, but the string is indexed from 0.  That is, the first character of the string is s[0].

 

The problem is to read two strings P and T from the keyboard.  Generally, P will be shorter than T.  The program is then to report whether or not string P occurs inside string T (without rearranging the characters and without intervening spaces), and if so, where in string T string P can be found.  For example, if P = "hot" and T = "The hog shot the frog", then the program would report that P occurs in T starting at the 10-th character.  (Spaces are characters, so the h in shot is the 10-th character.)  If P does not occur in T, then the program should say so.

 

The overall program will be captured by procedure main.  The description of procedure main is just the preceding paragraph.  Procedure main does the following.


  1. Read P and T from the user.

  2. Find out where P occurs in T, if at all.

  3. Print the result from step 2.

 

There are three subproblems.  We will create a procedure for each one. Since this is somewhat involved, we introduce one approximation: Find out where "hot" occurs in "The hog shot the frog".  Then we can deal with input later.  So there are only two subproblems for the approximate problem.

 

Subproblem 1: find an occurrence of P in T.  An initial description is "Find where P occurs in T, if at all".  Detaching that from its context, we get "Given strings X and Y, find where X occurs in Y, if at all."  Now we need to make that precise.  There are clearly two in-parameters, X and Y.  There is one output, the position where X occurs in Y.  Since that position will be a positive integer, we are free to use value 0 as an indication that X does not occur in Y.  Since there is only one output, we will use a function.  Call the function locate.

 

// locate(X,Y) returns the position where X occurs in Y, or

// returns 0 if X does not occur in Y.

//

//   in: X          The pattern being searched for.

//

//   in: Y          The string being searched for the

//                  pattern X.

//

//   return-value:  The start location of a match where X

//                  occurs in Y, or 0 if X does not occur in

//                  Y.  For example, if X = "abc" and Y =

//                  "ccababcd", then locate(X,Y) would

//                  return 5.

 

int locate(char* X, char* Y)

 

Subproblem 2: print the output.  Procedure PrintTheOutput(r) will print the output of the program, where r is the result of locate(P,T).  This procedure is quite specific to this problem, so there isn't much need to detach it from context.

 

Solution to procedure main (approximation 1). Procedure main is now very simple.  It is a sequence of statements.

 

int main()

{

  char* P;

  char* T;

  int  r;


  P = "hot";

  T = "The hog shot the frog";

  r = locate(P,T);

  PrintTheOutput(r);

  return 0;

}

 

Solution to subproblem 1: searching for a match.  To determine whether string X occurs in string Y, we can try each position in Y, asking if X occurs there.  This is an example of a problem that involves searching for evidence.  If X is "abc" and Y is "ccababcd", then there are 6 positions to try:

 

     ccababcd

abc

 abc

  abc

   abc

    abc

     abc

 

In general, if X has m characters and Y has n characters, then there are n-m+1 positions to try.  (In the example, m = 3 and n = 8, so there are 8-3+1 = 6 positions to try.)  It would be easy to explain the method as a loop if we understood how to tell whether X matches Y at a particular position.  The explanation is, "For each k = 1, 2, ..., n-m+1, check whether X matches Y at position k.  Stop as soon as a match is found, or when all of the values of k are exhausted."  So a subproblem of checking whether X occurs in Y at a given position is suggested. 

 

Subproblem 3: check for a match. Detaching it from its context, the procedure or function that checks for a match should take as parameters two strings A and B, and an integer k, and tell whether string A occurs in string B starting at position k.  Since there is just one output (which will be either true or false), we will use a function.  So subproblem 3 is described as follows.

 

// matches(A,B,k) is true if string A occurs in string B

// starting at position k.  Note that the first position is

// position 1, not position 0.

//   in: A          The pattern

//   in: B          The string to find A in

//   in: k          The position in B to look for A.

//

//   return value:   1 if string A occurs in string B at

//                   position k, and 0 otherwise.  For

//                   example, if A is "abc", B is


//                   "cabcd" and k is 1, then the result

//                   is 1, since "abc" occurs in "cabcd"

//                   starting at position 2.

 

bool matches(char* A, char* B, int k)

 

Now the solution to subproblem 1 (function locate) is a simple search loop.  Variable k holds the current position being tried.  k starts at 1 and counts until k > n-m+1, or until a match is found, whichever comes first.  A match is found at position k if matches(X,Y,k) is true.  We use library function strlen to tell the length of a string.  After coding in C++, function locate is as follows.

 

int locate(char* X, char* Y)

{

  int m = strlen(X);

  int n = strlen(Y);

  int k;

  for(k = 1; k <= n-m+1; k++) {

    if(matches(X,Y,k)) return k;

  }

  return 0;

}

 

Solution to subproblem 3: checking for a match. Suppose that string A has length n.  To determine whether string A occurs at position k in string B, it suffices to test that A[0] = B[k-1], A[1] = B[k], ..., A[n-1] = B[k+n-2].  (Think of checking for a match at position 1.  You must check that A[0] = B[0], A[1] = B[1], ...)  You can think of this as like a search problem;  you are searching for the first character where A and B disagree.  That is, you are searching for evidence that A and B do not match at position k, by looking for characters that do not match.  The loop needs to keep track of  the current position in the A string (variable a_ctr) and the current position in the B string (variable b_ctr).  Initially, a_ctr = 0 and b_ctr = k-1, so that the loop will start by comparing A[0] with B[k].  Each time around the loop, a_ctr and b_ctr will each be bumped up by 1, so that, for example, the second time around the loop A[1] will be compared with B[k].

 

There should be two ways out of the loop.  Recall that we are using n for the length of string A.  If a_ctr >= n, then the entire string A has been found to match, and the loop should be stopped.  If A[a_ctr] is different from B[b_ctr], then there is a mismatch, so A does not occur inside B at position k, and the loop should be stopped.  So the loop should keep going if a_ctr < n and A[a_ctr] = B[b_ctr].  After coding in C++,  function matches is as follows.

 

bool matches(char* A, char* B, int k)


{

  int n     = strlen(A);

  int a_ctr = 0;

  int b_ctr = k-1;

 

  while (a_ctr < n && A[a_ctr] == B[b_ctr]) {

    a_ctr++;

    b_ctr++;

  }

 

  return (a_ctr >= n);

}

 

Solution to subproblem 2: printing the output.  The description of procedure PrintTheOutput is above

 

void PrintTheOutput(int result)

{

  if (r == 0) cout << "There is no match";

  else cout << "There is a match at position " << r;

  cout << endl;

}

 

Putting all of the pieces together yields the following program.  The procedures and functions have been ordered so that each one is defined before it is used.

 

 

#include <iostream.h>

#include <string.h>

 

//************************ Matches ********************

//*****************************************************

// matches(A,B,k) is true if string A occurs in string B

// starting at position k.  Note that the first position is

// position 1, not position 0.

//   in: A          The pattern

//   in: B          The string to find A in

//   in: k          The position in B to look for A.

//

//   return value:   1 if string A occurs in string B at

//                   position k, and 0 otherwise.  For

//                   example, if A is "abc", B is

//                   "cabcd" and k is 1, then the result

//                   is 1, since "abc" occurs in "cabcd"

//                   starting at position 2.

//*****************************************************


 

bool matches(char* A, char* B, int k)

{

  int n     = strlen(A);

  int a_ctr = 0;

  int b_ctr = k-1;

  while (a_ctr < n && A[a_ctr] == B[b_ctr]) {

    a_ctr++;

    b_ctr++;

  }

  return (a_ctr >= n);

}

 

//*********************** Locate ************************

//*******************************************************

// locate(X,Y) returns the position where X occurs in Y, or

// returns 0 if X does not occur in Y.

//

//   in: X          The pattern being searched for.

//

//   in: Y          The string being searched for the

//                  pattern X.

//

//   return-value:  The start location of a match where X

//                  occurs in Y, or 0 if X does not occur in

//                  Y.  For example, if X = "abc" and Y =

//                  "ccababcd", then locate(X,Y) would

//                  return 5.

//*******************************************************

 

int locate(char* X, char *Y)

{

  int m = strlen(X);

  int n = strlen(Y);

  int k;

  for(k = 1; k <= n-m+1; k++) {

    if(matches(X,Y,k)) return k;

  }

  return 0;

}

 

//******************* PrintTheOutput **********************

//*********************************************************

// PrintTheOutput(r) prints the output, where r is the

// result of locate(P,T), P is the pattern being searched

// for and T is the text being searched.

//*********************************************************


 

void PrintTheOutput(int r)

{

  if (r == 0) cout << "There is no match";

  else cout << "There is a match at position " << r;

  cout << endl;

}

 

//********************** Main ****************************

//********************************************************

 

int main()

{

  char* P;

  char* T;

  int r;

  P = "hot";

  T = "The hog shot the frog";

  r = locate(P,T);

  PrintTheOutput(r);

  return 0;

}

 

Solution to the full problem.  After testing the first approximation, the next step is to add reading the input from the keyboard.

 

Subproblem 4: get the data.  An initial description is "read P and T".  This can be detached from its context by doing two reads, each with an in-parameter that is the prompt, and an out-parameter that is the string read.  Making this more precise, we get

 

// ReadStringWithPrompt(prompt,str) prints prompt and reads

// a string from the keyboard. 

// The string that was read is put in variable str.

//   in: prompt          The prompt

//   out: str       The string that was read

//

// Note: str should be an array where the result will be put.

 

void ReadStringWithPrompt(char* prompt, char*  str )

 

Solution to subproblem 4. Procedure ReadStringWithPrompt just prints and reads.  It is as follows.  The description is above. 

 

void readStringWithPrompt(char* prompt, char* str)

{

  cout << prompt;


  cin >> str;

}

 

Now the main program becomes as follows.  There is a modification to create the arrays P and T to hold the input strings.

 

 

int main()

{

  char P[100];

  char T[100];

  int r;

  ReadStringWithPrompt("What is the pattern string? ", P);

  ReadStringWithPrompt("What is the string to search? ", T);

  r = locate(P,T);

  PrintTheOutput(r)

  return 0;

}

 

Modification of the program above to incorporate procedure ReadStringWithPrompt and the modified procedure main is left to the reader.  A comment should be added at the top of the program indicating what the program (i.e. main) does.