Doomsday Fuel
Created: May 01, 2024
Last modified: Oct 11, 2026
TL, DR.
Doomsday Fuel
=============
Making fuel for the LAMBCHOP's reactor core is a tricky process because of the exotic matter involved. It starts as raw ore, then during processing, begins randomly changing between forms, eventually reaching a stable form. There may be multiple stable forms that a sample could ultimately reach, not all of which are useful as fuel.
Commander Lambda has tasked you to help the scientists increase fuel creation efficiency by predicting the end state of a given ore sample. You have carefully studied the different structures that the ore can take and which transitions it undergoes. It appears that, while random, the probability of each structure transforming is fixed. That is, each time the ore is in 1 state, it has the same probabilities of entering the next state (which might be the same state). You have recorded the observed transitions in a matrix. The others in the lab have hypothesized more exotic forms that the ore can become, but you haven't seen all of them.
Write a function solution(m) that takes an array of array of nonnegative ints representing how many times that state has gone to the next state and return an array of ints for each terminal state giving the exact probabilities of each terminal state, represented as the numerator for each state, then the denominator for all of them at the end and in simplest form. The matrix is at most 10 by 10. It is guaranteed that no matter which state the ore is in, there is a path from that state to a terminal state. That is, the processing will always eventually end in a stable state. The ore starts in state 0. The denominator will fit within a signed 32-bit integer during the calculation, as long as the fraction is simplified regularly.
For example, consider the matrix m:
[
[0,1,0,0,0,1], # s0, the initial state, goes to s1 and s5 with equal probability
[4,0,0,3,2,0], # s1 can become s0, s3, or s4, but with different probabilities
[0,0,0,0,0,0], # s2 is terminal, and unreachable (never observed in practice)
[0,0,0,0,0,0], # s3 is terminal
[0,0,0,0,0,0], # s4 is terminal
[0,0,0,0,0,0], # s5 is terminal
]
So, we can consider different paths to terminal states, such as:
s0 -> s1 -> s3
s0 -> s1 -> s0 -> s1 -> s0 -> s1 -> s4
s0 -> s1 -> s0 -> s5
Tracing the probabilities of each, we find that
s2 has probability 0
s3 has probability 3/14
s4 has probability 1/7
s5 has probability 9/14
So, putting that together, and making a common denominator, gives an answer in the form of
[s2.numerator, s3.numerator, s4.numerator, s5.numerator, denominator] which is
[0, 3, 2, 9, 14].
Languages
=========
To provide a Java solution, edit Solution.java
To provide a Python solution, edit solution.py
Test cases
==========
Your code should pass the following test cases.
Note that it may also be run against hidden test cases not shown here.
-- Java cases --
Input:
Solution.solution({{0, 2, 1, 0, 0}, {0, 0, 0, 3, 4}, {0, 0, 0, 0, 0}, {0, 0, 0, 0,0}, {0, 0, 0, 0, 0}})
Output:
[7, 6, 8, 21]
Input:
Solution.solution({{0, 1, 0, 0, 0, 1}, {4, 0, 0, 3, 2, 0}, {0, 0, 0, 0, 0, 0}, {0, 0, 0, 0, 0, 0}, {0, 0, 0, 0, 0, 0}, {0, 0, 0, 0, 0, 0}})
Output:
[0, 3, 2, 9, 14]
-- Python cases --
Input:
solution.solution([[0, 2, 1, 0, 0], [0, 0, 0, 3, 4], [0, 0, 0, 0, 0], [0, 0, 0, 0,0], [0, 0, 0, 0, 0]])
Output:
[7, 6, 8, 21]
Input:
solution.solution([[0, 1, 0, 0, 0, 1], [4, 0, 0, 3, 2, 0], [0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0]])
Output:
[0, 3, 2, 9, 14]
Welcome to level 3. The LAMBCHOP's fuel comes from a really weird ore that, while it's being processed, keeps switching forms
at random until it lands on a stable one, and there it stays. We get a matrix where m[i][j] counts how many times
state i was seen turning into state j. Everything starts at s0, and we have to say how likely
it is to end up in each stable state, as exact fractions over a common denominator.
If you've ever heard of Markov chains, this is the textbook problem: states, transition probabilities that never change, and a few states you can never leave. If you haven't, no worries, we'll take it one step at a time.
# Reading the matrix
The rows hold counts, not probabilities, but converting them is easy: divide each number by its row's total.
In the statement's example, s1 is [4, 0, 0, 3, 2, 0], which adds up to 9, so it moves to
s0 with probability 4/9, to s3 with 3/9, and to s4 with 2/9.
What about the stable states? They're the rows full of zeros: nobody ever saw them turn into anything else. In Markov lingo they're called absorbing states, and the rest are transient.
# The trouble is the loops
If the ore only ever moved forward, we could just multiply probabilities along each path. But in the example,
s0 can go to s1 and s1 can go back to s0, as many times as it likes.
There are infinitely many paths.
Let's look at s5. From s0 you get there directly with probability 1/2. But you could also take one full lap
s0 → s1 → s0 (probability $\tfrac{1}{2} \cdot \tfrac{4}{9} = \tfrac{2}{9}$)
and then head to s5, or take two laps, or three...
A geometric series! Infinitely many paths, yet they add up to an exact number. With a single loop you can work it out by hand; with several states tangled together, you need the matrix version.
# Same idea, with matrices
Sort the states so the transient ones are separated from the absorbing ones, and keep two pieces of the probability matrix:
Q: the probabilities of going from one transient state to another.
R: the probabilities of going from a transient state to an absorbing one.
$Q^k$ tells you how likely you are to still be wandering around after $k$ steps. Add up every possible number of steps and you get the same geometric series as before, just with matrices:
$N$ is called the fundamental matrix: $N_{ij}$ is how many times, on average, you pass through state $j$ when starting from $i$. And since every visit to a transient state is a chance to jump into an absorbing one, multiplying by $R$ gives exactly what we're after:
$B_{ij}$ is the probability of ending up in absorbing state $j$
when starting from $i$. We always start at s0, so the answer is the first row of
$B$.
# Drop some ore samples
Here are both examples from the statement as graphs: transient states on top, stable ones at the bottom (with a thick border).
Each sample starts at s0 and hops around at random following the arrows until it gets stuck. Drop a single one to see
its path, or a thousand at once: the bars show what fraction ended up in each state, and the black tick marks the exact probability.
How many samples does it take for the bars to hit their marks?
No samples dropped yet.
# The code
My solution is the recipe above, written by hand (Foobar didn't allow numpy). I based it on this article on absorption probabilities.
get_r_qt finds the absorbing states (rows that add up to 0) and builds r and q in a single
pass. Heads up: q already comes out as $I - Q$, with 1 - p on the diagonal
and -p everywhere else, ready to be inverted.
Everything uses Fraction. With floats, dividing 1 by 3 doesn't give exactly one third, but a 0.333… cut off at some decimal, and the answer has to be exact.
On top of that, Fraction simplifies itself after every operation, which is exactly what the statement asks for
to keep the denominator from overflowing.
inverse is Gauss-Jordan elimination: put the identity next to the matrix and clear it column by column.
It never needs to swap rows because, in an absorbing chain, the pivots of $I - Q$ never hit zero.
matrix_multiplication(inverse(q), r)[0] is the first row of $B = N \cdot R$.
Finally, lcmm finds the least common multiple of the denominators so everything can be written over the same one.
# https://complex-systems-ai.com/en/markov-process/probability-of-state-absorption/
import copy
from functools import reduce
from fractions import Fraction
def inverse(mat):
if len(mat) != len(mat[0]):
return 0
aux_mat = copy.deepcopy(mat)
mat = [[Fraction(int(i == j)) for i in range(len(aux_mat))]
for j in range(len(aux_mat))]
for pivot, val in enumerate(aux_mat):
pivotVal = aux_mat[pivot][pivot]
for j, (a, b) in enumerate(zip(val, mat[pivot])):
aux_mat[pivot][j] = Fraction(a, pivotVal)
mat[pivot][j] = Fraction(b, pivotVal)
for i in range(len(aux_mat)):
if i != pivot:
aux_piv = aux_mat[i][pivot]
for j in range(len(aux_mat)):
aux_mat[i][j] = aux_mat[i][j] - aux_piv * aux_mat[pivot][j]
mat[i][j] = mat[i][j] - aux_piv * mat[pivot][j]
return mat
def matrix_multiplication(a, b):
zip_b = list(zip(*b))
return [[sum(ele_a * ele_b for ele_a, ele_b in zip(row_a, col_b))
for col_b in zip_b] for row_a in a]
def get_r_qt(mat):
"""
This function convert the input matrix to Markov cannonical form in order to get the R matrix and (I-Q) matrix
I | 0
S = ---+---
R | Q
Qt = (I - Q)
:param mat:
:return:
"""
absorb_states = {
index for index, state in enumerate(mat) if sum(state) == 0
}
q = list()
r = list()
for i, states in enumerate(mat):
if i in absorb_states:
continue
denominator = sum(states)
_q = list()
_r = list()
for j, state in enumerate(states):
current_fraction = Fraction(state, denominator)
if j in absorb_states:
_r.append(current_fraction)
continue
if i == j:
_q.append(1 - current_fraction)
else:
_q.append(-current_fraction)
q.append(_q)
r.append(_r)
return r, q
def lcmm(l):
def gcd(a, b):
while b:
a, b = b, a % b
return a
return reduce(lambda x, y: (x * y) // gcd(x, y), l)
def solution(m):
if len(m) < 2:
return [1, 1]
# s0 is already stable: it ends there with probability 1
if sum(m[0]) == 0:
terminals = sum(1 for row in m if sum(row) == 0)
return [1] + [0] * (terminals - 1) + [1]
r, q = get_r_qt(m)
sol = matrix_multiplication(inverse(q), r)[0]
lcm = lcmm([i.denominator for i in sol])
output = [lcm // i.denominator * i.numerator for i in sol] + [lcm]
return output# The case that almost slips through
What if s0 is already stable? Say, [[0, 0, 0], [1, 0, 1], [0, 0, 0]]. The obvious answer is that the ore just
stays put: [1, 0, 1]. But the matrix recipe has no idea. s0 is absorbing, so it has no row in
$Q$ or $R$, and the "first row of $B$"
ends up being s1's: it returns [1, 1, 2], the odds for a sample that started somewhere else.
That's why solution starts with a check: if s0's row adds up to zero, it answers right away.
# Wrapping up
Infinitely many paths sounds like an impossible problem, until you notice they repeat in a pattern and add up to a geometric series. Absorbing Markov chains are exactly that, in matrix form: $(I - Q)^{-1}$ sums every lap in one go. Next time you see "this could repeat forever", check whether there's a series hiding in there.
