使用回溯法解决填字游戏的详细指南

837 阅读8分钟

在这篇文章中,我们介绍了填字游戏的反向追踪算法,并与蛮力方法进行了比较。我们介绍了各种情况下的时间和空间复杂度。

目录:

  1. 简介
  2. 天真方法
  3. 逆向追踪
  4. 时间和空间复杂度
  5. 结语

简介

纵横字谜是一种文字游戏,通常由一个正方形或长方形的白色和黑色阴影的方格组成。通过解开导致答案的线索,目标是用字母填满白色方格,产生单词或短语。解答的单词和短语从左到右("横")插入网格,从上到下以从左到右的语言书写("下")。这些单词或短语由阴影方块隔开。

纵横字谜解决方案
crossword-puzzlecrossword-solution

我们的目标是在6x6的网格中用单词填满所有的空格子,使网格变得有效。这里的限制条件是交点的字符必须是相同的。

天真方法

天真方法的过程是在没有逻辑的情况下填入空单元格,然后检查它是否是一个有效的位置。这可能需要很长的时间,而且效率很低。逆向追踪算法将是对这种天真的方法的改进。

逆向追踪

回溯是一种粗暴的方法,在解决涉及评估几个选项的问题时发挥作用,因为我们不知道哪个选项是准确的,我们试图用试错法来解决问题,一次一个决定,直到我们得到想要的答案。

上面描述的方法可以用来解决填字游戏。这种回溯算法遍历所有的空缺单元格,用从字典文件中检索到的可能的单词逐步填充这些单元格,然后在填充的单词不符合约束条件时进行回溯。这个过程不断重复,直到所有的单元格都被填满。

操作步骤
  • 我们首先寻找一个连续的空单元格行/列。
  • 当所有的单元格都被填满时,就得到了一个有效的字谜。
  • 我们尝试用从字典文件中检索到的值来填充连续的空单元格行/列。
  • 在放置之前,我们检查水平和垂直单词之间的交汇点是否有限制条件。
  • 如果我们能满足限制条件,我们将在该位置放置该词,开始坐标(a,b),结束坐标(x,y),然后重新启动程序,寻找下一个连续的行/列的空单元。
  • 如果没有一个字可以被放置,我们就必须回溯并改变之前访问的单元格的值。

让我们仔细看一下这个算法。

伪代码
backtrack_solver(crossword)
    if(no more cells are there to explore)
        return true
    for(all available possibilities)
        try one possibility p
        if(backtrack_solver(crossword with possibility p made) is true)
            return true
        unmake possibility p
    return false
实施

上述回溯算法的实现。

copy 库被导入,以便使用deepcopy() 函数。在深度复制的情况下,一个对象被复制到另一个对象中。这意味着对一个对象的副本所做的任何改变都不会反映在原始对象中。导入shapely 库是为了让我们在字谜中找到交点(约束)。

这个类被用来代表填字游戏中的每个词。当我们稍后应用回溯时,我们使用值变量来为每个词赋值。

import copy
from shapely.geometry import LineString

class Word:
	
	#coordinates of the starting and ending point
	start_coord = ()
	end_coord = ()
	
	#horizontal word = 0, vertical word = 1
	orientation = 0
	
	#word length
	length = 0

	#value assigned to this word
	value = ''

load_crossword_puzzleload_dictionary 函数接收一个输入(文件名),读取文件的内容,通过删除tabs,newlinesspaces 进行清理,并将结果作为一个list 返回:

def load_crossword_puzzle(filename):
	crossword = []
	with open(filename, 'r') as cfile:
		puzzle = cfile.readlines()
	for line in puzzle:
		replaced = line.replace("\t", "")
		replaced = replaced.replace("\n", "")
		replaced = replaced.replace(" ", "")
		crossword.append(list(replaced))
	return crossword

def load_dictionary(filename):
	dictionary = []
	with open(filename, 'r') as dfile:
		wordslist = dfile.readlines()
	for word in wordslist:
		replaced = word.replace("\n", "")
		dictionary.append(replaced)
	return dictionary

函数find_horizontal_words(crossword) 检验填字游戏中的单词方向是否是水平的。它通过迭代谜题中的每一行并检查该字符是否是0 。如果这是第一个发现的0 ,即前一个字符是# ,该函数将保存起始坐标(行,列)并跟踪该单词的长度。该函数将继续迭代到下一列并检查0 字符。如果下一个字符是# ,该函数将保存前一列的位置作为该词的结束坐标。

函数find_vertical_words(crossword) ,验证字谜中单词的方向是否是垂直的。该函数与find_horizontal_words(crossword) ,只是它是通过迭代字谜的每一列来定位单词的:

