Operating at these extreme ocean depths has overloaded the submarine's reactor; it needs to be rebooted.
The reactor core is made up of a large 3-dimensional grid made up entirely of cubes, one cube per integer 3-dimensional coordinate (x,y,z). Each cube can be either on or off; at the start of the reboot process, they are all off. (Could it be an old model of a reactor you've seen before?)
To reboot the reactor, you just need to set all of the cubes to either on or off by following a list of reboot steps (your puzzle input). Each step specifies a cuboid (the set of all cubes that have coordinates which fall within ranges for x, y, and z) and whether to turn all of the cubes in that cuboid on or off.
For example, given these reboot steps:
on x=10..12,y=10..12,z=10..12
on x=11..13,y=11..13,z=11..13
off x=9..11,y=9..11,z=9..11
on x=10..10,y=10..10,z=10..10
The first step (on x=10..12,y=10..12,z=10..12) turns on a 3x3x3 cuboid consisting of 27 cubes:
10,10,10
10,10,11
10,10,12
10,11,10
10,11,11
10,11,12
10,12,10
10,12,11
10,12,12
11,10,10
11,10,11
11,10,12
11,11,10
11,11,11
11,11,12
11,12,10
11,12,11
11,12,12
12,10,10
12,10,11
12,10,12
12,11,10
12,11,11
12,11,12
12,12,10
12,12,11
12,12,12
The second step (on x=11..13,y=11..13,z=11..13) turns on a 3x3x3 cuboid that overlaps with the first. As a result, only 19 additional cubes turn on; the rest are already on from the previous step:
11,11,13
11,12,13
11,13,11
11,13,12
11,13,13
12,11,13
12,12,13
12,13,11
12,13,12
12,13,13
13,11,11
13,11,12
13,11,13
13,12,11
13,12,12
13,12,13
13,13,11
13,13,12
13,13,13
The third step (off x=9..11,y=9..11,z=9..11) turns off a 3x3x3 cuboid that overlaps partially with some cubes that are on, ultimately turning off 8 cubes:
10,10,10
10,10,11
10,11,10
10,11,11
11,10,10
11,10,11
11,11,10
11,11,11
The final step (on x=10..10,y=10..10,z=10..10) turns on a single cube, 10,10,10. After this last step, 39 cubes are on.
The initialization procedure only uses cubes that have x, y, and z positions of at least -50 and at most 50. For now, ignore cubes outside this region.
Here is a larger example:
on x=-20..26,y=-36..17,z=-47..7
on x=-20..33,y=-21..23,z=-26..28
on x=-22..28,y=-29..23,z=-38..16
on x=-46..7,y=-6..46,z=-50..-1
on x=-49..1,y=-3..46,z=-24..28
on x=2..47,y=-22..22,z=-23..27
on x=-27..23,y=-28..26,z=-21..29
on x=-39..5,y=-6..47,z=-3..44
on x=-30..21,y=-8..43,z=-13..34
on x=-22..26,y=-27..20,z=-29..19
off x=-48..-32,y=26..41,z=-47..-37
on x=-12..35,y=6..50,z=-50..-2
off x=-48..-32,y=-32..-16,z=-15..-5
on x=-18..26,y=-33..15,z=-7..46
off x=-40..-22,y=-38..-28,z=23..41
on x=-16..35,y=-41..10,z=-47..6
off x=-32..-23,y=11..30,z=-14..3
on x=-49..-5,y=-3..45,z=-29..18
off x=18..30,y=-20..-8,z=-3..13
on x=-41..9,y=-7..43,z=-33..15
on x=-54112..-39298,y=-85059..-49293,z=-27449..7877
on x=967..23432,y=45373..81175,z=27513..53682
The last two steps are fully outside the initialization procedure area; all other steps are fully within it. After executing these steps in the initialization procedure region, 590784 cubes are on.
Execute the reboot steps. Afterward, considering only cubes in the region x=-50..50,y=-50..50,z=-50..50, how many cubes are on?
# Python imports
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Dict, Generator, Iterable, List, Set, Tuple
import numpy as np
# Paths to data
testpath1 = Path("day22_test1.txt")
testpath2 = Path("day22_test2.txt")
testpath3 = Path("day22_test3.txt")
datapath = Path("day22_data.txt")
We load in each of the reboot steps as a tuple:
def load_input(fpath: Path) -> Tuple[str, List[int], List[int], List[int]]:
"""Return instructions for toggling reactor cubes
:param fpath: Path to data file
"""
instrs = []
with fpath.open("r") as ifh:
for line in (_.strip() for _ in ifh.readlines() if len(_.strip())):
action, cubes = line.split()
xdata, ydata, zdata = (tuple(_[2:].split("..")) for _ in cubes.split(","))
xdata = tuple([int(_) for _ in xdata])
ydata = tuple([int(_) for _ in ydata])
zdata = tuple([int(_) for _ in zdata])
instrs.append((action, xdata, ydata, zdata))
return instrs
As this first puzzle is small, we represent the entire reactor space as a numpy
array, and toggle each of the cubes in the reactor directly.
class Reactor:
"""The reactor core as numpy array, with one cube per cell"""
def __init__(self, xsize: Tuple[int, int], ysize: Tuple[int, int], zsize: Tuple[int, int]):
"""Instantiate the reactor as a cuboid with all cubes off.
:param xsize: the limits of the reactor on the x-axis
:param ysize: the limits of the reactor on the y-axis
:param zsize: the limits of the reactor on the z-axis
"""
self._xmin, self._xmax = xsize
self._ymin, self._ymax = ysize
self._zmin, self._zmax = zsize
# Create reactor filled with zeros
# NOTE: the reactor bottom-left is at (0, 0, 0) and we need to
# account for this when dealing with reboot step instructions
self._reactor = np.zeros((self._xmax - self._xmin + 1,
self._ymax - self._ymin + 1,
self._zmax - self._zmin + 1)).astype(int)
def toggle(self, instr: Tuple[str, Tuple[int, int], Tuple[int, int], Tuple[int, int]]) -> None:
"""Toggles cubes in the reactor, based on reboot step instruction
:param instr: the reboot step instruction
"""
action, xrange, yrange, zrange = instr
# Do we turn cells on or off?
if action == "on":
toggleval = 1 # on
else:
toggleval = 0 # off
# Offset reboot instruction steps to account for location of
# reactor core (bottom-left corner at (0, 0, 0))
xmin, xmax = [int(_) + abs(self._xmin) for _ in xrange]
ymin, ymax = [int(_) + abs(self._ymin) for _ in yrange]
zmin, zmax = [int(_) + abs(self._zmin) for _ in zrange]
# Skip instructions that aren't within the reactor volume
if (xmin > self._reactor.shape[0] or # overshoots
ymin > self._reactor.shape[1] or
ymin > self._reactor.shape[2] or
xmax < 0 or # undershoots
ymax < 0 or
zmax < 0):
return
# Update reactor volume
self._reactor[xmin:xmax+1,
ymin:ymax+1,
zmin:zmax+1] = toggleval
@property
def cubes_on(self) -> int:
"""Returns count of cubes that are set to 'on'"""
return int(self._reactor.sum())
We test this out on the example data:
instrs = load_input(testpath1)
reactorsize = ((-50, 50), (-50, 50), (-50, 50))
reactor = Reactor(*reactorsize)
for instr in instrs:
reactor.toggle(instr)
reactor.cubes_on
39
instrs = load_input(testpath2)
reactorsize = ((-50, 50), (-50, 50), (-50, 50))
reactor = Reactor(*reactorsize)
for instr in instrs:
reactor.toggle(instr)
reactor.cubes_on
588407
And then with the puzzle data:
instrs = load_input(datapath)
reactorsize = ((-50, 50), (-50, 50), (-50, 50))
reactor = Reactor(*reactorsize)
for instr in instrs:
reactor.toggle(instr)
reactor.cubes_on
553201
Now that the initialization procedure is complete, you can reboot the reactor.
Starting with all cubes off, run all of the reboot steps for all cubes in the reactor.
Consider the following reboot steps:
on x=-5..47,y=-31..22,z=-19..33
on x=-44..5,y=-27..21,z=-14..35
on x=-49..-1,y=-11..42,z=-10..38
on x=-20..34,y=-40..6,z=-44..1
off x=26..39,y=40..50,z=-2..11
on x=-41..5,y=-41..6,z=-36..8
off x=-43..-33,y=-45..-28,z=7..25
on x=-33..15,y=-32..19,z=-34..11
off x=35..47,y=-46..-34,z=-11..5
on x=-14..36,y=-6..44,z=-16..29
on x=-57795..-6158,y=29564..72030,z=20435..90618
on x=36731..105352,y=-21140..28532,z=16094..90401
on x=30999..107136,y=-53464..15513,z=8553..71215
on x=13528..83982,y=-99403..-27377,z=-24141..23996
on x=-72682..-12347,y=18159..111354,z=7391..80950
on x=-1060..80757,y=-65301..-20884,z=-103788..-16709
on x=-83015..-9461,y=-72160..-8347,z=-81239..-26856
on x=-52752..22273,y=-49450..9096,z=54442..119054
on x=-29982..40483,y=-108474..-28371,z=-24328..38471
on x=-4958..62750,y=40422..118853,z=-7672..65583
on x=55694..108686,y=-43367..46958,z=-26781..48729
on x=-98497..-18186,y=-63569..3412,z=1232..88485
on x=-726..56291,y=-62629..13224,z=18033..85226
on x=-110886..-34664,y=-81338..-8658,z=8914..63723
on x=-55829..24974,y=-16897..54165,z=-121762..-28058
on x=-65152..-11147,y=22489..91432,z=-58782..1780
on x=-120100..-32970,y=-46592..27473,z=-11695..61039
on x=-18631..37533,y=-124565..-50804,z=-35667..28308
on x=-57817..18248,y=49321..117703,z=5745..55881
on x=14781..98692,y=-1341..70827,z=15753..70151
on x=-34419..55919,y=-19626..40991,z=39015..114138
on x=-60785..11593,y=-56135..2999,z=-95368..-26915
on x=-32178..58085,y=17647..101866,z=-91405..-8878
on x=-53655..12091,y=50097..105568,z=-75335..-4862
on x=-111166..-40997,y=-71714..2688,z=5609..50954
on x=-16602..70118,y=-98693..-44401,z=5197..76897
on x=16383..101554,y=4615..83635,z=-44907..18747
off x=-95822..-15171,y=-19987..48940,z=10804..104439
on x=-89813..-14614,y=16069..88491,z=-3297..45228
on x=41075..99376,y=-20427..49978,z=-52012..13762
on x=-21330..50085,y=-17944..62733,z=-112280..-30197
on x=-16478..35915,y=36008..118594,z=-7885..47086
off x=-98156..-27851,y=-49952..43171,z=-99005..-8456
off x=2032..69770,y=-71013..4824,z=7471..94418
on x=43670..120875,y=-42068..12382,z=-24787..38892
off x=37514..111226,y=-45862..25743,z=-16714..54663
off x=25699..97951,y=-30668..59918,z=-15349..69697
off x=-44271..17935,y=-9516..60759,z=49131..112598
on x=-61695..-5813,y=40978..94975,z=8655..80240
off x=-101086..-9439,y=-7088..67543,z=33935..83858
off x=18020..114017,y=-48931..32606,z=21474..89843
off x=-77139..10506,y=-89994..-18797,z=-80..59318
off x=8476..79288,y=-75520..11602,z=-96624..-24783
on x=-47488..-1262,y=24338..100707,z=16292..72967
off x=-84341..13987,y=2429..92914,z=-90671..-1318
off x=-37810..49457,y=-71013..-7894,z=-105357..-13188
off x=-27365..46395,y=31009..98017,z=15428..76570
off x=-70369..-16548,y=22648..78696,z=-1892..86821
on x=-53470..21291,y=-120233..-33476,z=-44150..38147
off x=-93533..-4276,y=-16170..68771,z=-104985..-24507
After running the above reboot steps, 2758514936282235 cubes are on. (Just for fun, 474140 of those are also in the initialization procedure region.)
Starting again with all cubes off, execute all reboot steps. Afterward, considering all cubes, how many cubes are on?
The numpy
-based approach above can't handle the new instructions, so we use a new Reactor
class to track updates to the space.
Each volume is considered in turn against the current list of _active_volumes
in the reactor. If there is no intersection, and the action is on
, the new volume is added to that list; if the action is off
and there are no intersections, we ignore it. But if there is an intersection, we store that intersection in the list of _inactive_volumes
- if the action is on
we store the new volume in the list of _active_volumes
, but if the action is off
we take no further action.
The trick is in how we store the intersection. Essentially, we need the intersection to neutralise the existing volume so that the new volume can take priority. For instance, if the existing volume is off
(with value -1
) we have the intersection take the value 1
; if the new volume is off
we do nothing but if it is on
, it is added to the list of _active_volumes
. This does have the problem of increasing the number of volumes faster than linear, which could slow things down.
The final number of active cubes is the sum of sizes in _active_volumes
minus that of _inactive_volumes
.
class Volume:
"""Stores volume information"""
def __init__(self, xrange: Tuple[int, int], yrange: Tuple[int, int], zrange: Tuple[int, int], value: int):
"""Instantiate volume with a value that reflects on/off
:param xrange: range of volume on x-axis
:param yrange: range of volume on y-axis
:param zrange: range of volume on z-axis
:param value: 1 if "on", -1 if "off"
"""
self.xmin, self.xmax = xrange
self.ymin, self.ymax = yrange
self.zmin, self.zmax = zrange
self.value = value
def intersects(self, ref):
"""Returns intersection Volume with passed reference, or None
:param ref: reference Volume to test for intersection
"""
# Check if there are intersections on each axis
xintersect = ((self.xmin <= ref.xmin <= self.xmax) or (self.xmin <= ref.xmax <= self.xmax) or
(ref.xmin <= self.xmin <= ref.xmax) or (ref.xmin <= self.xmax <= ref.xmax))
yintersect = ((self.ymin <= ref.ymin <= self.ymax) or (self.ymin <= ref.ymax <= self.ymax) or
(ref.ymin <= self.ymin <= ref.ymax) or (ref.ymin <= self.ymax <= ref.ymax))
zintersect = ((self.zmin <= ref.zmin <= self.zmax) or (self.zmin <= ref.zmax <= self.zmax) or
(ref.zmin <= self.zmin <= ref.zmax) or (ref.zmin <= self.zmax <= ref.zmax))
# If all axes intersect, create and return a new volume at this
# intersection, whose value is the inverse of the reference
# volume's value (thereby neutralising it and allowing the new
# volume to take precedence).
if xintersect and yintersect and zintersect:
xrange = (max(ref.xmin, self.xmin), min(self.xmax, ref.xmax))
yrange = (max(ref.ymin, self.ymin), min(self.ymax, ref.ymax))
zrange = (max(ref.zmin, self.zmin), min(self.zmax, ref.zmax))
return Volume(xrange, yrange, zrange, -ref.value) # invert value wrt reference volume
# No intersection
return None
def __repr__(self):
return f"<Volume: {id(self)}: {str(self)}>"
def __str__(self):
return f"{self.value=}: ({self.xmin}..{self.xmax}),({self.ymin}..{self.ymax}),({self.zmin}..{self.zmax})"
@property
def size(self) -> int:
"""Returns size of volume"""
return (self.xmax - self.xmin + 1) * (self.ymax - self.ymin + 1) * (self.zmax - self.zmin + 1)
class Reactor:
"""Store activated/deactivated volumes"""
def __init__(self):
self._volumes = []
def add_volume(self, instr):
"""Adds new active/inactive volume to the current reactor"""
# Process new volume
action, xrange, yrange, zrange = instr
if action == "on":
value = 1
else:
value = -1
vol = Volume(xrange, yrange, zrange, value)
# Check for intersections
newvols = []
for ref in self._volumes:
newvols.append(ref)
intersection = vol.intersects(ref)
if intersection:
newvols.append(intersection)
# If added volume is "on", add to active volumes
if action == "on":
newvols.append(vol)
self._volumes = newvols[:]
def __str__(self):
return f"Volumes: {len(self._volumes)}"
def __len__(self):
"""Return count of active cubes"""
return sum((_.size * _.value for _ in self._volumes))
We test this out on the example data:
instrs = load_input(testpath1)
reactor = Reactor()
for instr in instrs:
reactor.add_volume(instr)
print(f"{reactor}, {len(reactor)=}\n")
Volumes: 9, len(reactor)=39
The final two instructions in example 2 are outwith the reactor volume for the first part, so we skip them when checking:
instrs = load_input(testpath2)
reactor = Reactor()
for instr in instrs[:-2]:
reactor.add_volume(instr)
print(f"{reactor}, {len(reactor)=}\n")
Volumes: 10620, len(reactor)=588407
instrs = load_input(testpath3)
reactor = Reactor()
for instr in instrs:
reactor.add_volume(instr)
print(f"{reactor}, {len(reactor)=}\n")
Volumes: 6645, len(reactor)=2758514936282235
Then we apply this to the puzzle data:
instrs = load_input(datapath)
reactor = Reactor()
for instr in instrs:
reactor.add_volume(instr)
print(f"{reactor}, {len(reactor)=}\n")
Volumes: 42236, len(reactor)=1263946820845866
This is a bit slow, still. We could try to optimise by removing volumes that neutralise each other, so they don't contribute to later checks.