解决方案中的算法是一种详尽的深度优先搜索,并进行了一些优化。不幸的是,描述并没有准确地解释它。
穷举搜索意味着我们尝试枚举所有可能的位组合。深度优先意味着我们首先尝试将所有位设置为 1,然后将最后一个设置为 0,然后是倒数第二个,然后是最后一个和倒数第二个,等等。
第一个优化是在我们检测到奇偶校验不偶时立即回溯。因此,例如,当我们开始搜索并到达第一行时,我们检查该行是否具有零奇偶校验。如果没有,我们不会继续。我们停止、回溯,并尝试将行中的最后一位设置为零。
第二个优化类似于 DP,因为我们缓存部分结果并重新使用它们。这利用了这样一个事实:就问题而言,搜索中的不同路径可以收敛到相同的逻辑状态。什么是逻辑搜索状态?解决方案中的描述开始解释它(“开始”是关键词)。本质上,诀窍在于,在搜索的任何给定点,附加位翻转的最小次数并不取决于整个数独板的确切状态,而仅取决于我们需要跟踪的各种平价。 (请参阅下面的进一步说明。)我们正在跟踪 27 个奇偶校验(占 9 列、9 行和 9 个 3x3 框)。此外,我们可以优化其中的一些。考虑到我们执行搜索的方式,所有较高行的奇偶校验将始终是偶数,而尚未被搜索触及的所有较低行的奇偶校验不会改变。我们只跟踪 1 行的奇偶校验。同样的逻辑,忽略上下盒子的奇偶性,我们只需要跟踪“活跃”的3个盒子。
因此,我们只有 2^9 * 2^1 * 2^3 = 8,192 个状态,而不是 2^9 * 2^9 * 2^9 = 134,217,728 个状态。不幸的是,我们需要为搜索中的每个深度级别单独缓存。因此,我们乘以 81 个可能的搜索深度,发现我们需要一个大小为 663,552 的数组。借用 templatetypedef:
int memo[9][9][1<<9][1<<3][2];
^ ^ ^ ^ ^
| | | | |
row --+ | | | |
col -----+ | | |
column parity ---+ | |
box parity ----------+ |
current row parity---------+
1<<9 simply means 2^9, given how integers and bit shifts work.
进一步说明:由于奇偶校验的工作原理,位翻转总是会翻转其对应的 3 个奇偶校验。因此,具有相同奇偶性的数独板的所有排列都可以用相同的位翻转获胜模式来解决。 'solve' 函数给出了问题的答案:“假设您只能从位置 (x,y) 的单元格开始执行位翻转,那么获得已解决板的最小位翻转次数是多少。”所有具有相同奇偶性的数独板将产生相同的答案。搜索算法考虑了许多棋盘排列。它从顶部开始修改它们,计算它已经完成了多少位翻转,然后询问函数'solve'看看它还需要多少。如果已经使用相同的 (x,y) 值和相同的奇偶校验调用了 'solve',我们可以只返回缓存的结果。
令人困惑的部分是实际执行搜索和更新状态的代码:
/* Try setting the cell to 1. */
ref = !A[r][c] + solve(r, c + 1, mc ^ 1 << c, mb ^ 1 << c / 3, !p);
/* Try setting the cell to 0. */
ref = min(ref, A[r][c] + solve(r, c + 1, mc, mb, p));
可以更清楚地呈现为:
/* Try having this cell equal 0 */
bool areWeFlipping = A[r][c] == 1;
int nAdditionalFlipsIfCellIs0 = (areWeFlipping ? 1 : 0) + solve(r, c + 1, mc, mb, p); // Continue the search
/* Try having this cell equal 1 */
areWeFlipping = A[r][c] == 0;
// At the start, we assume the sudoku board is all zeroes, and therefore the column parity is all even. With each additional cell, we update the column parity with the value of tha cell. In this case, we assume it to be 1.
int newMc = mc ^ (1 << c); // Update the parity of column c. ^ (1 << c) means "flip the bit denoting the parity of column c"
int newMb = mb ^ (1 << (c / 3)); // Update the parity of 'active' box (c/3) (ie, if we're in column 5, we're in box 1)
int newP = p ^ 1; // Update the current row parity
int nAdditionalFlipsIfCellIs1 = (areWeFlipping ? 1 : 0) + solve(r, c + 1, newMc, newMb, newP); // Continue the search
ref = min( nAdditionalFlipsIfCellIs0, nAdditionalFlipsIfCellIs1 );
就个人而言,我会将搜索的两个方面实现为“翻转”和“不翻转”。这使得算法在概念上更有意义。它将使第二段变为:“深度优先意味着我们首先尝试不翻转任何位,然后翻转最后一个,然后是倒数第二个,然后是最后一个和倒数第二个,等等。 "此外,在开始搜索之前,我们需要为我们的棋盘预先计算 'mc'、'mb' 和 'p' 的值,而不是传递 0。
/* Try not flipping the current cell */
int nAdditionalFlipsIfDontFlip = 0 + solve(r, c + 1, mc, mb, p);
/* Try flipping it */
int newMc = mc ^ (1 << c);
int newMb = mb ^ (1 << (c / 3));
int newP = p ^ 1;
int nAdditionalFlipsIfFlip = 1 + solve(r, c + 1, newMc, newMb, newP);
ref = min( nAdditionalFlipsIfDontFlip, nAdditionalFlipsIfFlip );
但是,此更改似乎不会影响性能。
更新
最令人惊讶的是,该算法速度极快的关键似乎在于记忆数组最终变得相当稀疏。在每个深度级别,通常会访问 512 个(有时是 256 或 128 个)状态(共 8192 个)。此外,每列奇偶校验总是一个状态。盒子和行奇偶校验似乎无关紧要!从记忆数组中省略它们可以将性能提高 30 倍。然而,我们能证明它总是正确的吗?