【问题标题】:Can recursive union find be optimized?递归联合查找可以优化吗?
【发布时间】:2015-12-16 23:19:44
【问题描述】:

在实现 union-find 时,我通常会编写带有路径压缩的 find 函数,如下所示:

def find(x):
    if x != par[x]:
        par[x] = find(par[x])
    return par[x]

这很容易记住并且可以说很容易阅读。这也是描述该算法的书籍和网站的数量。

然而,天真的编译,这将使用堆栈内存线性输入大小。在许多默认情况下会导致堆栈溢出的语言和系统中。

我知道写find的唯一非递归方式是这样的:

def find(x):
    p = par[x]
    while p != par[p]:
        p = par[p]
    while x != p:
        x, par[x] = par[x], p
    return p

许多编译器似乎不太可能发现这一点。 (也许 Haskell 会?)

我的问题是在什么情况下可以安全使用旧版本的find?如果没有广泛使用的语言可以消除递归,我们不应该告诉人们使用迭代版本吗?是否有更简单的迭代实现?

【问题讨论】:

    标签: loops recursion stack-overflow tail-recursion union-find


    【解决方案1】:

    这里似乎有两个不同的问题。

    首先 - 优化编译器可以注意到这一点并重写它吗?如果不测试所有编译器和所有版本,很难回答这个问题。我在以下代码上使用 gcc 4.8.4 进行了尝试:

    size_t find(size_t uf[], size_t index) {
      if (index != uf[index]) {
        uf[index] = find(uf, uf[index]);
      }
      return uf[index];
    }
    
    void link(size_t uf[], size_t i, size_t j) {
      uf[find(uf, i)] = uf[find(uf, j)];
    }
    

    这并没有实现按秩优化,但支持路径压缩。我使用优化级别 -O3 编译了它,程序集如下所示:

     find:
    .LFB23:
        .cfi_startproc
        pushq   %r14
        .cfi_def_cfa_offset 16
        .cfi_offset 14, -16
        pushq   %r13
        .cfi_def_cfa_offset 24
        .cfi_offset 13, -24
        pushq   %r12
        .cfi_def_cfa_offset 32
        .cfi_offset 12, -32
        pushq   %rbp
        .cfi_def_cfa_offset 40
        .cfi_offset 6, -40
        pushq   %rbx
        .cfi_def_cfa_offset 48
        .cfi_offset 3, -48
        leaq    (%rdi,%rsi,8), %rbx
        movq    (%rbx), %rax
        cmpq    %rsi, %rax
        je  .L2
        leaq    (%rdi,%rax,8), %rbp
        movq    0(%rbp), %rdx
        cmpq    %rdx, %rax
        je  .L3
        leaq    (%rdi,%rdx,8), %r12
        movq    %rdx, %rax
        movq    (%r12), %rcx
        cmpq    %rcx, %rdx
        je  .L4
        leaq    (%rdi,%rcx,8), %r13
        movq    %rcx, %rax
        movq    0(%r13), %rdx
        cmpq    %rdx, %rcx
        je  .L5
        leaq    (%rdi,%rdx,8), %r14
        movq    %rdx, %rax
        movq    (%r14), %rsi
        cmpq    %rsi, %rdx
        je  .L6
        call    find           // <--- Recursion!
        movq    %rax, (%r14)
    .L6:
        movq    %rax, 0(%r13)
    .L5:
        movq    %rax, (%r12)
    .L4:
        movq    %rax, 0(%rbp)
    .L3:
        movq    %rax, (%rbx)
    .L2:
        popq    %rbx
        .cfi_def_cfa_offset 40
        popq    %rbp
        .cfi_def_cfa_offset 32
        popq    %r12
        .cfi_def_cfa_offset 24
        popq    %r13
        .cfi_def_cfa_offset 16
        popq    %r14
        .cfi_def_cfa_offset 8
        ret
        .cfi_endproc
    

    鉴于中间存在递归调用,看起来这个尾调用并没有被消除。公平地说,这是因为您所描述的转换非常重要,所以我对它没有找到它并不感到惊讶。这并不意味着 no 优化编译器可以找到它,但一个主要的不会。

    您的第二个问题是为什么我们以这种方式呈现算法。作为同时教授算法和编程的人,我认为使用尽可能简单的演示文稿来讨论算法非常有价值,即使这意味着抽象出一些特定的实现细节。在这里,算法背后的关键思想是更新在通往代表的路上遇到的所有节点的父指针。递归恰好是描述该想法的一种非常简洁的方式,即使在天真地实现时它也存在堆栈溢出的风险。但是,通过以这种特定方式表达伪代码,更容易描述和讨论它,并证明它可以像宣传的那样工作。我们可以用另一种方式来描述它以避免堆栈溢出,但在 Theoryland 中,我们通常不担心这样的细节,更新的演示文稿虽然可以更直接地转化为实践,但会更难看到关键思想。

    在查看更高级算法和数据结构的伪代码时,通常会忽略至关重要的实现细节,并暗示某些任务可以在特定时间范围内完成。当讨论建立在更复杂的算法和数据结构之上的算法或数据结构时,通常不可能为所有内容编写伪代码,因为您在层层之上有层,在层之上还有一层层被掩盖的细节。从理论上讲,这很好——如果读者确实想实现它,他们可以填补空白。另一方面,如果读者对论文中的关键技术和理论(在学术环境中很常见)更感兴趣,他们就不会陷入实现细节的困境。

    【讨论】:

    • 感谢您的详细解答。我的第二个问题更多关于联合查找的算法方面。有可能表明,递归永远不会很深,因此第一个版本可以完全安全地实现。不过我不知道有任何这样的证明,所以我想现在,我会尽量记住对迭代版本进行编码。
    • 这里的递归可以是线性深度的。想象一下,总是将其中一个列表的头部链接到一个单例列表。这建立了一个线性长度的链。现在在最深的节点上进行查找。
    • 好点。有了“等级”当然不会发生这种情况,但没有它我们必须小心。
    猜你喜欢
    • 2017-05-23
    • 2021-11-22
    • 2010-09-16
    • 2012-03-04
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2014-06-02
    • 1970-01-01
    相关资源
    最近更新 更多