编写一个高效解决纵横字谜的程序。

13
我有一个填字游戏和一个可以用来解决它的单词列表(单词可以被放置多次或者甚至一次都不放)。对于给定的填字游戏和单词列表,总是存在解决方案。
我搜索了一些关于如何解决这个问题的线索,发现它是NP-Complete问题。我的填字游戏的最大尺寸是250乘250,单词列表的最大长度(可以用来解决游戏的单词数量)是200。我的目标是通过蛮力法/回溯法解决这个尺寸的填字游戏,这应该可以在几秒钟内完成(这是我估计的,如果我错了,请纠正我)。
例如:
给定的可以用来解决填字游戏的单词列表:
- can - music - tuna - hi
给定的空白填字游戏(X代表不能填写的格子,需要填充的是空白格子):

An empty crossword which needs to be solved

解决方案:

The solution of the problem above

现在我的当前方法是将填字游戏表示为一个二维数组,并搜索空格(对填字游戏进行两次迭代)。然后,根据单词的长度将单词与空格匹配,然后尝试所有长度相同的单词与空格的组合。这种方法非常混乱,我在尝试实现时迷失了方向,是否有更优雅的解决方案?

3
对于那么大的输入数据,暴力破解几秒钟似乎有点乐观。回溯法似乎比暴力破解更好,但即使采用回溯法,你仍需搜索一个可能非常庞大的树形结构。 - John Coleman
回溯可能是一个不错的开始。下一个更强大的方法可能是SAT求解器(需要进行丑陋的转换)和约束编程(更容易表述)。后者可以从强大的全局约束中受益。 - sascha
@sascha,你指的是哪个丑陋的转换? - PlsWork
@AnnaVopureta 请考虑如何在合取范式中对此问题进行建模。您会发现,这里存在一些困难(以及许多决策)。以下是一个简单的例子(在您的问题中不一定需要):对100个变量中的k>=5进行建模(5个或更多为真)。在CNF形式下,这是非常棘手的。但在CP中,只需一行代码即可解决。 - sascha
2
对于暴力破解,您可以可能只需从Java中用于解决填字游戏的递归回溯中复制代码(在评论中有一个链接到一个完整程序的链接)。 - Bernhard Barker
显示剩余5条评论
5个回答

5
您的基本想法非常明智:
1.识别板上的插槽。
2.尝试每个适合的单词插入每个插槽。
3.如果可以填充每个插槽而不冲突,则解决了问题。
这是一个很好的计划。下一步是将其转化为设计。对于像这样的小程序,我们可以直接使用伪代码。正如其他答案所解释的那样,它的要点是递归
1  Draw a slot from the slot pool.
2     If slot pool is empty (all slots filled), stop solving.
3  For each word with correct length:
4     If part of the slot is filled, check conflict.
5        If the word does not fit, continue the loop to next word.
      // No conflict
6     Fill the slot with the word.
      // Try next slot (down a level)
7     Recur from step 1.
8     If the recur found no solution, revert (take the word back) and try next.
   // None of them works
9  If no words yield a solution, an upper level need to try another word.
   Revert (put the slot back) and go back.

以下是我根据您的需求编写的一个简短但完整的示例。

有多种方法可以解决问题。 我的代码交换了步骤1和2,并将步骤4到6合并为一个填充循环。

要点:
  • 使用格式化程序来适应您的代码风格。
  • 2D棋盘以行主序的方式存储在线性字符数组中。
  • 这使得棋盘可以通过clone()保存并通过arraycopy还原。
  • 在创建时,从两个方向对棋盘进行两次扫描以查找空槽。
  • 两个空槽列表由相同的循环解决,主要区别在于如何填充空槽。
  • 递归过程被显示出来,以便您了解它是如何工作的。
  • 做出了许多假设。没有单个字母空槽,所有单词大小写相同,棋盘正确等。
  • 耐心点。学习任何新知识都需要时间来吸收。

来源:

import java.awt.Point;
import java.util.*;
import java.util.function.BiFunction;
import java.util.function.Supplier;
import java.util.stream.Stream;

public class Crossword {

