Intro
I put the code for this on github, it's a bit messy but works: https://github.com/jameslyons/crossword_gen.
In previous posts I have been playing with python-constraint, and building crosswords is something that interests me for some reason. I decided to do it in a two step process: step 1 generate the patterns of white and black squares, step 2 fill the patterns with letters. This post will deal with step 1 only, the next post will deal with step 2.
Generating the patterns is well suited to using a constraint solver since you can just specify the things you'd like to be true of the final result, then ask the constraint solver to do it for you. In this case we have e.g. 15 by 15 binary variables, we choose the constraints so that the final pattern looks like a crossword puzzle.
What do pretty patterns look like
I am interested in automatically generating patterns that look like nice crossword patterns e.g.
Some of the things I'd like to be true of the patterns I generate:
- I'd like 4-fold symmetry, because I think it looks good.
- No 2x2 blocks of white squares.
- No 2x2 blocks of black squares.
- No length 1 words.
- No length 2 words.
- No words longer than MAXLEN.
- No sequences of black squares longer than 3.
- White squares must be fully connected.
Some of these conditions are pretty easy to implement with python-constraint, the fully connected constraint I can just apply to the final solutions and discard them at that point. By fully connected I mean you can get from one square to every other square in the pattern, so you can't get disjoint islands.
The implementation
Examples of what the program outputs:
3EF9C51EBFAFAAF8FE37577BFB9D5CEFEF75763F8FAAFAFEBC51CFBEg
X X . . . . . X . . . . . X X
. . . X X X . X . X X X . . .
. X . X . . . . . . . X . X .
. . . . X . X . X . X . . . .
. X X X . . . . . . . X X X .
. X . . . X . X . X . . . X .
. . . X . . . . . . . X . . .
X X . . . X . X . X . . . X X
. . . X . . . . . . . X . . .
. X . . . X . X . X . . . X .
. X X X . . . . . . . X X X .
. . . . X . X . X . X . . . .
. X . X . . . . . . . X . X .
. . . X X X . X . X X X . . .
X X . . . . . X . . . . . X X
The hex string is the binary to hex encoding of the pattern, 'X's are zeros, '.'s are ones. The hex string will be passed to step 2 which will fill in the words using a wordlist.
It's pretty easy to generate a few million example patterns with this program (if you are willing to wait a day), which is what I suggest doing. Once you have a heap of patterns you can further filter them by whatever criteria you want e.g. no more than X 3-letter words, no more than X% black squares etc. Once you have the filtered list, you can try filling them with words.
from __future__ import print_function
from constraint import *
from itertools import combinations
from math import sqrt,floor
# this program will create crossword patterns, but not fill it with words
# i.e. just patterns of zeros and ones for the black and white squares
# tests if a solution is fully connected i.e. can every point be reached from
# any random point. True if fullyconnected, false if disjoint parts.
# Could easily be made more efficient, but this works fine.
def isfullyconnected(solution):
L = int(sqrt(len(solution)))
# get list of all non-zero points
plist = []
for i in range(L):
for j in range(L):
if solution[i*L + j]: plist.append((i,j))
# start top right corner, try to get to every other point
reached = {plist[0]:1}
rlen = len(reached)
oldrlen = 0
while rlen != oldrlen:
temp = list(reached.keys())
for i,j in temp:
if (i+1,j) in plist: reached[(i+1,j)]=1
if (i-1,j) in plist: reached[(i-1,j)]=1
if (i,j+1) in plist: reached[(i,j+1)]=1
if (i,j-1) in plist: reached[(i,j-1)]=1
oldrlen = rlen
rlen = len(reached)
# if rlen is not eq number of all points, we couldn't reach some points
if rlen < len(plist): return False
return True
from random import randint
def getPattern(sidelen=15,maxlen=9):
problem = Problem()
s = sidelen
maxWordLen = maxlen
problem.addVariables(list(range(s*s)), [0,1])
# ensure there are no 2x2 blocks of ones or zeros
def no4x4block(*x):
if sum(x)==0: return False
if sum(x)==len(x): return False
else: return True
for i in range(s-1):
for j in range(s-1):
problem.addConstraint(no4x4block,[i*s+j,(i*s+j)+1,(i*s+j)+s,(i*s+j)+s+1])
#ensure matrix is symmetric
def eq(a,b):
return a==b
for i in range(s):
for j in range(s):
problem.addConstraint(eq,[i*s+j,(s-i-1)*s+j])
problem.addConstraint(eq,[i*s+j,i*s+(s-j-1)])
problem.addConstraint(eq,[i*s+j,(s-i-1)*s+(s-j-1)])
#ensure no words longer than maxWordLen
def maxlen(*x):
if sum(x) == len(x): return False
else: return True
seq = list(range(maxWordLen+1))
for i in range(s - maxWordLen):
for j in range(s):
problem.addConstraint(maxlen,[(k+i)+j*s for k in seq])
problem.addConstraint(maxlen,[(k+i)*s+j for k in seq])
# no two letter words
def no2letter(*x):
if x == (0,1,1,0): return False
else: return True
seq = list(range(4))
for i in range(s - 3):
for j in range(s):
problem.addConstraint(no2letter,[(k+i)+j*s for k in seq])
problem.addConstraint(no2letter,[(k+i)*s+j for k in seq])
# no two letter words around the edge either
def no2letterEdge(*x):
if x == (1,1,0): return False
else: return True
seq = list(range(4))
for i in range(s):
problem.addConstraint(no2letterEdge,[i,i+s,i+2*s])
problem.addConstraint(no2letterEdge,[i*s,i*s+1,i*s+2])
problem.addConstraint(no2letterEdge,[i*s+s-1,i*s+s-2,i*s+s-3])
problem.addConstraint(no2letterEdge,[i+s*(s-1),i+s*(s-2),i+s*(s-3)])
# no one letter squares
def no1letter(*x):
if sum(x) == 0: return True
if sum(x[1:]) == 0: return False
return True
seq = list(range(4))
for i in range(s):
for j in range(s):
vals = [i*s+j]
if i-1 >= 0: vals.append((i-1)*s+j)
if i+1 < s: vals.append((i+1)*s+j)
if j-1 >= 0: vals.append(i*s+(j-1))
if j+1 < s: vals.append(i*s+(j+1))
problem.addConstraint(no1letter,vals)
# no more than three 0's in a row
def nothreeblank(*x):
if sum(x) == 0: return False
return True
seq = list(range(4))
for i in range(s - 3):
for j in range(s):
problem.addConstraint(nothreeblank,[(k+i)+j*s for k in seq])
problem.addConstraint(nothreeblank,[(k+i)*s+j for k in seq])
for sol in problem.getSolutionIter():
if not isfullyconnected(sol): continue
yield(sol)
from test_bin2hex import bin2hex
import sys
print(len(sys.argv))
if len(sys.argv)<2: sidelen = 11
else: sidelen = int(sys.argv[1])
print('using sidelen',sidelen)
f = open('pattern'+str(sidelen)+'.txt','w')
for count,sol in enumerate(getPattern(sidelen=sidelen,maxlen=8)):
f.write(bin2hex(''.join([str(sol[a]) for a in range(sidelen*sidelen)]))+'\n')
if count % 100 == 0:
print('count: '+str(count))
print(bin2hex(''.join([str(sol[a]) for a in range(sidelen*sidelen)])))
for i in range(sidelen):
for j in range(sidelen):
if sol[i*sidelen+j]==1: print('.',end=' ')
else: print('X',end=' ')
print('')
print('')
#break
The hex string is generated using the following python script (test_bin2hex.py). The extra letters are so we can encode binary strings that aren't necessarily divisible by 4.
bin = ['0000','0001','0010','0011','0100','0101','0110','0111','1000','1001','1010','1011','1100','1101','1110','1111',
'0','1','00','01','10','11','000','001','010','011','100','101','110','111']
hex = "0123456789ABCDEFghijklmnopqrst"
def bin2hex(binstr):
map = dict(zip(bin,hex))
ret = ''
for i in range(0,len(binstr),4):
sub = binstr[i:i+4]
ret += map[sub]
return ret
def hex2bin(hexstr):
map = dict(zip(hex,bin))
ret = ''
for i in hexstr:
ret += map[i]
return ret