loopsrecursionstack-overflowtail-recursionunion-find

Can recursive union find be optimized?


When implementing union-find, I would usually write the find function with path compression like this:

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

This is easy to remember and arguably easy to read. This is also how many books and websites describe the algorithm.

However, naively compiled, this would use stack memory linear in the input size. In many languages and systems that would by default result in a stack overflow.

The only non-recursive way I know of writing find is this:

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

It seems unlikely that many compilers would find that. (Maybe Haskell would?)

My question is in what cases it is safe to use the former version of find? If no widely used language can remove the recursion, shouldn't we tell people to use the iterative version? And might there be a simpler iterative implementation?


Solution

  • There seem to be two separate questions here.

    First - can optimizing compilers notice this and rewrite it? It's difficult to answer this question without testing all compilers and all versions. I tried this out using gcc 4.8.4 on the following code:

    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)];
    }
    

    This doesn't implement the union-by-rank optimization, but does support path compression. I compiled this using optimization level -O3 and the assembly is shown here:

     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
    

    Given the existence of the recursive call in the middle, it doesn't look like this tail call was eliminated. In fairness, that's because the transformation you're describing is pretty nontrivial, so I'm not surprised it didn't find it. This doesn't mean that no optimizing compiler can find it, but that one major one won't.

    Your second question is why we present the algorithm this way. As someone who teaches both algorithms and programming, I think it's extremely valuable to discuss algorithms using a presentation that's as simple as possible, even if it means abstracting away some particular implementation details. Here, the key idea behind the algorithm is to update the parent pointers of all the nodes encountered up on the way to the representative. Recursion happens to be a pretty clean way of describing that idea, even though when implemented naively it risks a stack overflow. However, by expressing the pseudocode in that particular way, it's easier to describe and discuss it and to prove that it will work as advertised. We could describe it the other way to avoid a stack overflow, but in Theoryland we usually don't worry about details like that and the updated presentation, while more directly translatable into practice, would make it harder to see the key ideas.

    When looking at pseudocode for more advanced algorithms and data structures, it's common to omit critically important implementation details and to handwave that certain tasks are possible to do in certain time bounds. When discussing algorithms or data structures that build on top of even more complex algorithms and data structures, it often becomes impossible to write out pseudocode for everything because you have layers on top of layers on top of layers of glossed-over details. From a theoretical perspective, this is fine - if the reader does want to implement it, they can fill in the blanks. On the other hand, if the reader is more interested in the key techniques from the paper and the theory (which in academic settings is common), they won't get bogged down in implementation details.