DeepSeek总结的算法 X 与舞蹈链文章

原文地址The Algorithm X and the Dancing Links

算法 X 与舞蹈链

2013年4月28日

引言

在高德纳的一篇名为《Dancing Links》的论文[1]中,他展示了一种算法,可以通过回溯高效地解决像数独这样的谜题。

这个回溯算法因为缺乏更好的名字[1],并且因为它非常简单且不是论文的重点,所以被简单地命名为算法 X。

其核心概念实际上是用于实现算法 X 的一种数据结构。这是一个稀疏矩阵,高德纳在其中使用了一些巧妙的技巧,使得删除/恢复列和行的操作变得高效且是原地操作。他将这些操作称为舞蹈链,以比喻这些操作过程中单元格的指针如何变化。

在这篇文章中,我们将更详细地描述我们试图解决的问题,然后介绍算法 X 的思想。接着,我们将描述这种数据结构,以及如何使用舞蹈链来实现该算法的主要步骤。

最后,我们将展示一个用 Python 实现的简单示例。

集合覆盖

数独谜题可以被建模为一个更一般的问题,即集合覆盖问题。

给定一个元素集合 U 和一个由若干集合组成的集合 S,其中每个集合覆盖 U 的某个子集。集合覆盖问题在于找到一个 S 的子集,使得每个元素恰好被一个集合覆盖。已知该问题是 NP 完全问题。

集合覆盖问题可以看作一个二进制矩阵,其中列代表需要覆盖的元素,行代表集合。单元格 i,j 中的条目 1 表示集合 i 覆盖元素 j。

目标是找到一个行的子集,使得对于每一列,恰好有一个条目为 1。

实际上,这是该问题常见整数线性规划公式的约束矩阵。

算法 X

高德纳的算法以递归方式对所有可能的解决方案进行穷举搜索,在搜索树的每个节点处,我们有一个代表子问题的子矩阵。

在给定节点,我们尝试向我们的解决方案添加一个给定的集合。然后,我们丢弃该集合覆盖的所有元素,同时也丢弃所有覆盖了这些元素中至少一个的其他集合,因为根据定义,一个元素不能被多个集合覆盖,所以我们确信这些其他集合不会在最终解中。然后,我们在剩余的子问题上重复此操作。

如果在任何时候,存在一个无法被任何集合覆盖的元素,我们就回溯,尝试选择另一个集合。另一方面,如果没有剩余元素,则我们当前的解决方案是可行的。

更正式地说,在回溯树的给定节点处,我们有一个二进制矩阵 M。我们首先选择某一列 j。对于每个满足 M[i][j] = 1 的行 i,我们尝试将 i 添加到当前解中,并递归处理一个子矩阵 M',该子矩阵是通过从 M 中移除所有满足 M[i][j'] = 1 的列 j',以及所有满足存在列 k 使得 M[i'][k] = M[i][k] = 1 的行 i' 而构造的。

舞蹈链

上述算法的一个简单实现会在每个搜索树节点扫描整个矩阵以生成子矩阵,并存储一个新矩阵。

高德纳的见解是将二进制矩阵表示为一个双向链接的稀疏矩阵。正如我们稍后将看到的,这种结构允许我们撤销为递归所做的操作,因此我们可以始终使用此稀疏矩阵的单个实例。

稀疏矩阵的基本思想是为每个非零条目创建一个节点,并将其链接到同一列中的相邻单元格以及同一行中的相邻单元格。

在我们的例子中,我们的节点(图1中以绿色描绘)是双向链接的并形成一个循环链。我们还有每个列的一个头节点(蓝色),链接到该列的第一个和最后一个节点;以及一个单独的总头节点(黄色),连接第一列和最后一列的头节点。

以下是一个矩阵 (0 1 1 1) 的示例:

复制代码
图1:稀疏矩阵示例

请注意,进出页面边界的指针是循环的。

对于每个节点,我们还有一个指向其所在列对应头节点的指针。

