如何从单词列表中创建Boggle板?(反向Boggle求解器!)

6
我将尝试解决反向Boggle问题。简单来说,给定一个单词列表,在一个4x4的字母网格中,尽可能多地找到单词列表中可以在相邻字母序列中找到的单词(字母在正交和对角线上都是相邻的)。 我不想使用已知的板子进行解决。那是一个简单的TRIE问题,并且已经在这里为人们的CS项目进行了讨论/解决。 示例单词列表:
margays, jaguars, cougars, tomcats, margay, jaguar, cougar, pumas, puma, toms

解决方案:
ATJY
CTSA
OMGS
PUAR

这个问题对我来说很难。我目前的算法如下:
  1. 对于输入中的每个单词,都列出它可以合法地出现在棋盘上的所有可能方式。
  2. 尝试在这些棋盘上放置第二个单词的所有可能组合,并保留没有冲突的那些。
  3. 重复直到列表结束。
  4. ...
  5. 赚钱!!!(对于那些阅读/的人)
显然,还有一些实现细节。从最长的单词开始。忽略其他单词的子字符串。
我可以在大约0.4秒内生成一个7个字符单词的所有68k个可能的棋盘。然后当我添加另一个7个字符的棋盘时,我需要比较68k x 68k个棋盘,进行7次比较。解决时间变得非常缓慢。
肯定有更好的方法来解决这个问题!!!
一些代码:
BOARD_SIDE_LENGTH = 4

class Board:
    def __init__(self):
        pass

    def setup(self, word, start_position):
        self.word = word
        self.indexSequence = [start_position,]
        self.letters_left_over = word[1:]
        self.overlay = []
        # set up template for overlay.  When we compare boards, we will add to this if the board fits
        for i in range(BOARD_SIDE_LENGTH*BOARD_SIDE_LENGTH):
            self.overlay.append('')
        self.overlay[start_position] = word[0]
        self.overlay_count = 0

    @classmethod
    def copy(boardClass, board):
        newBoard = boardClass()
        newBoard.word = board.word
        newBoard.indexSequence = board.indexSequence[:]
        newBoard.letters_left_over = board.letters_left_over
        newBoard.overlay = board.overlay[:]
        newBoard.overlay_count = board.overlay_count
        return newBoard

    # need to check if otherboard will fit into existing board (allowed to use blank spaces!)
    # otherBoard will always be just a single word
    @classmethod
    def testOverlay(self, this_board, otherBoard):
        for pos in otherBoard.indexSequence:
            this_board_letter = this_board.overlay[pos]
            other_board_letter = otherBoard.overlay[pos]
            if this_board_letter == '' or other_board_letter == '':
                continue
            elif this_board_letter == other_board_letter:
                continue
            else:
                return False
        return True

    @classmethod
    def doOverlay(self, this_board, otherBoard):
        # otherBoard will always be just a single word
        for pos in otherBoard.indexSequence:
            this_board.overlay[pos] = otherBoard.overlay[pos]
        this_board.overlay_count = this_board.overlay_count + 1

    @classmethod
    def newFromBoard(boardClass, board, next_position):
        newBoard = boardClass()
        newBoard.indexSequence = board.indexSequence + [next_position]
        newBoard.word = board.word
        newBoard.overlay = board.overlay[:]
        newBoard.overlay[next_position] = board.letters_left_over[0]    
        newBoard.letters_left_over = board.letters_left_over[1:]
        newBoard.overlay_count = board.overlay_count
        return newBoard

    def getValidCoordinates(self, board, position):
        row = position / 4
        column = position % 4
        for r in range(row - 1, row + 2):
            for c in range(column - 1, column + 2):
                if r >= 0 and r < BOARD_SIDE_LENGTH and c >= 0 and c < BOARD_SIDE_LENGTH:
                    if (r*BOARD_SIDE_LENGTH+c not in board.indexSequence):
                        yield r, c

class boardgen:
    def __init__(self):
        self.boards = []

    def createAll(self, board):
        # get the next letter
        if len(board.letters_left_over) == 0:
            self.boards.append(board)
            return
        next_letter = board.letters_left_over[0]    
        last_position = board.indexSequence[-1]
        for row, column in board.getValidCoordinates(board, last_position):
            new_board = Board.newFromBoard(board, row*BOARD_SIDE_LENGTH+column)
            self.createAll(new_board)

并像这样使用它:

