Dazbo's Advent of Code solutions, written in Python
Work in progress…
SymPy is so cool! I came across when I was trying to solve some equations as part of 2023 AoC puzzles. It is computer alegebra systems (CAS): a library that allows us to perfrom algebra and mathematical computations.
You can use it for things like:
We need to define variables before we can use them in SymPy. We do this by creating symbols.
One interesting observation here is that when you evaluate a SymPy expression a Jupyter notebook, it is rendered in pretty-printed (mathematical) format. But when we print using print(), it is printed in conventional Python style.
If you want to explicitly pretty-print from a Python notebook, you can use IPython.display.
Alternatively, you can optionally run init_printing() to enable the best printer for your environment. Or run init_session() to automatically import everything in SymPy, create some common Symbols, setup plotting, and run init_printing().
import sympy
from sympy import latex
from IPython.display import display, Markdown
sympy.init_session() # set up default behavour and printing - not essential
a, b, x, y = sympy.symbols("a b x y") # define symbols (variables)
a = (x + 1)**2 # set a to be an expresssion
b = x**2 + 2*x + 1 # set b to be an equivalent expression
display(Markdown(f"$a = {latex(a)}$")) # pretty-print a
print(f"{latex('a = ')}{latex(a)}") # If we just want the raw latex
display(Markdown(f"$b = {latex(b)}$")) # pretty-print b
# Expand a...
display(Markdown("Expanding $a$..."))
expanded = sympy.expand(a)
display(Markdown(f"$a = {latex(expanded)}$"))
# Show a-b without simplifying...
a_minus_b = a-b
display(Markdown(f"$a-b = {latex(a_minus_b)}$"))
# Simplify...
display(Markdown("Simplifying..."))
display(Markdown(f"$a-b$ $= {latex(sympy.simplify(a_minus_b))}$"))

We can also test if two expressions are the same, e.g.
a.equals(b)
You can’t simply assign values to the Python variable. If you did so, they would cease to be a symbol. Instead, you need to use subs() to associate a value with a SymPy symbol.
x, y = sympy.symbols("x y")
expr = x+y
result = expr.subs({x: 2, y: 5})
print(f"{expr=}, {result=}")

import sympy
x = sympy.symbols("x")
expr = x**2
deriv = sympy.diff(expr)
print(deriv)
integral = sympy.integrate(deriv)
print(integral)

Here, I use solve() to determine the values of $x$. I’ve specified dict=True such that $x$ can be retrieved by passing its symbol as a dictionary key to the returned solutions dictionary.
expr = x**2 + 3*x - 10
solutions = sympy.solve(expr, x, dict=True)
solutions
for solution in solutions:
print(f"x={solution[x]}")

Note that the expressions still need to be written in valid Python. So we can write 3*x, but we can’t write 3x.
What if you want to ignore complex solutions and only include real solutions? In this case, you can use solveset(), and specify the domain as domain=sympy.S.Reals. (Whereas domain=S.Complexes is the default.)
expressions = []
expressions.append(x**2 - 9) # there are only real solutions
expressions.append(x**2 + 9) # there are only complex solutions
for expr in expressions:
display(expr)
solutions = sympy.solveset(expr, x)
print(f"There are {len(solutions)} solutions for x...")
display(solutions)
display(expressions[-1])
solutions = sympy.solveset(expressions[-1], x, domain=sympy.S.Reals)
print(f"There are {len(solutions)} real solutions for x.")

We can evaluate to obtain the float value of a symbol, and display to an arbitrary level of precision:
expr = sympy.sqrt(8)
display(expr)
display(expr.evalf(4)) # to 4 digits of precisions

In this example taken from 2023 Day 6, I’m solving a quadratic:
\[h^2 - th + d = 0\]def solve_part2_sympy_quadratic(data):
""" h^2 - th + d = 0 """
race_duration = int("".join(x for x in data[0].split(":")[1].split()))
distance = int("".join(x for x in data[1].split(":")[1].split()))
logger.debug(f"{race_duration=}, {distance=}")
# solve using quadratic with SymPy
h = sympy.symbols("h", real=True)
equation = sympy.Eq(h**2 - race_duration*h + distance, 0)
solutions = sympy.solve(equation, h, dict=True)
answers = [solution[h].evalf() for solution in solutions] # there should be two
In this example taken from 2023 Day 24, I have a series of equations that are necessary to find several unknown variables. Specifically:
\[\begin{align} t &= \frac{x_{r} - x_{h}}{v_{x_{h}} - v_{x_{r}}} = \frac{y_{r} - y_{h}}{v_{y_{h}} - v_{y_{r}}} = \frac{z_{r} - z_{h}}{v_{z_{h}} - v_{z_{r}}} \\ \notag \\ (x_{r} - x_{h})(v_{y_{h}} - v_{y_{r}}) &= (y_{r} - y_{h})(v_{x_{h}} - v_{x_{r}}) \\ (y_{r} - y_{h})(v_{z_{h}} - v_{z_{r}}) &= (z_{r} - z_{h})(v_{y_{h}} - v_{y_{r}}) \\ (z_{r} - z_{h})(v_{x_{h}} - v_{x_{r}}) &= (x_{r} - x_{h})(v_{z_{h}} - v_{z_{r}}) \\ \end{align}\]And here’s the code:
def solve_part2(data: list[str]):
"""
Determine the sum of the rock's (x,y,z) coordinate at t=0, for a rock that will hit every hailstone
in our input data. The rock has constant velocity and is not affected by collisions.
"""
stones = parse_stones(data)
logger.debug(f"We have {len(stones)} stones.")
# define SymPy rock symbols - these are our unknowns representing:
# initial rock location (xr, yr, zr)
# rock velocity (vxr, vyr, vzr)
xr, yr, zr, vxr, vyr, vzr = sympy.symbols("xr yr zr vxr vyr vzr")
equations = [] # we assemble a set of equations that must be true
for stone in stones[:10]: # we don't need ALL the stones to find a solution. We need just enough.
x, y, z = stone.posn
vx, vy, vz = stone.velocity
equations.append(sympy.Eq((xr-x)*(vy-vyr), (yr-y)*(vx-vxr)))
equations.append(sympy.Eq((yr-y)*(vz-vzr), (zr-z)*(vy-vyr)))
solutions = sympy.solve(equations, dict=True) # SymPy does the hard work
if solutions:
solution = solutions[0]
logger.info(solution)
return sum([solution[xr], solution[yr], solution[zr]])
logger.info("No solutions found.")
return None