【问题标题】:Progressive Connected Component Labeling渐进式连接组件标记
【发布时间】:2025-12-16 23:10:02
【问题描述】:

我正在使用具有“开”和“关”两种状态的正方形网格。我有一个相当简单的Connected Component Labeling 算法,它可以找到所有“ON”组件。通常,但不总是,只有一个“ON”组件。

我希望构建一种算法,该算法将开/关单元格矩阵、组件标签(可能格式化为单元格的哈希集列表)以及自标签形成以来已更改的单元格列表作为输入,并输出一个新的标签。显而易见的解决方案是从头开始重新计算,尽管这效率不高。通常,已更改的单元格列表会很小。

如果更改列表只是已打开的单元格,这很容易做到:

Groups G;
Foreach changed cell C:
  Group U = emptygroup;
  U.add(C);
  Foreach Group S in G:
    if (S contains a cell which is adjacent to C)
      G.Remove(S);
      U.UnionWith(S);
  G.add(C);

但是,如果更改包含任何已关闭的单元格,我不知道该怎么做。请记住,所有 ON 单元格必须恰好是一个组的成员。因此,一种解决方案是获取与新关闭的单元格相邻的每个单元格,并查看它们是否相互连接(例如,使用 * 寻路)。这将产生 1-4 个连续组(除非该单元格是其组中唯一的单元格,因此有 0 个相邻单元格要检查,在这种情况下它会产生 0 个组)。然而,这仅比从头开始好一点,因为通常(但并非总是)将这些相邻的方格连接在一起就像找到一个连续的组一样困难(除非有人建议用一种聪明的方法来做到这一点)。另外,如果有很多变化的单元格,这有点可怕......虽然我承认通常没有。

上下文,对于那些坚持知道我为什么这样做的人: Nurikabe 谜题中的一条规则是,您可能只有一组连续的墙。上面是我试图解决以提高速度(并玩寻路)的问题的简化。基本上,我希望检查连续的墙壁,而不会浪费从以前的此类测试中获得的信息。我想看看我的求解器中有多少地方可以利用以前的信息来提高速度,因为当 O(f(Δ)) 时使用 O(f(N)) 算法似乎有点痛苦算法就足够了(N 是拼图的大小,Δ 是自上次运行算法以来所做的更改)。

分析确实表明改进这个算法会改变执行时间,但这是一个有趣而不是盈利的项目,所以这并不重要,除了能够来衡量更改是否有任何影响。

注意: 我省略了解释我当前的算法,但它基本上是通过在它找到的第一个 ON 方格上执行基于堆栈的Flood Fill 算法,然后检查是否还有更多 ON 方格(这意味着有多个,它不会费心去检查)。

编辑:增强想法:Yairchu 和 John Kugelman 的建议在我的脑海中形成了这个改进,这实际上并不是这个问题本身的解决方案,但可能会使这部分代码和其他几段代码运行得更快:

当前循环:

foreach (Square s in m.Neighbors[tmp.X][tmp.Y])    
{
    if (0 != ((byte)(s.RoomType) & match) && Retval.Add(s)) curStack.Push(s);
}

改进思路:

foreach (Square s in m.NeighborsXX[tmp.X][tmp.Y])    
{
    if (Retval.Add(s)) curStack.Push(s);
}

这将需要维护多个 m.NeighborsXX 实例(一个用于需要增强的每种匹配类型)并在正方形发生变化时全部更新它们。我需要对此进行基准测试,看看它是否真的有帮助,但它看起来像是用一些内存换取一些速度的标准案例。

