【问题标题】:Solve sudoku by backtracking (java)通过回溯解决数独(java)
【发布时间】:2015-12-12 23:52:58
【问题描述】:
public static int[][] solve(int[][] input){

        for (int i = 0; i < 9*9; i++){
            if(input[i / 9][i % 9] != 0){
                continue;
            }
            for (int j = 1; j <= 9; j++){
                    if(validNumber(input, i / 9, i % 9, j)){
                        input[i / 9][i % 9] = j;
                        solve(input);
                    }
            }
        }
        return input;
    }

无论初始情况如何,此方法都应通过回溯解决(可解决的)数独难题。它的工作原理是这样的:

给定一个数独谜题,它从每行的左上角迭代到二维数组的右下角。当已经有一个数字时,它会被跳过。当有一个零(空字段)时,它通过validNumber 方法计算可能的值。将第一个有效数字(从 1 到 9)放入字段中,然后方法转到下一个字段。

在这个算法中,该方法现在不知道一个有效的数字是否最终会导致谜题无法解决。

我想这样改变它:

最后,当方法完成对整个二维数组的迭代时,数组的每个条目都会被测试是否为零。

如果甚至有一个零,整个算法必须进入第一个“有效”数字的位置。现在,输入下一个“有效”数字,依此类推,直到没有零算法结束。

我在实现这个想法时遇到了一些麻烦。在我看来,某处必须有另一个 for 循环,或者类似于 goto 语句,但我不知道该放在哪里。

有什么建议吗?

