Skip to content

Instantly share code, notes, and snippets.

@dsasse07
Last active April 2, 2024 20:49
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save dsasse07/3ff7ae0eff2a7b3efd276e3f10f59f91 to your computer and use it in GitHub Desktop.
Save dsasse07/3ff7ae0eff2a7b3efd276e3f10f59f91 to your computer and use it in GitHub Desktop.
const BLANK_BOARD = [
[0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0]
]
let counter
const numArray = [1, 2, 3, 4, 5, 6, 7, 8, 9]
function shuffle( array ) {
let newArray = [...array]
for ( let i = newArray.length - 1; i > 0; i-- ) {
const j = Math.floor( Math.random() * ( i + 1 ) );
[ newArray[ i ], newArray[ j ] ] = [ newArray[ j ], newArray[ i ] ];
}
return newArray;
}
/*--------------------------------------------------------------------------------------------
--------------------------------- Check if Location Safe -------------------------------------
--------------------------------------------------------------------------------------------*/
const rowSafe = (puzzleArray, emptyCell, num) => {
// -1 is return value of .find() if value not found
return puzzleArray[ emptyCell.rowIndex ].indexOf(num) == -1
}
const colSafe = (puzzleArray, emptyCell, num) => {
return !puzzleArray.some(row => row[ emptyCell.colIndex ] == num )
}
const boxSafe = (puzzleArray, emptyCell, num) => {
boxStartRow = emptyCell.rowIndex - (emptyCell.rowIndex % 3) // Define top left corner of box region for empty cell
boxStartCol = emptyCell.colIndex - (emptyCell.colIndex % 3)
let safe = true
for ( boxRow of [0,1,2] ) { // Each box region has 3 rows
for ( boxCol of [0,1,2] ) { // Each box region has 3 columns
if ( puzzleArray[boxStartRow + boxRow][boxStartCol + boxCol] == num ) { // Num is present in box region?
safe = false // If number is found, it is not safe to place
}
}
}
return safe
}
const safeToPlace = ( puzzleArray, emptyCell, num ) => {
return rowSafe(puzzleArray, emptyCell, num) &&
colSafe(puzzleArray, emptyCell, num) &&
boxSafe(puzzleArray, emptyCell, num)
}
/*--------------------------------------------------------------------------------------------
--------------------------------- Obtain Next Empty Cell -------------------------------------
--------------------------------------------------------------------------------------------*/
const nextEmptyCell = puzzleArray => {
const emptyCell = {rowIndex: "", colIndex: ""}
puzzleArray.forEach( (row, rowIndex) => {
if (emptyCell.colIndex !== "" ) return // If this key has already been assigned, skip iteration
let firstZero = row.find( col => col === 0) // find first zero-element
if (firstZero === undefined) return; // if no zero present, skip to next row
emptyCell.rowIndex = rowIndex
emptyCell.colIndex = row.indexOf(firstZero)
})
if (emptyCell.colIndex !== "" ) return emptyCell
// If emptyCell was never assigned, there are no more zeros
return false
}
/*--------------------------------------------------------------------------------------------
--------------------------------- Generate Filled Board -------------------------------------
--------------------------------------------------------------------------------------------*/
const fillPuzzle = startingBoard => {
const emptyCell = nextEmptyCell(startingBoard)
// If there are no more zeros, the board is finished, return it
if (!emptyCell) return startingBoard
// Shuffled [0 - 9 ] array fills board randomly each pass
for (num of shuffle(numArray) ) {
// counter is a global variable tracking the number of iterations performed in generating a puzzle
// Most puzzles generate in < 500ms, but occassionally random generation could run in to
// heavy backtracking and result in a long wait. Best to abort this attempt and restart.
// 20_000_000 iteration maximum is approximately 1.3 sec runtime.
// See initializer function for more
counter++
if ( counter > 20_000_000 ) throw new Error ("Recursion Timeout")
if ( safeToPlace( startingBoard, emptyCell, num) ) {
startingBoard[ emptyCell.rowIndex ][ emptyCell.colIndex ] = num // If safe to place number, place it
// Recursively call the fill function to place num in next empty cell
if ( fillPuzzle(startingBoard) ) return startingBoard
// If we were unable to place the future num, that num was wrong. Reset it and try next value
startingBoard[ emptyCell.rowIndex ][ emptyCell.colIndex ] = 0
}
}
return false // If unable to place any number, return false, which triggers previous round to go to next num
}
const newSolvedBoard = _ => {
const newBoard = BLANK_BOARD.map(row => row.slice() ) // Create an unaffiliated clone of a fresh board
fillPuzzle(newBoard) // Populate the board using backtracking algorithm
return newBoard
}
/*--------------------------------------------------------------------------------------------
--------------------------------- Generate Playable Board ------------------------------------
--------------------------------------------------------------------------------------------*/
const pokeHoles = (startingBoard, holes) => {
const removedVals = []
while (removedVals.length < holes) {
const val = Math.floor(Math.random() * 81) // Value between 0-81
const randomRowIndex = Math.floor(val / 9) // Integer 0-8 for row index
const randomColIndex = val % 9
if (!startingBoard[ randomRowIndex ]) continue // guard against cloning error
if ( startingBoard[ randomRowIndex ][ randomColIndex ] == 0 ) continue // If cell already empty, restart loop
removedVals.push({ // Store the current value at the coordinates
rowIndex: randomRowIndex,
colIndex: randomColIndex,
val: startingBoard[ randomRowIndex ][ randomColIndex ]
})
startingBoard[ randomRowIndex ][ randomColIndex ] = 0 // "poke a hole" in the board at the coords
const proposedBoard = startingBoard.map ( row => row.slice() ) // Clone this changed board
// Attempt to solve the board after removing value. If it cannot be solved, restore the old value.
// and remove that option from the list
if ( !fillPuzzle( proposedBoard ) ) {
startingBoard[ randomRowIndex ][ randomColIndex ] = removedVals.pop().val
}
}
return [removedVals, startingBoard]
}
/*--------------------------------------------------------------------------------------------
--------------------------------- Initialize -------------------------------------
--------------------------------------------------------------------------------------------*/
function newStartingBoard (holes) {
// Reset global iteration counter to 0 and Try to generate a new game.
// If counter reaches its maximum limit in the fillPuzzle function, current attemp will abort
// To prevent the abort from crashing the script, the error is caught and used to re-run
// this function
try {
counter = 0
let solvedBoard = newSolvedBoard()
// Clone the populated board and poke holes in it.
// Stored the removed values for clues
let [removedVals, startingBoard] = pokeHoles( solvedBoard.map ( row => row.slice() ), holes)
return [removedVals, startingBoard, solvedBoard]
} catch (error)
return newStartingBoard(holes)
}
}
// The board will be completely solved once for each item in the empty cell list.
// The empty cell array is rotated on each iteration, so that the order of the empty cells
// And thus the order of solving the game, is different each time.
// The solution for each attempt is pushed to a possibleSolutions array as a string
// Multiple solutions are identified by taking a unique Set from the possible solutions
// and measuring its length. If multiple possible solutions are found at any point
// If will return true, prompting the pokeHoles function to select a new value for removal.
function multiplePossibleSolutions (boardToCheck) {
const possibleSolutions = []
const emptyCellArray = emptyCellCoords(boardToCheck)
for (let index = 0; index < emptyCellArray.length; index++) {
// Rotate a clone of the emptyCellArray by one for each iteration
emptyCellClone = [...emptyCellArray]
const startingPoint = emptyCellClone.splice(index, 1);
emptyCellClone.unshift( startingPoint[0] )
thisSolution = fillFromArray( boardToCheck.map( row => row.slice() ) , emptyCellClone)
possibleSolutions.push( thisSolution.join() )
if (Array.from(new Set(possibleSolutions)).length > 1 ) return true
}
return false
}
// This will attempt to solve the puzzle by placing values into the board in the order that
// the empty cells list presents
function fillFromArray(startingBoard, emptyCellArray) {
const emptyCell = nextStillEmptyCell(startingBoard, emptyCellArray)
if (!emptyCell) return startingBoard
for (num of shuffle(numArray) ) {
pokeCounter++
if ( pokeCounter > 60_000_000 ) throw new Error ("Poke Timeout")
if ( safeToPlace( startingBoard, emptyCell, num) ) {
startingBoard[ emptyCell.rowIndex ][ emptyCell.colIndex ] = num
if ( fillFromArray(startingBoard, emptyCellArray) ) return startingBoard
startingBoard[ emptyCell.rowIndex ][ emptyCell.colIndex ] = 0
}
}
return false
}
// As numbers get placed, not all of the initial cells are still empty.
// This will find the next still empty cell in the list
function nextStillEmptyCell (startingBoard, emptyCellArray) {
for (coords of emptyCellArray) {
if (startingBoard[ coords.row ][ coords.col ] === 0) return {rowIndex: coords.row, colIndex: coords.col}
}
return false
}
// Generate array from range, inclusive of start & endbounds.
const range = (start, end) => {
const length = end - start + 1
return Array.from( {length} , ( _ , i) => start + i)
}
// Get a list of all empty cells in the board from top-left to bottom-right
function emptyCellCoords (startingBoard) {
const listOfEmptyCells = []
for (const row of range(0,8)) {
for (const col of range(0,8) ) {
if (startingBoard[row][col] === 0 ) listOfEmptyCells.push( {row, col } )
}
}
return listOfEmptyCells
}
@SirPhemmiey
Copy link