【问题讨论】:

    标签: algorithm language-agnostic path-finding


    【解决方案1】:

    有趣的问题!这是我最初的想法。希望我能有更多,并会在他们到来时更新这个答案......

    [更新 2] 由于您只关心一组,A* 搜索似乎很理想。您是否分析过 A* 搜索与重新标记?我不得不认为一个写得很好的 A* 搜索会比洪水填充更快。如果没有,也许您可​​以发布您的实际代码以获得优化帮助?

    [更新 1] 如果您知道新的 OFF 单元格 C 在组 G 中,那么您可以重新运行 CCL 算法,但只需重新标记单元格群组G。其他 ON 单元格可以保留其现有标签。您不必检查网格的其余部分,与整个网格的初始 CCL 相比,这可以显着节省。 (作为一个*的 Nurikabe 求解器,这应该在已解决的谜题中至少节省 33%,在进行中的谜题中非常显着节省,不是吗?“33%”来自我的估计已解决的谜题大约有 2/3 黑色和 1/3 白色。)

    为此,您必须存储每个组中包含的单元格列表,以便您可以快速迭代组G 中的单元格并仅重新标记这些单元格。

    【讨论】:

    • @John:一个有趣的想法,但是由于通常只有一个组,因此这种更改很少。或者,我可以争辩说,我当前的算法已经隐含地进行了这种改进,因为一旦找到第一组,如果遇到不在该组中的 ON 单元,它就会放弃。 “如果遇到不在组中的单元格就停止”可能会被优化掉......但它是如此之快以至于它真的无关紧要。
    • 已更新...您确定 A* 不比洪水填充好吗?
    • 关于 A*:切换到 A* 可能会减少需要扫描的正方形的数量,但我什至对测试它犹豫不决,因为当有很多变化。您的储蓄 cmets 确实提出了一个相当有趣的想法,即根据邻居的类型维护单独的邻居列表,从而产生更快的遍历。这可能会在整个程序的多个地方带来一致的加速。
    【解决方案2】:

    不是一个完整的解决方案,但这里有:

    • 为每个连接的组件在内存中保留一个生成树
      • 树属性 A:我们的生成树有一个概念,即哪个节点在哪个“上方”(就像在搜索树中一样)。上面哪个是任意的选择
    • 让我们讨论删除和添加边
    • 添加边时:
      • 通过检查两个节点的树根是否相同来检查两个节点是否在同一个组件中
        • 树属性 B:树应该是密集的,所以这个检查是 O(log n)
      • 如果在同一组中,则什么也不做
      • 如果它们在不同的组中,则使用新边加入树。
        • 这将需要转换其中一棵树的“形状”(谁在谁之上的定义),以便我们的新边缘可以在它“之上”
    • 删除边缘时:
      • 如果此边不参与组的生成树,则什么也不做。
      • 如果是,我们需要检查组是否仍然连接
        • DFS 从一组尝试到达另一组
        • 最好从两者中较小的一个做
          • 树属性 C:我们为树中的每个节点维护其子树的大小
          • 使用属性 C,我们可以知道两组的大小
        • 由于属性 B:通常较小的组会非常小,而较大的组会非常大
        • 如果组是连接的,那么我们就好像添加了连接边
        • 如果组没有连接,那么我们应该爬树以维护属性 C(从祖先的子树大小中减去先前连接的子树的大小)
    • 问题:我们如何维护属性 B(树很茂密)?

    我希望这是有道理的:)

    【讨论】:

    • 嗯。使用生成树来存储组件的想法是我没有考虑过的。进行此更改需要进行很多权衡。我不清楚如何有效地从较小的树 DFS 到较大的树(无需遍历两棵树,也无需重复执行 O(logn) 检查以查看节点是否属于另一个组件)。如果没有办法做到这一点,那么从较小的树开始的好处就几乎失去了。
    【解决方案3】:

    这与在围棋游戏(日本的 Igo)中计算(假设网格上有 4 个连通性)连接的棋子串是相同的问题,并且增量计算是高性能围棋算法的关键之一。

    话虽如此,在这个领域中,最简单的情况是当您打开一个网格元素(在板上添加一块石头)时,因为那时您只能加入以前未连接的组件。有问题的情况是,当您关闭一个网格元素(由于算法中的撤消而移除一块石头)时,一个组件可能会被划分为两个不连接的组件。

    基于我对这个问题的有限理解,我建议您在打开元素时使用 union-find 来合并标记组,并在关闭网格元素时从头开始重新计算相关组.为了优化这一点,每当您打开和关闭网格元素时,首先处理关闭情况,这样不会浪费联合查找操作。如果您想拥有更高级的增量算法,您可以开始维护每个元素的增量连接数据,但它很可能不会得到回报。

    【讨论】: