And make some cool visualisations along the way
I was recently invited to a French CS community which was running monthly coding contests, the current one was about solving nonograms. Around the same time I got into making JavaScript based visualisations, so I submitted an animated Nonogram solver as my entry to the contest, and created a new Markdown flavour which is used to write this article.
Nonograms are really similar to the 90s hit game Sudoku, in the sense that they both involve filling up a grid. Instead of filling the grid with numbers 1 to 9, a completed Nonogram consists of cells coloured black and white - making it far more visually appealing as they can be made to show an image when completed.
Have a look at this cool Nonogram of a rabbit!
What you are seeing now are the steps that the solver took to complete the Nonogram.
Before writing that, we need to teach the human how to solve Nonograms.
Let's start with something simpler.
An unsolved Nonogram is an empty grid with rows of numbers to the left and above - each of these rows gives the requirement on how the row should be filled.
See the example below
Focus just on the first row, the rule for it is just 3 - meaning there are 3 connected black cells on this row.
Colouring the 3 black cells as so would satisfy the rule.
Moving on, the second row has 4 connected black cells, this is one way to colour the cells.
The third row has a 2-block and a 1-block, which are both present.
Continue until the entire grid is filled.
Then verify that the rules for each vertical column are satisfied as well.
Now that we know the rules of a Nonogram, let's figure out some specific techniques used to solve it, so we can use them in the solver.
Oh, looks like we need a new board for that.
Similar to Sudoku, most Nonograms can be solved by repeatedly taking obvious steps until it is solved completely.
Starting from the most obvious one, a row with 0 black squares is just an empty row. We mark a cell empty to indicate we are certain it is white.
Similarly, a row with 5 black squares would just be a full row.
Consider a 2-2 row, there is only one way to colour this row.
This is also true for a 1-3 row, or a 1-1-1 row. In general, there is only one way to fill a row with cells if .
A full row is just a special case of this.
Consider a 4 row, there are two ways to fill it.
We colour (or mark) only the cells that we are certain of. Although we don't know which is the correct way of filling the row, we are certain about what's common in both the cases - the middle three squares.
Convince yourself a that similar process can be used to partially solve a 3 row or a 2-2 row. The Really Obvious Steps is just a special case of this (with only one case).
Consider the following row.
Since the forth cell is white, it is obvious the only one way to fill this row is as below. How can a computer know this?
Have a go at some examples that can be fully or partially solved.
With what's said, let's see how these techniques are used to solve a Nonogram.
Look at the entire board to see if there is a row that can be solved.
For example, this 3-1 row.
The 4-column gives three black cells.
The 2 row can be partially solved. How? (hint: make a list of all possible ways to fill the row)
As exercise, try solve for each row before revealing the solution to it.
Filling in the rest of the board.
Which is our completed Nonogram.
As a quick recap, we just solved a Nonogram in three simple steps:
Starting with the smallest item each cells can have three states:
enum CellState {
Blackened,
Marked,
Empty
}
For a Nonogram of dimension
width
= and height
= .int[]
. The first rules are the rules for the horizontal rows, the rest of the lines are the rules for the vertical columns.CellState
.struct Board {
width: u32, // int
height: u32, // int
rules: Vec<Vec<u32>>, // int[][]
state: Vec<Vec<CellState>>, // CellState[][]
}
We also declare a few helper functions, which implementation will be omitted. Invalid Rust code is also used for better readability. The actual solver include some optimisations which are not shown here.
impl Board {
// Create an empty board given dimensions and rules.
fn new(w: u32, height: u32, rules: Vec<Vec<u32>>) -> Board;
// Returns true if there are no empty cells
fn is_solved() -> bool;
// Returns the value of m + n
fn row_count(&self) -> u32;
// If i < n, return the rule of a row
// If n ≤ i, return the rule of a column
fn get_rule(&self, i: u32) -> Vec<u32>;
// If i < n, return the cells of a row
// If n ≤ i, return the cells of a column
fn get_row(&self, i: u32) -> Vec<CellState>;
// Returns None if there is a conflict
// Returns a copy of Board otherwise,
// so the original copy is never modified.
fn with_row(&self, i: u32, state: Vec<CellState>) -> Option<Board>;
}
Notice that with_row
may fail. This is because a cell is only filled if we are absolutely sure of its colour - if a cell is shaded black (it is certainly black) then later marked white (it is certainly white), this would not be valid! In this case, either
How do we know if a row can be fully or partially solved?
If we make a list of all possible ways to fill a row, and find what's common between all those cases, those are the cells we can fill in for now.
Create a function that returns a list of all possible ways to fill the row given the rules.
fn possible_cases(rule: &Vec<u32>, row_length: u32)
-> Vec<Vec<CellState>> {
// If the rule is just a `0`
// The only possible fill is
// a list of `row_length` white cells
if rule == vec![0] {
return vec![ vec![CellState::Marked; row_length] ]
}
let first_chunk: u32 = rule.first();
// If the chunk is wider than there are space left,
// There are 0 possible ways to fill the row,
// So the list of ways is empty.
if first_chunk > row_length {
return vec![]
}
let mut possible_cases = vec![];
if rule.len() == 1 {
// If this is the only chunk in the rule,
// Then you can put it anywhere in the row.
let possible_locations = 0..(row_length - first_chunk + 1);
// [marked cells] [black cells] [marked cells]
// <----------- width = row_length ---------->
// <------------> = start_location
for start_location in possible_locations {
let mut single_possibility = vec![];
let blank_cells_after = row_length - first_chunk
- start_location;
single_possibility.append(
vec![CellState::Marked; start_location]);
single_possibility.append(
vec![CellState::Blackened; first_chunk]);
single_possibility.append(
vec![CellState::Marked; blank_cells_after]);
possible_cases.push(single_possibility)
}else {
} // There are chunks after this,
// You need to leave at least one empty space afterwards
// to separate the next chunk from the current one
let possible_locations = 0..(row_length - first_chunk);
let mut possible_cases = vec![];
for start_location in possible_locations {
let row_length_remaining = row_length - first_chunk
- start_location - 1;
// This holds the row with the chunks at start_location
// And a marked cell afterwards to
// separate it from the next chunk.
let mut incomplete_row = vec![];
incomplete_row.append(
vec![CellState::Marked; start_location]);
incomplete_row.append(
vec![CellState::Blackened; first_chunk]);
incomplete_row.push(CellState::Marked);
// Recursion!
// Get a list of possible ways to
// fill the remaining chunks in the remaining space
let possible_cases_remaining =
possible_cases(rules[1..], row_length_remaining);
// For each of those ways,
// Append it to the incompleted row
for remaining_case in possible_cases_remaining {
let completed_row = incomplete_row.clone();
completed_row.append(remaining_case);
possible_cases.push(completed_row)
}
}
possible_cases
}
}
The row may already be partially filled, from the list of all possible cases, get rid of the ones that conflicts with the partially filled row, since once shaded, the colour of the cell cannot be changed.
// Returns false if there is a conflict between the current row
// Otherwise true
fn no_conflict(
current_row: &Vec<CellState>,
possibile_row: &Vec<CellState>)
-> bool {
for (current, possible) in current_row.zip(possibile_row) {
// Do not allow if trying to assign
// a different value to a non-empty cell
if current != CellState::Empty && current != possible {
return false
}
}
true
}
// Use step 1 to get a list of all valid possibilities
fn valid_possibilities(
current_row: &Vec<CellState>,
rule: &Vec<u32>)
-> Vec<Vec<CellState>> {
possible_cases(rule, current_row.len())
.filter(|possibile_row|
no_conflict(current_row, possibile_row))
.collect()
}
Find what's common between the possible cases, and apply them to the row.
// Find cells shaded the same in all possibilities
fn common_cells(current_row: &Vec<CellState>, rule: &Vec<u32>)
-> Option<Vec<CellState>> {
let possibilities = valid_possibilities(current_row, rule);
// There are no valid ways to fill the row
if possibilities.is_empty() {
return None
}
let mut commons = vec![CellState::Empty; rule.len()];
// If a cell is the same as the first possibility
// in all possibilities
for (i, cell) in possibilities.first().enumerate() {
if commons.all(|row| row[i] == cell) {
commons[i] = cell;
}
}
Some(commons)
}
// Apply common_cells to progress the row
fn progress_row(current_row: &Vec<CellState>, rule: &Vec<u32>)
-> Option<Vec<CellState>> {
// Return None if common_cells returns None
let commons = common_cells(current_row, rule)?;
let progressed_row = current_row.clone();
// Apply changes
for (common, new_state) in commons.zip(progress_row.iter_mut()) {
if common != CellState::Empty {
*new_state = common;
}
}
Some(progress_row)
}
Since each of these function is ran only once when progress_row
is called, the overall time complexity of progress_row
is just .
We now have a function progress_row
that will try to progress a row as far as possible, we will use it to write a function that progress the board as far as possible.
progress_row
function. This overwhelmingly becomes considering the huge time complexity of progress_row
compared to other parts of the algorithm.fn progress_board(board: &Board) -> Option<Board> {
let mut board = board.clone();
// Iterate through all the rows and columns, and repeat
loop {
let mut changed_rows = 0;
for i in 0..board.row_count() {
let current_row = board.get_row(i);
let rule = board.get_rule(i);
// Return None if progress_row returns None
let progressed_row = progress_row(current_row, rule)?;
// Only update if the row has been changed
if progress_row != current_row {
board = board.with_row(i, progress_row);
changed_rows += 1;
}
}
// Stops the function either when the board is solved fully
// Or if the last iteration did not change anything
// If we dont quit the function,
// it will change nothing in the next iteration, and so on
if board.is_solved() || changed_rows == 0 {
return Some(board);
}
}
}
What would cause progress_board
to be stuck and exit without solving the board completely? For most Nonograms, including the Nonogram showcased at the beginning, progress_board
can solve them by repeatedly taking obvious steps.
Not all Nonograms can be solved by taking obvious steps, for example:
This is one possible solution of the Nonogram.
And here is another possible solution.
Focusing on only one row, the two solutions have no cells in common.
The progress_row
function works by finding common cells between different ways of filling the row, and therefore will fail in this situation.
In this case, we resort to blind guessing.
This square is either black or white.
Suppose it is white, then the row has an obvious solution, and so does the board. At which point we can run progress_board
to solve the board completely.
If the guess wasn't correct (although in this case it is), we can simply roll that back and try it with a black cell instead.
Since a cell is either black or white, one of the two guesses has to be correct.
We allow guessing the shading of one cell if the algorithm failed to make any progress. Then apply progress_board
to see if the board can be progressed without guessing.
progress_board
to go as far as possible, then alternate between blind guessing (backtracking) and progress_board
until the board is solved.// This function solves a board completely
// Returns None if it is impossible to do so
fn solve(board: &Board) -> Option<Board> {
// If progress_board returns None,
// then the board cannot be solved,
// So return None
let progressed_board = progress_board(board)?;
// progress_board stops only for 2 reasons:
// - the board is solved
// - there are no obvious steps
if progressed_board.is_solved() {
Some(progressed_board)
else {
} // Pick an empty cell, implementation omitted
let cell_x = todo!();
let cell_y = todo!();
// If the board is possible to solve
// One of these guesses must be correct
let possibility_1 = board.blacken(cell_x, cell_y);
let possibility_2 = board.mark(cell_x, cell_y);
let solved_1 = solve(possibility_1);
if solved_1.is_some() {
// Possiblitly 1 is correct
// And here's the solved board
return solved_1
else {
} return solve(possibility_2)
}
}
}
The specifics in implementation could improve performance of the algorithm.
There may also be faster ways to find common cells, who knows.
You can define your own Nonogram below and watch the solver solve it:
m n
states the number of rows m
and columns n
of the Nonogram.m
lines are the rules for rows, followed by n
lines of rules for columns.Then just scroll down to see the solver does its work.
Scroll down to see the Nonogram.
Almost there...
This is a submission to the Devos Code Contest. Article source code available on GitHub.