def find_horizontal_words(crossword):
	horizontal_words = []

	for row in range(len(crossword)):
		
		column = 0
		word = Word()
		finished = False
		prev = '#' #prev mean the previous char in the word

		while column <= len(crossword[row])-1:
			
			if crossword[row][column] == '0':
				
				if prev == '0':
					word.length += 1
					prev = '0'
					if column == len(crossword[row])-1:
						if not finished:
							finished = True
						word.end_coord = (row, column)
						prev = '#'

				elif prev == "#":
					if finished:
						finished = False
					word.start_coord = (row, column)
					word.length += 1
					prev = '0'

			elif crossword[row][column] == '#':
				
				if prev == '0':
					if not finished:
						finished = True
					if word.length > 1:
						word.end_coord = (row, column-1)
					else:
						word = Word()
					prev = '#'

			if word.length > 1 and finished:
				word.orientation = 0
				horizontal_words.append(word)
				word = Word()
				finished = False	

			column += 1
		
	return horizontal_words

def find_vertical_words(crossword):
	vertical_words = []
	word = Word()
	started = False
	
	for column in range(0, len(crossword[0])):
		started = False
		for row in range(0, len(crossword)-1):
			if crossword[row][column] == '0' and crossword[row+1][column] == '0':
				if started == False:
					started = True
					word.start_coord = (row, column)
				
				if row == len(crossword)-2 and started:
					word.end_coord = (row+1, column)
					word.length = word.end_coord[0] - word.start_coord[0] + 1
					word.orientation = 1
					vertical_words.append(word)
					word = Word()
					started = False
			else:
				if started:
					word.end_coord = (row, column)
					word.length = word.end_coord[0] - word.start_coord[0] + 1
					word.orientation = 1
					vertical_words.append(word)
					word = Word()
					started = False
	return vertical_words

接下来我们定义了回溯算法的函数。这个函数包含三个变量,assigned_variable_list,not_assigned_variable_list,dictnot_assigned_variable_list 包括所有有待填入字谜中的横向和纵向单词。dict 变量是由load_dictionary()函数返回的值。assigned_variable_list 保存了填字游戏中所有符合限制条件的值。

接下来调用get_possible_values() ,以返回符合填字游戏字长的所有可能的词(值)。调用check_constraint() ,以确保分配的值满足填字游戏的限制。

如果所有可能的值都不能满足限制条件,这意味着之前分配的 "字 "是错误的,因此算法将回溯并留下未分配的字单元以尝试其他可能性。

def backtracking(assigned_variable_list, not_assigned_variable_list, dict):

	#theres are no variables to assign a value so we are done
	if len(not_assigned_variable_list) == 0:
		return assigned_variable_list

	var = not_assigned_variable_list[0]
	
	possible_val = get_possible_values(var, assigned_variable_list, dict)

	for val in possible_val:
		# we create the variable check_var to do the checking and avoid assigning values which do not comply with the constraint
		check_var = copy.deepcopy(var)
		check_var.value = val
		if check_constraint(check_var, assigned_variable_list):
			var.value = val
			result = backtracking(assigned_variable_list+[var], not_assigned_variable_list[1:], dict)
			if result != None:
				return result
            # we've reached here because the choice we made by putting some 'word' here was wrong 
            # hence now leave the word cell unassigned to try another possibilities 
			var.value = ''

	return None

get_possible_values 函数是通过字典并返回所有符合我们正在解决的字谜的字长的可能值。该函数还检查已经分配给字谜的值,以避免重复。

#returns all possible values for the desired variable
def get_possible_values(var, assigned_variable_list, dict):
	possibles_values = []
	
	for val in dict:
		if len(val) == var.length:
			possibles_values.append(val)
	
	for item in assigned_variable_list:
		if item.value in possibles_values:
			possibles_values.remove(item.value)

	return possibles_values

该函数check_constraint() 检查拟分配的值 (var) 和assigned_variable_list 。如果它们的方向相同(都是水平的或都是垂直的),则不需要进一步检查。如果不是这样(一个是水平的,另一个是垂直的),将调用check_intersections() 来帮助检查交点(如果有的话)。

check_intersections() 使用了我们之前导入的shapely 模块中的LineString 函数。由于我们在这里把字当作线,我们可以找到水平和垂直字的交点(字符位置--交点是算法必须应用的约束条件,以获得有效的解决方案)。

