1. 概要

この記事では、数独パズルとそれを解くために使用されるアルゴリズムを見ていきます。

次に、Javaでソリューションを実装します。 最初の解決策は、単純なブルートフォース攻撃です。 2つ目は、 DancingLinksテクニックを利用します。

ここでは、OOP設計ではなく、アルゴリズムに焦点を当てることに注意してください。

2. 数独パズル

簡単に言えば、数独は、1から9までの数字で部分的に埋められた9×9のセルグリッドを備えた組み合わせの数字配置パズルです。 目標は、残りの空白のフィールドに残りの数値を入力して、各行と列に各種類の数値が1つだけになるようにすることです。

さらに、グリッドの3 x 3サブセクションごとに、番号を複製することはできません。 難易度は、各ボードの空白フィールドの数とともに自然に上昇します。

2.1. テストボード

ソリューションをより面白くし、アルゴリズムを検証するために、「世界で最も難しい数独」ボードを使用します。これは次のとおりです。

8 . . . . . . . . 
. . 3 6 . . . . . 
. 7 . . 9 . 2 . . 
. 5 . . . 7 . . . 
. . . . 4 5 7 . . 
. . . 1 . . . 3 . 
. . 1 . . . . 6 8 
. . 8 5 . . . 1 . 
. 9 . . . . 4 . .

2.2. 解決済みボード

そして、解決策をすばやく台無しにするために–正しく解決されたパズルは、次の結果をもたらします。

8 1 2 7 5 3 6 4 9 
9 4 3 6 8 2 1 7 5 
6 7 5 4 9 1 2 8 3 
1 5 4 2 3 7 8 9 6 
3 6 9 8 4 5 7 2 1 
2 8 7 1 6 9 5 3 4 
5 2 1 9 7 4 3 6 8 
4 3 8 5 2 6 9 1 7 
7 9 6 3 1 8 4 5 2

3. バックトラッキングアルゴリズム

3.1. 序章

バックトラッキングアルゴリズムは、有効な解決策について各セルをテストすることにより、パズルを解決しようとします。

制約の違反がない場合、アルゴリズムは次のセルに移動し、すべての潜在的なソリューションを入力して、すべてのチェックを繰り返します。

違反がある場合は、セルの値が増加します。 セルの値が9に達すると、まだ違反があり、アルゴリズムは前のセルに戻り、そのセルの値を増やします。

考えられるすべての解決策を試します。

3.2. 解決

まず、ボードを整数の2次元配列として定義しましょう。 空のセルとして0を使用します。

int[][] board = {
  { 8, 0, 0, 0, 0, 0, 0, 0, 0 },
  { 0, 0, 3, 6, 0, 0, 0, 0, 0 },
  { 0, 7, 0, 0, 9, 0, 2, 0, 0 },
  { 0, 5, 0, 0, 0, 7, 0, 0, 0 },
  { 0, 0, 0, 0, 4, 5, 7, 0, 0 },
  { 0, 0, 0, 1, 0, 0, 0, 3, 0 },
  { 0, 0, 1, 0, 0, 0, 0, 6, 8 },
  { 0, 0, 8, 5, 0, 0, 0, 1, 0 },
  { 0, 9, 0, 0, 0, 0, 4, 0, 0 } 
};

board を入力パラメーターとして受け取り、行、列、値を繰り返して各セルの有効なソリューションをテストするsolve()メソッドを作成しましょう。

private boolean solve(int[][] board) {
    for (int row = BOARD_START_INDEX; row < BOARD_SIZE; row++) {
        for (int column = BOARD_START_INDEX; column < BOARD_SIZE; column++) {
            if (board[row][column] == NO_VALUE) {
                for (int k = MIN_VALUE; k <= MAX_VALUE; k++) {
                    board[row][column] = k;
                    if (isValid(board, row, column) && solve(board)) {
                        return true;
                    }
                    board[row][column] = NO_VALUE;
                }
                return false;
            }
        }
    }
    return true;
}

