在这里使用尾递归有什么好处?

时间:2013-11-08 07:50:50

标签: algorithm quicksort tail-recursion

我一直在阅读文章,描述如何通过使用尾递归版本来减少快速排序的空间复杂性,但我无法理解这是怎么回事。以下是两个版本:

QUICKSORT(A, p, r)
       q = PARTITION(A, p, r)
       QUICKSORT(A, p, q-1)
       QUICKSORT(A, q+1, r)


TAIL-RECURSIVE-QUICKSORT(A, p, r)
   while p < r
      q = PARTITION(A, p, r)
      TAIL-RECURSIVE-QUICKSORT(A, p, q-1)
      p = q+1

(来源 - http://mypathtothe4.blogspot.com/2013/02/lesson-2-variations-on-quicksort-tail.html

据我所知,这两个都会导致数组的左半部分和右半部分的递归调用。在这两种情况下,一次只处理一半,因此在任何时候只有一个递归调用将使用堆栈空间。我无法看到尾递归快速排序如何节省空间。

上面的伪代码来自文章 - http://mypathtothe4.blogspot.com/2013/02/lesson-2-variations-on-quicksort-tail.html 文章中提供的解释让我更加困惑 -

  

Quicksort对给定的子数组进行分区并继续递归两次;   一个在左子阵列上,一个在右侧。每一个   递归调用将需要其自己的堆栈空间流。   此空间用于存储数组的索引变量   一定程度的递归。如果我们从头开始想象这种情况   在执行结束时,我们可以看到堆栈空间在每个堆栈空间中翻倍   层

     

那么Tail-Recursive-Quicksort如何修复所有这些?

     

好吧,我们现在只对recurse on而不是递归两个子数组   一。这消除了在每层加倍堆叠空间的需要   执行。我们通过使用while循环来解决这个问题   执行相同任务的迭代控制。而不是需要   我们简单地说,堆栈为两个递归调用保存变量集   改变同一组变量并使用单个递归调用   新变量。

在常规快速排序的情况下,我没有看到堆栈空间在每个执行层都是如何加倍的。

注意: - 文章中没有提及编译器优化。

5 个答案:

答案 0 :(得分:23)

尾递归函数调用允许编译器执行特殊优化,通常不能通过常规递归执行。在尾递归函数中,递归调用是最后要执行的事情。在这种情况下,编译器可以重写代码以简单地重用当前的堆栈帧,而不是为每个调用分配堆栈帧,这意味着尾递归函数将只使用单个堆栈帧而不是数百甚至数千个。 / p>

这种优化是可能的,因为编译器知道一旦进行了尾递归调用,就不需要先前的变量副本,因为没有更多的代码可以执行。例如,如果print语句跟随递归调用,编译器将需要知道在递归调用返回后要打印的变量的值,因此不能重用堆栈帧。

如果您想了解更多有关“节省空间”和堆栈重用实际如何工作的信息,请参阅维基页面,并附带示例:Tail Call

编辑:我没有解释这适用于quicksort,是吗?好吧,在那篇文章中抛出了一些术语,这些术语使一切都让人困惑(其中一些是完全错误的)。给定的第一个函数(QUICKSORT)在左侧进行递归调用,在右侧进行递归调用,然后退出。请注意,右侧的递归调用是函数中发生的最后一件事。如果编译器支持尾递归优化(如上所述),则只有左调用会创建新的堆栈帧;所有正确的调用只是重用当前帧。这可以保存一些堆栈帧,但仍然会遇到分区创建一系列调用的情况,其中尾递归优化无关紧要。另外,即使右侧呼叫使用相同的帧,右侧呼叫中内的左侧呼叫仍然使用堆栈。在最坏的情况下,堆栈深度为N.

所描述的第二个版本是一个尾递归快速排序,而是一个快速排序,其中只有左排序是递归完成的,并且正确的排序是使用循环完成的。实际上,这个快速排序(如前面另一个用户所描述的)不能对其应用尾递归优化,因为递归调用不是最后执行的操作。这是如何运作的?正确实现时,对quicksort的第一次调用与原始算法中的左侧调用相同。但是,甚至没有调用右侧递归调用。这是如何运作的?好吧,循环处理:不是排序“左,然后”,而是通过调用对左边进行排序,然后通过连续排序右边的左边来排序右边。这听起来真的很荒谬,但它基本上只是排序了很多左派,权利成为单一元素,不需要排序。这有效地消除了正确的递归,使得函数不那么递归(伪递归,如果你愿意的话)。但是,真正的实现并不是每次只选择左侧;它选择最小的一面。这个想法仍然是一样的;它基本上只在一侧进行递归调用而不是两者。挑选较短的一侧将确保堆栈深度永远不会大于log2(N),这是正确的二叉树的深度。这是因为短边总是最多只是当前阵列部分的一半。然而,本文给出的实现并不能确保这一点,因为它可能遭受“左是整棵树”的最坏情况。如果你愿意做更多的阅读,本文实际上给出了很好的解释:Efficient selection and partial sorting based on quicksort

答案 1 :(得分:10)

优点,“混合递归/迭代”版本的整个点,即通过递归处理一个子范围而通过迭代处理另一个子范围的版本,是通过选择要处理的两个子范围中的哪一个递归地,您可以保证递归的深度永远不会超过log2 N,无论枢轴选择有多糟糕

对于问题中提供的TAIL-RECURSIVE-QUICKSORT伪代码,首先通过文字递归调用执行递归处理,该递归调用应该被赋予 short 子范围。这本身将确保递归深度受log2 N的限制。因此,为了实现递归深度保证,代码必须在决定通过递归调用处理哪个子范围之前比较子范围的长度。

该方法的正确实现可能如下(借用您的伪代码作为起点)

HALF-RECURSIVE-QUICKSORT(A, p, r)
   while p < r
      q = PARTITION(A, p, r)
      if (q - p < r - q)
        HALF-RECURSIVE-QUICKSORT(A, p, q-1)
        p = q+1
      else
        HALF-RECURSIVE-QUICKSORT(A, q+1, r)
        r = q-1

您提供的TAIL-RECURSIVE-QUICKSORT伪代码不会尝试比较子范围的长度。在这种情况下,它没有任何好处。不,它不是真正的“尾递归”。 QuickSort不可能简化为尾递归算法。

如果您使用术语“qsort loguy higuy”进行谷歌搜索,您将很容易找到另一个流行的QuickSort实现(C标准库样式)的大量实例,基于两个中只有一个使用递归的相同想法子范围。 32位平台的实现使用最大深度为~32的显式堆栈,因为它可以保证递归深度永远不会高于此值。 (类似地,64位平台只需要大约64的堆栈深度。)

在这方面,进行两次文字递归调用的QUICKSORT版本明显更糟,因为重复的错误选择枢轴可以使其达到非常高的递归深度,在最坏的情况下高达N 。通过两次递归调用,您无法保证递归深度将受log2 N的限制。智能编译器可能能够通过迭代替换对QUICKSORT的尾随调用,即将QUICKSORT转换为TAIL-RECURSIVE-QUICKSORT,但执行子范围不够智能长度比较。

答案 2 :(得分:1)

使用tail-recursion的优点:=使编译器优化代码并将其转换为非递归代码。

非递归代码优于递归代码的优点:=非递归代码比递归代码需要更少的内存来执行。这是因为递归消耗的空闲堆栈帧。

以下是有趣的部分: - 即使编译器可以理论上执行该优化,但实际上它们并非如此。即使像dot-net和java这样的广泛编译器也不会执行这种优化。

所有代码优化都面临的一个问题是调试能力的牺牲。优化的代码不再对应源代码,因此堆栈跟踪和异常细节不易理解。高性能代码或科学应用程序是一回事,但对于大多数消费者应用程序,即使在发布后也需要进行调试。因此,没有那么积极地进行优化。

请参阅:

  1. https://stackoverflow.com/q/340762/1043824
  2. Why doesn't .NET/C# optimize for tail-call recursion?
  3. https://stackoverflow.com/a/3682044/1043824

答案 3 :(得分:1)

这里似乎存在一些词汇混淆。

第一个版本是尾递归的,因为最后一个语句是递归调用:

QUICKSORT(A, p, r)
  q = PARTITION(A, p, r)
  QUICKSORT(A, p, q-1)
  QUICKSORT(A, q+1, r)

如果你应用尾递归优化,即将递归转换为循环,你会得到第二个,它不再是尾递归的:

TAIL-RECURSIVE-QUICKSORT(A, p, r)
  while p < r
    q = PARTITION(A, p, r)
    TAIL-RECURSIVE-QUICKSORT(A, p, q-1)
    p = q+1

这样做的好处是通常需要更少的堆栈内存。这是为什么?要理解,想象您想要对包含31个项目的数组进行排序。在极不可能的情况下,所有分区都是完美的,即它们将数组分开在中间,你的递归深度将是4.实际上,第一个分割将产生两个分区的15个项目,第二个分区产生两个分区的7个项目,第三个是3个项目中的两个,在第四个项目之后,所有内容都被排序。

但是分区很少是完美的。因此,并非所有递归都同样深入。在我们的示例中,您可能有一些只有三个级别深,一些是7或更多(最坏的情况是30)。通过消除一半递归,您很可能会减少最大递归深度。

正如AndreyT指出的那样,通常会对范围进行比较,以确保最大的分区始终是迭代处理的,而最小的分区是递归处理的。这保证了给定输入和枢轴选择策略的最小可能递归深度。

但事实并非如此。有时,人们希望尽快产生结果,或者只想查找和排序前n个元素。在这些情况下,他们总是希望在第二个分区之前对第一个分区进行排序。即使在这种情况下,消除尾部递归通常也会提高内存使用率,并且永远不会使它变得更糟。

答案 4 :(得分:0)

我不确切地知道这是否是提出这个疑问的正确位置,或者我应该发布一个新问题,但我有一个非常相似的疑问。

void print(int n) {
  if (n < 0) return;
  cout << " " << n;
// The last executed statement is recursive call
  print(n-1);
  print(n-1);
}

void print(int n) { if (n < 0) return; cout << " " << n; // The last executed statement is recursive call print(n-1); print(n-1); } 这尾是递归的吗?

相关问题