Your most important tool for discovering algorithms is common sense. Think about how you would solve a problem by hand. Try examples. Then work out how to express your hand algorithm so that a computer can carry it out.
Some problems break naturally down into a sequence of a fixed number of steps. The steps themselves can be big ones, each involving an algorithm of its own, but the big picture is a sequence of steps.
As an example, consider the problem of writing a nonnegative integer i using at least n characters by adding spaces as required. But instead of padding with spaces on the left, we will add spaces to the right end. For example,
writePadded(25,4); writePadded(725,4); writePadded(9,4); printf(".");writes
25 725 9 .using 4 total characters for each number, but with the added spaces to the right of the number. (If i uses more than n characters, then writePadded(i, n) just writes i, without any added spaces.)
It is possible to use printf to do this job. A simple definition of writePadded is as follows.
void writePadded(int x, int n) { printf("%-*i", n, x); }but our purpose here is not so much about writing a definition of writePadded, but instead of using it to illustrate algorithm design, so we avoid using printf.
An initial stab at the steps to do writePadded(x,n) are as follows.
But at step 2, how can we know how many spaces to write? That information depends not only on n but on the number of digits in x. Step 1 can easily say how many characters it printed, so let's make it do that.
The individual steps still need to be solved, but you don't need to worry about that right away. Breaking the problem down into steps has yielded some simpler problems.
At this point in writing a definition of writePadded, you might hope to find a function in the library that carries out step 1 and another that carries out step 2. But you do not always find one. In that case, the principle of top-down design suggests that you should imagine that suitable functions are already available, and use them. Then work out how to solve them; write them yourself. Of course, in order to do this, you need to know exactly what each of the imagined functions is supposed to do. So you need a contract and heading for each one. You fill in the function bodies later.
For writePadded(x, n), here are some obvious tools, one for doing step 1 and the other for doing step 2.
writeInteger(x) writes nonnegative integer x and returns the number of characters written.
writeSpaces(n) writes n spaces.
Using those tools yields a simple definition of writePadded.
void writePadded(const int x, const int n) { int k = writeInteger(x); if(n > k) { writeSpaces(n - k); } }Notice that the body of writePadded is clearly two steps, one after the other.
If a problem involves doing a variable number of steps, a loop is an obvious tool to use. For example, how would you write n spaces? It suffices to write one space n times. That is a simple repetition.
void writeSpaces(const int n)
{
for(int i = 1; i <= n; i++)
{
putchar(' ');
}
}
We will encounter other algorithms that are
naturally expressed as loops.
Some problems break down into two or more cases. For example, to find the larger of x and y, you know that
int larger(const int x, const int y) { if(x > y) { return x; } else { return y; } }
Recursion is generally combined with solution by cases; in some cases, the function calls itself, while in other cases it does not. There are two natural cases for writeInteger.
If x < 10 then x is a single digit. Just write that digit and return 1 (since the function returns the total number of characters written).
If x ≥ 10 then more than one character is needed. It is easy to break x down into two parts by dividing it by 10, getting the quotient and the remainder. Here are a few examples.
x | x/10 | x%10 |
---|---|---|
35 | 3 | 5 |
9341 | 934 | 1 |
720 | 72 | 0 |
The inspiration needed to finish this case is simple. We have a function that writes an integer. It is called writeInteger! Use it to do each of the steps.
We use an if-statement to decide which case we are dealing with, and write the solution of each case inside the if-statement. The first case converts integer x to character x + '0'. For example, if x is 3 then x + '0' is '3'.
int writeInteger(const int x)
{
if(i < 10)
{
putchar(x + '0');
return 1;
}
else
{
int a = writeInteger(x/10);
int b = writeInteger(x%10);
return a + b;
}
}
Notice that, when x ≥ 10, both x/10 and x%10 are less than x, so infinite recursion is not possible.
The following are some basic methods for discovering algorithms.
Top-down design: When you would like to have a particular function, assume that you have it, and write it later. That helps you keep function definitions short and easy to understand. Be sure to choose sensible names for the new functions.
Sequencing: Break the algorithm into a fixed number of steps.
Cases: Break an algorithm down into a fixed number of cases and solve each case separately. The function definition will have if-statements to choose among the cases.
Loops: Use a loop for problems that involve repetition. But if the problem is difficult to solve with a loop, consider using recursion instead.
Recursion: An algorithm for function f can call f . Recursion is a verstatile tool. Recursion always involves cases, since there is at least one case (called a basis case) that does not use recursion.