words = ['margays', 'jaguars', 'cougars', 'tomcats', 'margay', 'jaguar', 'cougar', 'pumas', 'puma']
words.sort(key=len)

first_word = words.pop()

# generate all boards for the first word
overlaid_boards = []
for i in range(BOARD_SIDE_LENGTH*BOARD_SIDE_LENGTH):
    test_board = Board()
    test_board.setup(first_word, i)
    generator = boardgen()
    generator.createAll(test_board)
    overlaid_boards += generator.boards

能否简单描述一下您所说的“Boggle”是什么意思?例如,您是否考虑了实际的Boggle方块(来自某些变体)?例如,在实际游戏中,“Qu”只出现在一个立方体面上,并且有特定的字母分布。 - ooga
@ooga:http://en.wikipedia.org/wiki/Boggle,你可以忽略Qu和其他双字母组合。字母分布仅基于提供的字母。 - Richard
我想你可以通过预先计算某些东西(例如国际象棋开局/残局)来节省很多工作。只有68k个棋盘意味着你可以合理地在字典中包含常见单词的哈希值以表示唯一的棋盘状态。然后,你的问题就变成了识别包含每个单词的已知棋盘,并确定哪个棋盘出现最频繁。 - William Gaul
@WilliamGaul:你没抓住重点。重点是要找到包含已知单词的游戏版面,而不是使用字典来解决游戏版面。仅一个单词在4x4网格上就有68k种可能的排列方式。 - Richard
@Richard 不是我没有理解反向求解的部分,而是我忽略了一个单词就有68,000种组合的部分。所以,这个想法是不切实际的。 - William Gaul
2个回答

2
这是一个有趣的问题。我无法想出一个完整且优化的解决方案,但是以下是一些你可以尝试的想法。
难点在于需要找到最佳子集,如果无法容纳所有单词,则会增加很多复杂性。首先消除显然不起作用的单词组合。删除任何长度大于16个字符的单词。计算所需的唯一字母数量。一定要考虑在同一个单词中重复出现的字母。例如,如果列表包括“eagle”,则不能同时使用同一个'e'代表单词两端。如果所需字母的列表超过16个,则必须舍弃一些单词。找出应该首先删除哪些单词是一个有趣的子问题……我会从包含使用最少的字母的单词开始。将所有子列表按得分排序可能会有所帮助。
然后您可以处理单词长度总和小于16的简单情况。之后,您可以从完整的单词列表开始,查看是否存在解决方案。如果没有,请确定要删除哪个单词,然后重试。
给定单词列表,则核心算法是找到包含所有这些单词的网格(如果存在)。
愚笨的暴力方法是迭代使用所需字母的所有可能网格,并测试每个网格以查看是否适合所有单词。这太过严酷:中间情况是16!= 2x10exp13个板子。唯一字母的精确公式是…(16!)/(16-n)!x pow(n,16-n)。最坏情况在3x10exp16的范围内。即使您可以避免旋转和翻转,也仅能为您节省1/16的搜索空间。
比较聪明的贪心算法是按某些标准(例如难度或长度)对单词进行排序。一种递归解决方案是取列表中剩余的顶部单词,并尝试将其放置在网格上。然后使用该网格和剩余单词列表进行递归。如果在用完单词之前填满网格,则必须返回并尝试其他放置方式。更贪婪的方法是首先尝试重复使用最多字母的放置方法。您可以进行一些修剪。如果在任何时候网格中剩余的空格数小于所需唯一字母集,则可以消除这些子树。还有一些其他情况,当剩余网格空间小于最后一个单词的长度时,很明显没有解决方案可以被切掉。此搜索空间取决于单词长度和重复使用的字母数量。我确信这比暴力方法更好,但我不知道是否足以使问题合理化。
聪明的方式是使用某种形式的动态规划。我还没有完整的算法。一个想法是将字母的树或图与单词列表中的“相邻”字母连接起来。然后从最相连的字母开始,尝试将树映射到网格上。始终放置完成单词列表中最多的字母。必须有一些处理网格中多个相同字母的方法。而且我不确定如何排序,以便您不必搜索每个组合。
最好的方法是具有动态算法,该算法还包括所有子单词列表。因此,如果列表中有“fog”和“fox”,并且fox不适合但fog适合,它将能够在不必在列表的两个版本上运行整个过程的情况下处理它。这增加了复杂性,因为现在您必须按分数对每个解决方案进行排名。但是,在所有单词都不适合的情况下,它将节省大量时间。
祝你好运。

