一次數獨生成及解題演算法的剖析(Java實現)

aQiu18發表於2020-12-18

數獨生成及解題演算法剖析(Java實現)

關鍵詞

  • 數獨9x9
  • 數獨生成演算法
  • 數獨解題演算法

序言

最近業務在鞏固Java基礎,編寫了一個基於JavaFX的數獨小遊戲(隨後放連結)。寫到核心部分發現平時玩的數獨這個東西,還真有點意思:

行、列、子宮格之間的數字互相影響,牽一髮而動全身,一不留神就碰撞衝突了,簡直都能搞出玄學的意味,怪不得古人能由此“九宮格”演繹出八卦和《周易》。

於是自己想了不少演算法,也查詢了不少資料,但是都沒有找到理想的Java實現;最後無意間在Github發現一個國外大佬寫了這樣一個演算法,體味一番,頓覺精闢!

本篇就是把國外大佬的這個演算法拿過來,進行一個深入的解析,希望能幫助到用得上的人。


正文

先上地址

數獨演算法Github地址:https://github.com/a11n/sudoku

數獨演算法Github中文註解地址:https://github.com/JobsLeeGeek/sudoku

程式碼只有三個類:

  • Generator.java

生成器 -> 生成數獨格子

  • Solver.java

解法器 -> 數獨求解

  • Grid.java

網格物件 -> 基礎數獨格子物件

直接上main方法看下基本呼叫:

public static void main(String[] args) {
        // 生成一個20個空格的9x9數獨
        Generator generator = new Generator();
        Grid grid = generator.generate(20);
        System.out.println(grid.toString());
        // 9x9數獨求解
        Solver solver = new Solver();
        solver.solve(grid);
        System.out.println(grid.toString());
    }

看下輸出結果(輸出方法我自己進行了修改):

生成的9x9數獨(0為空格)

[9, 8, 0, 1, 0, 2, 5, 3, 7]
[1, 4, 2, 5, 0, 7, 9, 8, 6]
[0, 3, 7, 0, 8, 0, 1, 0, 0]
[8, 9, 1, 0, 2, 4, 3, 0, 5]
[6, 2, 0, 0, 0, 5, 8, 0, 0]
[3, 7, 0, 8, 9, 1, 6, 2, 4]
[4, 6, 9, 2, 1, 8, 7, 5, 3]
[2, 1, 8, 0, 0, 0, 4, 6, 9]
[0, 5, 3, 4, 6, 9, 2, 1, 8]

數獨求解

[9, 8, 6, 1, 4, 2, 5, 3, 7]
[1, 4, 2, 5, 3, 7, 9, 8, 6]
[5, 3, 7, 9, 8, 6, 1, 4, 2]
[8, 9, 1, 6, 2, 4, 3, 7, 5]
[6, 2, 4, 3, 7, 5, 8, 9, 1]
[3, 7, 0, 8, 9, 1, 6, 2, 4]
[4, 6, 9, 2, 1, 8, 7, 5, 3]
[2, 1, 8, 7, 5, 3, 4, 6, 9]
[7, 5, 3, 4, 6, 9, 2, 1, 8]

使用起來很簡單,速度也很快;其核心部分的程式碼,其實只有三個點。

1. 第一點 解法

  • 遞迴填數

在Solver.java中solve方法實現,程式碼我已經做了中文註釋:

/**
 * 求解方法
 *
 * @param grid
 * @param cell
 * @return
 */
private boolean solve(Grid grid, Optional<Grid.Cell> cell) {
    // 空格子 說明遍歷處理完了
    if (!cell.isPresent()) {
        return true;
    }
    // 遍歷隨機數值 嘗試填數
    for (int value : values) {
        // 校驗填的數是否合理 合理的話嘗試下一個空格子
        if (grid.isValidValueForCell(cell.get(), value)) {
            cell.get().setValue(value);
            // 遞迴嘗試下一個空格子
            if (solve(grid, grid.getNextEmptyCellOf(cell.get()))) return true;
            // 嘗試失敗格子的填入0 繼續為當前格子嘗試下一個隨機值
            cell.get().setValue(EMPTY);
        }
    }
    return false;
}

2. 第二點 構建

  • 物件陣列

整個物件的構建在Grid.java中,其中涉及到兩個物件Grid和Cell,Grid由Cell[][]陣列構成,Cell中記錄了格子的數值、行列子宮格維度的格子列表及下一個格子物件:

