
Google foobar, part vi: growing cells
written on March 10, 2023
This post is the fifth installment of the foobar challenge series. You can read the first one to learn more about foobar and see what sort of intro problems the challenge presents if you donโt want any real spoilers.
warn
Obviously, MAJOR spoilers ahead, for the problems themselves and possible solutions or hints. Even if youโre attempting one of these and feel completely stuck, give it another go before reading the rest. Keep reading only if you donโt mind getting spoiled!
Problem statement
This one was by far the most confusing/hard to understand for me. I think itโs because theyโre trying to incorporate the whole bunny story into it, and sometimes itโs not clear whether something is there to support the narrative or whether itโs a hard constraint on the problem itself. Anyway, Iโll try to explain it more clearly here; the original problem talks about bombs, but to me it makes more intuitive sense with cells:
Consider two types of cells: and . On each generation, the cells of one type can produce one cell of the other type each; however, only one type of cell can reproduce on a given generation. For example, if you start with 3 cells and 2 cells, either the 3 cells can produce one cell each (resulting in 3 cells and 5 cells), or the 2 cells can produce one cell each (resulting in 5 cells and 2 cells).
Given a number of cells and a number of cells (as strings), write a function that returns the number (as a string) of generations needed to arrive at that configuration starting from 1 cell and 1 cell, or the string
"impossible"if it is not possible to arrive at that configuration.
Letโs work with a few concrete examples. For and , we would need 4 generations:
| cells | cells | Generation |
|---|---|---|
But for and , itโs impossible (you can convince yourself by trying all the possible evolution options).
warn
@@@@@@@@@@
@@@@@@@@@@
FINAL WARNING: If you want to solve this yourself, STOP HERE! Anything past this point is a spoiler of some part of figuring out the solution, or of the solution itself. Youโve been warned!
@@@@@@@@@@
@@@@@@@@@@
First ideas
As always, letโs get a solid understanding of the problem first by figuring out all the possible outcomes for, say, 4 generations. To simplify the notation, letโs write the amount of cells and cells as a tuple (where obviously is the number of cells and is the number of cells):
Letโs now make some observations:
- The left and right subtrees (from the absolute root, ) are symmetric: this is good to keep in mind because it means we do not need to produce both of them to get the entire tree. I guess that a corollary of this is that, if is a valid configuration, is valid, too (for any , ).
- / is always a valid configuration, as is / (for ), and there are probably more patterns like this.
Okayโฆ so where do we go from here? We could try going through the generations and check if the input matches any of the generated configurations. Itโs not great in terms of complexity: for example, for the biggest input () we would potentially need to go up to the -th generation (see the leftmost children of the tree in the previous figure), and donโt forget that thatโs not simply as the number of generated configurations increases exponentially with each generation (even ignoring the high-level symmetry of the tree I mentioned before).
Plus, how would we cover the case of the impossible configurations? It would be extremely inefficient to have to go all the way up to to return "impossible" for something like .
Letโs give this a try anyway, though, to get our feet wet:
solution.py
Remember, our inputs are strings, and our output needs to be a string too. This is why the solution() function is really a wrapper, that makes sure to feed (long) integers to find_generation(), that does the actual work.
The load-bearing function (find_generation()) is pretty simple: it starts at to get rid of the extra symmetric results we talked about. This is why we need to handle the case separately on lines 2โ3. From that point on, we continuously generate new configurations until we hit one of two conditions:
- or is in the configurations of the current generation, which means that we found the configuration (lines 10โ11);
- One of , , , is in the configurations of the current generation, which means that the configuration is impossible (lines 13โ19).
I admit that Iโm not sure if (2) completely covers the "impossible" condition, but hey, this is just a first try.
Letโs try it out:
$ shell
It seems to work! What about performance?
$ shell
Thatโs really not great. Weโre at barely > 0% of the maximum value of the input and the execution time is already almost 6 seconds! We need to change strategy.
I spent some time trying to figure out a formula that links a given pair to a generation number (kind of like in the second problem, but I couldnโt find something reliable; for instance, I looked at the minimum and maximum sums () and differences () for each generation, but that doesnโt help because it doesnโt generate unique โcharacteristicsโ for each generation. For example, the minimum-maximum difference for generation 4 is 1โ5, but the one for generation 2 for example is 1โ2. So if you get a pair where , you canโt know if it belongs to generation 2 or 4 (e.g., belongs to generation 2, but belongs to generation 4).
Solution
The biggest problem with this approach is something that had been bugging me since the start: itโs soโฆ awkward to solve this problem going โupwardsโ in generations, because thereโs no clear end condition. What if we did the opposite? If we started to go โdownwardsโ in generations, counting them until we reach , we have a clear end condition: either we hit it and we return the generation count, or we donโt, which means that the configuration is impossible starting from .
How do we go backwards in generations though? Well, letโs look at the reproduction rule: for a given pair , we can generate and . This means that, in order to go backwards, we only have to do the opposite, meaning we need to subtract either from or from for the bigger term, and keep the smaller term the same. Simply put, for an pair, we go backwards one generation by computing .
Thereโs another thing: we donโt have to check that we arrived at precisely; when weโre at any / configuration, we can just return as the generation. This gives us the following algorithm:
solution.py
Again, the solution() function is just a wrapper; the interesting stuff happens in rewinder().
As we said, if either of the terms of the tuple is , we can return the other one minus one to get the generation. If either of them is negative or zero, that means that weโve come here from an invalid configuration, so we need to return "impossible". Finally, we recurse by going down a generation (and adding one to the generation count).
Letโs test it out:
$ shell
Wow, interesting! This is bad news though; if weโre stuck at , how are we supposed to get to ?
Letโs examine what rewinder() gets called with:
See the problem? If one term is big and the other one is small, we spend ages subtracting the small term from the big term, no kidding this hits the maximum recursion depth!
The solution to this is actually trivial: whatโs a way to speed up a subtraction? Wellโฆ a division! Instead of spending ages subtracting a small number from a big one, we can just divide the big one by the small one and skip over a big number of generations!
solution.py
Of course, we need to not forget to handle the case where we canโt skip generations; for example, if we have , we simply need to subtract like before. This is why the if-else block is needed on lines 12โ15.
Letโs test it out:
$ shell
And thatโs it!