# 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