   public static void main ( String[] args ) {
      new Crossword( Arrays.asList( "5 4 4\n#_#_#\n_____\n#_##_\n#_##_\ntuna\nmusic\ncan\nhi".split( "\n" ) ) );
      new Crossword( Arrays.asList( "6 6 4\n##_###\n#____#\n___#__\n#_##_#\n#____#\n##_###\nnice\npain\npal\nid".split( "\n" ) ) );
   }

   private final int height, width; // Board size
   private final char[] board; // Current board state.  _ is unfilled.  # is blocked.  other characters are filled.
   private final Set<String> words; // List of words
   private final Map<Point, Integer> vertical = new HashMap<>(), horizontal = new HashMap<>();  // Vertical and horizontal slots

   private String indent = ""; // For formatting log
   private void log ( String message, Object... args ) { System.out.println( indent + String.format( message, args ) ); }

   private Crossword ( List<String> lines ) {
      // Parse input data
      final int[] sizes = Stream.of( lines.get(0).split( "\\s+" ) ).mapToInt( Integer::parseInt ).toArray();
      width = sizes[0];  height = sizes[1];
      board = String.join( "", lines.subList( 1, height+1 ) ).toCharArray();
      words = new HashSet<>( lines.subList( height+1, lines.size() ) );
      // Find horizontal slots then vertical slots
      for ( int y = 0, size ; y < height ; y++ )
         for ( int x = 0 ; x < width-1 ; x++ )
            if ( isSpace( x, y ) && isSpace( x+1, y ) ) {
               for ( size = 2 ; x+size < width && isSpace( x+size, y ) ; size++ ); // Find slot size
               horizontal.put( new Point( x, y ), size );
               x += size; // Skip past this horizontal slot
            }
      for ( int x = 0, size ; x < width ; x++ )
         for ( int y = 0 ; y < height-1 ; y++ )
            if ( isSpace( x, y ) && isSpace( x, y+1 ) ) {
               for ( size = 2 ; y+size < height && isSpace( x, y+size ) ; size++ ); // Find slot size
               vertical.put( new Point( x, y ), size );
               y += size; // Skip past this vertical slot
            }
      log( "A " + width + "x" + height + " board, " + vertical.size() + " vertical, " + horizontal.size() + " horizontal." );
      // Solve the crossword, horizontal first then vertical
      final boolean solved = solveHorizontal();
      // Show board, either fully filled or totally empty.
      for ( int i = 0 ; i < board.length ; i++ ) {
         if ( i % width == 0 ) System.out.println();
         System.out.print( board[i] );
      }
      System.out.println( solved ? "\n" : "\nNo solution found\n" );
   }

   // Helper functions to check or set board cell
   private char get ( int x, int y ) { return board[ y * width + x ]; }
   private void set ( int x, int y, char character ) { board[ y * width + x ] = character; }
   private boolean isSpace ( int x, int y ) { return get( x, y ) == '_'; }

   // Fit all horizontal slots, when success move to solve vertical.
   private boolean solveHorizontal () {
      return solve( horizontal, this::fitHorizontal, "horizontally", this::solveVertical );
   }
   // Fit all vertical slots, report success when done
   private boolean solveVertical () {
      return solve( vertical, this::fitVertical, "vertically", () -> true );
   }

   // Recur each slot, try every word in a loop.  When all slots of this kind are filled successfully, run next stage.
   private boolean solve ( Map<Point, Integer> slot, BiFunction<Point, String, Boolean> fill, String dir, Supplier<Boolean> next ) {
      if ( slot.isEmpty() ) return next.get(); // If finished, move to next stage.
      final Point pos = slot.keySet().iterator().next();
      final int size = slot.remove( pos );
      final char[] state = board.clone();
      /* Try each word */                                                   indent += "  ";
      for ( String word : words ) {
         if ( word.length() != size ) continue;
         /* If the word fit, recur. If recur success, done! */              log( "Trying %s %s at %d,%d", word, dir, pos.x, pos.y );
         if ( fill.apply( pos, word ) && solve( slot, fill, dir, next ) )
            return true;
         /* Doesn't match. Restore board and try next word */               log( "%s failed %s at %d,%d", word, dir, pos.x, pos.y );
         System.arraycopy( state, 0, board, 0, board.length );
      }
      /* No match.  Restore slot and report failure */                      indent = indent.substring( 0, indent.length() - 2 );
      slot.put( pos, size );
      return false;
   }