移除节点。 从一个双向循环链表中移除或分离一个节点的已知方法是使其邻居相互指向:

python 复制代码
node.prev.next = node.next
node.next.prev = node.prev

恢复节点。 高德纳告诉我们,假设自移除后我们没有触碰该节点,也可以将其恢复或重新连接到其原始位置:

python 复制代码
node.prev.next = node
node.next.prev = node

移除一列。 对于我们的算法,移除一列只是将其对应的头节点从其他头节点中分离出来(不是从该列的节点中分离),因此我们称之为水平分离。

移除一行。 要移除一行,我们希望将该行中的每个节点从其垂直邻居中分离,但我们不触及同一行中节点之间的链接,因此我们称之为垂直分离。

Python 实现

我们将展示这些思想在 Python 中的一个简单实现。完整代码可在 Github 上找到。

数据结构

我们的基本结构是表示单元格的节点和表示列的头节点(以及一个特殊的头哨兵节点)。我们需要所有四个方向的链接(左、上、右、下),但我们不需要显式声明它们,因为 Python 允许我们动态设置它们。我们将有一个额外的字段指向对应的头节点。主要区别在于,我们只垂直分离/附加节点,水平分离/附加头节点,因此我们有不同的方法:

python 复制代码
class Node:
    def __init__(self, row, col):
        self.row, self.col = row, col

    def deattach(self):
        self.up.down = self.down
        self.down.up = self.up

    def attach(self):
        self.down.up = self.up.down = self

class Head:
    def __init__(self, col):
        self.col = col

    def deattach(self):
        self.left.right = self.right
        self.right.left = self.left

    def attach(self):
        self.right.left = self.left.right = self

现在我们需要从常规的 Python 矩阵构建我们的稀疏矩阵。我们基本上为矩阵中每个为 1 的条目创建一个节点,为每列创建一个头节点,以及一个全局头节点。然后我们用辅助函数链接它们:

python 复制代码
class SparseMatrix:

    def createLeftRightLinks(self, srows):
        for srow in srows:
            n = len(srow)
            for j in range(n):
                srow[j].right = srow[(j + 1) % n]
                srow[j].left = srow[(j - 1 + n) % n]

    def createUpDownLinks(self, scols):
        for scol in scols:
            n = len(scol)
            for i in range(n):
                scol[i].down = scol[(i + 1) % n]
                scol[i].up = scol[(i - 1 + n) % n]
                scol[i].head = scol[0]

    def __init__(self, mat):

        nrows = len(mat)
        ncols = len(mat[0])

        srow = [[ ] for _ in range(nrows)]
        heads = [Head(j) for j in range(ncols)]
        scol = [[head] for head in heads]

        # 列头节点的头节点
        self.head = Head(-1)
        heads = [self.head] + heads

        self.createLeftRightLinks([heads])

        for i in range(nrows):
            for j in range(ncols):
                if mat[i][j] == 1:
                    node = Node(i, j)
                    scol[j].append(node)
                    srow[i].append(node)

        self.createLeftRightLinks(srow)
        self.createUpDownLinks(scol)

迭代器

我们在多个地方重复了以下代码:

python 复制代码
it = node.left
while it != node:
  # 做一些操作
  it = it.left

其中 left 最终可能被替换为 rightupdown。所以我们使用迭代器进行抽象:

python 复制代码
class NodeIterator:

    def __init__(self, node):
        self.curr = self.start = node

    def __iter__(self):
        return self

    def next(self):
        _next = self.move(self.curr)
        if _next == self.start:
            raise StopIteration
        else:
            self.curr = _next
            return _next

    def move(self):
        raise NotImplementedError

这基本上使用特定的移动操作遍历链表。因此,我们可以为每个方向实现特定的迭代器:

python 复制代码
class LeftIterator (NodeIterator):
    def move(self, node):
        return node.left

class RightIterator (NodeIterator):
    def move(self, node):
        return node.right

class DownIterator (NodeIterator):
    def move(self, node):
        return node.down

