11. N-Queens#
11.1. Bitwise Operators in Python#
One can get the binary string for an integer using f-strings.
>>> f"1:b"
1
>>> f"2:b"
10
>>> f"3:b"
11
>>> f"4:b"
100
>>> f"9:b"
1001
When reading the binary representation, start from the right. So,
1001
is 1 + 8
.
There are several bitwise operators in python. Here are some examples for the and and or operators.
>>> x = 3; print(f"{x:b}")
'11'
>>> y = 5; print(f"{y:b}")
'101'
>>> f"x|y : {x|y} : {x|y:b}"
'x|y : 7 : 111'
>>> f"x&y : {x&y} : {x&y:b}"
'x&y : 1 : 1'
The operators are for shifting bits left and right: x << y
shifts
x
’s bits to the left by y
places, and x << y
shifts
x
’s bits to the right by y
places.
>>> x = 3; print(f"{x:b}")
'11'
>>> f"{x << 1:b}"
'110'
>>> f"{x << 2:b}"
'1100'
>>> f"{x >> 1:b}"
'1'
>>> f"{x >> 2:b}"
'0'
11.2. Solving N-Queens in a Functional Way#
In the cpython testing suite, there is a class for solving the N-Queens problem
called Queens
.
class Queens:
def __init__(self, n):
self.n = n
rangen = range(n)
# Assign a unique int to each column and diagonal.
# columns: n of those, range(n).
# NW-SE diagonals: 2n-1 of these, i-j unique and invariant along
# each, smallest i-j is 0-(n-1) = 1-n, so add n-1 to shift to 0-
# based.
# NE-SW diagonals: 2n-1 of these, i+j unique and invariant along
# each, smallest i+j is 0, largest is 2n-2.
# For each square, compute a bit vector of the columns and
# diagonals it covers, and for each row compute a function that
# generates the possibilities for the columns in that row.
self.rowgenerators = []
for i in rangen:
rowuses = [(1 << j) | # column ordinal
(1 << (n + i-j + n-1)) | # NW-SE ordinal
(1 << (n + 2*n-1 + i+j)) # NE-SW ordinal
for j in rangen]
def rowgen(rowuses=rowuses):
for j in rangen:
uses = rowuses[j]
if uses & self.used == 0:
self.used |= uses
yield j
self.used &= ~uses
self.rowgenerators.append(rowgen)
# Generate solutions.
def solve(self):
self.used = 0
for row2col in conjoin(self.rowgenerators):
yield row2col
def printsolution(self, row2col):
n = self.n
assert n == len(row2col)
sep = "+" + "-+" * n
print(sep)
for i in range(n):
squares = [" " for j in range(n)]
squares[row2col[i]] = "Q"
print("|" + "|".join(squares) + "|")
print(sep)
Let us talk through how this code works. The constructor has the argument
n
which is the size of the board. A list called rowgenerators
is then created which stores the columns, and diagonals that are covered by
each square. The columns/diagonals covered by each square are stored as a
binary strings created in the rowuses
list comprehension.
11.3. Representing the board in rowuses
#
The rowuses
variable is a list where each element represents the
columns, NE-SW, and NW-SE diagonals that are covered by each square in the row.
The coverage of each square is encoded in a binary string. Let us breakdown the
operations that make these strings by first looking at an example. Let us look
at the output for the first row when n
is 4.
>>> n = 4
>>> i = 0
>>> rowuses = [
... (1 << j) |
... (1 << (n + i-j + n-1)) |
... (1 << (n + 2*n-1 + i+j))
... for j in rangen]
>>> rowuses = [2177, 4162, 8228, 16408]
The list rowuses
is a list of seemingly random integers. These integers
represent the columns and diagonals that each square in the first row cover.
Look at the binary representation of the first number.
>>> f"{2177:b}"
'100010000001'
This string contains has three 1’s. The top left square covers 1 column, 1 NW-SE diagonal, and 1 NE-SW diagonal. Perhaps each of these 1’s represent the columns and diagonals? In fact, the right most digit is a 1, perhaps this means the first column is covered?
We have a few ideas so let us breakdown what each term in the list comprehension does.
11.4. Term 1 (1 << j)
#
In the comment, it says the first term is for the column ordinal. In other
words, the first term deals with representing the columns that are covered. Let
us check the values of (1 << j)
for the different values of j
.
>>> f"{1 << 0:b}"
1
>>> f"{1 << 1:b}"
10
>>> f"{1 << 2:b}"
100
>>> f"{1 << 3:b}"
1000
These binary strings are used to represent covers columns 1, 2, 3, and 4
respectively. In the binary for the top left square, 100010000001
, the
1 on the right tells us this square covers the first column.
11.5. Term 2 (1 << n + i-j + n-1)
#
This term handles the NW-SE diagonals. Recall the comment about the NW-SE diagonals.
# NW-SE diagonals: 2n-1 of these, i-j unique and invariant along
# each, smallest i-j is 0-(n-1) = 1-n, so add n-1 to shift to 0-
# based.
Let us break down each part of this comment. There are 2n-1
NW-SE
diagonals. There are n-1
NW-SE diagonals that start on the left side of
the board (not including the diagonal) and there are n-1
NW-SE
diagonals that start along the top of the board (not including the diagonal).
Including the diagonal, we have n-1 + n-1 + 1 = 2n-1
NW-SE diagonals.
Taking the column index j
from the row index i
gives a unique
integer for each diagonal that does not change along the diagonal. Let us write
out i-j
for n
is 4
.
0 |
-1 |
-2 |
-3 |
1 |
0 |
-1 |
-2 |
2 |
1 |
0 |
-1 |
3 |
2 |
1 |
0 |
There is a unique integer for each NW-SE diagonal and the integer does not
change along the diagonal. Negative integers are handled slightly differently
in python, so to keep everything simple add a buffer of n-1
. Doing
this, the matrix becomes.
3 |
2 |
1 |
0 |
4 |
3 |
2 |
1 |
5 |
4 |
3 |
2 |
6 |
5 |
4 |
3 |
So, in 1 << n + i-j + n-1
, the term i-j
uniquely represents the
NW-SE diagonal that the square covers and n-1
is used to make the
negative i-j
’s positive. So what does the final n
do? The final
n
is to make sure the digits from the 1 << j
term are not
overwritten.
So, i << n + i-j + n-1
turns on the bit which uniquely represents the
NW-SE diagonal that the square covers without overwriting the bit which covers
the column the square covers.
What is the minimum value of 1 << n + i-j + n-1
? The minimum value
occurs when i
and j
are zero. When n
is 4
, this
means,
>>> f"{1 << 4 + 0 - 0 + 3 << 0:b}"
'10000000'
Including the first term, we get,
>>> f"{1 << 0 | 1 << 4 + 0 - 0 + 3 << 0:b}"
'10000001'
The 1
on the right represents the first column being covered, the
1
on the left represents the main diagonal being covered.
11.6. Term 3 (1 << (n + 2*n-1 + i+j))
#
Term 3 is very similar to term 2. This term covers the NE-SW diagonals. Recall the comment for these diagonals.
# NE-SW diagonals: 2n-1 of these, i+j unique and invariant along
# each, smallest i+j is 0, largest is 2n-2.
By the same logic as before, there are 2n-1
NE-SW diagonals. Let us
write out the i + j
terms when n
is 4.
0 |
1 |
2 |
3 |
1 |
2 |
3 |
4 |
2 |
3 |
4 |
5 |
3 |
4 |
5 |
6 |
Each NE-SW diagonal is represented by a unique integer. All these integers are
positive, so we do not have to add n-1
like before. So, in term 3,
i+j
is used to uniquely identify the NE-SW diagonal.
We still need to make sure we do not overwrite the 1
’s from term 1 and
term 2. This is done by adding n + 2*n-1
to i+j
. Similar to
before n
ensures we do not overwrite the bits from term 1, but what
about 2*n-1
?
From term 2, the maximum value of i-j + n-1
is n-1 - 0 + n-1 =
2*n-2
. So, adding 2*n-1
ensures that we do not overwrite the 1
from term 2.
11.7. The rowgen
Method#
Recall the rowgen
method.
def rowgen(rowuses=rowuses):
for j in rangen:
uses = rowuses[j]
if uses & self.used == 0:
self.used |= uses
yield j
self.used &= ~uses
The argument for rowgen
is rowuses
which is the list of
integers integer that encode the columns, NW-SE diagonals, and NE-SW diagonals
that each square in the row covers. The method is also a generator function
since it uses the yield
keyword.
This method changes the attribute self.used
which is uninitialised in
__init__
. This variable is not initialised until solve
is
called. The self.used
is another integer which encodes the columns,
NE-SW, and NW-SE diagonals that are covered in the current solution.
So what does this method do?
Iterates through each square in the row. Squares are referred to by their column index
j
.Checks if the square,
j
, is covered by some other queen in the solution.If the square,
j
is not covered, add a queen there and thus add the columns, NE-SW, and NW-SE diagonals covered by j to the covered squares inself.used
.Yield
j
.After
j
is yielded, remove the columns, NE-SW, and NW-SE diagonals covered byj
from the covered columns and diagonals inself.used
.Try the next square and see if a queen can be put in it.
Repeat from step 2.
The row generators for each row are stored in a list.
11.8. Finding the Solutions#
Solutions are found in the solve
method. Recall the solve
method.
# Generate solutions.
def solve(self):
self.used = 0
for row2col in conjoin(self.rowgenerators):
yield row2col
This method is very short. In fact it implements a backtracking method to find
the solutions using the conjoin
method. In the same
Lib/test/test_generators.py
script there is simplified conjoin
method. This method does the same thing as conjoin
but, as the name
suggests, in a simpler way; so we will limit our discussion to
simple_conjoin
.
def simple_conjoin(gs):
values = [None] * len(gs)
def gen(i):
if i >= len(gs):
yield values
else:
for values[i] in gs[i]():
for x in gen(i+1):
yield x
for x in gen(0):
yield x
simple_conjoin
takes a list of generator functions as arguments. It
then dynamically iterates through the iterators. We limit our discussion to
just our N-queens problem.
So, gs
is our list of row generators which iterators through the
columns of a row and yield j
if a queen can be put in the j
-th
column given the current solution. For a 4x4 N-queens grid there will be 4
elements in gs
- one for each row.
The variables values
is a list of n
integers, where n
is the number of number of rows in the grid.
The gen
method recursively iterates through every row and tests every
possible solution. The rowuses
generators will only return an integer
if the queen can be put on the i
-th square without affecting the other
solutions.
Since values
stores, the current solution let’s add a couple of print
statements, and track the solutions.
def simple_conjoin(gs):
values = [None] * len(gs)
def gen(i):
if i >= len(gs):
print("'values' when 'values' is yielded: ", values)
yield values
else:
for values[i] in gs[i]():
for x in gen(i+1):
print("'values' when 'x' is yielded: ", values)
yield x
for x in gen(0):
yield x
And we replace conjoin
with simple_conjoin
in the solve
method. Let us calculate the case for n
is 4.
>>> [sol for sol in Queens(4).solve()]
'values' before 'x' is yielded: [0, None, None, None]
'values' before 'x' is yielded: [0, 2, None, None]
'values' before 'x' is yielded: [0, 3, None, None]
'values' before 'x' is yielded: [0, 3, 1, None]
'values' before 'x' is yielded: [1, 3, 1, None]
'values' before 'x' is yielded: [1, 3, 1, None]
'values' before 'x' is yielded: [1, 3, 0, None]
'values' before 'x' is yielded: [1, 3, 0, 2]
'values' when 'values' is yielded: [1, 3, 0, 2]
'values' before 'x' is yielded: [2, 3, 0, 2]
'values' before 'x' is yielded: [2, 0, 0, 2]
'values' before 'x' is yielded: [2, 0, 3, 2]
'values' before 'x' is yielded: [2, 0, 3, 1]
'values' when 'values' is yielded: [2, 0, 3, 1]
'values' before 'x' is yielded: [3, 0, 3, 1]
'values' before 'x' is yielded: [3, 0, 3, 1]
'values' before 'x' is yielded: [3, 0, 2, 1]
'values' before 'x' is yielded: [3, 1, 2, 1]
[[3, 1, 2, 1], [3, 1, 2, 1]]
>>>
Let us walk through a few of these steps.
[0, None, None, None] # Queen in top left corner
[0, 2, None, None] # 1-st row queen in 2-nd column not 1-st due to diagonal
[0, 3, None, None] # With [0, 2, None, None] cannot put queen on second row
[0, 3, 1, None]
[1, 3, 1, None] # No solution so move queen in 0-th column
[1, 3, 1, None] # Conflict in 2-nd col so move it
[1, 3, 0, None]
[1, 3, 0, 2]
[1, 3, 0, 2] # Solution!
[2, 3, 0, 2] # Incremenet the first column so start next solution ...
[2, 0, 0, 2]
[2, 0, 3, 2]
[2, 0, 3, 1]
[2, 0, 3, 1]
[3, 0, 3, 1]
[3, 0, 3, 1]
[3, 0, 2, 1]
[3, 1, 2, 1]