   // Try fit a word to a slot.  Return false if there is a conflict.
   private boolean fitHorizontal ( Point pos, String word ) {
      final int x = pos.x, y = pos.y;
      for ( int i = 0 ; i < word.length() ; i++ ) {
         if ( ! isSpace( x+i, y ) && get( x+i, y ) != word.charAt( i ) ) return false; // Conflict
         set( x+i, y, word.charAt( i ) );
      }
      return true;
   }
   private boolean fitVertical ( Point pos, String word ) {
      final int x = pos.x, y = pos.y;
      for ( int i = 0 ; i < word.length() ; i++ ) {
         if ( ! isSpace( x, y+i ) && get( x, y+i ) != word.charAt( i ) ) return false; // Conflict
         set( x, y+i, word.charAt( i ) );
      }
      return true;
   }
}

练习:您可以重写递归为迭代;更快且可以支持更大的棋盘。一旦完成,它可以转换为多线程并运行得更快。


1
一个填字游戏是一个约束满足问题,通常是NP-完全的,但有许多解算器可以将最有效的算法应用于您指定的约束问题。Z3 SMT求解器可以非常容易地解决这些问题并且可以扩展。您只需要编写一个Java程序,将填字游戏转换为SMT问题,然后将其交给求解器进行解决。Z3具有Java绑定,所以应该很简单。我已经编写了用于解决下面第一个示例的Z3代码。在您的Java程序中指定任意大小的填字游戏模式应该不难跟随此模式。
; Declare each possible word as string literals
(define-const str1 String "tuna")
(define-const str2 String "music")
(define-const str3 String "can")
(define-const str4 String "hi")

; Define a function that returns true if the given String is equal to one of the possible words defined above.
(define-fun validString ((s String)) Bool 
    (or (= s str1) (or (= s str2) (or (= s str3) (= s str4)))))

; Declare the strings that need to be solved
(declare-const unknownStr1 String)
(declare-const unknownStr2 String)
(declare-const unknownStr3 String)
(declare-const unknownStr4 String)

; Assert the correct lengths for each of the unknown strings.
(assert (= (str.len unknownStr1) 4))
(assert (= (str.len unknownStr2) 5))
(assert (= (str.len unknownStr3) 3))
(assert (= (str.len unknownStr4) 2))

; Assert each of the unknown strings is one of the possible words.
(assert (validString unknownStr1))
(assert (validString unknownStr2))
(assert (validString unknownStr3))
(assert (validString unknownStr4))

; Where one word in the crossword puzzle intersects another assert that the characters at the intersection point are equal.
(assert (= (str.at unknownStr1 1) (str.at unknownStr2 1)))
(assert (= (str.at unknownStr2 3) (str.at unknownStr4 1)))
(assert (= (str.at unknownStr2 4) (str.at unknownStr3 0)))

; Solve the model
(check-sat)
(get-model)

我推荐使用Z3 SMT求解器,但还有很多其他约束求解器可用。你没有必要自己实现约束求解算法,就像你没有必要自己实现排序算法一样。


1
你是正确的,这个问题是NP完全问题。因此,你最好的机会是通过暴力破解来解决它(如果你找到了多项式算法,请告诉我,我们都可以变得富有 =))。
我建议你看一下回溯backtracking。它将允许你编写一个优雅的(尽管在输入大小给定的情况下很慢)解决填字游戏问题的解决方案。
如果你需要更多灵感,请看看this solver,它使用回溯作为导航解决方案树的方法。
请注意,实际上可能有比纯粹的暴力破解更好的算法(即使仍然具有指数复杂度)。此外,在scholar上快速搜索会发现很多关于这个主题的论文,你可能会想看一下,例如以下内容:

