A picture of some notes I took during the foobar challenges

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 ๐‘€=2 and ๐น=7, we would need 4 generations:

๐‘€ cells ๐น cells Generation
1 1 0
2 1 1
2 3 2
2 5 3
2 7 4

But for ๐‘€=2 and ๐น=4, 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):

Number of cells for each generation, from 0 to 4

The different configuration attainable in 0 to 4 generations.

Letโ€™s now make some observations:

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 (1050) we would potentially need to go up to the (1050โˆ’1)-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 (1050,1) to return "impossible" for something like (4,2).

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 (2,1) to get rid of the extra symmetric results we talked about. This is why we need to handle the (1,1) case separately on lines 2โ€“3. From that point on, we continuously generate new configurations until we hit one of two conditions:

  1. (๐‘€,๐น) or (๐น,๐‘€) is in the configurations of the current generation, which means that we found the configuration (lines 10โ€“11);
  2. One of (๐‘€+1,๐น), (๐‘€,๐น+1), (๐น+1,๐‘€), (๐น,๐‘€+1) 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 |๐‘šโˆ’๐‘“|=2, you canโ€™t know if it belongs to generation 2 or 4 (e.g., (3,1) belongs to generation 2, but (7,5) 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 (1,1), 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 (1,1).

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 (|๐‘šโˆ’๐‘“|,min(๐‘š,๐‘“)).

Thereโ€™s another thing: we donโ€™t have to check that we arrived at (1,1) precisely; when weโ€™re at any (1,๐‘ฅ)/(๐‘ฅ,1) configuration, we can just return ๐‘ฅโˆ’1 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 1, 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 5000, how are we supposed to get to 1050?

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 (2,3), 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!