Source: Latternfish
Part 1: Simulate a population of lanternfish. Each fish is immortal, starts having children after 9 days, and has another child after 7 more days. Calculate the number of fish after 80 days.
I can almost guarantee that part 2 will be scaling up significantly, so the obvious answer of keeping a list of ages and simulating each will… probably get very slow. Instead, we don’t care at all about individual fish, let’s just keep their ages:
class School:
'''Represents a school of fish.'''
def __init__(self, fish):
'''Loads the given list of fish into a map of ages.'''
self.data = {
age: 0
for age in range(9)
}
for each in fish:
self.data[each] += 1
@staticmethod
def from_file(file: TextIO):
'''Load a school from a file-like object'''
fish = [
int(age)
for line in file
for age in line.split(',')
]
return School(fish)
def step(self):
'''
Advance this school 1 day.
All fish increase in age by 1 tick
Fish that are of a spawning age reset to 7 days to spawn and create a new 9 day to spawn fish.
Remember 0 based indexing.
'''
# Remember how many fish are going to spawn
breeding = self.data[0]
# Increase age of each fish by 1
for age in range(1, 9):
self.data[age - 1] = self.data[age]
# Each fish that spawns moves to age 6 (don't overwrite previously age 7) and spawns a new one of age 8
self.data[6] += breeding
self.data[8] = breeding
def __str__(self):
'''Return a comma-delimited list of fish ages.'''
return ','.join(
str(age)
for age, qty in self.data.items()
for _ in range(qty)
)
def size(self):
'''Return the number of fish in the school.'''
return sum(qty for age, qty in self.data.items())
That really handles it. Most of it is bookkeeping: loading in the fish from a file, printing them nicely, and counting the len
/ number of fish. The interesting bit is step
, where we have to be a little careful with ages:
- All fish age 1-8 increase in age 1 step (which means they are one step closer to breeding age)
- All fish age 0 revert to age 6 (which means 6 steps to breeding age) plus spawn a new one
If we count up, that means each number overwrites the one we’ve already done, which is fine.
And… that’s all we really need:
@app.command()
def main(ticks: int, file: typer.FileText):
school = School.from_file(file)
for i in range(ticks):
school.step()
print(school.size())
Run it for 80:
$ python3 we-all-glow-down-here.py 80 input.txt
395627
Fin.
Part 2: Run the simulation for 256 days.
😄
Called it.
$ python3 we-all-glow-down-here.py 256 input.txt
1767323539209
And it’s barely any slower:
$ python3 we-all-glow-down-here.py 80 input.txt
395627
# time 38854250ns / 0.04s
$ python3 we-all-glow-down-here.py 256 input.txt
1767323539209
# time 38013958ns / 0.04s
Since the time to simulate is mostly taken up with loading/saving, the actual iterations are fast. Heck:
$ time python3 we-all-glow-down-here.py 1000000 input.txt
11251...59768
1.12e37838
________________________________________________________
Executed in 3.22 secs fish external
usr time 3.13 secs 55.00 micros 3.13 secs
sys time 0.03 secs 652.00 micros 0.03 secs
1 MILLION generations in 3 seconds. I did add this to print the scientific notation:
size_string = str(school.size())
print(f'{size_string[0]}.{size_string[1:3]}e{len(size_string)}')
I tried to use f{school.size():e}
first, but…
>>> print(f{school.size():e})
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
OverflowError: int too large to convert to float
>>> 1e37838
inf
Yeah. It’s too big for floating point numbers. Fun times!
Fun (subtle) problems with types
So this was an interesting error:
Traceback (most recent call last):
File "/Users/jp/Projects/advent-of-code/2021/06/we-all-glow-down-here.py", line 85, in <module>
app()
File "/opt/homebrew/lib/python3.9/site-packages/typer/main.py", line 214, in __call__
return get_command(self)(*args, **kwargs)
File "/opt/homebrew/lib/python3.9/site-packages/click/core.py", line 829, in __call__
return self.main(*args, **kwargs)
File "/opt/homebrew/lib/python3.9/site-packages/click/core.py", line 782, in main
rv = self.invoke(ctx)
File "/opt/homebrew/lib/python3.9/site-packages/click/core.py", line 1066, in invoke
return ctx.invoke(self.callback, **ctx.params)
File "/opt/homebrew/lib/python3.9/site-packages/click/core.py", line 610, in invoke
return callback(*args, **kwargs)
File "/opt/homebrew/lib/python3.9/site-packages/typer/main.py", line 500, in wrapper
return callback(**use_params) # type: ignore
File "/Users/jp/Projects/advent-of-code/2021/06/we-all-glow-down-here.py", line 81, in main
print(len(school))
OverflowError: cannot fit 'int' into an index-sized integer
Any guesses?
It’s a bit subtle, but the problem is that len
has to return an ‘index-sized integer’, not just any int. So once the size gets too large, I couldn’t use __len__
for the size of the school any more and why I changed to a .size()
function instead.