Basic idea
In computer programming the most basic and common tradeoff is the one between time efficiency and space efficiency. Memoization can be good for time but bad for space and that is the case here. Your program is crashing because that memoization dictionary is holding a lot of data. Right off the bat your recurrence relation means you never need the data that is being held in the N - 3
spot so you can get rid of it. This somewhat alleviates the memory burden (but not by much).
Problems/concerns with code
- Don't memoize values you don't need (see above).
- Python's handling of mutable default argument means the
memo
dict is only created once. See this SO post for more details. This also means that the dictionary is sitting around (in memory) after the function returns... not good. Generally don't use mutable default arguments unless you have a compelling reason.
list
comprehensions can be a bit faster than explicit for loops. More importantly in this case, they are more readable.
- This last one is more a style thing. You are adding the
1
or the 2
to the tail of the items returned from the recursive call. Typically those elements are added at the head.
Solutions
Same algorithm but more memory and time efficient
def stepsdyn_new(N, memo):
try:
return memo[N]
except KeyError:
l1 = [(1,) + items for items in stepsdyn_new(N - 1, memo)]
l2 = [(2,) + items for items in stepsdyn_new(N - 2, memo)]
memo.pop(N - 2)
memo[N] = l1 + l2
return memo[N]
Note: I pass the base cases in as an argument, but you can add the original if
/else
if desired.
Returning strings
def stepsdyn_str(N, memo):
try:
return memo[N]
except KeyError:
l1 = ['1' + x for x in stepsdyn_str(N - 1, memo)]
l2 = ['2' + x for x in stepsdyn_str(N - 2, memo)]
memo.pop(N - 2)
memo[N] = l1 + l2
return memo[N]
This will return a list of string (e.g. ['111', '12', '21']) instead of a list of tuples. Because each character in a python string only uses 1 byte (instead of the 8 bytes per element in a list/tuple) this yields a lot of memory savings. The result can be converted back to a list of tuples with the following code (though this would incur additional speed/memory penalties):
[tuple(map(int, tuple(x))) for x in stepsdyn_str(N, {0: [''], 1: ['1']})]
Efficiency
Note: the steps
function is a non-memoized solution (included below for completeness).
Speed
|--------------|----------------------------|----------------------------|
| | N = 20 | N = 33 |
|--------------|----------------------------|----------------------------|
| steps | 47 ms ± 7.34 ms per loop | 41.2 s ± 1.6 s per loop |
|--------------|----------------------------|----------------------------|
| stepsdyn | 10.1 ms ± 1.23 ms per loop | 9.46 s ± 691 ms per loop |
|--------------|----------------------------|----------------------------|
| stepsdyn_new | 6.74 ms ± 1.03 ms per loop | 7.41 s ± 396 ms per loop |
|--------------|----------------------------|----------------------------|
| stepsdyn_str | 3.28 ms ± 68.8 µs per loop | 3.67 s ± 121 ms per loop |
|--------------|----------------------------|----------------------------|
Obtained using:
%timeit steps(N)
%timeit stepsdyn(N, memo={})
%timeit stepsdyn_new(N, {0: [()], 1: [(1,)]})
%timeit stepsdyn_str(N, {0: [''], 1: ['1']})
Memory
These estimates are specific to my 16GB memory MBP while evaluating the functions for N=33
:
steps
: 10.8% max memory
stepsdyn
: 22.0% max memory
stepsdyn_new
: 15.7% max memory
stepsdyn_str
: 3.6% max memory
Non-memoized solution
def steps(N):
if N == 0:
return [()]
elif N == 1:
return [(1,)]
else:
l1 = [(1,) + items for items in steps(N - 1)]
l2 = [(2,) + items for items in steps(N - 2)]
return l1 + l2