必要なもう1つのメソッドは、 isValid()メソッドです。このメソッドは、数独の制約をチェックします。つまり、行、列、および3×3グリッドが有効かどうかをチェックします。

private boolean isValid(int[][] board, int row, int column) {
    return (rowConstraint(board, row)
      && columnConstraint(board, column) 
      && subsectionConstraint(board, row, column));
}

これらの3つのチェックは比較的似ています。 まず、行チェックから始めましょう。

private boolean rowConstraint(int[][] board, int row) {
    boolean[] constraint = new boolean[BOARD_SIZE];
    return IntStream.range(BOARD_START_INDEX, BOARD_SIZE)
      .allMatch(column -> checkConstraint(board, row, constraint, column));
}

次に、ほぼ同じコードを使用して列を検証します。

private boolean columnConstraint(int[][] board, int column) {
    boolean[] constraint = new boolean[BOARD_SIZE];
    return IntStream.range(BOARD_START_INDEX, BOARD_SIZE)
      .allMatch(row -> checkConstraint(board, row, constraint, column));
}

さらに、3×3サブセクションを検証する必要があります。

private boolean subsectionConstraint(int[][] board, int row, int column) {
    boolean[] constraint = new boolean[BOARD_SIZE];
    int subsectionRowStart = (row / SUBSECTION_SIZE) * SUBSECTION_SIZE;
    int subsectionRowEnd = subsectionRowStart + SUBSECTION_SIZE;

    int subsectionColumnStart = (column / SUBSECTION_SIZE) * SUBSECTION_SIZE;
    int subsectionColumnEnd = subsectionColumnStart + SUBSECTION_SIZE;

    for (int r = subsectionRowStart; r < subsectionRowEnd; r++) {
        for (int c = subsectionColumnStart; c < subsectionColumnEnd; c++) {
            if (!checkConstraint(board, r, constraint, c)) return false;
        }
    }
    return true;
}

最後に、 checkConstraint()メソッドが必要です。

boolean checkConstraint(
  int[][] board, 
  int row, 
  boolean[] constraint, 
  int column) {
    if (board[row][column] != NO_VALUE) {
        if (!constraint[board[row][column] - 1]) {
            constraint[board[row][column] - 1] = true;
        } else {
            return false;
        }
    }
    return true;
}

一度実行されると、 isValid()メソッドは単にtrueを返すことができます。

これで、ソリューションをテストする準備がほぼ整いました。 アルゴリズムが完了しました。 ただし、trueまたはfalseのみを返します。

したがって、ボードを視覚的に確認するには、結果を印刷するだけです。 どうやら、これはアルゴリズムの一部ではありません。

private void printBoard() {
    for (int row = BOARD_START_INDEX; row < BOARD_SIZE; row++) {
        for (int column = BOARD_START_INDEX; column < BOARD_SIZE; column++) {
            System.out.print(board[row][column] + " ");
        }
        System.out.println();
    }
}

数独パズルを解くバックトラッキングアルゴリズムの実装に成功しました!

明らかに、アルゴリズムは可能な組み合わせを何度も何度も素朴にチェックするため、改善の余地があります(特定のソリューションが無効であることがわかっている場合でも)。

4. ダンスリンク

4.1. 正確なカバー

別の解決策を見てみましょう。 数独は、正確なカバーの問題として説明できます。これは、2つのオブジェクト間の関係を示す接続行列で表すことができます。

たとえば、1から7までの数字を取り、セットのコレクション S = {A、B、C、D、E、F} の場合、次のようになります。

  • A = {1、4、7}
  • B = {1、4}
  • C = {4、5、7}
  • D = {3、5、6}
  • E = {2、3、6、7}
  • F = {2、7}

私たちの目標は、各番号が1回だけ存在し、繰り返されるものがないようなサブセットを選択することです。

列が数値で行がセットである行列を使用して問題を表すことができます。

  | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 
