Skip to main content

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}