|
1 | 1 | use crate::solution::SolutionMultiset; |
2 | 2 | use crate::dir::Dir; |
3 | 3 | use crate::par; |
| 4 | +use crate::board::Idx; |
4 | 5 | use crate::{Board, Move}; |
5 | 6 | use crate::{HashMap, HashSet, Solution}; |
6 | 7 | use std::collections::BTreeMap; |
7 | 8 | use std::num::NonZero; |
8 | 9 |
|
9 | | -/// we define two solutions as "equal" when the |
10 | | -/// multiset of steps is equivalent between them |
| 10 | +/// Dense slot for a move, keyed by its starting bit and direction: 64 positions x 4 |
| 11 | +/// directions. Sparse - only 76 of the 256 are reachable - but it makes the occurrence |
| 12 | +/// counter a flat array indexed by arithmetic rather than a map. |
| 13 | +const MOVE_SLOTS: usize = 64 * 4; |
| 14 | + |
| 15 | +/// A solution is 31 moves, so no single move can occur more often than that. |
| 16 | +const MAX_OCCURRENCES: usize = 32; |
| 17 | + |
| 18 | +fn move_slot(idx: usize, dir: Dir) -> usize { |
| 19 | + idx * 4 + dir.index() |
| 20 | +} |
| 21 | + |
| 22 | +/// The `Move` a starting bit and direction describe. |
11 | 23 | /// |
12 | | -/// Finds all *unique* solutions (by step-multiset) from `start` to any board in `goals`. |
| 24 | +/// Pure geometry, so it needs no board: `skip` is one step along `dir` and `target` two. |
| 25 | +/// `test_move_at_matches_get_legal_moves` pins it against `Board::get_legal_moves`, which |
| 26 | +/// derives the same thing the long way round. |
| 27 | +fn move_at(idx: usize, dir: Dir) -> Move { |
| 28 | + let row = (idx / Board::REPR as usize) as i32; |
| 29 | + let col = (idx % Board::REPR as usize) as i32; |
| 30 | + let (d_row, d_col) = match dir { |
| 31 | + Dir::North => (-1, 0), |
| 32 | + Dir::South => (1, 0), |
| 33 | + Dir::West => (0, -1), |
| 34 | + Dir::East => (0, 1), |
| 35 | + }; |
| 36 | + let step = |k: i32| (((row + d_row * k) as Idx), ((col + d_col * k) as Idx)); |
| 37 | + Move { |
| 38 | + pos: (row as Idx, col as Idx), |
| 39 | + skip: step(1), |
| 40 | + target: step(2), |
| 41 | + } |
| 42 | +} |
| 43 | + |
| 44 | +/// Random value per (move slot, occurrence count), for hashing a move *multiset* |
| 45 | +/// incrementally. |
13 | 46 | /// |
14 | | -/// Uses BFS/DFS over the feasible graph, accumulating the multiset of steps along |
15 | | -/// each path. When a goal is reached the current multiset is inserted into the |
16 | | -/// result set — duplicates collapse automatically. |
17 | | -pub fn all_unique_solutions( |
18 | | - start: Board, |
19 | | - feasible: impl Iterator<Item = Board>, |
20 | | -) -> std::collections::HashSet<SolutionMultiset> { |
21 | | - log::info!("calculating unique solutions ...."); |
22 | | - let feasible: HashSet<Board> = feasible.collect(); |
| 47 | +/// Deterministic rather than `rand::random`: the table is a pure function of the seed, so a |
| 48 | +/// run is reproducible, and it is built once up front instead of being filled lazily through |
| 49 | +/// a `HashMap` lookup on every edge. |
| 50 | +/// |
| 51 | +/// `count == 0` is deliberately zero, which makes the hash of a multiset exactly the XOR of |
| 52 | +/// `table[slot][count]` over the slots present - a move at count zero contributes nothing. |
| 53 | +struct Zobrist { |
| 54 | + table: Vec<[u64; MAX_OCCURRENCES]>, |
| 55 | +} |
23 | 56 |
|
24 | | - // Work-stack entry: (current_board, accumulated_multiset, hash of multiset) |
25 | | - // Using a stack (DFS) keeps memory proportional to path depth; |
26 | | - // swap for a VecDeque + pop_front if you prefer BFS. |
27 | | - let mut stack: Vec<(Board, SolutionMultiset, MultisetHash)> = vec![(start, BTreeMap::new(), 0)]; |
| 57 | +impl Zobrist { |
| 58 | + fn new() -> Self { |
| 59 | + // splitmix64, so each entry is an independent-looking constant with no state to carry |
| 60 | + const fn mix(mut x: u64) -> u64 { |
| 61 | + x = x.wrapping_add(0x9E37_79B9_7F4A_7C15); |
| 62 | + x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); |
| 63 | + x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); |
| 64 | + x ^ (x >> 31) |
| 65 | + } |
| 66 | + Self { |
| 67 | + table: (0..MOVE_SLOTS) |
| 68 | + .map(|slot| { |
| 69 | + let mut row = [0u64; MAX_OCCURRENCES]; |
| 70 | + for (count, value) in row.iter_mut().enumerate().skip(1) { |
| 71 | + *value = mix(((slot as u64) << 8) | count as u64); |
| 72 | + } |
| 73 | + row |
| 74 | + }) |
| 75 | + .collect(), |
| 76 | + } |
| 77 | + } |
28 | 78 |
|
29 | | - let mut unique_solutions: std::collections::HashSet<SolutionMultiset> = |
30 | | - std::collections::HashSet::default(); |
| 79 | + /// XOR to apply when a move's count goes from `count - 1` to `count`, or back. |
| 80 | + fn delta(&self, slot: usize, count: usize) -> u64 { |
| 81 | + self.table[slot][count - 1] ^ self.table[slot][count] |
| 82 | + } |
| 83 | +} |
| 84 | + |
| 85 | +type MultisetHash = u64; |
31 | 86 |
|
32 | | - let mut visited: std::collections::HashSet<(Board, MultisetHash)> = |
33 | | - std::collections::HashSet::new(); |
34 | | - println!(); |
35 | | - let mut zobrist = ZobristTable::default(); |
36 | | - visited.insert((start, 0)); |
| 87 | +/// Depth-first search over the feasible graph, collecting the distinct move multisets that |
| 88 | +/// reach the solved board. |
| 89 | +/// |
| 90 | +/// The state is maintained *in place* and undone on the way back out, which is the whole |
| 91 | +/// point: the previous version pushed `(board, multiset, hash)` onto an explicit stack and |
| 92 | +/// so cloned a `BTreeMap` for every edge it pushed - around 85 million of them for the |
| 93 | +/// central game. Here the multiset is one flat counter array, a move costs an increment and |
| 94 | +/// an XOR, and a `BTreeMap` is built only when a solution is actually found (12_752 times). |
| 95 | +struct Search<'a> { |
| 96 | + feasible: &'a HashSet<Board>, |
| 97 | + zobrist: Zobrist, |
| 98 | + /// occurrences of each move slot along the current path |
| 99 | + counts: Vec<u8>, |
| 100 | + /// multiset hashes already expanded - see `visit` for why the board is not part of the key |
| 101 | + visited: std::collections::HashSet<MultisetHash>, |
| 102 | + solutions: std::collections::HashSet<SolutionMultiset>, |
| 103 | +} |
37 | 104 |
|
38 | | - while let Some((board, multiset, hash)) = stack.pop() { |
| 105 | +impl Search<'_> { |
| 106 | + /// Expands `board`, whose path so far hashes to `hash`. |
| 107 | + /// |
| 108 | + /// `visited` keys on the multiset hash *alone*, where the previous version used |
| 109 | + /// `(board, hash)`. That is not a weakening: a move is an XOR with a fixed mask and XOR |
| 110 | + /// commutes, so the board is `start ^ (XOR of the masks applied, with even multiplicities |
| 111 | + /// cancelling)` - i.e. the multiset determines the board. Adding the board to the key |
| 112 | + /// therefore partitions nothing further, and dropping it halves the table, which at ~85M |
| 113 | + /// entries was most of the 3.4 GB this used to need. |
| 114 | + fn visit(&mut self, board: Board, hash: MultisetHash) { |
39 | 115 | if board.is_solved() { |
40 | | - unique_solutions.insert(multiset); |
41 | | - // Do NOT continue here if a goal board can still have outgoing |
42 | | - // moves that lead to *other* goals; change to `continue` if goals |
43 | | - // are always terminal. |
44 | | - continue; |
| 116 | + self.solutions.insert(self.materialize()); |
| 117 | + return; |
45 | 118 | } |
46 | 119 |
|
47 | | - for mov in board.get_legal_moves() { |
48 | | - let next_board = board.mov(mov); |
49 | | - // Only follow edges that stay within the feasible set |
50 | | - if !feasible.contains(&next_board.normalize()) { |
51 | | - continue; |
| 120 | + // `symmetries` once per board, then `normalize_after_move` XORs each move's mask into |
| 121 | + // the eight images - the identity `g(b ^ m) = g(b) ^ g(m)`. The previous version |
| 122 | + // called `board.mov(mov).normalize()`, normalizing every successor from scratch: eight |
| 123 | + // full symmetry transforms per edge instead of eight XORs. |
| 124 | + let syms = board.symmetries(); |
| 125 | + for dir in Dir::enumerate() { |
| 126 | + for idx in board.mov_pattern_mask(dir) { |
| 127 | + if !self |
| 128 | + .feasible |
| 129 | + .contains(&Board::normalize_after_move(&syms, idx, dir)) |
| 130 | + { |
| 131 | + continue; |
| 132 | + } |
| 133 | + let slot = move_slot(idx, dir); |
| 134 | + self.counts[slot] += 1; |
| 135 | + let next_hash = hash ^ self.zobrist.delta(slot, self.counts[slot] as usize); |
| 136 | + if self.visited.insert(next_hash) { |
| 137 | + // the search itself walks un-normalized boards, as it did before; only the |
| 138 | + // feasibility test above is symmetry-reduced |
| 139 | + self.visit(board.toggle_mov_idx_unchecked(idx, dir), next_hash); |
| 140 | + } |
| 141 | + self.counts[slot] -= 1; |
52 | 142 | } |
| 143 | + } |
| 144 | + } |
53 | 145 |
|
54 | | - // Extend the multiset with this step |
55 | | - let mut next_multiset = multiset.clone(); |
56 | | - |
57 | | - let new_count = { |
58 | | - let c = next_multiset.entry(mov).or_insert(0); |
59 | | - *c += 1; |
60 | | - *c |
61 | | - }; |
62 | | - let next_hash = hash ^ zobrist.delta(&mov, new_count); |
63 | | - |
64 | | - // Only push if this (board, multiset) state is genuinely new |
65 | | - if visited.insert((next_board, next_hash)) { |
66 | | - stack.push((next_board, next_multiset, next_hash)); |
| 146 | + /// Turns the current counter array into the `BTreeMap` the API returns. Only ever called |
| 147 | + /// on reaching a solution, so it can afford to be the slow part. |
| 148 | + fn materialize(&self) -> SolutionMultiset { |
| 149 | + let mut multiset = BTreeMap::new(); |
| 150 | + for (slot, &count) in self.counts.iter().enumerate() { |
| 151 | + if count > 0 { |
| 152 | + multiset.insert(move_at(slot / 4, Dir::from_index(slot % 4)), count as usize); |
67 | 153 | } |
68 | 154 | } |
| 155 | + multiset |
69 | 156 | } |
70 | | - unique_solutions |
| 157 | +} |
| 158 | + |
| 159 | +/// we define two solutions as "equal" when the |
| 160 | +/// multiset of steps is equivalent between them |
| 161 | +/// |
| 162 | +/// Finds all *unique* solutions (by step-multiset) from `start` to any board in `goals`. |
| 163 | +/// |
| 164 | +/// For the central game this is 12_752 multisets, against 40_861_647_040_079_968 move |
| 165 | +/// sequences - so each multiset admits on the order of 10^12 valid orderings, which is why |
| 166 | +/// enumerating the classes is tractable at all while enumerating the sequences is not. |
| 167 | +pub fn all_unique_solutions( |
| 168 | + start: Board, |
| 169 | + feasible: impl IntoIterator<Item = Board>, |
| 170 | +) -> std::collections::HashSet<SolutionMultiset> { |
| 171 | + log::info!("calculating unique solutions ...."); |
| 172 | + let feasible: HashSet<Board> = feasible.into_iter().collect(); |
| 173 | + let mut search = Search { |
| 174 | + feasible: &feasible, |
| 175 | + zobrist: Zobrist::new(), |
| 176 | + counts: vec![0u8; MOVE_SLOTS], |
| 177 | + visited: std::collections::HashSet::default(), |
| 178 | + solutions: std::collections::HashSet::default(), |
| 179 | + }; |
| 180 | + search.visited.insert(0); |
| 181 | + search.visit(start, 0); |
| 182 | + search.solutions |
71 | 183 | } |
72 | 184 |
|
73 | 185 | #[allow(unused)] |
@@ -135,7 +247,6 @@ impl ZobristTable { |
135 | 247 | } |
136 | 248 | } |
137 | 249 |
|
138 | | -type MultisetHash = u64; |
139 | 250 |
|
140 | 251 | /// Upper bound on the moves available from one board. |
141 | 252 | /// |
@@ -294,3 +405,40 @@ fn successors_of(board: Board, buffer: &mut [Board; MAX_MOVES], kind: PathKind) |
294 | 405 | } |
295 | 406 | &buffer[..len] |
296 | 407 | } |
| 408 | + |
| 409 | +/// `move_at` derives a `Move` from a starting bit and direction by pure geometry, while |
| 410 | +/// `Board::get_legal_moves` derives the same thing from the board via `get_legal_move`. |
| 411 | +/// The search uses the former for every edge, so they had better agree. |
| 412 | +#[test] |
| 413 | +fn test_move_at_matches_get_legal_moves() { |
| 414 | + let boards = [ |
| 415 | + Board::default(), |
| 416 | + Board(Board::full().0 & !Board::solved().0), |
| 417 | + Board::solved(), |
| 418 | + ] |
| 419 | + .into_iter() |
| 420 | + .chain((0..500).map(|_| { |
| 421 | + Board::from_compressed_repr(rand::random::<u64>() & ((1 << Board::SLOTS) - 1)) |
| 422 | + })); |
| 423 | + |
| 424 | + let mut checked = 0usize; |
| 425 | + for board in boards { |
| 426 | + let mut expected = board.get_legal_moves(); |
| 427 | + expected.sort_unstable(); |
| 428 | + |
| 429 | + let mut derived: Vec<Move> = Dir::enumerate() |
| 430 | + .into_iter() |
| 431 | + .flat_map(|dir| { |
| 432 | + board |
| 433 | + .mov_pattern_mask(dir) |
| 434 | + .into_iter() |
| 435 | + .map(move |idx| move_at(idx, dir)) |
| 436 | + }) |
| 437 | + .collect(); |
| 438 | + derived.sort_unstable(); |
| 439 | + |
| 440 | + assert_eq!(derived, expected, "move sets differ for {board:?}"); |
| 441 | + checked += expected.len(); |
| 442 | + } |
| 443 | + assert!(checked > 0, "no moves were compared"); |
| 444 | +} |
0 commit comments