Grid物件

/**
 * 由資料格子構成的數獨格子
 */
private final Cell[][] grid;

Cell物件

// 格子數值
private int value;
// 行其他格子列表
private Collection<Cell> rowNeighbors;
// 列其他格子列表
private Collection<Cell> columnNeighbors;
// 子宮格其他格子列表
private Collection<Cell> boxNeighbors;
// 下一個格子物件
private Cell nextCell;

3. 第三點 遍歷

  • 多維度引用

Grid初始化時,在Cell物件中,使用List構造了行、列、子宮格維度的引用(請注意這裡的引用,後面會講到這個引用的妙處),見如下程式碼及中文註釋:

/**
 * 返回數獨格子的工廠方法
 *
 * @param grid
 * @return
 */
public static Grid of(int[][] grid) {
    // 基礎校驗
    verifyGrid(grid);

    // 初始化格子各維度統計List 9x9 行 列 子宮格
    Cell[][] cells = new Cell[9][9];
    List<List<Cell>> rows = new ArrayList<>();
    List<List<Cell>> columns = new ArrayList<>();
    List<List<Cell>> boxes = new ArrayList<>();
    // 初始化List 9行 9列 9子宮格
    for (int i = 0; i < 9; i++) {
        rows.add(new ArrayList<Cell>());
        columns.add(new ArrayList<Cell>());
        boxes.add(new ArrayList<Cell>());
    }

    Cell lastCell = null;
    // 逐一遍歷數獨格子 往各維度統計List中填數
    for (int row = 0; row < grid.length; row++) {
        for (int column = 0; column < grid[row].length; column++) {
            Cell cell = new Cell(grid[row][column]);
            cells[row][column] = cell;

            rows.get(row).add(cell);
            columns.get(column).add(cell);
            // 子宮格在List中的index計算
            boxes.get((row / 3) * 3 + column / 3).add(cell);
            // 如果有上一次遍歷的格子 則當前格子為上個格子的下一格子
            if (lastCell != null) {
                lastCell.setNextCell(cell);
            }
            // 記錄上一次遍歷的格子
            lastCell = cell;
        }
    }

    // 逐行 逐列 逐子宮格 遍歷 處理對應模組的關聯鄰居List
    for (int i = 0; i < 9; i++) {
        // 逐行
        List<Cell> row = rows.get(i);
        for (Cell cell : row) {
            List<Cell> rowNeighbors = new ArrayList<>(row);
            rowNeighbors.remove(cell);
            cell.setRowNeighbors(rowNeighbors);
        }

        // 逐列
        List<Cell> column = columns.get(i);
        for (Cell cell : column) {
            List<Cell> columnNeighbors = new ArrayList<>(column);
            columnNeighbors.remove(cell);
            cell.setColumnNeighbors(columnNeighbors);
        }

        // 逐子宮格
        List<Cell> box = boxes.get(i);
        for (Cell cell : box) {
            List<Cell> boxNeighbors = new ArrayList<>(box);
            boxNeighbors.remove(cell);
            cell.setBoxNeighbors(boxNeighbors);
        }
    }

    return new Grid(cells);
}

看完程式碼,其實不難發現,演算法不是很複雜,簡潔易懂——通過隨機和遞迴進行列舉和試錯;

於是本人通過使用基本資料int[][],不使用物件,按照其核心邏輯實現了自己的一套數獨,卻發現極度耗時(大家可以自己嘗試下),很久沒有結果輸出。由此引發了對其效能的考量;

仔細思考,最後發現物件導向真的是個好東西,物件的引用從很大一層面上解決了數獨遞迴的效能問題。


寫一個有趣的例子來解釋下,用一個物件構建二維陣列,初始化數值後,分別按照行維度和列維度關聯到對應的List中,列印陣列和這些List;

然後我們修改(0,0)位置的數值,注意,這裡不是new一個新的物件,而是直接使用物件的set方法操作其對應數值,再列印陣列和這些List,程式碼和結果如下:

示例程式碼