我花了一个小时编写了一个遗传算法,它可以找到单词,但只有大约50%的单词(例如:12个单词中的6个),即使进行了5万代。我使用标准的一点交叉和变异,并将迄今为止最好的保留为新的种子板。顺便说一下,我发现了这个网站:http://dinsights.com/POTM/BOGGLE/whoami.php 但即使是最好的条目也是一个遗传算法,需要运行10分钟。还有这个网站:http://www.hees.us/reverseboggle.cgi 可以在几秒钟内解决问题。但我无法弄清楚比尔究竟在做什么,即使基于他给我的简要描述。 - Richard

0

有几个加速回溯搜索的一般性思路可以尝试:

1)早期检查。通常情况下,尽早丢弃永远不可能成功的部分解决方案会有所帮助,即使需要更多的工作。考虑将要拟合的单词切成所有的两个字符序列 - 例如,PUMAS贡献了PU、UM、MA和AS。这些都必须在最终答案中出现。如果一个部分解决方案没有足够的重叠的两个字符空间来容纳它尚未拥有的所有重叠的两个字符序列,则它无法扩展为最终答案。

2)对称性。我认为如果你想证明没有解决方案,这可能是最有用的。给定一种填充棋盘的方法,您可以旋转和反射该解决方案以找到填充棋盘的其他方法。如果您有68K个起始点,并且一个起始点是另一个起始点的旋转或反射,则您不需要尝试两者,因为如果您可以(或可以)从一个起始点解决问题,则可以通过旋转或反射棋盘从另一个起始点获得答案。因此,您可能能够将需要尝试的起始点数量除以某个整数。

这个问题不是唯一一个在每个阶段都有大量替代方案的问题。旅行推销员问题也受到影响。如果您可以接受没有保证找到绝对最佳答案,您可以尝试不跟进这68k个选择中最不可靠的选择。您需要一些评分来决定哪些选择要保留 - 您可能希望保留那些已经使用了尽可能多的字母的选择。一些解决旅行推销员问题的程序会非常早地丢弃节点之间不可靠的链接。而一种更通用的方法是丢弃部分解决方案,而不是进行完整的深度优先搜索或分支限界,这就是有限失配搜索 - 例如请参见http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.34.2426

当然,有些解决旅行商问题(TSP)的方法完全放弃了树搜索,而采用某种爬山算法。你可以从一个填满字母的方格开始,反复尝试在其中找到单词,通过修改一些字符来强制它们出现,试图找到逐步增加方格中可找到单词数量的步骤。最简单的爬山算法形式是从多个随机起点重复进行简单爬山。另一种方法是通过仅对迄今为止的解决方案的一部分进行随机化来重新开始爬山 - 由于你不知道要随机化的最佳部分大小,你可能决定随机选择要随机化的部分大小,以便至少有一部分时间你正在随机化正确大小的区域以生成新的方格作为起点。遗传算法和模拟退火在这里非常流行。一篇关于一种新想法的论文,晚期接受爬山算法(Late Acceptance Hill-Climbing),还描述了一些竞争对手 - http://www.cs.nott.ac.uk/~yxb/LAHC/LAHC-TR.pdf

早期检查是很好的。我会尽快丢弃板子。然而,想象一个三个字母的单词(例如:“abc”)。这个三个字母的单词在4x4网格上产生408个有效布局。当你做第二个单词时,你会得到另一组布局。现在你需要检查这两个单词的叠加是否有效。 - Richard
关于对称性,对于上述408种棋盘的一组,它们中的一部分将是其他棋盘的反射或旋转。但是当您为第二个单词生成棋盘时,您仍然需要检查所有(包括所有对称性)棋盘布局与Word 1的布局相比...因此,您只能保存4个旋转和4个镜像。这将把比较从68k x 68k减少到4k x 68k(对于每个额外的7字单词还有68k!)虽然可以优化,但仍无法在30秒内运行。 - Richard
拥有大量的替代方案也会影响旅行商问题的程序。在http://www.akira.ruc.dk/~keld/research/LKH/LKH-2.0/DOC/LKH_REPORT.pdf P 15第3.2节(5)中,您可以看到一种处理TSP的方法是舍弃除最有前途的替代方案以外的所有替代方案。我已经编辑了我的答案,添加了一些以速度为代价的准确性技巧的指针。 - mcdowella

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接