Hey Dan, helpful script right here.

But how to run it? What's the init function to run?

@dsasse07
Copy link
Author

function newStartingBoard(holes){…} is used to initialize a new board and retrieve the solution map. You could pass the starting board in to function fillPuzzle(startingBoard){…} to solve directly if the solution map is not available

@SirPhemmiey
Copy link

SirPhemmiey commented Aug 23, 2022 via email

@dsasse07
Copy link
Author

holes Is the number of empty spaces you would like for the board starting board to have. As a note, the more empty spaces you desire the longer it will take to generate a board as it needs to ensure that there is only one valid solution to the board as it removes additional values. The max I have tried this with was 57

@SirPhemmiey
Copy link

SirPhemmiey commented Aug 23, 2022 via email

@SirPhemmiey
Copy link

SirPhemmiey commented Aug 24, 2022 via email

@gebardas
Copy link

Very fast code. But initialization of the pokeCounter variable is missing!

@dsasse07
Copy link
Author

@SirPhemmiey Im not sure why the base case isn’t being hit for you to stop the stack overflow. It’s been a while since I wrote this. When I have some free time I’ll debug a bit and comment back

@SirPhemmiey
Copy link

SirPhemmiey commented Oct 11, 2022 via email

@nelgin
Copy link

nelgin commented Nov 12, 2023

First puzzle I got is not solvable. It has multiple solutions.

847 |  9  | 1 5
39  |     |  27
    |   8 |  4 
---------------
62  |   3 |    
5   | 62  |    
9   | 184 |  56
---------------
45  | 9 7 | 618
768 | 2   |   3
1 9 |  6  | 47 

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment