最近在做scala99,其中有一道题目P97是sudoku(数独)。一开始我想到的是列举每个空格的可选数字,选择可选数字最少的的格子开始填写。每次填写,修改关联格子的可选数字。再次尝试填写。这里无解条件是存在没有可选数字的格子。还有就是easy或者normal级别的数独一般可选数字都是1个,分批填写很快就能完成。hard或以上就没有那么幸运了,必须猜测然后验证,存在回溯。
这里描述的算法其实就是搜索+启发。启发是选择性地从最少可选数的格子开始。猜测+验证,感觉这个问题属于NP问题。事实上也确实是,而且可以转化为NP完全的精确覆盖问题(Exact Cover),所以这个问题也是NP完全问题。
P97给的答案有两个,第一个就是搜索+启发,第二个是DLX。DLX是Exact Cover的高效解法。本文就是讲如何用DLX来解数独问题的。考虑到scala99上第二个实现大部分都是可变数据,所以我用Java实现了相应算法。
理解代码前先要了解几个概念:
精确覆盖问题
精确覆盖问题是这样一类问题:有一个大集合S,选择其中的部分元素得到集合T0, T1, T2… Tn,这些集合统称为T*,要求你从T*中找到这样的一组集合,使得这组集合(两两不相交)并集为S,换句话说,S中每个元素都再这组集合中出现并且仅出现一次。
举例(参照wikipedia),S为{1, 2, 3, 4},T* = {T0, T1, T2, T3},T0 = {}, T1 = {1, 3}, T2 = {2, 3}, T3 = {2, 4}。这里T1和T3组成S的一个精确覆盖,因为T1和T3两两不相交并且并集为S。
精确覆盖问题可以用矩阵来表述。比如S = {1, 2, 3, 4, 5, 6, 7}, 存在这样一组T*
- A = {1, 4, 7}
- B = {1, 4}
- C = {4, 5, 7}
- D = {3, 5, 6}
- E = {2, 3, 6, 7}
- F = {2, 7}
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 |
要从矩阵中找出这样几行,使得一列上只有一个1,而且总共有|S|个数个1。答案是{B, D, F}。
X算法
如果要用计算机来解Exact Cover问题的话,即可以尝试解这个矩阵。常规的一个解法称为X算法。在刚才的矩阵上应用X算法,步骤参照wikipedia。
- 如果当前矩阵没有列了,表示当前选择的行组成一个解,返回。
- 否则,选择一列。这里一般是选择1最少的列c,就像数独启发的那样。
- 遍历列c上值为1的行r
- (嵌套1)把r作为部分解
- (嵌套1)遍历行r上值为1列c2(理论上包括c)
- (嵌套2)遍历c2上所有值为1的行r2(理论上包括r)
- (嵌套3)删除行r2
- (嵌套2)删除列c2
- (嵌套1)递归下一个部分解
直接看上面这段代码过程描述可能还是难以理解,如果想具体看算法过程的,可以参考wikipedia上的过程。这里个人只提示下部分算法步骤的目的。第1步显然是没有列也没有行,全部选完了,自然是合理解。第2步也容易理解,选择最少1个数的列,这样关联的行选择就少,搜索相当于是被优化了。然后遍历这个1数量最少的列。每次尝试一行。选择这行后,需要做的事情除了作为部分解之外,还要把这行关联的所有数值为1的列删除,并且把这些列上值为1的行也删除。从集合上来看,你选择了某个集合。含有你这个集合元素的集合都要排除,其次从矩阵中排查你这个集合中得元素关联的矩阵列。因为你选择了这个集合,元素是排他的。
先别急着实现这个算法。因为这个算法有比较多的删除,递归和回溯,事实上还有特殊情况。比如你选择某组集合,但是某列上没有1,这就说明这组集合不是解,必须回溯。考虑到效率,事实上有一个专门的实现,dancing links,又称DLX。DLX不是一个全新的算法,它只是X的一种高效实现。但是很多人称为DLX算法,你可以理解为Dancing Links on X(个人分析)。
DLX算法
DLX的主要内容是用十字链表(一个节点存在上下左右四个节点的指针)来存储稀疏矩阵,同时利用环形链表的删除和恢复来实现X算法中删除行或者列以及相应的回溯。原本在递归算法中,是在栈中保存矩阵来实现回溯的,自然会产生很多中间数据。DLX算法的论文中文版在这里。使用其中一张十字链表图
使用链表来存储矩阵并不是什么少见的事情,只是这里用十字更符合算法需要的操作。第一行为Column Header,最左右为表头(h),算法之中只需要表头即可访问所有数据,同时Column Header中存在每列中1的个数,便于选择1的数量最少的列。算法执行时,需要从列a的1到列b的1,同时也需要从行a的1到行b的1,这里的十字链表正好可以通过指针快速移动和访问。还有一条,隐藏列或行的时候用链表很容易,同时利用栈数据的特性,回溯时重新把数据列或行恢复也很容易。这些就是个人理解的DLX实现X算法的主体思想。
DLX论文中的伪代码
搜索
如果 R[h]=h ,打印当前的解(见下)并且返回。 否则选择一个列对象c(见下)。 覆盖列c(见下)。 对于每个r←D[c],D[D[c]],……,当 r!=c, 设置 Ok<-r; 对于每个j←R[r],R[R[r]],……,当 j!=r, 覆盖列j(见下); search(k+1); 设置 r←Ok 且 c←C[r]; 对于每个j←L[r],L[L[r]],……,当 j!=r, 取消列j的覆盖(见下)。 取消列c的覆盖(见下)并且返回。
选择列
对于每个j←R[h],R[R[h]],……,当 j!=h, 如果 S[j]<s 设置 c←j 且 s←S[h]。
覆盖
设置 L[R[c]]←L[c] 且 R[L[c]]←R[c]。 对于每个i←D[c],D[D[c]],……,当 i!=c, 对于每个j←R[i],R[R{i]],……,当 j!=i, 设置 U[D[j]]←U[j],D[U[j]]←D[j], 并且设置 S[C[j]]←S[C[j]]-1。
取消覆盖
对于每个i←U[c],U[U[c]],……,当 j!=i, 对于每个j←L[i],L[L[i]],……,当 j!=i, 设置 S[C[j]]←S[C[j]]+1, 并且设置 U[D[j]]←j,D[U[j]]←j。 设置 L[R[c]]←c 且 R[L[c]]←c。
接下来讲个人的Java代码实现。注意,先别急着考虑数独,DLX之后你还需要考虑如何构造矩阵,数独如何转换为精确覆盖问题等。
实现十字链接,除了直接实现之外,还有一种用数组模拟的。数组模拟似乎调试更容易些,有需要的可以在看了构造矩阵部分后实现。个人这里是直接实现,为了调试,让每个节点自己的toString中包含了一些信息。
DLX Java实现
节点模型
abstract class AbstractCell { AbstractCell left; AbstractCell right; AbstractCell up; AbstractCell down; void appendRight(AbstractCell that) { right = that; that.left = this; } void appendDown(AbstractCell that) { down = that; that.up = this; } @Override public String toString() { return getDescription(); } protected abstract String getDescription(); } class Head extends AbstractCell { Head() { left = this; right = this; } @Override protected String getDescription() { return "head"; } } class ColumnHeader extends AbstractCell { int numberOfOnes = 0; int index = -1; // for debug ColumnHeader() { up = this; down = this; } @Override protected String getDescription() { return "column " + index; } } class Cell<T> extends AbstractCell { T name; ColumnHeader columnHeader; @Override protected String getDescription() { if (columnHeader == null) return "cell " + name; return "cell " + columnHeader.index + ", " + name; } }
这里有4个类,第一个抽象类,包含十字链表的上下左右,Head单独,没有值,Column Header包含1的个数和用于调试的index,单元格包含关联的列和行名。没有单独的行元素。
核心代码
@SuppressWarnings("unchecked") public <T> void search(Head h, int depth, T[] solution) { if (h.right == h) { // System.out.println(Arrays.toString(solution)); return; } ColumnHeader column = selectColumn(h); cover(column); // iterate each row of this column for (AbstractCell row = column.down; row != column; row = row.down) { solution[depth] = ((Cell<T>) row).name; // cover columns link to row for (AbstractCell column2 = row.right; column2 != row; column2 = column2.right) { cover(((Cell<?>) column2).columnHeader); } search(h, depth + 1, solution); // uncover columns link to row for (AbstractCell column2 = row.left; column2 != row; column2 = column2.left) { uncover(((Cell<?>) column2).columnHeader); } } uncover(column); } private <T> void uncover(ColumnHeader column) { // unhide current column for (AbstractCell row = column.up; row != column; row = row.up) { for (AbstractCell cell = row.left; cell != row; cell = cell.left) { ((Cell<?>) cell).columnHeader.numberOfOnes++; cell.up.down = cell; cell.down.up = cell; } } column.right.left = column; column.left.right = column; } private void cover(ColumnHeader column) { // hide current column column.right.left = column.left; column.left.right = column.right; // iterate each row of column for (AbstractCell row = column.down; row != column; row = row.down) { // find cell link to row for (AbstractCell cell = row.right; cell != row; cell = cell.right) { cell.down.up = cell.up; cell.up.down = cell.down; ((Cell<?>) cell).columnHeader.numberOfOnes--; } } } private ColumnHeader selectColumn(Head h) { int min = Integer.MAX_VALUE; ColumnHeader selected = null; for (AbstractCell cell = h.right; cell != h; cell = cell.right) { ColumnHeader column = (ColumnHeader) cell; if (column.numberOfOnes < min) { selected = column; min = column.numberOfOnes; } } return selected; }
基本就是参照DLX的论文实现的,没有增加其他逻辑。因为我分了多个类表示矩阵中的元素,所以实际方法的参数,变量的类型都有可能不同,不过相对来说类型安全些,全是单元格的话,分不清是列头还是行单元格。
矩阵生成
然后是让人感觉麻烦的矩阵生成。用数学的话讲就是建模后的输入。这里直接用之前的那个表格表示的矩阵。以下是我如何矩阵表示到十字链表的。顺便说一句,部分算法感觉本来就是为了数学家解决某些问题而设计的,也算是数学和计算机编程交界的地方,所以数学好的人可能会相对容易,当然也包括类似复杂度计算什么的。不过泛称的算法比如load balance感觉更属于处理策略的范畴,所以与其说更靠近数学,还不如说算法设计能体现一个人利用计算机的资源处理问题的能力。
扯远了,个人生成DLX的十字链表的策略是先Head,后Column Header,然后一行一行加数据。其中Column Header需要随机访问,所以Column Header除了按照链表节点方式构造之外,还需要存放在一个数组中,方便之后按行加数据。
构造Column Header
public ColumnHeader[] buildColumnHeaders(Head head, int length) { AbstractCell lastColumn = head; ColumnHeader[] columns = new ColumnHeader[length]; for (int i = 0; i < length; i++) { ColumnHeader column = new ColumnHeader(); column.index = i; columns[i] = column; lastColumn.appendRight(column); lastColumn = column; } lastColumn.right = head; return columns; }
增加行
// appendLine(columnHeaders, 'A', new int[] {0, 3, 6}); private <T> void appendLine(ColumnHeader[] columnHeaders, T name, int[] elements) { if (elements.length == 0) return; AbstractCell lastCell = new Cell<T>(); for (int i = 0; i < elements.length; i++) { Cell<T> cell = new Cell<T>(); ColumnHeader column = columnHeaders[elements[i]]; AbstractCell columnLastCell = column.up; columnLastCell.appendDown(cell); column.numberOfOnes++; column.up = cell; cell.down = column; cell.name = name; cell.columnHeader = column; lastCell.appendRight(cell); lastCell = cell; } AbstractCell firstCell = columnHeaders[elements[0]].up; lastCell.appendRight(firstCell); }
解释下这里增加行的过程。每个元素被加在某列的最后一个元素后,同时横向几个元素需要关联,所以你这里会看到appendDown和appendRight。这样,总体的构造过程变成:
Head h = new Head(); ColumnHeader[] columnHeaders = DancingLinks.buildColumnHeaders(h, 7); appendLine(columnHeaders, 'A', new int[] {0, 3, 6}); appendLine(columnHeaders, 'B', new int[] {0, 3}); appendLine(columnHeaders, 'C', new int[] {3, 4, 6}); appendLine(columnHeaders, 'D', new int[] {2, 4, 5}); appendLine(columnHeaders, 'E', new int[] {1, 2, 5, 6}); appendLine(columnHeaders, 'F', new int[] {1, 6}); // Character[] solution = new Character[6]; // dancingLinks.search(h, 0, solution);
之后用DLX解就行。
数独转换为Exact Cover问题
最后讲一下如何将数独问题转换为Exact Cover问题。参照这篇文章,得知转换的重点是确定约束条件,数独的必要条件是
- 81个格子中每个格子只能放一个数字
- 每一行的数字不能重复
- 每一列的数字不能重复
- 每一九宫内的数字不能重复
看起来很简单,但是怎么转换成矩阵。首先,列是所有元素的集合,这里的元素不是1-9,而是约束条件下的抽象元素。比如第1个条件可以表示为1-81号元素,每个元素表示第几个格子是否有元素。第2个条件可以理解为,假如第1行,理论上有1-9,那么有9个元素,总体有9行,所以有9行1-9,即81个抽象元素。依次类推,四个条件表示了4 * 81即324个抽象元素,即324列。那儿行该怎么表示?你选择在数独板的x行y列上填n,那么324中肯定会关联4个元素,分别是第几格,第几行的数字几,第几列的数字几和第几个九宫的数字几,也就是4个抽象元素。那么填了81个数字,理论上肯定会有324个抽象元素,而且应该两两不相交,满足精确覆盖条件。这里行就是x行y列数字n。实际中,因为部分格子已经填好,所以这部分行已经确定。但是未填的格子,理论上可以填1-9(虽然实际上由于限制不太可能),这里不做剪枝,直接把1-9作为x行y列数字n,共9行纳入矩阵,这样假如在没有任何元素的情况下,最多有81 * 9即729行,实际上没那么多,否则人就不能填了,只能靠计算机解。
这样定义行的代号
class SudokuCell { int x; int y; int n; SudokuCell(int x, int y, int n) { super(); this.x = x; this.y = y; this.n = n; } @Override public String toString() { return String.format("(%d, %d) -> %d", x, y, n); } }
数独输入代码
public void test() { Head h = new Head(); ColumnHeader[] columnHeaders = DancingLinks.buildColumnHeaders(h, 324); String sudoku = ".3..9..5.\n....1...4\n5..8....7\n....3.2..\n6....9.4.\n..7..25..\n.....19..\n..69....8\n12.....7."; String[] lines = sudoku.split("\n"); for (int y = 0; y < lines.length; y++) { char[] chars = lines[y].toCharArray(); for (int x = 0; x < chars.length; x++) { char c = chars[x]; if (c == '.') appendSudokuCell(columnHeaders, x, y); else appendSudokuCell(columnHeaders, x, y, c - '0'); } } SudokuCell[] solution = new SudokuCell[81]; dlx.search(h, 0, solution); int[][] board = new int[9][9]; for (SudokuCell cell : solution) { board[cell.y][cell.x] = cell.n; } for (int y = 0; y < 9; y++) { for (int x = 0; x < 9; x++) { System.out.print(board[y][x]); System.out.print(' '); } System.out.println(); } } private void appendSudokuCell(ColumnHeader[] columnHeaders, int x, int y) { for (int n : ONE_TO_NINE) { appendSudokuCell(columnHeaders, x, y, n); } } private void appendSudokuCell(ColumnHeader[] columnHeaders, int x, int y, int n) { appendLine(columnHeaders, new int[] {x + y * 9, y * 9 + n - 1 + 81, x * 9 + n - 1 + 162, 9 * (x / 3) + 27 * (y / 3) + n - 1 + 243}, new SudokuCell(x, y, n)); }
search之后的代码是把选择的行归类便于之后输出,在dlx算法中设置部分解的部分直接归类也是可以的。就看你怎么方便了。
ONE_TO_NINE是9个数字,1~9。每行针对的4列的index需要计算出来,最后一个九宫格的相对那一些,可以一步一步尝试推导,理论上你这个函数也是“Exact Cover”的就行,不存在不同n对用同一个index的情况即可。
上面的数独的解是,有兴趣的可以验证下。
7 3 4 2 9 6 8 5 1 2 6 8 5 1 7 3 9 4 5 9 1 8 4 3 6 2 7 9 1 5 7 3 4 2 8 6 6 8 2 1 5 9 7 4 3 3 4 7 6 8 2 5 1 9 8 5 3 4 7 1 9 6 2 4 7 6 9 2 5 1 3 8 1 2 9 3 6 8 4 7 5
完整代码在 https://github.com/xnnyygn/algorithm-learning/tree/master/dancing-link-java
scala99个人P97的实现在 https://github.com/xnnyygn/scala99/blob/master/src/main/scala/in/xnnyygn/scala99/P97.scala