class UpIterator (NodeIterator):
    def move(self, node):
        return node.up

然后,我们之前的 while 循环块就变成了:

python 复制代码
for it in LeftIterator(node):
  # 做一些操作

算法

我们的数据结构和语法糖迭代器设置好后,就可以实现我们的回溯算法了。

基本操作是覆盖和取消覆盖一列。覆盖包括移除该列以及其行列表中的所有行(记住,一列只能恰好被一行覆盖,因此我们可以从候选列表中移除其他行)。

python 复制代码
class DancingLinks:

    def cover(self, col):
        col.deattach()
        for row in DownIterator(col):
            for cell in RightIterator(row):
                cell.deattach()

    def uncover(self, col):
        for row in UpIterator(col):
            for cell in LeftIterator(row):
                cell.attach()
        col.attach()

   ...

当覆盖列 col 的一行时,我们从 col 右侧的列开始,到其左侧的列结束,因此,我们实际上并没有将来自 col 的单元格从其垂直邻居中分离。这是不必要的,因为我们已经从矩阵中"移除"了该列,这允许我们实现更优雅的代码。

重要的是,uncover 要按 cover 的相反顺序执行操作,这样我们就不会弄乱矩阵中的指针。

算法的主要部分如下所示,这本质上是算法 X 的定义,当它找到解决方案时返回 True。

python 复制代码
    def backtrack(self):
        # 让我们覆盖第一个未被覆盖的项
        col = self.smat.head.right
        # 没有剩余的列
        if col == self.smat.head:
            return True
        # 没有集合能覆盖这个元素
        if col.down == col:
            return False

        self.cover(col)

        for row in DownIterator(col):

            for cell in RightIterator(row):
                self.cover(cell.head)

            if self.backtrack():
                self.solution.append(row)
                return True

            for cell in LeftIterator(row):
                self.uncover(cell.head)

        self.uncover(col)

        return False

高德纳指出,为了减少搜索树的期望大小,我们应该通过选择包含最多 1 的列来最小化早期节点的分支因子,这将丢弃最多数量的候选行。但为了简单起见,我们选择第一个列。

结论

在这篇文章中,我们重温了集合覆盖问题和稀疏矩阵数据结构等概念。我们看到,通过一个巧妙的技巧,我们可以高效地原地移除和插入行和列。

复杂度

假设在给定节点我们有一个 n × m 的矩阵。在简单方法中,对于每个候选行,我们需要遍历所有行和列来生成一个新矩阵,导致每个节点的复杂度为 O(n²m)。

在最坏的情况下,使用稀疏矩阵将导致相同的复杂度,但困难的集合覆盖实例通常是稀疏的,因此在实践中我们可能会获得性能提升。此外,由于我们所有操作都是原地进行的,我们的内存占用要小得多。

参考文献

1\] [Dancing Links - D. E. Knuth](http://arxiv.org/pdf/cs/0011047v1.pdf) \[2\] Wikipedia - Algorithm X \[3\] Wikipedia - Dancing Links

相关推荐
栀秋6662 小时前
从零开始调用大模型:使用 OpenAI SDK 实现歌词生成,手把手实战指南
前端·llm·openai
gihigo19982 小时前
水声信号处理中DEMON谱分析的原理、实现与改进
算法·信号处理
歌_顿2 小时前
微调方法学习总结(万字长文!)
算法
@小码农2 小时前
202512 电子学会 Scratch图形化编程等级考试四级真题(附答案)
java·开发语言·算法
智航GIS2 小时前
6.2 while循环
java·前端·python
2201_757830872 小时前
AOP核心概念
java·前端·数据库
雪人.2 小时前
JavaWeb经典面试题
java·服务器·前端·java面试题
mit6.8242 小时前
右端点对齐|镜像复用
算法
JIngJaneIL2 小时前
基于java+ vue学生成绩管理系统(源码+数据库+文档)
java·前端·数据库·vue.js·spring boot·后端