【问题讨论】:

    标签: java recursion backtracking


    【解决方案1】:

    我以前实现过一次数独求解器。它比您拥有的要复杂一些,但眨眼间就解决了游戏。 :)

    您尝试做的是通过“蛮力”和使用(尾)递归解决数独问题。这意味着您正在尝试通过迭代所有 981 个可能的组合来解决棋盘问题。 9 的 81 次方是……嗯,这是一个很大的数字。所以你的方法将永远存在,但你会更快地用完尾递归的堆栈空间。

    当我实现 Sudoko 时,它更直接了。它保留了一个 9x9 的“项目”数组,其中每个项目是正方形中的值,以及一个代表候选者的 9 个布尔值数组(真 == 可行,假 == 消除)。然后它只是进行了一个非递归循环来求解棋盘。

    主循环将从寻找仅剩 1 个候选方格的简单过程开始。然后下一步将根据已分配的值进行简单的候选消除。然后它将进入更复杂的消除技术,例如X-Wing

    【讨论】:

    • 此策略需要一套全面的规则来消除数字。一些“难”级别的数独谜题需要复杂的规则,这些规则基本上模仿了尝试和检查的方法,而没有实际尝试不同的组合。我很好奇您的求解器是否可以处理空拼图作为输入。有关基于树的递归,请参阅我的答案。我什至将它用于空拼图,它在合理的有限时间内(秒)给了我一个正确的解决方案。
    • @Andrey - 不,我的解决方案无法处理空板。它基于“消除过程”。如果棋盘是空的,它会停止,因为它无法消除任何东西。我的解决方案使用了 6 或 7 种不同的消除技术过程。它会做的唯一尝试和检查方法是当棋盘只剩下 3 个空方格并且其他消除技术都无法解决它时。源代码不是立即可用的(磁盘离线),否则我会发布它。
    • @selbie 你是如何处理回溯的?使用递归时,堆栈会跟踪您的移动历史记录,因此您可以通过返回到前一个堆栈帧来回溯。像您提到的迭代解决方案比 OP 尝试的更恰当地称为“蛮力”。回溯根本不是蛮力。他只是没有做正确的回溯部分。我很想知道您的代码如何在现代台式机 CPU 上处理“硬”板(如我的代码答案中的帖子)。我的代码在我的机器上大约需要 75 毫秒。如果不回溯,我认为根本无法解决。
    • @Andrey 无法“解决”空板,因为几乎有无限的解决方案。 “解决”一个空板实际上是一个不同的问题,即填充一个新谜题的问题,这本身就是一个具有挑战性的问题,但根本不是同一个算法问题。
    • 已知最难的真正数独谜题(即只有一个解决方案)从填满 17 个棋盘格开始(人类有史以来最难解的谜题是 21 个方格)。带有修剪的回溯算法可以在大约 6M 步内解决它。这远远小于 9^81。而且您不会用完堆栈空间,因为堆栈的深度永远不会超过 81 帧(实际上是 81-17)。即使您没有修剪搜索树,并且确实需要所有 9^81 次迭代,您也不会用完堆栈空间(尽管显然您永远找不到答案)。
    【解决方案2】:

    您的算法实际上并没有回溯。如果可以的话,它会向前移动,但当它意识到自己被困在角落里时,它永远不会向后移动。这是因为它永远不会在堆栈中返回任何知识,也永远不会重置方块。除非你真的很幸运,否则你的代码会让游戏板进入一个角落状态,然后打印出那个角落状态。要回溯,您需要将您设置的最后一个方格(让您陷入困境的那个方格)重置为零,这样您的算法就会知道继续尝试其他事情。

    为了理解回溯,我强烈推荐 Steven Skiena 的《算法设计手册》一书。我在准备 SWE 面试时阅读了它,它确实提高了我对回溯、复杂性和图形搜索的了解。本书的后半部分是75个经典算法问题的目录,数独就是其中之一!他对您可以进行的优化分析进行了有趣的分析,以修剪搜索树并解决非常困难的拼图板。下面是我很久以前读完本章后写的一些代码(按照我目前的标准,可能质量不高,但它确实有效)。我很快就通读了一遍,并在solve 方法中添加了solveSmart 布尔值,它允许您打开或关闭其中一个优化,从而在解决“硬”类数独板时节省大量时间(一开始只有 17 个方格)。

    public class Sudoku {
    
      static class RowCol {
        int row;
        int col;
    
        RowCol(int r, int c) {
          row = r;
          col = c;
        }
      }
    
      static int numSquaresFilled;
      static int[][] board = new int[9][9];
    
      static void printBoard() {
        for (int i = 0; i < 9; i++) {
          for (int j = 0; j < 9; j++) {
            System.out.print(" " + (board[i][j] == 0 ? " " : board[i][j]) + " ");
            if (j % 3 == 2 && j < 8)
              System.out.print("|");
          }
          System.out.println();
          if (i % 3 == 2 && i < 8)
            System.out.println("---------|---------|---------");
        }
        System.out.println();
      }
    
      static boolean isEntireBoardValid() {
        for (int i = 0; i < 9; i++) {
          for (int j = 0; j < 9; j++) {
            if (!isBoardValid(i, j)) {
              return false;
            }
          }
        }
        return true;
      }
    
      static boolean isRowValid(int row) {
        int[] count = new int[9];
        for (int col = 0; col < 9; col++) {
          int n = board[row][col] - 1;
          if (n == -1)
            continue;
          count[n]++;
          if (count[n] > 1)
            return false;
        }
        return true;
      }
    
      static boolean isColValid(int col) {
        int[] count = new int[9];
        for (int row = 0; row < 9; row++) {
          int n = board[row][col] - 1;
          if (n == -1)
            continue;
          count[n]++;
          if (count[n] > 1)
            return false;
        }
        return true;
      }
    
      static boolean isSquareValid(int row, int col) {
        int r = (row / 3) * 3;
        int c = (col / 3) * 3;
        int[] count = new int[9];
        for (int i = 0; i < 3; i++) {
          for (int j = 0; j < 3; j++) {
            int n = board[r + i][c + j] - 1;
            if (n == -1)
              continue;
            count[n]++;
            if (count[n] > 1)
              return false;
          }
        }
        return true;
      }
    
      static boolean isBoardValid(int row, int col) {
        return (isRowValid(row) && isColValid(col) && isSquareValid(row, col));
      }
    
      static RowCol getOpenSpaceFirstFound() {
        for (int i = 0; i < 9; i++) {
          for (int j = 0; j < 9; j++) {
            if (board[i][j] == 0) {
              return new RowCol(i, j);
            }
          }
        }
        return new RowCol(0, 0);
      }
    
      static RowCol getOpenSpaceMostConstrained() {
        int r = 0, c = 0, max = 0;
        int[] rowCounts = new int[9];
        int[] colCounts = new int[9];
        for (int i = 0; i < 9; i++) {
          for (int j = 0; j < 9; j++) {
            if (board[i][j] != 0)
              rowCounts[i]++;
            if (board[j][i] != 0)
              colCounts[i]++;
          }
        }
    
        int[][] squareCounts = new int[3][3];
        for (int i = 0; i < 3; i++) {
          for (int j = 0; j < 3; j++) {
            int count = 0;
            for (int m = 0; m < 3; m++) {
              for (int n = 0; n < 3; n++) {
                if (board[(i * 3) + m][(j * 3) + n] != 0)
                  count++;
              }
            }
            squareCounts[i][j] = count;
          }
        }
    
        for (int i = 0; i < 9; i++) {
          for (int j = 0; j < 9; j++) {
            if (board[i][j] == 0) {
              if (rowCounts[i] > max) {
                max = rowCounts[i];
                r = i;
                c = j;
              }
              if (colCounts[j] > max) {
                max = rowCounts[j];
                r = i;
                c = j;
              }
            }
          }
        }
        return new RowCol(r, c);
      }
    
      static boolean solve() {
        if (81 == numSquaresFilled) {
          return true;
        }
    
        boolean solveSmart = true;
        RowCol rc = solveSmart ? getOpenSpaceMostConstrained() : getOpenSpaceFirstFound();
        int r = rc.row;
        int c = rc.col;
        for (int i = 1; i <= 9; i++) {
          numSquaresFilled++;
          board[r][c] = i;
          if (isBoardValid(r, c)) {
            if (solve()) {
              return true;
            }
          }
          board[r][c] = 0;
          numSquaresFilled--;
        }
        return false;
      }
    
      public static void main(String[] args) {
    
        // initialize board to a HARD puzzle
        board[0][7] = 1;
        board[0][8] = 2;
        board[1][4] = 3;
        board[1][5] = 5;
        board[2][3] = 6;
        board[2][7] = 7;
        board[3][0] = 7;
        board[3][6] = 3;
        board[4][3] = 4;
        board[4][6] = 8;
        board[5][0] = 1;
        board[6][3] = 1;
        board[6][4] = 2;
        board[7][1] = 8;
        board[7][7] = 4;
        board[8][1] = 5;
        board[8][6] = 6;
        numSquaresFilled = 17;
    
        printBoard();
        long start = System.currentTimeMillis();
        solve();
        long end = System.currentTimeMillis();
        System.out.println("Solving took " + (end - start) + "ms.\n");
        printBoard();
      }
    }
    

    【讨论】:

      【解决方案3】:

      最终validNumber() 方法将不会返回任何数字,因为没有任何可能,这意味着之前的选择之一不正确。试想一下,算法从空网格开始(显然这个难题是可以解决的1)。

      解决方案是保留可能的选择树,如果某些选择不正确,则只需将它们从树中删除并使用下一个可用选择(如果没有选择,则退回到树的更高级别在这个分支中)。如果有的话,这种方法应该能找到解决方案。 (实际上这就是我前段时间实现数独求解器的方式。)


      1 恕我直言,有 3 种不同的数独:

      1. “真”正确的数独,有一个唯一的完整解;

      2. 具有多个不同完整解决方案的模棱两可数独,例如一个只有 7 个不同数字的谜题,因此它至少有两个不同的解决方案,它们通过交换第 8 个和第 9 个数字而有所不同;

      3. 不正确的数独,没有完整的解决方案,例如一行中出现两次或多次相同的数字。

      有了这个定义,求解算法应该:

      1. 证明无解;

      2. 返回满足初始网格的完整解决方案。

      在“真”数独的情况下,结果是定义上的“真”解。在模棱两可的数独的情况下,结果可能会因算法而异。空网格是模棱两可数独的终极例子。

      【讨论】:

      • 一个空的网格不是一个可解的数独游戏。数独谜题只有一个解决方案,这是这个问题的独特部分。它们基本上是迷宫。此外,通过递归回溯,调用堆栈就是您提到的树。你当然可以找到一种方法来使用显式的外部树来做同样的事情,但是编写代码会非常尴尬,而且效率肯定会降低。
      • @The111 我同意真正的数独应该有一个独特的解决方案,但其他情况也是可能的(例如发表在论文中或在线)。我在答案中添加了详细信息。至于算法,只是一个想法,没有具体实现。
      • 同意。回溯求解器确实会立即解决空网格。
      猜你喜欢
      • 1970-01-01
      • 2021-03-30
      • 2021-04-15
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2023-03-27
      相关资源
      最近更新 更多