A | 1 | 0 | 0 | 1 | 0 | 0 | 1 |
B | 1 | 0 | 0 | 1 | 0 | 0 | 0 |
C | 0 | 0 | 0 | 1 | 1 | 0 | 1 |
D | 0 | 0 | 1 | 0 | 1 | 1 | 0 |
E | 0 | 1 | 1 | 0 | 0 | 1 | 1 |
F | 0 | 1 | 0 | 0 | 0 | 0 | 1 |

サブコレクションS*= {B、D、F}は正確なカバーです:

  | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 
B | 1 | 0 | 0 | 1 | 0 | 0 | 0 |
D | 0 | 0 | 1 | 0 | 1 | 1 | 0 |
F | 0 | 1 | 0 | 0 | 0 | 0 | 1 |

各列には、選択したすべての行に1つだけが含まれます。

4.2. アルゴリズムX

アルゴリズムXは、正確なカバー問題のすべての解決策を見つけるための「試行錯誤アプローチ」です。 サンプルコレクションS= {A、B、C、D、E、F} から始めて、サブコレクション S * = {B、D、F}を見つけます。

アルゴリズムXは次のように機能します。

  1. 行列Aに列がない場合、現在の部分解は有効な解です。 正常に終了します。それ以外の場合は、列 c を選択します(決定論的に)
  2. A r、c =1となるような行rを選択します(非決定的に、つまり、すべての可能性を試してください)
  3. rを部分解に含めます
  4. A r、j =1となる各列jに対して、 Aとなる各行iに対して i、j = 1、行iをマトリックスAから削除し、jをマトリックスから削除します] A
  5. 縮小された行列Aで、このアルゴリズムを再帰的に繰り返します。

アルゴリズムXの効率的な実装は、Dr。によって提案された Dancing Links アルゴリズム(略してDLX)です。 ドナルド・クヌース。

次のソリューションは、 thisJavaの実装に大きく影響を受けています。

4.3. 正確なカバーの問題

まず、数独パズルを正確なカバーの問題として表す行列を作成する必要があります。 行列には9^3行があります。つまり、すべての可能な数(9つの数)のすべての可能な位置(9行x 9列)ごとに1つの行があります。

列は、ボード(ここでも9 x 9)に制約の数を掛けたものを表します。

すでに3つの制約を定義しました。

  • 各行には、各種類の番号が1つだけあります
  • 各列には、各種類の番号が1つだけあります
  • 各サブセクションには、各種類の番号が1つだけあります

さらに、暗黙の4番目の制約があります。

  • セルに含めることができる番号は1つだけです

これにより、合計4つの制約が与えられるため、正確なカバーマトリックスに9 x 9×4列が追加されます。

private static int BOARD_SIZE = 9;
private static int SUBSECTION_SIZE = 3;
private static int NO_VALUE = 0;
private static int CONSTRAINTS = 4;
private static int MIN_VALUE = 1;
private static int MAX_VALUE = 9;
private static int COVER_START_INDEX = 1;
private int getIndex(int row, int column, int num) {
    return (row - 1) * BOARD_SIZE * BOARD_SIZE 
      + (column - 1) * BOARD_SIZE + (num - 1);
}
private boolean[][] createExactCoverBoard() {
    boolean[][] coverBoard = new boolean
      [BOARD_SIZE * BOARD_SIZE * MAX_VALUE]
      [BOARD_SIZE * BOARD_SIZE * CONSTRAINTS];

    int hBase = 0;
    hBase = checkCellConstraint(coverBoard, hBase);
    hBase = checkRowConstraint(coverBoard, hBase);
    hBase = checkColumnConstraint(coverBoard, hBase);
    checkSubsectionConstraint(coverBoard, hBase);
    
    return coverBoard;
}

private int checkSubsectionConstraint(boolean[][] coverBoard, int hBase) {
    for (int row = COVER_START_INDEX; row <= BOARD_SIZE; row += SUBSECTION_SIZE) {
        for (int column = COVER_START_INDEX; column <= BOARD_SIZE; column += SUBSECTION_SIZE) {
            for (int n = COVER_START_INDEX; n <= BOARD_SIZE; n++, hBase++) {
                for (int rowDelta = 0; rowDelta < SUBSECTION_SIZE; rowDelta++) {
                    for (int columnDelta = 0; columnDelta < SUBSECTION_SIZE; columnDelta++) {
                        int index = getIndex(row + rowDelta, column + columnDelta, n);
                        coverBoard[index][hBase] = true;
                    }
                }
            }
        }
    }
    return hBase;
}

