數獨生成及求解方案剖析(Java實現)
關鍵詞
- 數獨9x9
- 數獨生成
- 數獨解題
序言
最近業務在鞏固Java基礎,編寫了一個基於JavaFX的數獨小游戲(鏈接點我)。寫到核心部分發現平時玩的數獨這個東西,還真有點意思:
行、列、子宮格之間的數字互相影響,牽一發而動全身,一不留神就碰撞沖突了,簡直都能搞出玄學的意味,怪不得古人能由此“九宮格”演繹出八卦和《周易》。
於是自己想了不少算法,也查找了不少資料,但是都沒有找到理想的Java實現;最后無意間在Github發現一個國外大佬寫了這樣一個算法,體味一番,頓覺精辟!
本篇就是把國外大佬的這個算法拿過來,進行一個深入的解析,希望能幫助到用得上的人。
正文
先上地址
數獨算法Github地址:https://github.com/a11n/sudoku
數獨算法Github中文注解地址:https://github.com/JobsLeeCN/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方法實現;
每次遍歷的是使用交換方法實現的隨機數組,保證了隨機數組空間的有限占用,並且能夠減少枚舉過程中的重復幾率。
代碼我已經做了中文注釋:
/**
* 獲取隨機數組
*
* @return
*/
private int[] generateRandomValues() {
// 初始化隨機數組 此處空格子0是因為格子初始化的時候 默認給的就是0 便於判斷和處理
int[] values = {EMPTY, 1, 2, 3, 4, 5, 6, 7, 8, 9};
Random random = new Random();
// 使用交換法構建隨機數組
for (int i = 0, j = random.nextInt(9), tmp = values[j];
i < values.length;
i++, j = random.nextInt(9), tmp = values[j]) {
if (i == j) continue;
values[j] = values[i];
values[i] = tmp;
}
return values;
}
/**
* 求解方法
*
* @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) {
...
// 初始化格子各維度統計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判斷行、列、3x3子宮格的數字是否重復:
/**
* 判斷格子填入的數字是否合適
*
* @param cell
* @param value
* @return
*/
public boolean isValidValueForCell(Cell cell, int value) {
return isValidInRow(cell, value) && isValidInColumn(cell, value) && isValidInBox(cell, value);
}
...
/**
* 判斷數獨行數字是否合規
*
* @param cell
* @param value
* @return
*/
private boolean isValidInRow(Cell cell, int value) {
return !getRowValuesOf(cell).contains(value);
}
...
/**
* 獲取行格子數值列表
*
* @param cell
* @return
*/
private Collection<Integer> getRowValuesOf(Cell cell) {
List<Integer> rowValues = new ArrayList<>();
for (Cell neighbor : cell.getRowNeighbors()) rowValues.add(neighbor.getValue());
return rowValues;
}
看完代碼,其實不難發現,算法不是很復雜,簡潔易懂——通過隨機和遞歸進行枚舉和試錯,外加List.contains()方法遍歷判斷;邏輯並不復雜,代碼也十分精煉;
於是本人通過使用基本數據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都不用手動再去修改;每次對各個維度數字進行判斷的時候,也就都是在對原始數格進行遍歷;其空間復雜度沒有增加。
總結
- 使用遞歸+隨機數組進行枚舉和試錯——邏輯簡明高效
- 使用List+對象構建數獨格子(行、列、3x3子宮格)各維度關聯
- 使用List遍歷和排查重復——方法調用簡單,引用完美控制了空間復雜度
分析到此,與其說是算法,不如說是對Java對象的構建,通過對Java對象的有效構建,來高效、簡便的完成了一次數獨的生成和求解。
這便是面向對象代碼構建的獨到之處!
妙哉妙哉!