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 a index in a `HashMap` we can calculate the offset and length of the
5//! cycle then use that to find to 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 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 so 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 handles 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 the number of balls the 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 the
44//! grid in a very compact format. For example my input has ~1600 fixed points. Using 2 bytes per
45//! point needs 3.2K total to represent the grid, compared to 100 * 100 = 10K for the simple approach.
46//! 3x less data is 3x faster to hash when storing states in a `HashMap` looking for duplicates.
47//!
48//! Second, calculating the new position of a ball is very fast. For each ball:
49//!
50//! * Use `fixed_*` to lookup the index in the corresponding `roll_*` vec.
51//! * This stores the current index of the last ball resting against that fixed point.
52//! * Increment this value by ±1 for horizontal movement or ±width for vertical movement
53//!   and then update the new location of this ball.
54//!
55//! For example, tilting a single row west, processing each ball from left to right where each line
56//! represent the new state would look like:
57//!
58//! ```none
59//!    grid              rounded         fixed_west                       roll_west
60//!    .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]
61//!    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]
62//!    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]
63//!    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]
64//!    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]
65//!    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]
66//! ```
67use crate::util::grid::*;
68use crate::util::hash::*;
69use crate::util::point::*;
70
71pub struct Input {
72    width: i32,
73    height: i32,
74    // Index of each ball.
75    rounded: Vec<i16>,
76    // Index into corresponding `roll_` vec for each possible grid location.
77    fixed_north: Vec<i16>,
78    fixed_west: Vec<i16>,
79    fixed_south: Vec<i16>,
80    fixed_east: Vec<i16>,
81    // The current index of the ball resting against each fixed point.
82    roll_north: Vec<i16>,
83    roll_west: Vec<i16>,
84    roll_south: Vec<i16>,
85    roll_east: Vec<i16>,
86}
87
88pub fn parse(input: &str) -> Input {
89    // Expand the grid by 2 in each direction to handle edges the same way as fixed points.
90    let inner = Grid::parse(input);
91    let mut grid = Grid::new(inner.width + 2, inner.height + 2, b'#');
92
93    // Copy inner grid.
94    for y in 0..inner.width {
95        for x in 0..inner.width {
96            let src = Point::new(x, y);
97            let dst = Point::new(x + 1, y + 1);
98            grid[dst] = inner[src];
99        }
100    }
101
102    let mut rounded = Vec::new();
103    let mut north = grid.same_size_with(0);
104    let mut west = grid.same_size_with(0);
105    let mut south = grid.same_size_with(0);
106    let mut east = grid.same_size_with(0);
107    let mut roll_north = Vec::new();
108    let mut roll_west = Vec::new();
109    let mut roll_south = Vec::new();
110    let mut roll_east = Vec::new();
111
112    // Starting index of each rounded ball.
113    for y in 0..grid.height {
114        for x in 0..grid.width {
115            let point = Point::new(x, y);
116            if grid[point] == b'O' {
117                rounded.push((grid.width * point.y + point.x) as i16);
118            }
119        }
120    }
121
122    // For each direction, store the next index that a ball will roll to in that direction.
123
124    // North
125    for x in 0..grid.width {
126        for y in 0..grid.height {
127            let point = Point::new(x, y);
128            if grid[point] == b'#' {
129                roll_north.push((grid.width * point.y + point.x) as i16);
130            }
131            north[point] = (roll_north.len() - 1) as i16;
132        }
133    }
134
135    // West
136    for y in 0..grid.height {
137        for x in 0..grid.width {
138            let point = Point::new(x, y);
139            if grid[point] == b'#' {
140                roll_west.push((grid.width * point.y + point.x) as i16);
141            }
142            west[point] = (roll_west.len() - 1) as i16;
143        }
144    }
145
146    // South
147    for x in 0..grid.width {
148        for y in (0..grid.height).rev() {
149            let point = Point::new(x, y);
150            if grid[point] == b'#' {
151                roll_south.push((grid.width * point.y + point.x) as i16);
152            }
153            south[point] = (roll_south.len() - 1) as i16;
154        }
155    }
156
157    // East
158    for y in 0..grid.height {
159        for x in (0..grid.width).rev() {
160            let point = Point::new(x, y);
161            if grid[point] == b'#' {
162                roll_east.push((grid.width * point.y + point.x) as i16);
163            }
164            east[point] = (roll_east.len() - 1) as i16;
165        }
166    }
167
168    Input {
169        width: grid.width,
170        height: grid.height,
171        rounded,
172        fixed_north: north.bytes,
173        fixed_west: west.bytes,
174        fixed_south: south.bytes,
175        fixed_east: east.bytes,
176        roll_north,
177        roll_west,
178        roll_south,
179        roll_east,
180    }
181}
182
183pub fn part1(input: &Input) -> i32 {
184    let Input { width, height, fixed_north, roll_north, .. } = input;
185
186    // Tilt north only once.
187    let mut result = 0;
188    let rounded = &mut input.rounded.clone();
189    let state = tilt(rounded, fixed_north, roll_north, *width as i16);
190
191    // Find vertical distance of each ball from the bottom, remembering that the grid is 2 bigger.
192    for (&a, &b) in input.roll_north.iter().zip(state.iter()) {
193        for index in (a..b).step_by(input.width as usize) {
194            let y = (index as i32) / width;
195            result += height - 2 - y;
196        }
197    }
198
199    result
200}
201
202pub fn part2(input: &Input) -> i32 {
203    let Input { width, height, .. } = input;
204
205    let rounded = &mut input.rounded.clone();
206    let mut seen = FastMap::with_capacity(100);
207
208    // Simulate tilting until a cycle is found.
209    let (start, end) = loop {
210        tilt(rounded, &input.fixed_north, &input.roll_north, *width as i16);
211        tilt(rounded, &input.fixed_west, &input.roll_west, 1);
212        tilt(rounded, &input.fixed_south, &input.roll_south, -(*width) as i16);
213        let state = tilt(rounded, &input.fixed_east, &input.roll_east, -1);
214
215        if let Some(previous) = seen.insert(state, seen.len()) {
216            break (previous, seen.len());
217        }
218    };
219
220    // Find the index of the state after 1 billion repetitions.
221    let offset = 1_000_000_000 - 1 - start;
222    let cycle_width = end - start;
223    let remainder = offset % cycle_width;
224    let target = start + remainder;
225
226    let (state, _) = seen.iter().find(|&(_, &i)| i == target).unwrap();
227    let mut result = 0;
228
229    for (&a, &b) in input.roll_east.iter().zip(state.iter()) {
230        // Number of balls resting against the fixed point.
231        let n = (a - b) as i32;
232        // Distance from bottom.
233        let y = (a as i32) / width;
234        // Total load.
235        result += n * (height - 1 - y);
236    }
237
238    result
239}
240
241/// Very fast calculation of new state after tilting in the specified direction.
242fn tilt(rounded: &mut [i16], fixed: &[i16], roll: &[i16], direction: i16) -> Vec<i16> {
243    let mut state = roll.to_vec();
244
245    for rock in rounded {
246        let index = fixed[*rock as usize] as usize;
247        state[index] += direction;
248        *rock = state[index];
249    }
250
251    state
252}