private int checkColumnConstraint(boolean[][] coverBoard, int hBase) {
    for (int column = COVER_START_INDEX; column <= BOARD_SIZE; c++) {
        for (int n = COVER_START_INDEX; n <= BOARD_SIZE; n++, hBase++) {
            for (int row = COVER_START_INDEX; row <= BOARD_SIZE; row++) {
                int index = getIndex(row, column, n);
                coverBoard[index][hBase] = true;
            }
        }
    }
    return hBase;
}

private int checkRowConstraint(boolean[][] coverBoard, int hBase) {
    for (int row = COVER_START_INDEX; row <= BOARD_SIZE; r++) {
        for (int n = COVER_START_INDEX; n <= BOARD_SIZE; n++, hBase++) {
            for (int column = COVER_START_INDEX; column <= BOARD_SIZE; column++) {
                int index = getIndex(row, column, n);
                coverBoard[index][hBase] = true;
            }
        }
    }
    return hBase;
}

private int checkCellConstraint(boolean[][] coverBoard, int hBase) {
    for (int row = COVER_START_INDEX; row <= BOARD_SIZE; row++) {
        for (int column = COVER_START_INDEX; column <= BOARD_SIZE; column++, hBase++) {
            for (int n = COVER_START_INDEX; n <= BOARD_SIZE; n++) {
                int index = getIndex(row, column, n);
                coverBoard[index][hBase] = true;
            }
        }
    }
    return hBase;
}

次に、新しく作成したボードを最初のパズルレイアウトで更新する必要があります。

private boolean[][] initializeExactCoverBoard(int[][] board) {
    boolean[][] coverBoard = createExactCoverBoard();
    for (int row = COVER_START_INDEX; row <= BOARD_SIZE; row++) {
        for (int column = COVER_START_INDEX; column <= BOARD_SIZE; column++) {
            int n = board[row - 1][column - 1];
            if (n != NO_VALUE) {
                for (int num = MIN_VALUE; num <= MAX_VALUE; num++) {
                    if (num != n) {
                        Arrays.fill(coverBoard[getIndex(row, column, num)], false);
                    }
                }
            }
        }
    }
    return coverBoard;
}

これで次の段階に進む準備ができました。 セルをリンクする2つのクラスを作成しましょう。

4.4. ダンスノード

Dancing Linksアルゴリズムは、ノードの二重リンクリストに対する次の操作という基本的な観察に基づいて動作します。

node.prev.next = node.next
node.next.prev = node.prev

ノードを削除しますが、次のようになります。

node.prev = node
node.next = node

ノードを復元します。

DLXの各ノードは、左、右、上、下のノードにリンクされています。

DancingNode クラスには、ノードを追加または削除するために必要なすべての操作が含まれます。

class DancingNode {
    DancingNode L, R, U, D;
    ColumnNode C;

    DancingNode hookDown(DancingNode node) {
        assert (this.C == node.C);
        node.D = this.D;
        node.D.U = node;
        node.U = this;
        this.D = node;
        return node;
    }

    DancingNode hookRight(DancingNode node) {
        node.R = this.R;
        node.R.L = node;
        node.L = this;
        this.R = node;
        return node;
    }

    void unlinkLR() {
        this.L.R = this.R;
        this.R.L = this.L;
    }

    void relinkLR() {
        this.L.R = this.R.L = this;
    }

    void unlinkUD() {
        this.U.D = this.D;
        this.D.U = this.U;
    }

    void relinkUD() {
        this.U.D = this.D.U = this;
    }

    DancingNode() {
        L = R = U = D = this;
    }

    DancingNode(ColumnNode c) {
        this();
        C = c;
    }
}

4.5. 列ノード

