Solving Leetcode Problem #399, "Evaluate Division"
Because I studied engineering and not computer science, I never took a specific class on algorithms and data structures. In an attempt to self-educate and catch up, I've been working through a number of Leetcode problems, working with Python. This has helped me get a better grasp of some of the standard algorithm and data structure problems and approaches, as well as helping me learn more about Python's data structures and their properties, and brushing up my Python style.
A nice problem, for which I devised what I think is a fairly nice solution, is 399. Evaluate Division.
The problem
The problem is to use a series of divisor equations to solve another series of equations. In other words, a set of ratios between a number of variables is given, and a different set of ratios of variables is required. For example, the given equations may be:
and the required equations to evaluate may be:
In the problem definition, unsolvable equations (including those actually solvable but using undefined variables like $\frac{x}{x}$ here) should receive the result -1.0, and values are otherwise positive to ensure the insoluble case is distinguishable. Solving the equations manually is straightforward. But it's quite an interesting problem to write a programme to solve a general set of input and output equations with the added complication that some required equations can't be solved.
Working towards a solution
After briefly considering whether a matrix-based approach might be useful, and quickly discovering how rusty my linear algebra is, I turned to a graph based approach. Each variable from the input equations is represented by a node, and the relation of one variable to another is represented by a directed and weighted edge. So the equation: can be represented by the graph:
A more complex set of equations from one of the problem test cases is: This can be represented in the graph:To solve the required equations, one simply traverses the graph, starting at the numerator node and ending at the denominator node, and taking the product of the edges over the route. For example, if a required equation is $\frac{w}{b}$, we can solve this by the substitutions:
From this procedure, it is clear that the product of edges traversed is equivalent to the substitutions required to get the numerator in terms of the denominator, and therefore the product of edges gives the result. We move from node $w$ to node $d$, with edge weight 8, then from node $d$ to node $b$ with edge weight 4, and the product gives 32.
Initially, I thought the task would be best approached by using a single direction of edge to represent each input equation. While the equation defines two relations: the difficulty of encoding both relations in the graph is that it introduces cycles into the graph.
Using only a single edge will result in a non-cyclic graph for a well determined set of input equations, and this makes traversing the graph simple.
A single direction of graph makes traversal simple, but brings a challenge. Undefined equations (which receive the answer -1.0) are those for which no path exists from numerator to denominator. But if the equation demanded in the example above were not $\frac{w}{b}$ but instead $\frac{b}{w}$, this would result in a wrong answer.
My solution to overcome this was to first try to traverse the graph from numerator to denominator. But if this failed, then try traversing from denominator to numerator, then return the inverse of the edge products. Thus while the traversal $b \rightarrow w$ will result in failure and a wrong returned value of -1.0, when we try $w \rightarrow b$, we have success, and can invert the product to get the reverse journey.
Two further cases require attention. Using the information encoded in the graph, both $\frac{a}{c}$ and $\frac{d}{x}$ are solvable, using substitution of values. In the case of $\frac{a}{c}$, the common child node $b$ gives a common term to solve, while in the case of $\frac{d}{x}$, the parent of both nodes $w$ provides a common term. I therefore added further logic to check if nodes had either a common child or a common parent, and use these to get the correct result.
However, the nature of the connected graph is that any pair of nodes in the graph will yield a valid solution, by enough steps of substitution, and using direct common parents and children is not enough. For example, the relation $\frac{z}{c}$ can be obtained by substitution through $x, w, d, b$.1 This could be resolved by recursively searching for common children and parents of the intermediate nodes at every point until an exhaustive search has been carried out between the two nodes. But at this point, it was clear that the single edge, non-cyclic graph was actually becoming a liability rather than a help.
Simplifying by adding complexity
The path from any node within a connected graph to any other could be easily found if we make the graph more complex: by adding both relations represented by an equation. While the cycles this introduces between every pair of directly connected nodes must be carefully navigated in traversing the graph (so as to avoid falling into infinite loops), the additional complexity of traversal pays for itself in being able to follow a straightforward path between any two nodes in the graph.
Adding both relations for the example we have been following results in the following graph:
With this new, more complex graph, solving any determinate equation becomes as simple as following the edges and taking their product. However, the traversal becomes slightly more complicated in that we need to avoid following infinite cycles in the graph.
I used a depth first search (DFS) approach to traversal, using a recursive function to follow to any connected nodes from each node visited. To avoid infinite loops, it becomes necessary to keep track of nodes which have been already visited, and avoid trying them at each new level of recursion. This is quite straightforward, simply maintaining a list of visited nodes and adding the current node to the list before calling the recursive function on child nodes. So here we find our paradoxical result: by making the graph more complex, we have greatly simplified the problem as a whole. And probably if I had been aware of this from the beginning, I would have solved the problem quicker, and wouldn't think of it as being quite so nice a problem as it seems having started with an overly complex strategy. There are upsides in taking the scenic route.
Code and walk-through
So now that the strategy is clear, here is the code I submitted to solve the Leetcode problem.
class Node:
def __init__(self, name="", edgeNodes=[], edgeWeights=[]):
self.name = name
self.edgeNodes = edgeNodes
self.edgeWeights = edgeWeights
class Solution:
nodeDict = {}
def traversalCost(
self, start: Optional["Node"], end: "Node", visited: List["Node"], weight: float
) -> float:
if start is None:
return -1 * weight
elif end == start:
return weight
else:
for idx, n in enumerate(start.edgeNodes):
if n in visited:
continue
res = self.traversalCost(
n, end, visited + [n], weight * start.edgeWeights[idx]
)
if res >= 0:
return res
return -1.0
def calcEquation(
self, equations: List[List[str]], values: List[float], queries: List[List[str]]
) -> List[float]:
self.nodeDict = {}
for i in range(len(equations)):
if equations[i][0] not in self.nodeDict:
self.nodeDict[equations[i][0]] = Node(equations[i][0], [], [])
if equations[i][1] not in self.nodeDict:
self.nodeDict[equations[i][1]] = Node(equations[i][1], [], [])
self.nodeDict[equations[i][0]].edgeNodes.append(
self.nodeDict[equations[i][1]]
)
self.nodeDict[equations[i][0]].edgeWeights.append(values[i])
self.nodeDict[equations[i][1]].edgeNodes.append(
self.nodeDict[equations[i][0]]
)
self.nodeDict[equations[i][1]].edgeWeights.append(1.0 / values[i])
res = [0] * len(queries)
for idx, q in enumerate(queries):
print(f"Handling query {idx}, which is {q}")
if q[0] not in self.nodeDict or q[1] not in self.nodeDict:
print(f"One of {q[0]} or {q[1]} not known, give -1.0")
res[idx] = -1.0
else:
res[idx] = self.traversalCost(
self.nodeDict[q[0]], self.nodeDict[q[1]], [], 1.0
)
return res
Lines 1–5 define the Node
class which is used to represented the graph. Edges
between nodes are defined as attributes of the parent node from which they come,
with a reference to the child node. This is useful for traversing the graph,
since everything needed to operate from a given node is contained within that
object.
In the Solution
class, a class attribute nodeDict
is make which can be accessed
by all class methods. This provides a lookup map from a node's name, which is
the variable it represents, to the object which defines that node.
The method traversalCost
performs the traversal using a DFS approach, and
returns the cost of the product of edges for traversing from a starting node to
an end node. If the returned value is negative, the end node is not reachable
from the starting node, and the value returned is unimportant. If the start node
is None
, no nodes can be reached, so a negative result is returned. If we have
reached the end node, there is no additional cost, since the end has already
been reached, and we return the current cost, that is weight
. Otherwise, we loop
through the child nodes from the current node. In order to avoid infinite loops,
if a node appears in the visited
list, it is skipped. Otherwise, we perform the
recursion with the child node. traversalCost
is called again, now using the
child node as the starting node, keeping the same target node, end
. The parent
node is added to the list of visited nodes passed to the next level of
recursion, and the weight of the edge from parent to child is multiplied by the
weight from the original start to the present node. If any child node returns
with a positive value, it means the end node was reached, with a cost from the
current node of res
, so return this value to the calling function. If none of
the children return a positive value, traversalCost
will return with value -1.0.
The main function is calcEquation
. This first initialises the empty nodeDict
to
makes sure the graph is empty when beginning a new test case. The arguments
equations
and values
together define the input equations, and these are used to
build the graph in the first for loop, lines 35–47.
Once the graph is built, it can be used to get the result for each of the
queries
, the required equations. For each query, the two variables which define
the equation are first checked to be known in the graph, and if they are not,
the result of -1.0 is used. If they are both known, traversalCost
is called from
the numerator to the denominator. If the returned value is negative, -1.0 is the
defined value as required in the problem statement, or otherwise, the product of
edges is returned. Once all queries have been evaluated, calcEquation
returns
the list of results.
The exact performance of my approach is somewhat hard to measure, since Leetcode gives a huge variance of run times and percentiles for different runs of the same code. I tend to find that even if I use the same code as one of the very fastest solutions, my best runs are a bit slower – perhaps recent Python versions have bigger imports and slower runtimes? So I was pleased with a result of this one of 31ms and beating 94.90% of all Python solutions for the problem. While I don't read too much into Leetcode's figures, and my purpose here is to learn about algorithms, data structures, and idiomatic Python – I'm not trying to score the very best times – these results seem pretty solid to me.
If you can think of a better way of approaching this problem, or a better way of implementing it in Python, let me know on Mastodon.
Footnotes
Left as an exercise for the reader.