递归联合查找可以优化吗?

时间:2015-09-20 02:40:27

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

实现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是安全的?如果没有广泛使用的语言可以删除递归,我们是否应该告诉人们使用迭代版本?可能有一个更简单的迭代实现吗?

1 个答案:

答案 0 :(得分:2)

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

首先 - 可以优化编译器注意到这一点并重写它吗?如果不测试所有编译器和所有版本,很难回答这个问题。我在下面的代码中使用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

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

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

在查看伪代码以获得更高级的算法和数据结构时,忽略极为重要的实现细节以及在特定时间范围内可能执行某些任务的手动情况是很常见的。在讨论构建在更复杂的算法和数据结构之上的算法或数据结构时,通常无法为所有内容写出伪代码,因为在覆盖的细节层之上的层顶部有层。从理论的角度来看,这很好 - 如果读者确实想要实现它,他们可以填补空白。另一方面,如果读者对论文和理论中的关键技术(在学术环境中很常见)更感兴趣,他们就不会陷入实施细节中。