Rocket Fuel

June 17, 2020

Each winter on those long and chilly evenings we test our wits on another edition of the Advent of Code. Competitive programming is not my cup of tea, but I find it quite satisfying to test my skills each year. However, the exhilarating effect of a flawless couple of days fades, as certainly, life will mess up our plans and the calendar unexpectedly fills up. There are also those days where the solution takes longer to appear than you had hoped for – when you suddenly realize you never got right that tiny detail in the problem description.

You miss a day or two and begin to backlog exercises. You promise yourself that on the weekend you will try to keep up. However, the odds are not in your favour, as each day we have a probability of around 15% of giving up the chase. Every year we expect it to become our victory this time around, but that success is unlikely to come.

The longest I have lasted was until day 20 back in 2017, after realising it was quite silly of me to spend Christmas Eve trying to catch up and further commit, instead of spending time with my loved ones.

Last year I only got to do a handful of problems. I skipped some problems and only did those that seem more fun. The last I did was Day 14 – Space Stoichiometry. I found it quite interesting and it entered directly to the top of my favorite Advent of Code puzzles.

the mandatory mine-cart

the mandatory mine-cart

We are out of fuel

As we travel throughout the Solar System our spaceship begins to run low on FUEL. We stop at the rings of Saturn and are tasked to convert ORE into FUEL using a nanofactory. As input we are given a list of reactions that the factory can do. For example:

5 ORE => 7 A
3 A => 10 B
1 A, 3 B => 1 C
5 C, 2 B => 1 FUEL

In this problem:

  • A reaction has a list of the required ingredients and which product it produces;
  • Each reaction creates only one type of product;
  • There is only one way of producing a given product;
  • Reactions cannot be partially run;
  • The output of a reaction can be used as the input of another reaction;
  • ORE is the only raw-material available to us at the beginning;
  • All chemicals, except ORE, must be produced using a reaction;
  • FUEL is the end-product the we need to create.

The key to the problem is to establish the relationship between ORE and FUEL.

In Part I, we need to calculate the quantity of ORE required to produce a certain quantity of FUEL. $$ \mathtt{ORE} = f(\mathtt{FUEL}) $$ In Part II, we need to calculate its inverse and find how many FUEL can we generate with a given quantity of ORE. $$\mathtt{FUEL} = f^-1(\mathtt{ORE}). $$

PART I - Making rocket fuel

Stockpiling chemicals

We can, starting with the 1 FUEL, replace each chemical by its ingredients until we turn all ingredients into their ORE equivalents – like seeing a culinary video on reverse.

To do this, we need to keep a list of needs which we seed with 1 FUEL. For each iteration of our algorithm, we process one chemical in the needs list. The chemical is replaced by its ingredients. We must calculate how many times we need to run a reaction to create enough of the required chemical. We also need to keep a count of the excess chemicals produced – a stock.

If the ingredient being processed is ORE we remove it from our list and add it to the total ORE required.

the highlighted chemicals in the needs column are the ones processed on each iteration

the highlighted chemicals in the needs column are the ones processed on each iteration

In this implementation, we choose to process the last item of our needs list at each iteration. Running this example on our input would tell us that we need 10 ORE to generate 1 FUEL. It took 6 iterations deplete our needs list.

def ore_required_stock(reactions, qty):
    ore_required = 0
    needs = defaultdict(int, {'FUEL': qty})
    stock = defaultdict(int)

    def get_from_stock(qty, chemical):
        in_stock = stock[chemical]
        qty -= in_stock
        stock[chemical] = abs(qty) if qty < 0 else 0
        return in_stock - stock[chemical]
    iterations = 0
    while needs:
        iterations += 1
        chemical, qty_required = needs.popitem()
        from_store = get_from_stock(qty_required, chemical)
        qty_required -= from_store
        qty_produced, ingredients = reactions[chemical]
        n = math.ceil(qty_required/qty_produced)
        stock[chemical] = qty_produced*n - qty_required
        for qty_ingredient, ingredient in ingredients:
            if ingredient == 'ORE':
                ore_required += qty_ingredient*n
                needs[ingredient] += qty_ingredient*n
    return (ore_required, iterations)

Ordering chemicals