public static void main(String[] args) {
        Entity[][] ee = new Entity[3][3];
        for (int i = 0; i < 3; i++) {
            for (int j = 0; j < 3; j++) {
                Entity e = new Entity();
                e.setX(i);
                e.setY(j);
                ee[i][j] = e;
            }
        }
        System.out.println(Arrays.deepToString(ee));

        List<List<Entity>> row = new ArrayList<>();
        List<List<Entity>> column = new ArrayList<>();
        for (int i = 0; i < 3; i++) {
            row.add(new ArrayList<>());
        }
        for (int i = 0; i < 3; i++) {
            for (int j = 0; j < 3; j++) {
                row.get(i).add(ee[i][j]);
            }
        }
        for (int j = 0; j < 3; j++) {
            column.add(new ArrayList<>());
        }
        for (int j = 0; j < 3; j++) {
            for (int i = 0; i < 3; i++) {
                column.get(j).add(ee[i][j]);
            }
        }
        System.out.println(row);
        System.out.println(column);

        System.out.println("");

        ee[0][0].setX(9);
        ee[0][0].setY(9);
        System.out.println(Arrays.deepToString(ee));
        System.out.println(row);
        System.out.println(column);
    }

    static class Entity {
        private int x;
        private int y;

        public int getX() {
            return x;
        }

        public void setX(int x) {
            this.x = x;
        }

        public int getY() {
            return y;
        }

        public void setY(int y) {
            this.y = y;
        }

        @Override
        public String toString() {
            return "Entity{" +
                    "x=" + x +
                    ", y=" + y +
                    '}';
        }
    }

輸出結果

[[Entity{x=0, y=0}, Entity{x=0, y=1}, Entity{x=0, y=2}], [Entity{x=1, y=0}, Entity{x=1, y=1}, Entity{x=1, y=2}], [Entity{x=2, y=0}, Entity{x=2, y=1}, Entity{x=2, y=2}]]
[[Entity{x=0, y=0}, Entity{x=0, y=1}, Entity{x=0, y=2}], [Entity{x=1, y=0}, Entity{x=1, y=1}, Entity{x=1, y=2}], [Entity{x=2, y=0}, Entity{x=2, y=1}, Entity{x=2, y=2}]]
[[Entity{x=0, y=0}, Entity{x=1, y=0}, Entity{x=2, y=0}], [Entity{x=0, y=1}, Entity{x=1, y=1}, Entity{x=2, y=1}], [Entity{x=0, y=2}, Entity{x=1, y=2}, Entity{x=2, y=2}]]

[[Entity{x=9, y=9}, Entity{x=0, y=1}, Entity{x=0, y=2}], [Entity{x=1, y=0}, Entity{x=1, y=1}, Entity{x=1, y=2}], [Entity{x=2, y=0}, Entity{x=2, y=1}, Entity{x=2, y=2}]]
[[Entity{x=9, y=9}, Entity{x=0, y=1}, Entity{x=0, y=2}], [Entity{x=1, y=0}, Entity{x=1, y=1}, Entity{x=1, y=2}], [Entity{x=2, y=0}, Entity{x=2, y=1}, Entity{x=2, y=2}]]
[[Entity{x=9, y=9}, Entity{x=1, y=0}, Entity{x=2, y=0}], [Entity{x=0, y=1}, Entity{x=1, y=1}, Entity{x=2, y=1}], [Entity{x=0, y=2}, Entity{x=1, y=2}, Entity{x=2, y=2}]]

神奇的地方就在這裡,行列關聯的List裡面的數值跟隨著一起改變了。

這是為什麼呢?

Java的集合中存放的型別

(1)如果是基本資料型別,則是value;

(2) 如果是複合資料型別,則是引用的地址;

List中放入物件時,實際放入的不是物件本身而是物件的引用;

物件陣列只需要自己佔據一部分記憶體空間,List來引用物件,就不需要額外有陣列記憶體的開支;

同時對原始陣列中物件的修改(注意,修改並非new一個物件,因為new一個就開闢了新的記憶體地址,引用還會指向原來的地址),就可以做到遍歷一次、處處可見了!

這樣一來陣列記憶體還是原來的一塊陣列記憶體,我們只需用List關聯引用,就不用需要每次遍歷和判斷的時候開闢額外空間了;

然後每次對原始數格處理的時候,其各個維度List都不用手動再去修改;每次對各個維度數字進行判斷的時候,也就都是在對原始數格進行遍歷;其空間複雜度沒有增加。


總結

  1. 使用遞迴+隨機陣列進行列舉和試錯
  2. 使用List+物件構建數獨格子(行、列、3x3子宮格)各維度關聯
  3. 引用完美控制了空間複雜度
  4. 隨機+位置互換生成隨機陣列,增加隨機性和減少衝突

這便是上面程式碼構建的獨到之處!

妙哉妙哉!

相關文章