#checks var against assigned variable list
def check_constraint(var, assigned_variable_list):
	if assigned_variable_list != None:
		for word in assigned_variable_list:
			#if orientation is equal they will never interesect!
			if var.orientation != word.orientation:
				intersection = check_intersections(var, word)
				if len(intersection) != 0:
					if var.orientation == 0: #horizontal 
						if var.value[int(intersection[0][1]-var.start_coord[1])] != word.value[int(intersection[0][0]-word.start_coord[0])]:          
							return False
					else: #vertical 
						if var.value[int(intersection[0][0]-var.start_coord[0])] != word.value[int(intersection[0][1]-word.start_coord[1])]:          
							return False
	return True

#treat words here like lines so we find the intersection point of horizontal and vertical words (the character position - intersection point is the constraints which the algorithm must apply to get a valid solution)
def check_intersections(w1, w2):
	line1 = LineString([w1.start_coord, w1.end_coord])
	line2 = LineString([w2.start_coord, w2.end_coord])

	intersection_point = line1.intersection(line2)

	if not intersection_point.is_empty:
		return [intersection_point.coords[0]] #result(float)
	else:
		return []

函数insert_word_to_puzzle() 现在将根据它们的方向、起始坐标和结束坐标将我们的求解器找到的单词插入到填字游戏中。

def insert_word_to_puzzle(crossword, word, coord, orientation):
	pos_count = 0
	for char in word:
		if orientation == 0: #horizontal if orientation == 0
			crossword[coord[0]][coord[1]+pos_count] = char
		else:
			crossword[coord[0]+pos_count][coord[1]] = char
		pos_count += 1
	return crossword

下面的代码将尝试运行我们的填字游戏求解器。puzzle.txt 是我们试图解决的填字游戏布局。布局的例子如下,# 代表填字游戏的深色阴影区域,0 代表要填充的单词单元。

#	0	#	#	#	#
#	0	#	#	#	#
#	0	#	#	#	0
#	0	0	0	0	0
#	0	#	#	#	0
#	0	#	#	#	0

words.txt 文件是一个由新行分隔的单词列表,供我们的解算器作为可能的值来填入单词单元。以下是一个例子

ability
able
about
above
accept
according
cw_puzzle = load_crossword_puzzle("puzzle.txt")
dict = load_dictionary("words.txt")
horizontal_word = find_horizontal_words(cw_puzzle)
vertical_word = find_vertical_words(cw_puzzle)
total_words = horizontal_word + vertical_word
assign_var_list = []
suggested_solution = backtracking(assign_var_list, total_words, dict)

print("---------- Crossword ---------")
for line in cw_puzzle:
	print(line)
print("------------------------------")

print("---------- Solution ----------")

if suggested_solution is None:
	print("No solution found")
else: 
	for word in suggested_solution:
		insert_word_to_puzzle(cw_puzzle, word.value, word.start_coord, word.orientation)

	for line in cw_puzzle:
		print(line)

print("------------------------------")

一旦我们运行该代码,我们应该看到以下输出

---------- Crossword ---------
['#', '0', '#', '#', '#', '#']
['#', '0', '#', '#', '#', '#']
['#', '0', '#', '#', '#', '0']
['#', '0', '0', '0', '0', '0']
['#', '0', '#', '#', '#', '0']
['#', '0', '#', '#', '#', '0']
------------------------------
---------- Solution ----------
['#', 'a', '#', '#', '#', '#']
['#', 'l', '#', '#', '#', '#']
['#', 'w', '#', '#', '#', 'i']
['#', 'a', 'b', 'o', 'u', 't']
['#', 'y', '#', '#', '#', 'e']
['#', 's', '#', '#', '#', 'm']
------------------------------

时间和空间复杂度

时间复杂度

我们的回溯法的时间复杂度。O((M * P)^D)

其中:

  • N是网格中空单元的连续行/列的数量(要填的字)
  • P是要测试的交叉词约束的可能单词列表
  • 对于回溯函数,这个递归函数的深度(D)将等于字词约束。D = 水平和垂直单词之间的交汇点(s)。
  • M是单词的平均长度

时间复杂度将是O((M * P)^D),因为每个连续的单元格将只有一个字,所以将使用D+1个字。在每个交叉点,我们需要检查P个词。让我知道你的想法。M是单词的平均长度,这一点是需要的,因为我们需要检查约束条件(一个字符串的读取时间)。

在最坏的情况下,我们从N个单元格开始调用这个函数。因此,整体的时间复杂性将是O(N*(D^P))。

空间复杂度

O(L) : 其中L是给定字的长度。这个空间被用于递归堆栈。

结论

其他解决填字游戏的方法包括:

  • 正向检查
  • 动态变量排序
  • 冲突导向的回跳
  • 弧形一致性

通过OpenGenus的这篇文章,你一定对使用回溯法解决填字游戏有了完整的认识。