【问题标题】:Understanding a five-dimensional DP with bitshifts and XORs?了解具有位移和异或的五维 DP?
【发布时间】:2014-06-07 23:53:45
【问题描述】:

我正在查看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,反之亦然。


解决方案涉及动态编程,每个状态都包含最少的移动次数,这样直到当前行的所有行都具有偶校验(偶数个)。

但是,我不了解它们的实现细节。首先,在他们的记忆数组中

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 &lt;&lt; c 做了什么——为什么需要异或和位移?子网格奇偶校验也是如此——mb ^ 1 &lt;&lt; c / 3。它在做什么?

有人能解释一下这些是如何工作的吗?

【问题讨论】:

  • 我认为您需要在这个问题中提供更多背景知识。将人们发送到其他网站并期望他们在回答之前消化所有信息通常被认为是不礼貌的。你能总结一下这个问题以及到目前为止你对解决方案的理解吗?
  • @templatetypedef 添加背景信息
  • 我的两分钱':链接代码的文档记录很差,很难破译。请写出比这更好的代码!
  • 这是一场优化竞赛,还是任何可行的解决方案都可以?
  • @AleksandrDubinsky 运行时间限制约为 1 秒

标签: algorithm bit-manipulation dynamic-programming


【解决方案1】:

我想我已经弄清楚了。这个想法是从上到下,从左到右扫描。在每一步,我们尝试通过将当前框设置为 0 或 1 来移动到下一个位置。

在每一行的末尾,如果奇偶校验是偶数,我们继续下一行;否则我们回溯。在每第三行的末尾,如果所有三个盒子的奇偶性都是偶数,我们继续下一行;否则我们回溯。最后,在棋盘的最后,如果所有列都是偶数,我们就完成了;否则我们回溯。

递归在任意点的状态可以用以下五条信息来描述:

  • 当前行和列。
  • 所有列的奇偶校验。
  • 我们当前所在的三个盒子的奇偶性(每行与三个相交)。
  • 列的当前奇偶校验。

这是记忆表的样子:

int memo[9][9][1<<9][1<<3][2];
         ^  ^    ^     ^   ^
         |  |    |     |   |
   row --+  |    |     |   |
   col -----+    |     |   |
column parity ---+     |   |
  box parity ----------+   |
current row parity---------+

要了解为什么会有位移,让我们看看列奇偶校验。有 9 列,因此我们可以将它们的奇偶校验写为 9 位的位向量。等效地,我们可以使用 9 位整数。 1 &lt;&lt; 9 给出了可能的 9 位整数的数量,因此我们可以使用单个整数同时编码所有列奇偶校验。

为什么要使用 XOR 和位移?好吧,将位向量 A 与第二个位向量 B 进行异或运算会反转 A 中在 B 中设置的所有位,并保持所有其他位不变。如果您正在跟踪奇偶校验,您可以使用 XOR 来切换各个位以表示奇偶校验翻转;发生移位是因为我们将多个奇偶校验位打包到一个机器字中。您提到的划分是从列索引映射到它通过的框的水平索引。

希望这会有所帮助!

【讨论】:

  • 啊,我开始明白了——非常感谢!不过还有一个问题——他们的代码 cmets 状态说 Try setting the cell to 1.Try setting the cell to 0. 但如果我理解正确他们实际上在做什么,那就是尝试两种选择:翻转当前状态并让它不理会。对吗?
  • 此外,数组中的每个元素都表示到达数组索引中指定的状态所需的移动次数,对吧?这个值在什么时候增加?
  • @user2612743 不,它正在做它所说的。如果该位为 0,则!A[] 计数一个(即,额外的翻转),但如果它已经为 1,则!A[] 不计数。
【解决方案2】:

解决方案中的算法是一种详尽的深度优先搜索,并进行了一些优化。不幸的是,描述并没有准确地解释它。

穷举搜索意味着我们尝试枚举所有可能的位组合。深度优先意味着我们首先尝试将所有位设置为 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 倍。然而,我们能证明它总是正确的吗?

【讨论】:

  • 哇!我希望我能接受你两个出色的答案。我可能需要一天左右的时间(当我有时间的时候)来阅读并理解你的写作。再次感谢您的努力!
猜你喜欢
  • 2023-03-17
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2021-11-09
  • 2010-10-02
  • 1970-01-01
  • 1970-01-01
  • 2017-02-10
相关资源
最近更新 更多