我正在查看this问题here的解决方案,我不太明白动态编程(DP)是如何工作的。
问题摘要如下:给出9x9网格中的1或0,排列在9个3x3子网格中,如下所示:
000 000 000
001 000 100
000 000 000
000 110 000
000 111 000
000 000 000
000 000 000
000 000 000
000 000 000
您需要找到所需的最小更改次数,以便九个行,列和3x3子网格中的每一个都包含偶数个1。这里,更改定义为将给定元素从1切换为0,反之亦然。
解决方案涉及动态编程,每个状态由最小移动次数组成,这样直到当前行的所有行都具有偶数奇偶校验(偶数个)。
但是,我不了解其实施细节。首先,在他们的memoization数组中
int memo[9][9][1<<9][1<<3][2];
每个索引代表什么?我收集到前两个用于当前行和列,第三个用于列奇偶校验,第四个用于子网格奇偶校验,第五个用于行奇偶校验。但是,为什么列奇偶校验需要2 ^ 9个元素,而行奇偶校验只需要2?
接下来,状态之间的转换是如何处理的?我会假设你在尝试每个元素的时候穿过这行,并在完成后移动到下一行,但看到他们的代码后我很困惑
int& ref = memo[r][c][mc][mb][p];
/* 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));
他们如何通过翻转网格中的当前位来尝试将单元格设置为1?我理解当你将行奇偶校验变为一个时,如!p
所示,但我不明白列奇偶校验会如何影响,或mc ^ 1 << c
做什么 - 为什么你需要xor和bitshifts?子网格奇偶校验同样如此 - mb ^ 1 << c / 3
。它在做什么?
有人可以解释这些是如何工作的吗?
答案 0 :(得分:7)
我想我已经弄明白了。这个想法是从上到下,从左到右扫描。在每一步中,我们通过将当前框设置为0或1来尝试移动到下一个位置。
在每一行的末尾,如果奇偶校验是偶数,我们继续前进到下一行;否则我们会回溯。在每三行结束时,如果所有三个框的奇偶校验均匀,我们将继续前进到下一行;否则我们会回溯。最后,在电路板的最后,如果所有列都具有偶校验,我们就完成了;否则我们会回溯。
任何时候递归的状态都可以用以下五条信息来描述:
这就是memoization表的样子:
int memo[9][9][1<<9][1<<3][2];
^ ^ ^ ^ ^
| | | | |
row --+ | | | |
col -----+ | | |
column parity ---+ | |
box parity ----------+ |
current row parity---------+
要了解为什么存在位移,请查看列奇偶校验。有9列,所以我们可以将它们的奇偶校验写成9位的位向量。等价地,我们可以使用九位整数。 1 << 9
给出了可能的9位整数的数量,因此我们可以使用单个整数同时对所有列奇偶校验进行编码。
为什么要使用XOR和位移?好吧,使用第二个比特向量B对一个位向量A进行异或,反转A中所有在B中设置的位,并使所有其他位保持不变。如果您正在跟踪奇偶校验,则可以使用XOR切换各个位以表示奇偶校验的翻转;发生转变是因为我们将多个奇偶校验位打包成一个机器字。您引用的分区是从列索引映射到它通过的框的水平索引。
希望这有帮助!
答案 1 :(得分:5)
解决方案中的算法是详尽的深度优先搜索,并进行了一些优化。不幸的是,描述并没有完全解释它。
详尽搜索意味着我们尝试枚举每个可能的位组合。深度优先意味着我们首先尝试将所有位设置为1,然后将最后一位设置为零,然后将倒数第二位设置为零,然后将最后一位设置为倒数第二位,等等。
第一个优化是在我们检测到奇偶校验不均匀时立即回溯。因此,例如,当我们开始搜索并到达第一行时,我们检查该行是否具有零奇偶校验。如果没有,我们不会继续。我们停止,回溯,并尝试将行中的最后一位设置为零。
第二个优化类似于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 );
但是,此更改似乎不会影响性能。
<强>更新强>
最令人惊讶的是,算法炽热速度的关键似乎是memoization数组最终相当稀疏。在每个深度级别,通常访问512个(有时是256或128个)状态(在8192中)。而且,每列奇偶校验总是一个状态。盒子和行的平价似乎并不重要!从memoization数组中省略它们可以将性能提高30倍。然而,我们能证明它总是如此吗?