aoc/year2023/day14.rs
1//! # Parabolic Reflector Dish
2//!
3//! To solve part two we look for a cycle where the dish returns to a previously seen state.
4//! By storing each dish and an index in a `HashMap` we can calculate the offset and length of the
5//! cycle then use that to find the state at the billionth step.
6//!
7//! Calculating the state needs to be done sequentially so we use some tricks to make it as fast as
8//! possible.
9//!
10//! First the location of each ball is stored in a `vec`. My input had ~2,000 balls compared to
11//! 10,000 grid squares total, so this approach reduces the amount of data to scan by 5x. The 2D
12//! coordinates are converted to a 1D number, for example the index of a ball on the second row
13//! second column would be 1 × 100 + 1 = 101.
14//!
15//! Next for each possible tilt orientation (north, south, east and west) an approach similar to a
16//! prefix sum is used. Each edge or fixed rock is assigned an index. We expand the grid by 2 in
17//! each direction (one for each edge) to handle the edges. For example, using west (left):
18//!
19//! ```none
20//! ..#.#..
21//! ```
22//!
23//! is represented in `fixed_west` as (noticing the extra 0 for the left edge):
24//!
25//! ```none
26//! 0 0 0 1 1 2 2 2
27//! ```
28//!
29//! The number of balls that come to rest against each fixed point is counted, for example:
30//!
31//! ```none
32//! OO#.#OO
33//! ```
34//!
35//! is stored in `roll_west` similar to:
36//!
37//! ```none
38//! 2 0 2
39//! ```
40//!
41//! This approach has two huge advantages:
42//!
43//! First, the number of balls resting against each fixed point completely represents the state of
44//! the grid in a very compact format. For example, my input has ~1600 fixed points. Using 2 bytes
45//! per point needs 3.2K total to represent the grid, compared to 100 × 100 = 10K for the simple
46//! approach. 3x less data is 3x faster to hash when storing states in a `HashMap` looking for
47//! duplicates.
48//!
49//! Second, calculating the new position of a ball is very fast. For each ball:
50//!
51//! * Use `fixed_*` to lookup the index in the corresponding `roll_*` vec.
52//! * This stores the current index of the last ball resting against that fixed point.
53//! * Increment this value by ±1 for horizontal movement or ±width for vertical movement and then
54//! update the new location of this ball.
55//!
56//! For example, tilting a single row west, processing each ball from left to right where each line
57//! represents the new state would look like:
58//!
59//! ```none
60//! grid rounded fixed_west roll_west
61//! .O#..O.OO.#..O [1 5 7 8 13] [0 0 1 1 1 1 1 1 1 1 2 2 2 2] [-1 2 10]
62//! O.#..O.OO.#..O [0 5 7 8 13] [0 0 1 1 1 1 1 1 1 1 2 2 2 2] [0 2 10]
63//! O.#O...OO.#..O [0 3 7 8 13] [0 0 1 1 1 1 1 1 1 1 2 2 2 2] [0 3 10]
64//! O.#OO...O.#..O [0 3 4 8 13] [0 0 1 1 1 1 1 1 1 1 2 2 2 2] [0 4 10]
65//! O.#OOO....#..O [0 3 4 5 13] [0 0 1 1 1 1 1 1 1 1 2 2 2 2] [0 5 10]
66//! O.#OOO....#O.. [0 3 4 5 11] [0 0 1 1 1 1 1 1 1 1 2 2 2 2] [0 5 11]
67//! ```
68use crate::util::grid::*;
69use crate::util::hash::*;
70use crate::util::point::*;
71
72pub struct Input {
73 width: i32,
74 height: i32,
75 // Index of each ball.
76 rounded: Vec<i16>,
77 // Index into corresponding `roll_` vec for each possible grid location.
78 fixed_north: Vec<i16>,
79 fixed_west: Vec<i16>,
80 fixed_south: Vec<i16>,
81 fixed_east: Vec<i16>,
82 // The current index of the ball resting against each fixed point.
83 roll_north: Vec<i16>,
84 roll_west: Vec<i16>,
85 roll_south: Vec<i16>,
86 roll_east: Vec<i16>,
87}
88
89pub fn parse(input: &str) -> Input {
90 // Expand the grid by 2 in each direction to handle edges the same way as fixed points.
91 let inner = Grid::parse(input);
92 let mut grid = Grid::new(inner.width + 2, inner.height + 2, b'#');
93
94 // Copy inner grid.
95 for y in 0..inner.height {
96 for x in 0..inner.width {
97 let src = Point::new(x, y);
98 let dst = Point::new(x + 1, y + 1);
99 grid[dst] = inner[src];
100 }
101 }
102
103 let mut rounded = Vec::new();
104 let mut north = grid.same_size_with(0);
105 let mut west = grid.same_size_with(0);
106 let mut south = grid.same_size_with(0);
107 let mut east = grid.same_size_with(0);
108 let mut roll_north = Vec::new();
109 let mut roll_west = Vec::new();
110 let mut roll_south = Vec::new();
111 let mut roll_east = Vec::new();
112
113 // Starting index of each rounded ball.
114 for y in 0..grid.height {
115 for x in 0..grid.width {
116 let point = Point::new(x, y);
117 if grid[point] == b'O' {
118 rounded.push((grid.width * point.y + point.x) as i16);
119 }
120 }
121 }
122
123 // For each direction, store the next index that a ball will roll to in that direction.
124
125 // North is intentionally iterated in column-major order.
126 for x in 0..grid.width {
127 for y in 0..grid.height {
128 let point = Point::new(x, y);
129 if grid[point] == b'#' {
130 roll_north.push((grid.width * point.y + point.x) as i16);
131 }
132 north[point] = (roll_north.len() - 1) as i16;
133 }
134 }
135
136 // West
137 for y in 0..grid.height {
138 for x in 0..grid.width {
139 let point = Point::new(x, y);
140 if grid[point] == b'#' {
141 roll_west.push((grid.width * point.y + point.x) as i16);
142 }
143 west[point] = (roll_west.len() - 1) as i16;
144 }
145 }
146
147 // South is intentionally iterated in reverse column-major order.
148 for x in 0..grid.width {
149 for y in (0..grid.height).rev() {
150 let point = Point::new(x, y);
151 if grid[point] == b'#' {
152 roll_south.push((grid.width * point.y + point.x) as i16);
153 }
154 south[point] = (roll_south.len() - 1) as i16;
155 }
156 }
157
158 // East
159 for y in 0..grid.height {
160 for x in (0..grid.width).rev() {
161 let point = Point::new(x, y);
162 if grid[point] == b'#' {
163 roll_east.push((grid.width * point.y + point.x) as i16);
164 }
165 east[point] = (roll_east.len() - 1) as i16;
166 }
167 }
168
169 Input {
170 width: grid.width,
171 height: grid.height,
172 rounded,
173 fixed_north: north.bytes,
174 fixed_west: west.bytes,
175 fixed_south: south.bytes,
176 fixed_east: east.bytes,
177 roll_north,
178 roll_west,
179 roll_south,
180 roll_east,
181 }
182}
183
184pub fn part1(input: &Input) -> i32 {
185 let Input { width, height, fixed_north, roll_north, .. } = input;
186
187 // Tilt north only once.
188 let mut result = 0;
189 let rounded = &mut input.rounded.clone();
190 let state = tilt(rounded, fixed_north, roll_north, *width as i16);
191
192 // Find vertical distance of each ball from the bottom, remembering that the grid is 2 bigger.
193 for (&a, &b) in input.roll_north.iter().zip(state.iter()) {
194 for index in (a..b).step_by(input.width as usize) {
195 let y = (index as i32) / width;
196 result += height - 2 - y;
197 }
198 }
199
200 result
201}
202
203pub fn part2(input: &Input) -> i32 {
204 let Input { width, height, .. } = input;
205
206 let rounded = &mut input.rounded.clone();
207 let mut seen = FastMap::with_capacity(100);
208
209 // Simulate tilting until a cycle is found.
210 let (start, end) = loop {
211 tilt(rounded, &input.fixed_north, &input.roll_north, *width as i16);
212 tilt(rounded, &input.fixed_west, &input.roll_west, 1);
213 tilt(rounded, &input.fixed_south, &input.roll_south, -(*width) as i16);
214 let state = tilt(rounded, &input.fixed_east, &input.roll_east, -1);
215
216 if let Some(previous) = seen.insert(state, seen.len()) {
217 break (previous, seen.len());
218 }
219 };
220
221 // Find the index of the state after 1 billion repetitions.
222 let offset = 1_000_000_000 - 1 - start;
223 let cycle_width = end - start;
224 let remainder = offset % cycle_width;
225 let target = start + remainder;
226
227 let (state, _) = seen.iter().find(|&(_, &i)| i == target).unwrap();
228 let mut result = 0;
229
230 for (&a, &b) in input.roll_east.iter().zip(state.iter()) {
231 // Number of balls resting against the fixed point.
232 let n = (a - b) as i32;
233 // Distance from bottom.
234 let y = (a as i32) / width;
235 // Total load.
236 result += n * (height - 1 - y);
237 }
238
239 result
240}
241
242/// Very fast calculation of new state after tilting in the specified direction.
243fn tilt(rounded: &mut [i16], fixed: &[i16], roll: &[i16], direction: i16) -> Vec<i16> {
244 let mut state = roll.to_vec();
245
246 for rock in rounded {
247 let index = fixed[*rock as usize] as usize;
248 state[index] += direction;
249 *rock = state[index];
250 }
251
252 state
253}