Core Problem Description
Given a matrix space where a point in the top row is the starting position, and the rest of the space contains several splitters. A beam of light is emitted downward from the starting point. When the beam encounters a splitter, it divides into two beams, which continue propagating downward from the left and right sides of the splitter, respectively. Calculate:
- The total number of splitters the light beam will pass through.
- The total number of distinct paths the light beam will take.
Problem Diagram
The circle represents the starting point, triangles represent splitters. Green triangles indicate splitters the light passes through, red triangles indicate splitters the light does not pass through, and white lines represent the light paths.
Problem Diagram
The circle represents the starting point, triangles represent splitters. Green triangles indicate splitters the light passes through, red triangles indicate splitters the light does not pass through, and white lines represent the light paths.
Thought Process
Simulation Method
The most intuitive approach is to simulate the propagation of the light beam. The simplest idea is to maintain a set of beam coordinates beams, initially containing only the starting point coordinate (0, 0). Then we iteratively process each beam coordinate in beams:
- If outside the matrix boundaries, remove it from
beams. - If on an empty cell, move the beam down one cell.
- If on a splitter, remove it from
beams, and add new beam coordinates to the left and right respectively. Also, add the splitter’s coordinate to avisitedset.
Repeat the above process until beams is empty. Finally, the size of the visited set is the total number of splitters the light passes through. The number of distinct paths can be calculated by recording the beam coordinates added to beams each time.
This method is straightforward and easy to implement, but has several issues:
- It may involve a large amount of redundant calculation, especially with many splitters. Beam propagation might repeatedly traverse the same paths, leading to exponential growth in computation.
- It requires maintaining a large number of beam coordinates, consuming significant memory.
- As the vertical size of the space increases proportionally, the algorithm spends more time on step 2, as most beams travel straight down through empty areas. This computation is essentially meaningless and does not affect the final result.
More importantly, for the second part of the problem, the simulation method may encounter issues with an excessively large number of paths, potentially exceeding memory limits.
Precomputing Shortcuts
To address the above problems, we can start by tackling the inefficiency from step 2. It’s evident that all beams, when passing through empty grid cells, will continue forward until one of two conditions is met:
- Encountering a splitter.
- Exiting the matrix boundaries.
Therefore, as long as the distribution of splitters or the matrix size does not change, the trajectory from any given cell is uniquely determined. Thus, we can precompute the number of cells each position needs to traverse to reach the next splitter or exit, storing it in a 2D array shortcut. The specific calculation method is:
- Traverse each cell from the bottom of the matrix upwards, row by row.
- If the current cell is a splitter or outside the boundary, then
shortcut[i][j] = 0. - Otherwise,
shortcut[i][j] = shortcut[i + 1][j] + 1. Ifi + 1is out of bounds, thenshortcut[i][j] = 1.
It’s easy to see that since the shortcut value for each cell depends only on the cell directly below it, the computation for each column can be parallelized, significantly improving the efficiency of the precomputation. As shown below:
let mut shortcut = Array2::zeros((grid.nrows(), grid.ncols()));
Zip::from(shortcut.lanes_mut(Axis(0))) .and(grid.lanes(Axis(0))) .par_for_each(|mut shortpass, lane| { let mut next_splitter = 0; for (s, c) in shortpass.iter_mut().zip(lane.iter()).rev() { match c { Grid::Splitter => next_splitter = 0, _ => next_splitter += 1, } *s = next_splitter; } });Through precomputation, we obtain shortcuts as shown below:
Precomputed Shortcuts
The color and number in each cell of the heatmap represent the shortcut length, i.e., the number of cells to traverse to reach the next splitter or exit.
Precomputed Shortcuts
The color and number in each cell of the heatmap represent the shortcut length, i.e., the number of cells to traverse to reach the next splitter or exit.
In this way, when simulating beam propagation, we only need to look up the precomputed shortcut length based on the current beam coordinate, directly moving the beam downward by that number of cells, thereby skipping a large amount of redundant computation.
Building on this, since beams will only appear at fixed positions (start point, splitters, or outside the boundary), we can further address the issue of an excessively large number of paths for the second part. Specifically, this transforms into a dynamic programming problem. Let denote the total number of paths passing through coordinate . Then the total number of paths through that point is the sum of paths coming from the left and right splitters above it ():
Therefore, we can use coordinates as keys and the number of paths passing through them as values, recording the dynamic programming values while iterating over splitters by their row. Each time we take a beam coordinate from frontier, we check its next position:
- If it’s outside the boundary, the beam has terminated; accumulate its path count to the final result.
- If it’s a splitter, distribute its path count to new beam coordinates to its left and right, respectively.
A viable implementation is as follows:
let width = self.shortcut.ncols();let height = self.shortcut.nrows();let mut count = 0usize;
let mut frontier = vec![(self.start, 1)];while !frontier.is_empty() { let mut next_layer = BTreeMap::new(); for ((r, c), n) in frontier { let nr = r + self.shortcut[[r, c]]; if nr >= height {
count += n; continue; }
[-1, 1] .iter() .filter_map(|&side| { let nc = c.wrapping_add_signed(side); (nc < width).then_some((nr, nc)) }) .for_each(|pos| { next_layer.entry(pos).and_modify(|e| *e += n).or_insert(n); }); } frontier = next_layer.into_iter().collect();}count.to_string()State Compression Dynamic Programming
Since we’ve considered using dynamic programming to calculate paths, let’s take this view a step back. Let’s revisit the state transition equation from the perspective of the simulation method:
Let’s visualize the transition equation for the case where is an empty cell separately, as shown below:
State Transition
Each state depends only on three states from the row above. Dashed lines indicate the path may not exist. States within the same row have no dependencies on each other.
State Transition
Each state depends only on three states from the row above. Dashed lines indicate the path may not exist. States within the same row have no dependencies on each other.
It’s clear that since states within the same row have no dependencies, we don’t actually need to store states using two-dimensional coordinates. We can instead use a one-dimensional array to store the states of the current row, a.k.a. state compression dynamic programming. Specifically, we can use a 1D array dp to store the states of the current row, initialized with dp[start] = 1 and 0 elsewhere. Then we traverse the matrix row by row. For each cell i:
- If it’s a splitter, set
next[i]to0. - If it’s empty, update
next[i]according to the transition equation above.
After updating a row, assign next to dp and continue with the next row. Finally, the sum of all positions in the dp array is the total number of paths.
In fact, observing the input reveals that splitters in the same row are separated by at least one empty cell, so they don’t interfere with each other. This allows us to integrate the logic for checking if a cell is a splitter from the two branching steps, which can be represented as:
State Transition (Independent Branches)
The state transition of each splitter is independent of others, dashed lines indicate paths implied by in-place modification.
State Transition (Independent Branches)
The state transition of each splitter is independent of others, dashed lines indicate paths implied by in-place modification.
Based on this, we can further simplify the state transition process:
- Traverse the matrix row by row. For each cell
i:- If it’s a splitter, add
dp[i]to the adjacent cellsi-1andi+1, and setdp[i]to0. - If it’s empty, no action is needed.
- If it’s a splitter, add
In this way, we completely avoid processing empty cells, further improving algorithm efficiency. This is effectively equivalent to:
- Traverse each splitter cell
iin the matrix by row, adddp[i]to the adjacent cellsi-1andi+1, and setdp[i]to0.
In this optimized version, we even avoid precomputing shortcuts, as this part is implicitly integrated into the state compression dynamic programming process. It also doesn’t require maintaining complex data structures, leading to a significant performance boost. For the given problem’s input scale, computation times improved by approximately tens to nearly a thousand times (from to and from to ).
To be honest, when implementing this version, I didn’t realize it was state compression dynamic programming until I looked back at the code afterward…
The derivation above is purely a post hoc rationalization to help readers better understand this optimization approach. The “Aha!” moment is not the focus of this article; please excuse the author.
Core Algorithm Implementation
The following code snippets omit input parsing and other parts. The logic for sorting splitter coordinates is also omitted. Only the core algorithm implementation is retained. The full code is available at the link at the end.
impl Solution for Puzzle { fn part1(&self) -> String {
let mut beams = vec![false; self.width]; beams[self.start.1] = true; let (_, count) = self.splitters .iter() .fold((beams, 0), |(mut beams, mut count), &(_, c)| { if beams[c] { count += 1;
beams[c] = false; if let Some(v) = beams.get_mut(c.wrapping_add_signed(-1)) { *v = true; } if let Some(v) = beams.get_mut(c + 1) { *v = true; } } (beams, count) }); count.to_string() }
fn part2(&self) -> String {
let mut beams = vec![0; self.width]; beams[self.start.1] = 1; let beams = self.splitters.iter().fold(beams, |mut beams, &(_, c)| { match beams[c] {
0 => beams,
cnt => { if let Some(v) = beams.get_mut(c.wrapping_add_signed(-1)) { *v += cnt; } if let Some(v) = beams.get_mut(c + 1) { *v += cnt; } beams[c] = 0; beams } } }); beams.into_iter().sum::<u64>().to_string() }}Summary
The complete code is available here.
This problem itself is not difficult; even using the simulation method with some minor optimizations can complete the calculation within a reasonable time. However, the two optimization approaches for this problem are both very interesting: the idea of precomputing shortcuts is used in many classic algorithms, especially in areas like pattern matching; state compression is a common technique in dynamic programming. This problem is not as intuitively similar to classic DP problems, but by analyzing state transitions, we can still transform it into state compression dynamic programming, greatly improving algorithmic efficiency. This analytical approach can be very useful in future algorithm design.