We can improve our approach by choosing which chemical we process at each iteration. Instead of removing the last item of our needs list, we should try to extract an item that minimizes the number of iterations.

But how could we decide on criteria for this?

In our previous implementation, we needed to process B twice. However, if instead we processed C we would generate more B. Then, we can convert all the B chemicals in a single iteration. This example encourages us to process first the chemicals that are not needed later as ingredients.

As our reactions list is a directed acyclic graph (DAG), we can do this is by finding its topological ordering. This will return the chemical process order that guarantees we process a product before its sub-products. In our example, this would guarantee that we process C before its sub-product B. Depending on the reaction list the topological order can be unique or not, but this as no practical impact on our solution.

remember that the arrows point to the ingredients of a product

remember that the arrows point to the ingredients of a product

We can find the topological ordering using depth-first search (DFS). After finding the deepest node and while returning backwards we list the nodes we visit. The topological ordering will then be the reverse of this list.

def topological_order(reactions):
    visited_nodes = []
    order = []

    def dfs(reactions, node):
        if node == 'ORE':
        _, ingredients = reactions[node]
        for _, ingredient in ingredients:
            if ingredient not in visited_nodes:
                dfs(reactions, ingredient)

    dfs(reactions, 'FUEL')
    ordering = {element: index for index, element in enumerate(order)}
    return ordering

We now need to make some changes to our ore_required function to process the chemicals in our newly found ordering.

def ore_required_topological(reactions, ordering, qty):
    ore_required = 0
    needs = defaultdict(int, {'FUEL': qty})
    iterations = 0
    while needs:
        iterations += 1
        chemical = sorted(needs.keys(), key=lambda k: ordering[k])[0]
        qty_required = needs.pop(chemical)
        qty_produced, ingredients = reactions[chemical]
        n = math.ceil(qty_required/qty_produced)
        for qty_ingredient, ingredient in ingredients:
            if ingredient == 'ORE':
                ore_required += qty_ingredient*n
                needs[ingredient] += qty_ingredient*n
    return (ore_required, iterations)

Using the topological ordering we do not need to consider any extra chemical produced in a reaction and add them to the stock, as each chemical will only be processed once.

Thus, the number of iterations in this solution will always be the same as the number of reactions given in the input. For our example reaction list we reduced the number of iterations from 6 to 4. However, this comes at the cost of calculating the topological order before processing any chemicals.

PART 2 - We found a trillion ore nuggets

Part 2 task us to find how much FUEL can we produce with one trillion ORE.

As we know how many ORE we require to produce FUEL, but do not know the inverse, we can use successive approximation to find the solution.

We start by calculating the ORE required for 1 FUEL. If the result is less that 1 trillion we try again, but this time calculating the ORE required to make 2 FUEL. A so on until we reach our target. However, this approach could take a long time to compute.

We could speed-up the search by providing an initial estimation. As an initial estimate, we can divide a trillion by the amount of ORE required to make 1 FUEL.

$$\mathtt{estimation_{\nobreakspace under}} = \frac{\mathtt{1\nobreakspace trillion}}{f(1 \nobreakspace \mathtt{FUEL})}$$

This underestimates the solution, as extra byproducts generated by creating one FUEL could be used as ingredients when making another. We can see this by calculating the ORE/FUEL ratio when producing different amounts of FUEL.

it takes a couple of FUEL byproducts to begin to see a reduction on the ORE used

it takes a couple of FUEL byproducts to begin to see a reduction on the ORE used

We follow the same procedure as before, evaluating the function and incrementing FUEL on each iteration. Although this shortens the search, it can still be unacceptably long.

For our search to finish quickly, we can use the bisect method, which has a time complexity of \( \log_2(\mathtt{N}) \), where \(\mathtt{N}\) is the size of our search interval. We can do this because ore_required is a monotonic function.

The method works like a binary search and in each iteration, we split the search interval in two. We do this by calculating a midpoint between a low and high bounds and computing \( f( \mathtt{mid}) \). If it is higher than our target, we change our upper bound to the newly found midpoint. If it is lower, we change the lower bound to the midpoint.

step-by-step view of the bisect method

step-by-step view of the bisect method

