JavaScript Sudoku solver

My wife has developed a passion for Sudoku. I do not share it, I've always thought that this is a problem for computers, not humans, so I've never tried to solve one by hand. However, even if it's a high school problem, I thought it'd be a cute little program to work on. This is the obligatory blog post about it.

Update: see bestest version.

Problem definition

It's pretty simple. You are given a board like this:

You have to fill every empty cell with a non-zero digit [1..9], such that no digit appears twice on the same column, row, or within the same 3x3 square. Click any empty field to see a list of allowed digits.

It's a search problem, so the tool that comes to mind is backtracking. Some developers have an anxiety about this word, as if it's some kind of weird black magic, but really, it's pretty simple. I'll describe here my sudoku solver.

Board representation

Since it has 81 cells, we'll keep it as an array with 81 digits (zero for empty cells). It will be convenient to access it both using an index 0..80, or as (row, column), so we'll have a couple of functions to convert between the two:

// index -> { row, col }
function i2rc(index) {
    return { row: Math.floor(index / 9), col: index % 9 };
}

// { row, col } -> index
function rc2i(row, col) {
    return row * 9 + col;
}

Next, we need a function that tells us what are the available choices, that is, what digits are acceptable in a given cell (I'm also gonna call them “moves”). Here's a reasonable implementation:

function getChoices(board, index) {
    let choices = [];
    for (let value = 1; value <= 9; ++value) {
        if (acceptable(board, index, value)) {
            choices.push(value);
        }
    }
    return choices;
}

The board is our array with 81 elements, and index is 0..80 — this function will return an array of digits that are currently permitted on the cell at this index, according to the rules of the puzzle.

The actual rules of the puzzle are implemented in the function acceptable: we can use a digit if it's not already used on the same row, column or in the same 3x3 square. So here it is:

function acceptable(board, index, value) {
    let { row, col } = i2rc(index);

    // if already present on the column, not acceptable
    for (let r = 0; r < 9; ++r)
        if (board[rc2i(r, col)] == value) return false;

    // if already present on the row, not acceptable
    for (let c = 0; c < 9; ++c)
        if (board[rc2i(row, c)] == value) return false;

    // if already present in the same 3x3 square, also not acceptable
    let r1 = Math.floor(row / 3) * 3;
    let c1 = Math.floor(col / 3) * 3;
    for (let r = r1; r < r1 + 3; ++r) {
        for (let c = c1; c < c1 + 3; ++c) {
            if (board[rc2i(r, c)] == value) return false;
        }
    }

    // we have a "go"
    return true;
}

There are optimizations we could make (we could use a single loop, and we could skip converting between index/row,column with some clever tricks) but let's not bother. We better start working on the problem now, that is, let's write a function that figures out the solution for a given puzzle.

The “brute force” approach

As the name sounds, this is pretty dumb. We'll iterate through the empty cells, assign to each one of the acceptable digits and try to move as far as we can. If we can manage to fill the whole board, then the problem is solved, and if we get to a point where there are no acceptable digits, we have to “backtrack” — reach back to the previous cell where we had multiple choices, and pick something else. It's trivial to write this function. For convenience, let's assume board is an external variable; our solver function only has to receive the current index — the cell that it currently deals with. It will return true if it found a solution, and false otherwise.

function solve(index) {
    while (index < 81 && board[index]) ++index; // skip non-empty cells
    if (index == 81) return true;               // we filled'em all, success!
    let moves = getChoices(board, index);
    for (let m of moves) {
        board[index] = m;              // try one choice
        if (solve(index + 1))          // if we can solve for the next cell
            return true;               // then return true, success!
    }
    board[index] = 0;  // no move worked; we failed, clear the cell
    return false;      // and backtrack
}

You can play with it below, select a few samples (or clear the board and enter your own). “Solve!” will solve it instantly, while “Play” will step through it so you can see how it fills the cells, and how it backtracks when it needs to try different values. When it's done it will tell you how many moves it had to take back. Note that “Play” will take a long time on this unoptimized version, so don't bother to wait for it to complete; you can use “Pause” or “Reset” to stop it.

If you try the the “really hard” puzzle, the brute-force search has to backtrack 8.9 million times and takes a few seconds.

An improvement: move ordering

How does a human approach the problem? Intuitively, we look at the whole board and pick the cells where we have little choices. Ideally, if we can determine that only one value is possible, then we write it down and forget about it.

This leads us to the “greedy” search: instead of trying values for each cell in order, fill first those cells where we have the fewest acceptable choices — that is, there's a smaller probability that we'll change our mind about it. Here's the new solve function:

function solve() {
    let { index, moves } = bestBet(board);    // find the best place to fill
    if (index == null) return true;           // we filled'em all, success!
    for (let m of moves) {
        board[index] = m;                     // try one choice
        if (solve()) return true;             // if we solved further, success!
    }
    board[index] = 0;                         // no digit fits here, backtrack!
    return false;
}

So this time our function doesn't need to take the index as an argument, instead it'll determine the next index to fill using bestBet — a new function that will return the index of the cell with the fewest possible moves. Since, in order to figure that out, it needs to compute the moves too, it will return them as well, so that we avoid another call to getChoices. Here is bestBet:

function bestBet(board) {
    let index, moves, bestLen = 100;
    for (let i = 0; i < 81; ++i) {
        if (!board[i]) {
            let m = getChoices(board, i);
            if (m.length < bestLen) {
                bestLen = m.length;
                moves = m;
                index = i;
                if (bestLen == 0) break;
            }
        }
    }
    return { index, moves };
}

Below you can play with the new algorithm (or rather, it's the same algorithm, it just tries moves in a different order) and you can see that it makes a world of difference (look at the number of take-backs, compared to the previous implementation). On this new version, “Play” will complete within a few seconds for any of the medium examples.

We can notice a significant reduction of the search tree (much fewer take-backs, for example the “really hard” puzzle backtracks 430K instead of 8.9M times). However for most puzzles the actual run time is a bit higher! This got me thinking. Every time we call search(), bestBet will repeat most work it already did on the previous iteration, looking for the best place to fill and computing moves for every single field, again. To make this optimization worthy of its cost we need to add some ugliness (with some clever tricks in bestBet we could cache part of the results), but I won't do it here.

More human touch

Maybe I'd stop here, but my wife, who actually solves sudoku with her brain, told me a little trick that I didn't consider (I probably would have thought about it, had I ever tried to solve sudoku by hand). Look at the board below and focus on the yellow cell. Click on it to see what my program thinks are the available moves.

You see, it claims that the allowed digits for that field are 2, 4, 5, 8, 9, and that's correct by the rules, but now if you look at the pink fields, you can see that 8 is not allowed on the 4th and 6th columns, nor on the 3rd row, because an 8 is already there. And still we know that the top-middle 3x3 square must contain an 8, and this leaves the yellow cell as the only option; clearly we have an unique choice there, we can fill it immediately and there's no need to try 2, 4, 5 or 9.

Implementing this new optimization is not hard and makes a new world of difference, because it applies not only to the initial configuration but also after every subsequent move that the algorithm tries. Check the new numbers:

The “really hard” puzzle now takes less than half a second and only backtracks 26K times, a huge reduction of the search tree.

To implement this new optimization we only have to improve getChoices, using a new utility function which implementes the trick:

function getChoices(board, index) {
    let choices = [];
    for (let value = 1; value <= 9; ++value) {
        if (acceptable(board, index, value)) {
            if (unique(board, index, value))
                return [ value ]; // it's useless to try anything else
            else
                choices.push(value);
        }
    }
    return choices;
}

function unique(board, index, value) {
    let { row, col } = i2rc(index);
    let r1 = Math.floor(row / 3) * 3;
    let c1 = Math.floor(col / 3) * 3;
    for (let r = r1; r < r1 + 3; ++r) {
        for (let c = c1; c < c1 + 3; ++c) {
            let i = rc2i(r, c);
            if (i != index && !board[i] && acceptable(board, i, value)) {
                return false;
            }
        }
    }
    return true;
}

The code

Here is the script used for the boards in this page. The code isn't exactly pretty, I made it just for the purpose of this blog post. Use it like this:

import { Sudoku } from "sudoku.js";
let sdk = new Sudoku(document.querySelector("#container"), {
    smartOrdering: true,
    smartChoices: true
});
sdk.writeBoard([
    0, 0, 0,  0, 0, 0,  0, 0, 6,
    0, 3, 0,  0, 7, 1,  0, 4, 0,
    0, 0, 0,  0, 0, 0,  8, 0, 0,

    0, 0, 0,  9, 0, 8,  0, 7, 1,
    1, 0, 3,  0, 0, 0,  0, 0, 0,
    0, 0, 2,  0, 3, 0,  9, 0, 0,

    5, 0, 7,  0, 0, 6,  0, 0, 0,
    2, 0, 0,  0, 0, 0,  7, 0, 0,
    0, 0, 1,  8, 0, 0,  0, 0, 2,
], true);

Fastest version yet (see below)

Update: leaving this here, but I've got a better version in the next section.

“The rest is engineering”. I couldn't figure out any more algorithmic improvements, but I couldn't help it and I've micro-optimized the heck out of it. Here's the “really hard” puzzle on the best version I've got:

If you're wondering what changed, look at the acceptable function (I used a single loop to iterate across both row and column; using the same loop to iterate through the 3x3 square seems to do more harm than good). Also at bestBet — I inlined getChoices into it and instead of building an array of moves, we bit-code them into an integer in order to avoid allocations, because the garbage collector costs time. And solve was updated accordingly.

Bestest version :)

With even more engineering, but also an important improvement in the unique function, the “Really Hard™” puzzle turns out to be Actually Quite Simple™ and the algorithm solves it in just a few milliseconds with only 7 take-backs. Here is the bestest version.

Note: the hardest puzzle in this example is actually the one named “Harder”. It doesn't seem to be designed for humans, as no digit can be placed safely in the initial configuration (or I can't see it). The computer has to guess 1209 times (meaning, there are as many situations in which it can't place a safe digit and has to chose among multiple options).

The new unique function checks for uniqueness not only within the 3x3 block, but also on the whole row and column, which again drastically reduces the search tree. As for the engineering improvements: instead of storing digits as decimals, they are now bit-coded (each digit's corresponding bit is set). This allows us to use bitwise operations to detect available choices (getChoices was renamed getMoves), which turns out to be much faster. There are other improvements as well, but this post is a bit too long already, feel free to check the code.

Two comments, oh gees! Speak to me!

Add your comment

# Mishoo
2021-06-27 15:31
Comments are now open, just testing...
# Test
2021-06-27 15:32
Anonymous test
Welcome! (login)
May
15
2021

JavaScript Sudoku solver

  • Published: 2021-05-15
  • Modified: 2021-06-05 15:34
  • By: Mishoo
  • Tags: javascript, backtracking, sudoku
  • Comments: 2 (add)