Skip to content

Commit 5b51f16

Browse files
author
Ferdinand Schober
committed
unique jump map (uses a lot of memory)
1 parent bd2751f commit 5b51f16

3 files changed

Lines changed: 163 additions & 29 deletions

File tree

solitaire-solver/src/lib.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,7 @@ pub use calc_naive::calculate_all_solutions_naive;
2727
pub use calc_success::calculate_p_random_chance_success;
2828
pub use feasible::calculate_feasible_set;
2929
pub use solution::print_solution;
30-
pub use unique_solutions::{all_unique_board_paths, all_unique_paths, all_unique_solutions};
30+
pub use unique_solutions::{
31+
JumpMap, NOT_REMOVED, all_unique_board_paths, all_unique_jump_maps, all_unique_paths,
32+
all_unique_solutions,
33+
};

solitaire-solver/src/unique_solutions.rs

Lines changed: 147 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,153 @@ pub fn all_unique_solutions(
183183
search.solutions
184184
}
185185

186+
/// Sentinel in a [`JumpMap`] for the one peg that is never removed.
187+
pub const NOT_REMOVED: u8 = u8::MAX;
188+
189+
/// Which peg jumped which, both identified by the slot the peg *started* in.
190+
///
191+
/// Indexed by the victim's starting slot; the value is the jumper's. Every peg is removed
192+
/// exactly once - 32 pegs down to 1, one removal per move - so this is a *function* with 31
193+
/// entries and distinct keys, not a multiset. That is what makes the whole equivalence
194+
/// cheaper than the move-multiset one: no occurrence counting, and the canonical form is a
195+
/// fixed-size array rather than a `BTreeMap`.
196+
///
197+
/// Raw bit positions, so the array is 64 wide with the off-board indices unused.
198+
pub type JumpMap = [u8; 64];
199+
200+
/// Skip and target bit positions of the move starting at `idx` and going in `dir`.
201+
fn move_bits(idx: usize, dir: Dir) -> (usize, usize) {
202+
let step = match dir {
203+
Dir::North => -(Board::REPR as isize),
204+
Dir::South => Board::REPR as isize,
205+
Dir::West => -1,
206+
Dir::East => 1,
207+
};
208+
let at = |k: isize| (idx as isize + step * k) as usize;
209+
(at(1), at(2))
210+
}
211+
212+
/// Depth-first search collecting the distinct [`JumpMap`]s that reach the solved board.
213+
///
214+
/// Separate from [`Search`] rather than sharing it, because the two equivalences need
215+
/// genuinely different state: the multiset one is a function of the moves alone, while this
216+
/// one depends on *peg identity*, which is a function of the whole path.
217+
///
218+
/// That difference is also why this may not be tractable where the multiset version is. The
219+
/// multiset version prunes on the multiset alone, since a move is an XOR with a fixed mask
220+
/// and so the multiset determines the board. Here two paths can reach the same board with the
221+
/// same partial jump map and still have their pegs arranged differently, which changes every
222+
/// pair they can produce afterwards - so the state that determines the future is
223+
/// `(board, identity assignment, partial map)`, and there are far more of those than boards.
224+
struct JumpSearch<'a> {
225+
feasible: &'a HashSet<Board>,
226+
/// random value per (slot, peg identity), for hashing the identity assignment
227+
placed: Vec<u64>,
228+
/// random value per (victim, jumper), for hashing the partial map
229+
jumped: Vec<u64>,
230+
/// slot -> starting slot of the peg currently in it; only occupied slots are meaningful
231+
identity: JumpMap,
232+
/// the canonical form being built
233+
map: JumpMap,
234+
visited: FxHashSet<(Board, u64)>,
235+
maps: FxHashSet<JumpMap>,
236+
states: u64,
237+
}
238+
239+
impl JumpSearch<'_> {
240+
fn visit(&mut self, board: Board, hash: u64) {
241+
if board.is_solved() {
242+
self.maps.insert(self.map);
243+
return;
244+
}
245+
self.states += 1;
246+
if self.states.is_multiple_of(50_000_000) {
247+
log::info!(
248+
"jump maps: {} states expanded, {} distinct maps so far",
249+
self.states,
250+
self.maps.len()
251+
);
252+
}
253+
254+
let syms = board.symmetries();
255+
for dir in Dir::enumerate() {
256+
for idx in board.mov_pattern_mask(dir) {
257+
if !self
258+
.feasible
259+
.contains(&Board::normalize_after_move(&syms, idx, dir))
260+
{
261+
continue;
262+
}
263+
let (skip, target) = move_bits(idx, dir);
264+
let jumper = self.identity[idx];
265+
let victim = self.identity[skip];
266+
267+
// The hash covers the identity assignment *and* the partial map, so that two
268+
// states are merged only when both agree - see the note on the struct.
269+
let next_hash = hash
270+
^ self.placed[idx * 64 + jumper as usize]
271+
^ self.placed[skip * 64 + victim as usize]
272+
^ self.placed[target * 64 + jumper as usize]
273+
^ self.jumped[victim as usize * 64 + jumper as usize];
274+
275+
let next_board = board.toggle_mov_idx_unchecked(idx, dir);
276+
if !self.visited.insert((next_board, next_hash)) {
277+
continue;
278+
}
279+
280+
let vacated = self.identity[target];
281+
self.identity[target] = jumper;
282+
self.map[victim as usize] = jumper;
283+
284+
self.visit(next_board, next_hash);
285+
286+
self.map[victim as usize] = NOT_REMOVED;
287+
self.identity[target] = vacated;
288+
}
289+
}
290+
}
291+
}
292+
293+
/// Finds the distinct [`JumpMap`]s of all solutions from `start`.
294+
///
295+
/// Two solutions are the same here when every peg was jumped by the same peg, tracking pegs
296+
/// by where they started - the equivalence [`all_unique_solutions`]'s move multiset does not
297+
/// capture, since that identifies moves by *slots* and so cannot tell which physical peg made
298+
/// the jump.
299+
pub fn all_unique_jump_maps(
300+
start: Board,
301+
feasible: impl IntoIterator<Item = Board>,
302+
) -> FxHashSet<JumpMap> {
303+
log::info!("calculating unique jump maps ....");
304+
let feasible: HashSet<Board> = feasible.into_iter().collect();
305+
306+
const fn mix(mut x: u64) -> u64 {
307+
x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
308+
x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
309+
x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
310+
x ^ (x >> 31)
311+
}
312+
313+
let mut identity = [NOT_REMOVED; 64];
314+
for bit in start {
315+
identity[bit] = bit as u8;
316+
}
317+
318+
let mut search = JumpSearch {
319+
feasible: &feasible,
320+
placed: (0..64 * 64).map(|i| mix(i as u64)).collect(),
321+
jumped: (0..64 * 64).map(|i| mix(0x5EED_0000_0000_0000 | i as u64)).collect(),
322+
identity,
323+
map: [NOT_REMOVED; 64],
324+
visited: FxHashSet::default(),
325+
maps: FxHashSet::default(),
326+
states: 0,
327+
};
328+
search.visit(start, 0);
329+
log::info!("jump maps: {} states expanded", search.states);
330+
search.maps
331+
}
332+
186333
#[allow(unused)]
187334
fn canonicalize(
188335
unique_solutions: FxHashSet<SolutionMultiset>,
@@ -225,29 +372,6 @@ fn canonicalize(
225372
unique_solutions
226373
}
227374

228-
/// Precomputed random values for each (Step, occurrence_index) pair.
229-
/// occurrence_index 0 means "going from 0 to 1 occurrences", etc.
230-
#[derive(Default)]
231-
struct ZobristTable {
232-
table: std::collections::HashMap<(Move, usize), u64>,
233-
}
234-
235-
impl ZobristTable {
236-
fn delta(&mut self, step: &Move, new_count: usize) -> u64 {
237-
// XOR out the old count contribution, XOR in the new one
238-
let old = self.get(step, new_count - 1);
239-
let new = self.get(step, new_count);
240-
old ^ new
241-
}
242-
243-
fn get(&mut self, step: &Move, count: usize) -> u64 {
244-
*self
245-
.table
246-
.entry((*step, count))
247-
.or_insert_with(rand::random)
248-
}
249-
}
250-
251375
/// Upper bound on the moves available from one board.
252376
///
253377
/// The cross holds 38 collinear triples and each can be jumped from either end, so no

src/main.rs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ enum Command {
142142
UniqueSolutions,
143143
/// calculate unique paths of solutions
144144
UniquePaths,
145+
/// count solutions distinct by which peg jumped which, tracking pegs by start slot
146+
UniqueJumpMaps,
145147
}
146148

147149
fn main() {
@@ -213,13 +215,18 @@ fn main() {
213215
.collect();
214216
assert_eq!(solutions, solutions_naive)
215217
}
218+
Command::UniqueJumpMaps => {
219+
let feasible = solitaire_solver::calculate_feasible_set(args.threads);
220+
log::info!("feasible: {}", feasible.len());
221+
let maps =
222+
solitaire_solver::all_unique_jump_maps(Board::default(), feasible);
223+
log::info!("unique jump maps: {}", maps.len());
224+
}
216225
Command::UniqueSolutions => {
217-
let feasible = solitaire_solver::calculate_feasible_set(None);
226+
let feasible = solitaire_solver::calculate_feasible_set(args.threads);
218227
log::info!("feasible: {}", feasible.len());
219-
let solutions = solitaire_solver::all_unique_solutions(
220-
Board::default(),
221-
feasible.into_iter(),
222-
);
228+
let solutions =
229+
solitaire_solver::all_unique_solutions(Board::default(), feasible);
223230
log::info!("unique solutions: {}", solutions.len());
224231
}
225232
Command::UniquePaths => {

0 commit comments

Comments
 (0)