Skip to content

Commit c4b47c2

Browse files
author
Ferdinand Schober
committed
improve unique-solution calculation
1 parent cc99c8e commit c4b47c2

1 file changed

Lines changed: 197 additions & 49 deletions

File tree

solitaire-solver/src/unique_solutions.rs

Lines changed: 197 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,185 @@
11
use crate::solution::SolutionMultiset;
22
use crate::dir::Dir;
33
use crate::par;
4+
use crate::board::Idx;
45
use crate::{Board, Move};
56
use crate::{HashMap, HashSet, Solution};
67
use std::collections::BTreeMap;
78
use std::num::NonZero;
89

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.
1123
///
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.
1346
///
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+
}
2356

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+
}
2878

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;
3186

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+
}
37104

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) {
39115
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;
45118
}
46119

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;
52142
}
143+
}
144+
}
53145

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);
67153
}
68154
}
155+
multiset
69156
}
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
71183
}
72184

73185
#[allow(unused)]
@@ -135,7 +247,6 @@ impl ZobristTable {
135247
}
136248
}
137249

138-
type MultisetHash = u64;
139250

140251
/// Upper bound on the moves available from one board.
141252
///
@@ -294,3 +405,40 @@ fn successors_of(board: Board, buffer: &mut [Board; MAX_MOVES], kind: PathKind)
294405
}
295406
&buffer[..len]
296407
}
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

Comments
 (0)