|
1 | 1 | use crate::solution::SolutionMultiset; |
| 2 | +use crate::dir::Dir; |
| 3 | +use crate::par; |
2 | 4 | use crate::{Board, Move}; |
3 | 5 | use crate::{HashMap, HashSet, Solution}; |
4 | | -use std::array; |
5 | 6 | use std::collections::BTreeMap; |
| 7 | +use std::num::NonZero; |
6 | 8 |
|
7 | 9 | /// we define two solutions as "equal" when the |
8 | 10 | /// multiset of steps is equivalent between them |
@@ -135,30 +137,160 @@ impl ZobristTable { |
135 | 137 |
|
136 | 138 | type MultisetHash = u64; |
137 | 139 |
|
138 | | -#[allow(unused)] |
139 | | -pub fn all_unique_paths(feasible: impl IntoIterator<Item = Board>) -> HashMap<Board, u64> { |
140 | | - let mut number_of_combinations: HashMap<Board, u64> = HashMap::default(); |
141 | | - let mut boards: [Vec<Board>; 33] = array::from_fn(|_| Default::default()); |
142 | | - let mut feasible_set: HashSet<Board> = HashSet::default(); |
143 | | - for board in feasible.into_iter() { |
144 | | - feasible_set.insert(board); |
145 | | - boards[board.count_pegs()].push(board); |
| 140 | +/// Upper bound on the moves available from one board. |
| 141 | +/// |
| 142 | +/// The cross holds 38 collinear triples and each can be jumped from either end, so no |
| 143 | +/// position can offer more than 76. Lets the successor buffer live on the stack instead of |
| 144 | +/// being heap-allocated per board - the old code allocated a `Vec` for every one of the |
| 145 | +/// 1_679_072 feasible boards. |
| 146 | +const MAX_MOVES: usize = 76; |
| 147 | + |
| 148 | +/// How to treat two different moves from the same board that reach the same normalized |
| 149 | +/// successor - see [`all_unique_paths`] and [`all_unique_board_paths`]. |
| 150 | +#[derive(Clone, Copy, PartialEq, Eq)] |
| 151 | +enum PathKind { |
| 152 | + /// Count both. Paths are sequences of *moves*. |
| 153 | + MoveSequences, |
| 154 | + /// Count once. Paths are sequences of *normalized boards*. |
| 155 | + BoardSequences, |
| 156 | +} |
| 157 | + |
| 158 | +/// For every feasible board, the number of distinct **move sequences** taking it to the |
| 159 | +/// solved board. |
| 160 | +/// |
| 161 | +/// From the start board this is 40_861_647_040_079_968, the published solution count for the |
| 162 | +/// central game, which is what pins this variant as correct. |
| 163 | +/// |
| 164 | +/// Two different moves reaching the same normalized successor count as two paths here, |
| 165 | +/// because they are two different sequences of moves. [`all_unique_board_paths`] is the |
| 166 | +/// variant that collapses them. |
| 167 | +pub fn all_unique_paths( |
| 168 | + feasible: impl IntoIterator<Item = Board>, |
| 169 | + threads: Option<NonZero<usize>>, |
| 170 | +) -> HashMap<Board, u64> { |
| 171 | + count_paths(feasible, PathKind::MoveSequences, threads) |
| 172 | +} |
| 173 | + |
| 174 | +/// For every feasible board, the number of distinct sequences of **normalized boards** |
| 175 | +/// taking it to the solved board. |
| 176 | +/// |
| 177 | +/// Differs from [`all_unique_paths`] only where a board has two moves reaching the same |
| 178 | +/// normalized successor - a board with a nontrivial stabilizer. From the start board this is |
| 179 | +/// 4_750_671_971_732_176 against the move-sequence count's 40_861_647_040_079_968. |
| 180 | +pub fn all_unique_board_paths( |
| 181 | + feasible: impl IntoIterator<Item = Board>, |
| 182 | + threads: Option<NonZero<usize>>, |
| 183 | +) -> HashMap<Board, u64> { |
| 184 | + count_paths(feasible, PathKind::BoardSequences, threads) |
| 185 | +} |
| 186 | + |
| 187 | +/// Shared layered dynamic program: a board's count is the sum of its successors' counts, and |
| 188 | +/// a move always removes exactly one peg, so layer `k` depends only on layer `k - 1`. |
| 189 | +/// |
| 190 | +/// Three things this does differently from the obvious formulation, in decreasing order of |
| 191 | +/// what they were worth: |
| 192 | +/// |
| 193 | +/// - Successors come from `normalize_after_move`, not `possible_moves` + `normalize_all`. The |
| 194 | +/// latter normalizes each successor from scratch, which is 8 symmetry transforms *per |
| 195 | +/// successor*; the former takes the board's 8 symmetries once and XORs the move's mask into |
| 196 | +/// each, which is the identity `g(b ^ m) = g(b) ^ g(m)` that `board.rs` already relies on. |
| 197 | +/// Over 17.2M successors that is 137M symmetry transforms replaced by 17.2M XOR rounds. |
| 198 | +/// - One `HashMap<Board, u32>` index instead of a `HashSet` plus a `HashMap`, so a successor |
| 199 | +/// costs one hash lookup rather than two: finding it *is* finding where its count lives. |
| 200 | +/// - The successor buffer is a stack array reused per board rather than a fresh `Vec`, which |
| 201 | +/// removes one heap allocation per feasible board. |
| 202 | +/// |
| 203 | +/// Each layer is then independent, so it is evaluated in parallel and written back in order. |
| 204 | +fn count_paths( |
| 205 | + feasible: impl IntoIterator<Item = Board>, |
| 206 | + kind: PathKind, |
| 207 | + threads: Option<NonZero<usize>>, |
| 208 | +) -> HashMap<Board, u64> { |
| 209 | + let mut index: HashMap<Board, u32> = HashMap::default(); |
| 210 | + // `Board::SLOTS + 2` rather than 33: `count_pegs` reaches `SLOTS`, and the old fixed |
| 211 | + // `[_; 33]` would have panicked on a board with that many pegs. No feasible board has |
| 212 | + // (the start has one hole) but nothing here guarantees the input is the feasible set. |
| 213 | + let mut by_pegs: Vec<Vec<Board>> = vec![Vec::new(); Board::SLOTS + 2]; |
| 214 | + for board in feasible { |
| 215 | + let next = index.len() as u32; |
| 216 | + index.insert(board, next); |
| 217 | + by_pegs[board.count_pegs()].push(board); |
146 | 218 | } |
147 | | - number_of_combinations.insert(Board::solved(), 1); |
148 | | - for boards in boards.iter().skip(2) { |
149 | | - for board in boards { |
150 | | - let mut next = Board::possible_moves(&[*board]); |
151 | | - Board::normalize_all(&mut next); |
152 | | - next.dedup(); |
153 | | - |
154 | | - let count = next |
155 | | - .into_iter() |
156 | | - .filter(|b| feasible_set.contains(b)) |
157 | | - .map(|b| number_of_combinations[&b]) |
158 | | - .sum(); |
159 | | - let entry = number_of_combinations.entry(*board).or_default(); |
160 | | - *entry = count; |
| 219 | + |
| 220 | + let mut counts = vec![0u64; index.len()]; |
| 221 | + if let Some(&solved) = index.get(&Board::solved()) { |
| 222 | + counts[solved as usize] = 1; |
| 223 | + } |
| 224 | + |
| 225 | + // `None` means "as many as the machine has", matching `calculate_feasible_set`. Passing |
| 226 | + // the count down to `par::parallel` is what makes `--threads 1` actually sequential: |
| 227 | + // `par_map_chunks` short-circuits to one chunk on the calling thread at 1, whereas |
| 228 | + // `configure_thread_pool` cannot help - it leaves an already-built pool alone, so by this |
| 229 | + // point the pool's width is whatever the first caller asked for. |
| 230 | + let threads = threads.unwrap_or_else(par::num_threads).get(); |
| 231 | + // skip(2): layer 0 is empty and layer 1 is the solved board, seeded above |
| 232 | + for layer in by_pegs.iter().skip(2) { |
| 233 | + if layer.is_empty() { |
| 234 | + continue; |
| 235 | + } |
| 236 | + // Read-only view of everything below this layer; the writes land afterwards, so no |
| 237 | + // two tasks touch the same count and none reads one this layer is producing. |
| 238 | + let (index_ref, counts_ref) = (&index, &counts); |
| 239 | + let layer_counts: Vec<u64> = par::parallel(layer, threads, move |chunk| { |
| 240 | + chunk |
| 241 | + .iter() |
| 242 | + .map(|board| { |
| 243 | + let mut buffer = [Board::empty(); MAX_MOVES]; |
| 244 | + let successors = successors_of(*board, &mut buffer, kind); |
| 245 | + successors |
| 246 | + .iter() |
| 247 | + .filter_map(|s| index_ref.get(s)) |
| 248 | + .map(|&i| counts_ref[i as usize]) |
| 249 | + .sum() |
| 250 | + }) |
| 251 | + .collect() |
| 252 | + }); |
| 253 | + for (board, count) in layer.iter().zip(layer_counts) { |
| 254 | + counts[index[board] as usize] = count; |
| 255 | + } |
| 256 | + } |
| 257 | + |
| 258 | + index |
| 259 | + .iter() |
| 260 | + .map(|(board, &i)| (*board, counts[i as usize])) |
| 261 | + .collect() |
| 262 | +} |
| 263 | + |
| 264 | +/// Fills `buffer` with `board`'s normalized successors and returns the filled prefix, |
| 265 | +/// deduplicated when `kind` asks for it. |
| 266 | +/// |
| 267 | +/// Deduplication sorts first, deliberately. A bare `dedup` removes only *adjacent* equals, |
| 268 | +/// and `possible_moves` groups its output by direction, so two moves in different directions |
| 269 | +/// reaching the same successor land far apart and survive it - 2_999 of them across the |
| 270 | +/// feasible set, each double-counting a subtree. That is what the previous version of this |
| 271 | +/// did, and it is why its answer matched neither of the two well-defined counts. |
| 272 | +fn successors_of(board: Board, buffer: &mut [Board; MAX_MOVES], kind: PathKind) -> &[Board] { |
| 273 | + let syms = board.symmetries(); |
| 274 | + let mut len = 0; |
| 275 | + for dir in Dir::enumerate() { |
| 276 | + for idx in board.mov_pattern_mask(dir) { |
| 277 | + debug_assert!(len < MAX_MOVES, "more than {MAX_MOVES} moves from one board"); |
| 278 | + buffer[len] = Board::normalize_after_move(&syms, idx, dir); |
| 279 | + len += 1; |
| 280 | + } |
| 281 | + } |
| 282 | + let filled = &mut buffer[..len]; |
| 283 | + if kind == PathKind::BoardSequences { |
| 284 | + filled.sort_unstable(); |
| 285 | + // in-place dedup: `slice::partition_dedup` would do this but is still unstable |
| 286 | + let mut write = 0; |
| 287 | + for read in 0..len { |
| 288 | + if write == 0 || filled[write - 1] != filled[read] { |
| 289 | + filled[write] = filled[read]; |
| 290 | + write += 1; |
| 291 | + } |
161 | 292 | } |
| 293 | + len = write; |
162 | 294 | } |
163 | | - number_of_combinations |
| 295 | + &buffer[..len] |
164 | 296 | } |
0 commit comments