750 likes | 902 Views
Chapter 2. Recursion. Why do recursion?. Sometimes seemingly difficult problems can have simple recursive solutions. It breaks a problem into several smaller problems that are ostensibly the same problem as the original
E N D
Chapter 2 Recursion
Why do recursion? • Sometimes seemingly difficult problems can have simple recursive solutions. • It breaks a problem into several smaller problems that are ostensibly the same problem as the original • It is an important alternative to doing an iterative (loop-based) solution
Is recursion a “cure-all”? Certainly not. In fact, many recursive algorithms are inherently inefficient and therefore not practical. It is highly important to recognize when recursion is effective and when you should use iteration. Before we make that distinction, we must learn more about this recursion of which I speak.
An example from the dictionary How do we look up words in the dictionary? We start by looking at what we know about the structure of a dictionary: • In alphabetical order • The middle of the dictionary should have words that start with the letter “m”
Dictionary II: The return of the killer dictionary • We could simply do a sequential search where we look at every term starting with the letter “a” or “z”… But since we’ve taken 2315 we’re smarter and know more techniques. • We would typically begin by opening the dictionary to its middle (where we will find words with the letter “m”). We know that words beginning with “n”-”z” will be located in the right half of the dictionary and words beginning with “a”-”l” will be in the left half.
Dictionary III: The saga continues • We determine if the word for which we are searching occurs in the left or right half of the dictionary. We then take that half and subdivide it into two halves. • We repeat this process until we have narrowed down to the page where our word is located. • We can then do a sequential scan of the words on that page to find our word.
What can we learn from the dictionary? When we know that we have located the page on which our word resides, we have reached the base case (a.k.a. basis or degenerate case) for our problem; we can no longer employ our strategy of subdividing the remaining pages to get closer to our answer.
Binary search The approach that we took to solving the dictionary problem is known as a binary search and is a classic example of a more general strategy called divide-and-conquer.
Observations on our dictionary search • The function must call itself in order to be considered recursive, i.e. within the method binarySearch() must be another call to binarySearch(). • Each call to the search function passes a dictionary that is exactly one-half the size of the previous dictionary; we therefore know that the size is asymptotically approaching 1 and that we are converging on a solution.
More observations • We must continually check for our base case of only having one page in our dictionary. When this condition is true, the recursive part of our search is finished. • The speed at which the size of the dictionary diminishes determines how many iterations of the search algorithm will have to be run. In the case of a binary search, this speed is constant.
4 questions To do recursion, ask yourself the following four questions: • How can you define the problem in terms of a smaller problem of the same type? • How does each recursive call diminish the size of the overall problem? • What is the base case for the problem? • As the problem size diminishes, will you reach the base case(s)?
One caveat • Do not use recursion if the problem has a simple iterative solution! • Don’t use a hot and new technique just for the sake of using it.
Finding n! • We can find n! iteratively by doing the following: n! = n*(n-1)*(n-2)*…*1 | n>0 • We also note that 0! = 1 by the mathematical definition. • While the iterative is far and away better than the recursive approach, it is highly pedagogical for teaching recursion.
factorial(n) • To define factorial(n) recursively, we must define the factorial function in terms of a smaller function. • We start by observing the behavior of the function: n! = n*[(n-1)!], i.e. factorial(n)= n*factorial(n-1);
Recurrence relations • The definition of n! in terms of (n-1)! implies that (n-1)! can be written in terms of (n-2)! and so on. This is called a recurrence relation. • Our base case becomes 0! which, of course, equals 1.
More on the recursive definition of n! • Because n > 0 and our definition decrements this value by one after each iteration, we are guaranteed that we will eventually reach the base case of 0!. • In a more formal sense, we would need to prove that the recursive definition of n! is equivalent to the iterative definition of n! by means of mathematical induction… But we’ll skip most of those formalities in this class.
The code for computing n! int fact(int n) //Computes the factorial of a // non-negative number. //Precondition: n>0 //Postcondition: returns n! with // n unchanged. { if(n == 0) return 1; else return n*fact(n-1); }
Keeping track of recursion • Empirically, evaluating a recursive function has the same level of difficulty as evaluating an iterative function. • Realistically, the overhead of the function calls can make the bookkeeping get out of hand. • The box method is a systematic way to trace a recursive function. • It can help you understand and debug recursive functions.
The box method • Label each recursive call in the body of the recursive function. These labels help you keep track of the correct place to which you must return after the function call completes. • Represent each call to the function during the course of execution by a new box in which you note the function’s local environment. Each box will contain: • The value of each argument in the formal argument list • The function’s local variables • A placeholder for the value returned by each recursive call. • The value of the function itself. When you first create a box, you will only know the values of the input arguments.
More on the box method • Draw an arrow from the statement that initiates the recursive process to the first box. When you create a new box, draw an arrow from the box that makes the call to the newly created box. Label each arrow with the same name as the recursive call so you know where to return after the recursive call. • After you create the new box, start executing the body of the function with each reference in the local environment pointing to the corresponding value in the current box (regardless of how the current box was generated).
Still more on the box method • On exiting the function, cross off the current box and follow its arrow back to the box that called the function. Substitute the value returned by the just-terminated function call into the appropriate item in the current box.
Comments on fact(n) • If you ever violated the precondition of fact(n), the function would not behave correctly; in fact, you could generate an infinite loop by never reaching the base case. • The function should protect itself from ever having its conditions violated by always checking the arguments to the function.
Another recursive example: write a string backward • You can recursively write a string of length n backward by writing a string of length n-1 backward and then appending the last character to the string. • We can therefore make writing a string of length 1 or 0 the base case (depending on how we wish to implement the solution). • The golden question to this problem is: How can we write a string of length n backward if we can write a string of length n-1 backward?
Solving the problem • Unlike the factorial problem, there really isn’t a clearly “best” way to proceed. • We do know that the string of length n-1 must be a subset of the string of length n. • We must decide whether to strip away the first or the last character from the string of length n to create our substring of length n-1 and then we will perform a minor task to write the n-1 length string backward.
Let’s start with the last character • For our algorithm to be a valid solution to the problem, we must write the last character in the string first and then the rest of the string in a backward fashion. • Pseudo-code: writeBackward(myString) { if (myString is empty) return because base case reached else { Write the last character of myString writeBackward(myString-lastCharacter) } }
Converting our pseudo code to C++ We have to overcome a few small implementation details. We will let the string go from position 0 to length – 1. We will also assume that we’re given the length of the string.
Our C++ function void writeBackward(string s,int size) //Writes a string backward. //Precondition: size > 0, string exists. //Postcondition: s is displayed backward to the user, but s // unchanged. { if(size > 0) { //Write the last character cout << s.substr(size-1, 1); //Now write the rest of the string writeBackward(s, size-1); } else return; //Base case }
Does it work? • Each time our function is executed we strip off one character of the original string, therefore we ensure that we will indeed reach the base case. • Since we always write the last character first, the string will be written backward. • The return statements do not print anything, therefore the only thing we see is the string written backward. • Conclusion: Yes, it works!
How about doing it the other way? Can we make a recursive function call that will strip away the first character (instead of the last) and still work? Sure! It just requires us to jiggle things a bit.
Stripping away the first character • If we use our same pseudo-code algorithm but replace the phrase “last character” with “first character”, we see that what we are doing with our function is writing it in normal left-to-right fashion. • But, if we change the order of the statements in the pseudo-code we can make it work the way we want.
The new pseudo-code writeBackward(myString) { if (myString is empty) return because base case reached else { writeBackward(myString-lastCharacter) Write the first character of myString } }
What does this function do? • It writes the first character only after it writes the rest of the string backward. • In short, it goes through the entire string before it ever writes anything. • The last recursive call will then write the only character in the string, which happens to be the last in the original string • We return from this call to a previous recursion, specifically with the last two characters of the original string. Writing the first character of this string means that we write the next to last character of the original string. • …and so on until we run out of characters.
Some N.B.s • cout statements can help you trace through the logic of a recursive call and spot fallacies in your logic. • Be sure to remove those statements before you do your release module! • You can write two different recursive functions that employ two different strategies that achieve the same end.
Counting our problems • The next three problems at which we will look involve counting objects in some shape, form or fashion. • They illustrate how we can make a recursive solution to something that has more than one base case. • Parenthetically, they also show just how inefficient a recursive solution can be!
Rabbits, rabbits everywhere! • One of the most famous examples of a Fibonacci sequence is that of multiplying rabbits. • This problem assumes the following “facts”: • Rabbits never die • A rabbit reaches sexual maturity exactly two months after birth (at the beginning of its third month of life) • Rabbits are always born in male-female pairs • Rabbits always reproduce in monogamous couples and each union produces another male-female pair • The monogamous couples mate every month and they always have healthy male-female offspring
So… • If we start with a single newborn male-female pair and wanted to know how many rabbits there are at month 8, we could do so heuristically: • Month 1: Still the same original pair of rabbits • Month 2: Again the same original pair (not yet mature) • Month 3: 2 pairs • Month 4: 3 pairs (the first pair gave birth again) • Month 5: 5 pairs (all pairs in month 3 give birth) • Month 6: 8 pairs • Month 7: 13 pairs • Month 8: 21 pairs
How do we formulate a recursive solution to this problem? • We start by observing that the number of rabbits alive at any month is the sum of the number of rabbits alive in the two preceding months. • So at month n, there are rabbit(n-1) rabbits alive and rabbit(n-2) new pairs born. • We now have a recurrence relation: rabbit(n) = rabbit(n-1)+rabbit(n-2)
How about the base case? • The temptation is for us to select rabbit(1) as the base case. • But what about rabbit(2)? There is no real definition for it because we don’t really know rabbit(0). • We therefore select rabbit(1)=rabbit(2)=1, making two base cases. • Two base cases are required because there are two smaller problems to solve.
A C++ implementation of rabbit int rabbit(int n) //Computes # of rabbits according to the //Fibonacci sequence //Precondition: n > 0 //Postcondition: Returns nth Fibonacci number { if(n<=2) return 1; else return rabbit(n-1) + rabbit(n-2); }
Is this an efficient solution? No way! Think about how many recursive calls you have to make to compute the 10th Fibonacci number. Worse still, think about the number of calls for the 100th Fibonacci number! At best this function is inefficient, therefore an iterative approach would be more desirable.
An iterative solution to the problem int rabbit(int n) //Comments are the same { int temp1=1; int temp2=1; for(i=2; i<n; i++) { ithFibonacci = temp1+temp2; temp1 = temp2; temp2 = ithFibonacci; } }
Organizing a parade • The parade consists of bands and floats in a single line. • You want to organize the parade such that no two bands are adjacent to one another. • Given these two conditions, how many ways can you organize a parade of length n?
Solving the problem • We note that the parade can end with either a band or a float. Therefore, we can say that: P(n) = F(n) + B(n), where: P(n) = number of ways to organize a parade of length n. F(n) = number of parades of length n that end with a float. B(n) = number of parades of length n that end with a band.
More on solving the problem • We note that there are no strictures on float placement. We can make a valid parade of length n by inserting a float at the end of any valid parade of length n-1. • We can conclude that the number of parades that can end with a float F(n) is equal to the number of valid parades of length n-1, P(n-1).
Band on the run • Next, consider B(n). According to our rules, the only way that a parade can end with a band is if the preceding unit is a float. • Therefore the only way to end a parade with a band is to organize a parade of length n-1 that ends with a float and stick a band at the end of the line. • Hence, we realize B(n) = F(n-1). • But F(n) = P(n-1) • Therefore B(n) = F(n-1) = P(n-2)
I love a parade… • Lastly, we recall that P(n) = F(n) + B(n), so we observe that P(n) = P(n-1) + P(n-2). • The more perspicacious of you will have noticed that this is exactly the same as the recurrence relation for the rabbit problem. • We therefore conclude that there are 2 base cases • We therefore choose P(1) and P(2) to be our base cases
Caveat • However, there is no rational reason why P(1) should equal rabbit(1)! • Similarly, we shouldn’t expect that P(2) would equal rabbit(2). • A little thinking gives us the answer (ouch, that hurts!): • P(1)=2 <there can be a float or a band> • P(2) = 3 <float-float, float-band, band-float, but not band-band>
Post-mortem The parade problem teaches us two things: • Sometimes we can make a problem easier to solve if we break it up into cases • The values that you use for the base cases are extremely important • rabbit(20) = 6,765 • P(20) = 17,711 • As n grows, rabbit(n) and P(n) rapidly diverge
Mr. Spock’s dilemma The five year mission of the starship U. S. S. Enterprise is to seek out strange new worlds, to boldly split infinitives where no infinitive has been split before (not to mention the ending of the sentence with a preposition). The dilemma: The five years are almost completed, but they have just entered a new unexplored solar system with n planets. Unfortunately, they have time to only explore k of the n planets.
More on the dilemma • Because time is short, Spock does not care in what order the k planets are visited. • Let’s say that Spock is particularly interested in visiting the Planet of the Apes which just so happens to reside in their newfound solar system. • How many ways can we visit k of n planets?