An Introduction to Recursion, Part 2

来源:互联网 发布:软件系统故障应急预案 编辑:程序博客网 时间:2024/05/17 13:09
 

Scenario #2: Multiple Related Decisions

When our program only has to make one decision, our approach can befairly simple. We loop through each of the options for our decision,evaluate each one, and pick the best. If we have two decisions, we canhave nest one loop inside the other so that we try each possiblecombination of decisions. However, if we have a lot of decisions tomake (possibly we don't even know how many decisions we'll need tomake), this approach doesn't hold up.

For example, one very common use of recursion is to solve mazes. Ina good maze we have multiple options for which way to go. Each of thoseoptions may lead to new choices, which in turn may lead to new choicesas the path continues to branch. In the process of getting from startto finish, we may have to make a number of related decisions on whichway to turn. Instead of making all of these decisions at once, we caninstead make just one decision. For each option we try for the firstdecision, we then make a recursive call to try each possibility for allof the remaining decisions. Suppose we have a maze like this:

For this maze, we want to determine the following: is it possible toget from the 'S' to the 'E' without passing through any '*' characters.The function call we'll be handling is something like this:"isMazeSolveable(maze[ ][ ])". Our maze is represented as a 2dimensional array of characters, looking something like the grid above.Now naturally we're looking for a recursive solution, and indeed we seeour basic "multiple related decision" pattern here. To solve our mazewe'll try each possible initial decision (in this case we start at B3,and can go to B2 or B4), and then use recursion to continue exploringeach of those initial paths. As we keep recursing we'll explore furtherand further from the start. If the maze is solveable, at some pointwe'll reach the 'E' at G7. That's one of our base cases: if we areasked "can we get from G7 to the end", we'll see that we're already atthe end and return true without further recursion. Alternatively, if wecan't get to the end from either B2 or B4, we'll know that we can't getto the end from B3 (our initial starting point) and thus we'll returnfalse.

Our first challenge here is the nature of the input we're dealingwith. When we make our recursive call, we're going to want an easy wayto specify where to start exploring from - but the only parameter we'vebeen passed is the maze itself. We could try moving the 'S' characteraround in the maze in order to tell each recursive call where to start.That would work, but would be very slow because in each call we'd haveto first look through the entire maze to find where the 'S' is. Abetter idea would be to find the 'S' once, and then pass around ourstarting point in separate variables. This happens fairly often whenusing recursion: we have to use a "starter" function that willinitialize any data and get the parameters in a form that will be easyto work with. Once things are ready, the "starter" function calls therecursive function that will do the rest of the work. Our starterfunction here might look something like this:

function isMazeSolveable(maze[][])
{
declare variables x,y,startX,startY
startX=-1
startY=-1

// Look through grid to find our starting point
for each x from A to H
{
for each y from 1 to 8
{
if maze[x][y]=='S' then
{
startX=x
startY=y
}
}
}

// If we didn't find starting point, maze isn't solveable
if startX==-1 then return false

// If we did find starting point, start exploring from that point
return exploreMaze(maze[][],startX,startY)
}

We're now free to write our recursive function exploreMaze. Ourmission statement for the function will be "Starting at the position(X,Y), is it possible to reach the 'E' character in the given maze. Ifthe position (x,y) is not traversable, then return false." Here's afirst stab at the code:

function exploreMaze(maze[][],x,y)
{
// If the current position is off the grid, then
// we can't keep going on this path
if y>8 or y<1 or x<'A' or x>'H' then return false

// If the current position is a '*', then we
// can't continue down this path
if maze[x][y]=='*' then return false

// If the current position is an 'E', then
// we're at the end, so the maze is solveable.
if maze[x][y]=='E' then return true

// Otherwise, keep exploring by trying each possible
// next decision from this point. If any of the options
// allow us to solve the maze, then return true. We don't
// have to worry about going off the grid or through a wall -
// we can trust our recursive call to handle those possibilities
// correctly.
if exploreMaze(maze,x,y-1) then return true // search up
if exploreMaze(maze,x,y+1) then return true // search down
if exploreMaze(maze,x-1,y) then return true // search left
if exploreMaze(maze,x+1,y) then return true // search right

// None of the options worked, so we can't solve the maze
// using this path.
return false
}

Avoiding Cycles

If you're keen eyed, you likely noticed a flaw in our code above.Consider what happens when we're exploring from our initial position ofB3. From B3, we'll try going up first, leading us to explore B2. Fromthere, we'll try up again and go to B1. B1 won't work (there's a '*'there), so that will return false and we'll be back considering B2.Since up didn't work, we'll try down, and thus we'll consider B3. Andfrom B3, we'll consider B2 again. This will continue on until we errorout: there's an infinite cycle.

