Solving L-System Recursion¶
Here are step-by-step solutions to both the exercises presented at the end of Lindenmayer Systems.
Recursive version of a Koch curve L-system¶
Exercise: Modify the iterative code for a Koch curve L-system into recursive form. Make sure that your recursion preserves the original start and end points of the order 0 fractal - that is, if we have a Koch curve that begins at (-500, 0)
and ends at (500, 0)
, then any order of the Koch curve should do the same.
Solution: The first thing to do is to start small. We can simplify the problem by putting aside the whole drawing business and just generating the string using a recursive mechanism. If we can get the right theorem, then we should be able to draw the shape without a problem.
Let’s go back to Lindenmayer’s original system, where the rules of production state A —> AB and B —> A. Here is our original iterative code:
def lSysGenerate(s, iterations):
for i in range(iterations):
s = lSysCompute(s)
print('s =', s)
return s
def lSysCompute(s):
d = {'A': 'AB', 'B': 'A'}
return ''.join([d.get(c) or c for c in s])
axiom = 'A'
iterations = 3
print(lSysGenerate(axiom, iterations))
>>> s = AB
>>> s = ABA
>>> s = ABAAB
>>> ABAAB
We can approach the problem intuitively and conservatively, by keeping the code that we know works and rewriting the code that no longer applies. If you examine the two functions, lSysGenerate()
feeds the string s
to lSysCompute()
while lSysCompute()
‘does the work’. You can already see the similarity to mergeSort()
and sierpinski()
where we put the recursive action in one function and fed its outputs to other functions that merged and sorted lists, or drew triangles. So let’s begin by converting lSysGenerate()
from an iterative to a recursive form.
To do that, let’s divide the problem into its pre- and post-recursive sections. Pre-recursively, it seems like there’s really no work that needs to be done. We don’t need to seed our frames with anything on the way to the base case. We just want to set up the right number of frames, so that we recurse the correct number of times and get the correct final theorem.
This implies that all the work should happen post-recursively. More specifically, this means that the base case returns the seed for all further computations, just as we returned [[]]
for powerSet()
. With the base case in hand, every pass through the original code’s for
loop can be recursively restated as each successive frame’s application of the rules of production to the base case/preceding frame.
If we’ve done it right, our final returned string will have accumulated all the substitutions needed for the nth order. (I’m taking advantage of the fact that, since the recursion is linear, ‘frame’ and ‘order’ mean pretty much the same thing as ‘iterations’ did in the iterative code. Therefore I’ll substitute ‘order’ for ‘iterations’ from here on out.)
As for the base case, it’s obviously the axiom itself. In the case of the original L-system, this is A
, which, along with order
, we pass to lSysGenerate()
. So with order
as our counter, we keep decrementing until we get to the base case of order == 0
. Following our standard recursive template, we can assert:
def lSysGenerate(axiom, order):
if order == 0:
return axiom
else:
return lSysGenerate(axiom, order - 1)
order = 3
print(lSysGenerate('A', order)
>>> A
Since the function doesn’t have multiple recursive calls, we know that we’re dealing with a fairly simple structure here. Nevertheless, let’s throw in some print-tracing to keep track of frames, along with splitting the recursive call from the return
statement with r
:
frame = 0
def lSysGenerate(axiom, order):
global frame
if order == 0:
frame += 1
print('base case, frame =', frame)
print('returning', axiom)
return axiom
else:
frame += 1
print('frame =', frame)
r = lSysGenerate(axiom, order - 1)
frame -= 1
print('frame =', frame)
print('r =', r)
return r
print(lSysGenerate('A', 3)
>>> frame = 1
>>> frame = 2
>>> frame = 3
>>> base case, frame = 4
>>> returning A
>>> frame = 3
>>> r = A
>>> frame = 2
>>> r = A
>>> frame = 1
>>> r = A
>>> A
We next want the opportunity to apply the rules of production to the string axiom
that is being passed back to us from the base case. To do this, all we need is to call lSysCompute()
, and the most concise way to do it is in the return statement itself. (Calling the external function at the return
statement was exactly what we did when we called the merge()
function in our mergeSort()
program.)
So far we have:
def lSysGenerate(axiom, order):
if order == 0:
return axiom
else:
return lSysCompute(lSysGenerate(axiom, order - 1))
def lSysCompute(s):
d = {'A': 'AB', 'B': 'A'}
return ''.join([d.get(c) or c for c in s])
axiom = 'A'
order = 3
print(lSysGenerate(axiom, order))
>>> ABAAB
Ok, then. What about drawing? The fact is that we cannot draw the shape until we have the final string in hand, and this doesn’t happen until the recursive has completed. Since the iterative code doesn’t draw anything until the final theorem is generated, there is no change in the reationship between the draw()
and lSysGenerate()
functions.
Implementing absolute distance¶
But we are still missing a crucial part of the solution, which is the ability to draw at a scale that preserves the absolute distance described by the 0th order. With koch()
and sierpinski()
, we linked the variable tracking the fractal’s order with the length of the line that would be drawn. The higher the order, the shorter the line (or the smaller the shape).
In the current case, we can’t do anything at the base case except retrieve the axiom, and we can’t draw the complete figure until we’ve exited the recursion. So it looks like we’re asking two separate things of our code: figure out the smallest line length we need, and compute the final iteration of the L-system string. We already know (and pretty much don’t have a choice) about how the latter works. The trick is to figure out where to compute the former, and how to extract it from the recursive mechanism.
If we want two values from our recursion, it makes sense to include more than just the string in the recursive function’s return statement. This means that we will also have two variables in the base case’s return statement. Since we’ll have to return two values through the recursive cases, we’ll have to be careful about what we’re subjecting to computation. Recall from our expansion of Pascal’s triangle, we went from wanting to pass a single list (ie, the nth layer of the triangle) to a list of lists (ie, all layers of the triangle up to and including the nth layer). The trick is that, while we wanted the entire list of lists, we only wanted to recursively operate on the last returned sublist. We’ll be doing something similar here.
So far, we’ve got the string-generation part down, and there’s no need to mess with that. To find the smallest line length length
, we know that for every order, length
will be recomputed successively as length /= 3
. So it makes sense to conduct the division pre-recursively. Once we get down to the base case, we’ll have the correct minimum value for length
. Now all we need to do is pass that value of length
- unchanged - back through the recursive process and hand it to the frame that initially called the recursive function.
Here’s the final code for lSysGenerate()
, which I’ll walk through below:
def lSysGenerate(axiom, order, length):
if order == 0:
return [axiom, length]
else:
length /= 3
r = lSysGenerate(axiom, order - 1, length)
return [lSysCompute(r[0]), r[1]]
We first need to include length
as an argument when defining (and calling) lSysGenerate()
. Assuming that order > 0
, we skip the if
block and enter the else
block. There, we modify the pre-recursive portion of the else block to compute length /= 3
. This gets us to the point where we recursively call the function.
Now, the point of the recursive call is to get us to the base case, with the additional requirement that we bring the successively subdivided variable length
along with us. Since our function currently has the parameters lSysGenerate(axiom, order, length)
, we decrement order - 1
and the latest value of length
travels with it.
For the base case order == 0
, we don’t need to recompute length
, but we do need to capture it as part of our return statement. So we’ll recast our return statement as a list, [axiom, length]
. In the case of the Koch curve, if we wanted order 2 and had an initial length of 1000, what should be returned is ['A', 111]
.
But returned as what? Here I’ve been a bit more explicit, as I want to emphasize the fact that we’re passing a list back to the calling frame. As I’ve done throughout this guide, I use the variable name r
as a simple placeholder, which gives us a more intuitive view of the return statement. Since r
is a list that consists of the string and the minimum length, we want to apply the rules of production (by calling lSysCompute()
) to the first index item but leave the second one untouched.
In the interest of compact code, you could also write:
return [lSysCompute(lSysGenerate(axiom, order - 1, length)[0]), lSysGenerate(axiom, order - 1, length)[1]]
But this is difficult to read, and also implies an additional and unnecessary computation. Be nice to other people, and make your code easy to read.
The last modification that we need to make is in the arguments of the draw()
function, since we are now accessing two index items from a list - r[0] == theorem
and r[1] == length
. Here is the complete code:
import string
import turtle
def lSysGenerate(axiom, order, length):
if order == 0:
return [axiom, length]
else:
length //= 3
r = lSysGenerate(axiom, order - 1, length)
return [lSysCompute(r[0]), r[1]]
def lSysCompute(theorem):
d = {'A': 'A-A++A-A'}
return ''.join([d.get(c) or c for c in lString])
def draw(t, theorem, length, angle):
for c in theorem:
if c in string.ascii_letters:
t.fd(length)
elif c == '-':
t.left(angle)
elif c == '+':
t.right(angle)
def main():
t = turtle.Turtle()
wn = turtle.Screen()
wn.bgcolor('black')
t.color('orange')
t.pensize(1)
t.penup()
t.setpos(-500, 0)
t.pendown()
t.speed(0)
axiom = 'A'
length = 1000
angle = 60
order = 4
r = lSysGenerate(axiom, order, length)
draw(t, r[0], r[1], angle)
wn.exitonclick()
main()
You can quickly test this by writing a little for
loop outside of main()
that overlaps each order of the curve on top of the last, using a different pen color:
def main(order):
t = turtle.Turtle()
wn = turtle.Screen()
wn.bgcolor('black')
colormap = ['red', 'orange', 'yellow', 'green', 'blue', 'violet', 'white']
t.color(colormap[i])
t.pensize(3)
t.penup()
t.setpos(-1250, -600)
t.pendown()
t.speed(0)
axiom = 'A'
length = 1000
angle = 60
r = lSysGenerate(axiom, order, length)
draw(t, r[0], r[1], angle)
order = 7
for i in range(order):
main(i)
You’ll see that the code applies to any combination of alphabet, axiom and rules of production, although with varying and sometimes surprising results.
Recursively finding a target string¶
Exercise: For a given L-system, find if a string target
exists as part of the system after a certain number of iterations. Write a recursive solution that returns the order where target
appears, as well as its index position in the string as it exists in that order.
For example, say that we want to find at what point target = 'BAABAABA'
first appears in Lindenmayer’s original system. After running the function the output should print target BAABAABA is at index position 6 at order 7
. If it doesn’t, output should be target BAABAABA not found
.
Solution: Given the generative nature of L-systems, keep in mind that target
may show up in one order but, thanks to the next application of the rules of production, be substituted out at the next! By the same token, you have to write your solution so that what’s returned is the first time target
is found, not the most recent.
I mention this because recursion tends to favor retrieval of values at two points: at the base case, and at the end of the entire recursive cascade. It’s more difficult to capture values during recursion. One way we did this was with mergeSort()
, where we called an outside function for input that was then passed into the recursive cascade. We used this same technique for the solution to the first exercise above, where we called lSysCompute()
at every return statement of lSysGenerate()
.
Here is a somewhat different approach, where we don’t have to invoke another function. Instead, we’ll store the values as additional parameters of the recursive function itself. Let’s start with the solution we came up with for the previous exercise. Since we’re only interested in finding a string in the theorem, we can use Lindenmayer’s original system and dispense with a drawing component.
def lSysGenerate (axiom, order):
if order == 0:
return axiom
else:
return lSysCompute(lSysGenerate(axiom, order - 1))
def lSysCompute(lString):
d = {'A': 'AB', 'B': 'A'}
return ''.join([d.get(c) or c for c in lString])
def main():
axiom = 'A'
order = 8
print(lSysGenerate(axiom, order))
main()
>>> ABAABABAABAABABAABABAABAABABAABAABABAABABAABAABABAABABA
Great. We already know that we have to pass another argument to lSysGenerate()
- the string that we’re looking for. Let’s call it target
.
def lSysGenerate (axiom, order, target):
if order == 0:
return axiom
else:
return lSysCompute(lSysGenerate(axiom, order - 1, target))
def lSysCompute(lString):
d = {'A': 'AB', 'B': 'A'}
return ''.join([d.get(c) or c for c in lString])
def main():
axiom = 'A'
order = 8
target = 'BAABAABA'
print(lSysGenerate(axiom, order, target))
We also have to unpack the else
block in lSysGenerate()
so that we can test for the presence of target
. If it’s found, we want to record that, so we’ll need a variable hit
that we’ll initially set to None
. This variable is also added as a parameter to the function, so that it’s carried along with everything else.
def lSysGenerate (axiom, order, target):
hit = None
if order == 0:
return [axiom, hit]
else:
r = lSysGenerate(axiom, order - 1, target)
if target in r[0]:
r[1] = r[0].find(target)
return [lSysCompute(r[0]), r[1]]
At the base case, we return a list, initially ['A', None]
, so we know that r[0]
is the string, and, if we find target in r[0]
is True
, then the index position gets recorded in the second list item, r[1]
. But we’re still missing the exact order (or frame, or iteration) at which point this happens. So we’ll add another item to our base case’s returned list, order
.
def lSysGenerate (axiom, order, target):
hit = None
if order == 0:
return [axiom, hit, order]
else:
r = lSysGenerate(axiom, order - 1, target)
if target in r[0]:
r[1] = r[0].find(target)
r[2] = order
print(r)
return [lSysCompute(r[0]), r[1], r[2]]
I snuck in a print(r)
statement to see what’s going on here:
>>> ['A', None, 0]
>>> ['AB', None, 0]
>>> ['ABA', None, 0]
>>> ['ABAAB', None, 0]
>>> ['ABAABABA', None, 0]
>>> ['ABAABABAABAAB', None, 0]
>>> ['ABAABABAABAABABAABABA', 6, 7]
>>> ['ABAABABAABAABABAABABAABAABABAABAAB', 6, 8]
>>> ['ABAABABAABAABABAABABAABAABABAABAABABAABABAABAABABAABABA', 6, 8]
Hmmm, so we’re getting the correct index position for target
but the value for order
isn’t sticking. This is because every frame re-checks to see whether target
shows up within the namespace of r[0]
for that frame. We need to be able to set things up so that, once target
is found, we can stop looking, and preserve both the index position and order
as they were in that frame. There is no other way, since, unlike a loop, we can’t just break
out of the recursion.
To do this, we set up a variable flag
. Initially set to False
, once target
is found, we re-set flag
to True
and integrate it as a condition for the if
block. Once flag == True
then the loop is never triggered again, even if target in r[0]
continues to be True
for succeeding frames. Here’s our code so far:
def lSysGenerate (axiom, order, target, flag):
hit = None
if order == 0:
return [axiom, hit, order, flag]
else:
r = lSysGenerate(axiom, order - 1, target, flag)
if target in r[0] and r[3] == False:
r[1] = r[0].find(target)
r[2] = order
r[3] = True
print(r)
return [lSysCompute(r[0]), r[1], r[2], r[3]]
This looks pretty good! We are, however, missing one last piece. It’s always important to consider edge cases - what if target
is part of the axiom itself? As we have written the code so far, we will only return True
for order == 1
even if target
is present in the 0th order. So we have to add a check at the base case. Here is the final, complete code:
def lSysGenerate (axiom, order, target, flag):
hit = None
if order == 0:
if target in axiom:
hit = axiom.find(target)
flag = True
return [axiom, hit, order, flag]
else:
r = lSysGenerate(axiom, order - 1, target, flag)
if target in r[0] and r[3] == False:
r[1] = r[0].find(target)
r[2] = order
r[3] = True
return [lSysCompute(r[0]), r[1], r[2], r[3]]
def lSysCompute(lString):
d = {'A': 'AB', 'B': 'A'}
return ''.join([d.get(c) or c for c in lString])
def main():
axiom = 'A'
order = 8
target = 'BAABAABA'
flag = False
r = lSysGenerate(axiom, order, target, flag)
if r[1] != None:
print(``target``, target, 'is at index position', r[1], 'at order', r[2])
else:
print(``target``, target, 'not found')
main()
>>> target BAABAABA is at index position 6 at order 7
This code may not be as elegant or compact as many of the high-level recursive examples. On the other hand, it shows how you can retrieve values while the recursive cascade is still unfolding. Also, by gradually building up the code, I hope I made it a little less intimidating than if I’d simply introduced the final solution straight away.