Removing a recursion in Python, part 1
ericlippert.com
ericlippert.com
Ok, so a CPS transform starts by adding a new parameter, c, which I read "myReturn" or more likely, "returnapotamus". Then, you find the returns in the function and look at the expressions therein. An expression inside cost(...) is left alone, but the expression outside the application of cost is turned into an expression and then to a function that includes a call to returnapotamus, c:
def cost2(s, c=lambda s: s):
if s <= 1:
return c(0)
elif s % 2 == 0:
return cost2(s // 2, lambda s: c(s + 1))
else:
return cost2(s - 1, lambda s: c(min(1 + s, 5)))
The initial continuation is just the identity function. cost2 works the same as cost, but it doesn't solve our recursion problem. To do that, we can take Lipport's approach of a pseudo-function built from a stack of primitive functions: def cost3(s):
cont = [ lambda s: s ]
while s > 1:
if s % 2 == 0:
cont.append(lambda s: s + 1)
s = s // 2
else:
cont.append(lambda s: min(s + 1, 5))
s = s - 1
result = 0
while len(cont) > 0:
result = cont.pop()(result)
return result
On the other hand, it's possible to build the continuation as a function, as we go, but it gets a little complicated: def cost5(s):
cont = lambda s: s
while s > 1:
if s % 2 == 0:
cont = lambda s, c=cont: c(s + 1)
s = s // 2
else:
cont = lambda s, c=cont: c(min(s + 1, 5))
s = s - 1
return cont(0)
An explanation of what's going on here is beyond the scope of this comment.Most recursion can be implemented using a loop and a stack. If the recursive expression is self contained in the return call, then you don't need a stack.
For example:
def somefunc(x):
if x <= 0:
return 0
return somefunc(x-1)
is equal to: def somefunc(x)
while x > 0:
x = x - 1
return x
But if the recursion isn't self contained: def somefunc(x):
if x == 0:
return 0
return somefunc(x-1) + 1
then the iterative version
looks like this: def somefunc(x)
stack = []
while x > 0:
stack.append(x)
x = x - 1
while len(stack) != 0:
x = x + stack[-1]
stack = stack[:-1]
return x
During Recursion your program will use an internal stack to keep track of previous context. This is called the call stack. Whether or not you use iteration or recursion for the last example you will need a stack.The last example is actually less efficient than the recursive version because you are moving the call stack to the heap. The heap is slower than the call stack but the heap essentially has no limits so you won't encounter stack overflow.
In the first example of recursion you will actually benefit from conversion to iteration because you get rid of the stack all together in the iterative example. Some languages will be able to recognize this type of recursion and do an optimization step called "tail-recursion optimization" in which the compiler actually gets rid of the need for a the call stack. Python does not do this, however.
The story doesn't end here, however. There are examples of recursion where you cannot create a straightforward iterative version.
For example:
def somefunc(x):
if x <= 0:
return 0
return somefunc(x-1) + somefunc(x-2)
The straight forward conversion using an explicit stack looks like this: def somefunc(x)
stack = []
while x > 0:
stack.append(lambda: somefunc(x-2))
x = x - 1
while len(stack) != 0:
x + stack[:-1]()
stack = stack[:-1]
return x
You will note that the straightforward conversion doesn't get rid of recursion. The reason is because the previous context is basically another recursive call.You will note that the recursive version of this function actually does the same multiple recursive calls twice which leads to unnecessary repeated calculations. It is possible to optimize this repetition using something called memoization. However memoization requires and does not get rid of recursion.
It is not possible to imitate this "inn-efficiency" in an iterative form. The only way to do it is to do an optimization step similar to memoization before iterating. This step, however radically changes the way you think about the problem. It literally reverses the recursion from a top down approach to a bottom up approach. Here is what the iterative form looks like:
def somefunc(x):
store = [None for _ in range(x)]
store[0] = 0
for i in range(1, len(store)):
store[i] = store[i-1] + store[i-2] if i-2 >= 0 else 0
return store[-1]
This method is called "Dynamic programming using the tabular method."You will note that this version of the function still allocates storage to the heap with "store"
There is still a further optimization step you can do here as well. This optimization is basically noticing that you don't need to memorize the entire store in the algorithm. You only need the previous two values.
def somefunc(x):
store_i_minus_1 = 0
store_i_minus_2 = 0
for i in range(1, len(store)):
result = store_i_minus_1 + store_i_minus_2
store_i_minus_1, store_i_minus_2 = result, store_i_minus_1
return result
You will note that the the new example has no allocation to the heap and is the full efficient iterative version.Although my final example was applied to a recursive function that called itself twice in a single expression. The final technique I showed you can be applied to all forms of recursion to yield an iterative version WITHOUT the need to create an explicit stack in the heap. It is the ultimate optimization step in a procedural language.
One thing to note is that the final optimization can only be done in a procedural language. For a Functional programming language you will need to utilize another method.
original:
def cost(s):
if s <= 1:
return 0
if s % 2 == 0:
return 1 + cost(s // 2)
return min(1 + cost(s - 1), 5)
tabular: def cost(s):
if s <= 1:
return 0
store = [None for _ in range(s+1)]
store[0] = 0
store[1] = 0
for i in range(2, s+1):
store[i] = 1 + store[i//2] if i%2 == 0 else min(1 + store[i-1], 5)
return store[-1]
Because of this step: store[i//2], the algorithm reaches back into the store dynamically meaning that there is no fixed amount of memory that the algorithm reaches back to retrieve old results. I cannot just save the old results in a fixed size list or fixed amount of variables. So this is the furthest you can go following the tabular path.Is this true in Python? I thought the call stack was on the heap as well.
It's also worth noting that there are plenty of instances where (IMO), recursion is much more natural. For example, traversing trees recursively. I agree, though, there should definitely be more time spent teaching how to convert between the two.
Most courses, especially introductory ones, don’t have particularly good examples of when to use recursion. This doesn’t mean that there aren’t cases where recursion isn’t useful, though.
The presentation is very complicated, though.
Converting to continuation passing does result in tail calls.