As we are always using integers we need to increment or decrement the midpoint when assigning it to be the lower and upper bound respectively. This prevents an infinite loop.

def bisect(f, target, interval, args=()):
    iterations = 0
    low, high = interval
    while low <= high:
        iterations += 1
        mid = low + (high-low)//2
        f_mid = f(mid, *args)
        if f_mid == target:
            return (mid, iterations)
        if f_mid < target:
            low = mid + 1
            high = mid - 1
    return low-1, iterations

To use this method, we need to estimate a lower and upper bound. For the lower bound we can use the underestimation we have calculated previously.

For the upper bound we can calculate the ORE/FUEL ratio for several orders of magnitude of FUEL, starting at log10(estimation_over) and stop when we produced more than a trillion ORE.

Using this ratio we can then make another estimation. This time overestimating the solution.

$$\mathtt{estimation_{\nobreakspace over}} = \frac{\mathtt{1\nobreakspace trillion}}{\mathtt{lowest \nobreakspace ORE/FUEL \nobreakspace ratio}} $$

def estimate_fuel_produced(reactions, ore_qty):
    ordering = topological_order(reactions)
    ratio, _ = ore_required_topological(reactions, ordering, 1)
    under_estimation = ore_qty//ratio
    order = math.log10(under_estimation)
    qty = 10**order
    ore, _ = ore_required_topological(reactions, ordering, qty)
    while ore < ore_qty:
        order += 1
        qty = 10**order
        ore, _ = ore_required_topological(reactions, ordering, qty)
    ratio = math.floor(ore/qty)
    over_estimation = ore_qty//ratio
    return (under_estimation,over_estimation), order

With our input example, for 1 trillion ORE, we estimate to produce between 100000000000 and 142857142857 FUEL.

We can then use our estimates as the interval of the bisect method.

TRILLION = int(1E12)
def f(x, reactions, ordering):
    ore, _ = ore_required_topological(reactions, ordering, x)
    return ore
ordering = topological_order(reactions)
args = (reactions, ordering)
estimations, _ = estimate_fuel_produced(reactions, TRILLION)
result, i = bisect(f, TRILLION, estimations, args)

For our example it will return 138613861385 FUEL produced with 1 trillion ORE. It took 35 iterations of the bisect method to find the solution.

Another way to approximate our target value, its to use the secant method. This method is reminiscent of the Newton method but uses the slope between two values instead of the derivative.

Instead of evaluating one point and seeing if it is higher or lower than our target value like in the bisect method, the secant method evaluates the two extremes of the search interval and calculates the slope \(m\) between them – the secant.

$$m = \frac{f(x_1)-f(x_0)}{x_1-x_0}$$

Then it intersects the secant with our target.

$$x_2 = \frac{\mathtt{target} -f(x_1) + m x_1}{m}$$

If the value is below or above the target it changes the search interval accordingly.

step-by-step view of the secant method

step-by-step view of the secant method

This method converges faster to a solution than the bisect method. Also, in this problem the slope of our function is almost constant for high values of FUEL produced. This makes the secant line between our two initial guesses almost coincident with the function. Thus, the search algorithm only needs to pinpoint the value of FUEL which required over 1 trillion ORE.

def secant(f, target, interval, args=()):
    x0, x1 = interval
    iterations = 0
    while(x0 != x1):
        iterations += 1
        fx0 = f(x0, *args)
        fx1 = f(x1, *args)
        m = (fx1-fx0) / (x1-x0)
        x2 = math.floor((target - fx1 + m*x1)/m)
        x0, x1 = x1, x2
    return x0, iterations

Using the secant method took 2 iteration instead of the 35 took by the bisect method. However, each iteration of the secant method runs the evaluation function twice.


In summary we:

  • Applied two unique approaches to solve each part of the problem;
  • Answered part I by keeping a stock of the excess byproducts created on each reaction;
  • Solved part I using topological ordering;
  • Solved part II using two successive approximation algorithms: the bisect and the secant methods.

The complete code is available on Github.

Further reading

  • Scipy - Optimization and Root Finding documentation
  • WilliamFiset - Topological Sort Algorithm - video

If you liked this article you can follow me on Twitter: @alexdsmartins or subscribe to the feed.