2
你说得对,这个问题是NP完全问题。所以你最好的机会就是通过暴力求解来解决它。但这并不是很有意义。旅行商问题是NP完全问题,但现有的算法比暴力求解要好得多(尽管在渐进意义下仍然是指数级的)。 - John Coleman
是的,我知道,我不想说暴力破解是解决问题的唯一方法。我所指的“最好的机会”是指一个简单的回溯将是正确的,并且很容易实现(因为她在SO上寻求帮助,我认为这是一个很好的起点)。我会更新答案使其更清晰 =)。虽然你提出了一个好观点,谢谢。 - Davide Spataro
1
嗯,它们只在具有额外假设的非通用环境中比暴力算法更好。我不认为那个句子在理论上是错误的。 - sascha
@JohnColeman 我认为你需要谨慎地研究这个含义。当然,在实际案例中,如果我们利用额外结构,它是有效的。但对于一般情况下(所有实例),“没有免费午餐”定理应该适用。 - sascha
1
请问您能提供一些输入和输出的样例吗? - Davide Spataro
显示剩余6条评论

1
为了更容易解决这个问题,我将把它分解成更小、更容易的问题。请注意,我不包括代码/算法,因为我认为这对解决问题没有帮助(如果我们想要最好的代码,就会有索引和数据库以及让你看到它就头痛的黑魔法)。相反,这个答案试图通过谈论思考方法来回答问题,这些方法将帮助OP使用最适合读者的方法解决这个问题(以及未来的问题)。

你需要知道的

这个答案假设你知道如何做以下事情

  • 创建和使用具有属性和函数的对象
  • 选择一个适合你想要处理其内容的数据结构(不一定是好的)。

建模你的空间

因此,将你的填字游戏加载到n乘m矩阵(2D数组,以下简称“网格”)中很容易,但从实用角度来看,这非常困难。因此,让我们从将你的填字游戏从网格解析为合法对象开始。

就你的程序而言,填字游戏中的每个条目都有4个属性。

  1. 第一个字母在网格中的X-Y坐标
  2. 方向(向下或横向)
  3. 单词长度
  4. 单词价值
  5. 绑定索引的地图
    • 键:与另一条目共享索引的单词的索引
    • 值:共享该索引的条目
    • (您可以将其作为元组并包含来自其他条目的共享索引以便于参考)

您可以根据以下规则在扫描时在网格中找到这些内容。

  1. 如果Row_1_up关闭而Row_1_down打开,则这是向下单词的起始索引。 (向下扫描长度。对于绑定索引,左侧或右侧空间将保持打开状态。扫描左侧以获取链接条目的坐标ID)
  2. 与1相同,但旋转用于横向单词(您可以同时进行1的扫描)

在您的填字游戏对象中,您可以使用坐标+方向作为键来存储条目,以便于参考和易于转换为/从文本网格形式。

使用您的模型

现在您应该有一个包含交叉词条及其相关索引绑定的集合对象。现在,您需要找到一组值,以满足所有词条。
您的词条对象应该有像 isValidEntry(str) 这样的帮助方法,它检查给定的值和填字游戏的当前状态,是否可以在这里放置这个词?通过使模型中的每个对象负责其自己的逻辑级别,问题的代码层次只需调用逻辑而不必担心其实现(在本例中,您的求解器不必担心值有效性的逻辑,它只需询问 isValidEntry)。
如果您已经正确完成上述操作,则解决问题只是迭代所有词条的所有单词以找到解决方案的简单问题。
子问题列表
供参考,这是我列出的您需要编写一些内容来解决的子问题列表。
  • 如何理想地建模我的工作空间,以便我能轻松地使用它?
  • 对于我的每个模型部分,它需要知道什么?它可以为我处理什么逻辑?
  • 如何将我的文本输入转换为可用的模型对象?
  • 如何使用我的模型对象解决问题?(对于您来说,是迭代所有单词/所有条目以找到有效集。可能使用递归)

0

我刚刚用Scala实现了一个代码来解决这样的谜题。我只是使用递归来解决问题。简而言之,对于每个单词,我找到所有可能的空位,并选择一个空位并用该单词填充,然后尝试使用递归解决部分谜题。如果剩余的单词无法填充拼图,则尝试另一个空位,以此类推。如果不行,则拼图已经解决。

这是我的代码链接: https://github.com/mysilver/AMP/blob/master/Crossword.scala


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