We've forgotten one of our rules of thumb: we need to make sure theproblem we're considering is somehow getting smaller or simpler witheach recursive call. In this case, testing whether we can reach the endfrom B2 is no simpler than considering whether we can reach the endfrom B3. Here we can get a clue from real-life mazes: if you feel likeyou've seen this place before, then you may be going in circles. Weneed to revise our mission statement to include "avoid exploring fromany position we've already considered". As the number of places we'veconsidered grows, the problem gets simpler and simpler because eachdecision will have less valid options.

The remaining problem is, then, "how do we keep track of placeswe've already considered?". A good solution would be to pass aroundanother 2 dimensional array of true/false values that would contain a"true" for each grid cell we've already been to. A quicker-and-dirtierway would be to change maze itself, replacing the currentposition with a '*' just before we make any recursive calls. This way,when any future path comes back to the point we're considering, it'llknow that it went in a circle and doesn't need to continue exploring.Either way, we need to make sure we mark the current point as visitedbefore we make the recursive calls, as otherwise we won't avoid theinfinite cycle.

Scenario #3: Explicit Recursive Relationships

You may have heard of the Fibonacci number sequence. This sequencelooks like this: 0, 1, 1, 2, 3, 5, 8, 13... After the first two values,each successive number is the sum of the previous two numbers. We candefine the Fibonacci sequence like this:

    Fibonacci[0] = 0
Fibonacci[1] = 1
Fibonacci[n] = Fibonacci[n-2] + Fibonacci[n-1]

This definition already looks a lot like a recursive function. 0 and1 are clearly the base cases, and the other possible values can behandled with recursion. Our function might look like this:

function fib(n)
{
if(n<1)return 0
if(n==1)return 1
return fib(n-2) + fib(n-1)
}

This kind of relationship is very common in mathematics and computerscience - and using recursion in your software is a very natural way tomodel this kind of relationship or sequence. Looking at the abovefunction, our base cases (0 and 1) are clear, and it's also clear that n gets smaller with each call (and thus we shouldn't have problems with infinite cycles this time).

Using a Memo to Avoid Repetitious Calculation

The above function returns correct answers, but in practice it isextremely slow. To see why, look at what happens if we called "fib(5)".To calculate "fib(5)", we'll need to calculate "fib(4)" and "fib(3)".Each of these two calls will make two recursive calls each - and theyin turn will spawn more calls. The whole execution tree will look likethis:

The above tree grows exponentially for higher values of nbecause of the way calls tend to split - and because of the tendency wehave to keep re-calculating the same values. In calculating "fib(5)",we ended up calculating "fib(2)" 3 times. Naturally, it would be betterto only calculate that value once - and then remember that value sothat it doesn't need to be calculated again next time it is asked for.This is the basic idea of memoization. When we calculate an answer,we'll store it in an array (named memo for this example) so wecan reuse that answer later. When the function is called, we'll firstcheck to see if we've already got the answer stored in memo, and if we do we'll return that value immediately instead of recalculating it.

To start off, we'll initialize all the values in memo to -1to mark that they have not been calculated yet. It's convenient to dothis by making a "starter" function and a recursive function like wedid before:

function fib(n)
{
declare variable i,memo[n]

for each i from 0 to n
{
memo[i]=-1
}
memo[0]=0
memo[1]=1

return calcFibonacci(n,memo)
}

function calcFibonacci(n,memo)
{
// If we've got the answer in our memo, no need to recalculate
if memo[n]!=-1 then return memo[n]

// Otherwise, calculate the answer and store it in memo
memo[n] = calcFibonacci(n-2,memo) + calcFibonacci(n-1,memo)

// We still need to return the answer we calculated
return memo[n]
}

The execution tree is now much smaller because values that have beencalculated already no longer spawn more recursive calls. The result isthat our program will run much faster for larger values of n. If our program is going to calculate a lot of Fibonacci numbers, it might be best to keep memosomewhere more persistent; that would save us even more calculations onfuture calls. Also, you might have noticed another small trick in theabove code. Instead of worrying about the base cases inside calcFibonacci,we pre-loaded values for those cases into the memo. Pre-loading basevalues - especially if there's a lot of them - can make our recursivefunctions faster by allowing us to check base cases and the memo at thesame time. The difference is especially noticeable in situations wherethe base cases are more numerous or hard to distinguish.

This basic memoization pattern can be one of our best friends insolving TopCoder algorithm problems. Often, using a memo is as simpleas looking at the input parameters, creating a memo array thatcorresponds to those input parameters, storing calculated values at theend of the function, and checking the memo as the function starts.Sometimes the input parameters won't be simple integers that map easilyto a memo array - but by using other objects (like a hash table) for the memowe can continue with the same general pattern. In general, if you finda recursive solution for a problem, but find that the solution runs tooslowly, then the solution is often memoization.

Conclusion

Recursion is a fundamental programming tool that can serve you wellboth in TopCoder competitions and "real world" programming. It's asubject that many experienced programmers still find threatening, butpractice using recursion in TopCoder situations will give you a greatstart in thinking recursively, and using recursion to solve complicatedprogramming problems.