ColumnNode クラスは、列をリンクします。

class ColumnNode extends DancingNode {
    int size;
    String name;

    ColumnNode(String n) {
        super();
        size = 0;
        name = n;
        C = this;
    }

    void cover() {
        unlinkLR();
        for (DancingNode i = this.D; i != this; i = i.D) {
            for (DancingNode j = i.R; j != i; j = j.R) {
                j.unlinkUD();
                j.C.size--;
            }
        }
    }

    void uncover() {
        for (DancingNode i = this.U; i != this; i = i.U) {
            for (DancingNode j = i.L; j != i; j = j.L) {
                j.C.size++;
                j.relinkUD();
            }
        }
        relinkLR();
    }
}

4.6. ソルバー

次に、DancingNodeオブジェクトとColumnNodeオブジェクトで構成されるグリッドを作成する必要があります。

private ColumnNode makeDLXBoard(boolean[][] grid) {
    int COLS = grid[0].length;

    ColumnNode headerNode = new ColumnNode("header");
    List<ColumnNode> columnNodes = new ArrayList<>();

    for (int i = 0; i < COLS; i++) {
        ColumnNode n = new ColumnNode(Integer.toString(i));
        columnNodes.add(n);
        headerNode = (ColumnNode) headerNode.hookRight(n);
    }
    headerNode = headerNode.R.C;

    for (boolean[] aGrid : grid) {
        DancingNode prev = null;
        for (int j = 0; j < COLS; j++) {
            if (aGrid[j]) {
                ColumnNode col = columnNodes.get(j);
                DancingNode newNode = new DancingNode(col);
                if (prev == null) prev = newNode;
                col.U.hookDown(newNode);
                prev = prev.hookRight(newNode);
                col.size++;
            }
        }
    }

    headerNode.size = COLS;

    return headerNode;
}

ヒューリスティック検索を使用して列を検索し、マトリックスのサブセットを返します。

private ColumnNode selectColumnNodeHeuristic() {
    int min = Integer.MAX_VALUE;
    ColumnNode ret = null;
    for (
      ColumnNode c = (ColumnNode) header.R; 
      c != header; 
      c = (ColumnNode) c.R) {
        if (c.size < min) {
            min = c.size;
            ret = c;
        }
    }
    return ret;
}

最後に、答えを再帰的に検索できます。

private void search(int k) {
    if (header.R == header) {
        handleSolution(answer);
    } else {
        ColumnNode c = selectColumnNodeHeuristic();
        c.cover();

        for (DancingNode r = c.D; r != c; r = r.D) {
            answer.add(r);

            for (DancingNode j = r.R; j != r; j = j.R) {
                j.C.cover();
            }

            search(k + 1);

            r = answer.remove(answer.size() - 1);
            c = r.C;

            for (DancingNode j = r.L; j != r; j = j.L) {
                j.C.uncover();
            }
        }
        c.uncover();
    }
}

列がなくなったら、解決した数独ボードを印刷できます。

5. ベンチマーク

同じコンピューターで実行することにより、これら2つの異なるアルゴリズムを比較できます(このようにして、コンポーネントの違い、CPUまたはRAMの速度などを回避できます)。 実際の時間はコンピューターごとに異なります。

ただし、相対的な結果を確認できるはずです。これにより、どのアルゴリズムがより高速に実行されるかがわかります。

バックトラッキングアルゴリズムは、ボードを解決するのに約250msかかります。

これを約50msかかるDancingLinksと比較すると、明確な勝者がわかります。 この特定の例を解くと、DancingLinksは約5倍速くなります。

6. 結論

このチュートリアルでは、コアJavaを使用した数独パズルの2つの解決策について説明しました。 ブルートフォースアルゴリズムであるバックトラッキングアルゴリズムは、標準の9×9パズルを簡単に解くことができます。

もう少し複雑なDancingLinksアルゴリズムについても説明しました。 どちらも数秒で最も難しいパズルを解きます。

最後に、いつものように、ディスカッション中に使用されたコードは、GitHubにあります。