Quicksort和尾递归优化

时间:2013-09-30 12:27:08

标签: recursion language-agnostic quicksort tail-recursion

Introduction to Algorithms p169中,它讨论了为Quicksort使用尾递归。

本章前面的原始Quicksort算法是(伪代码)

Quicksort(A, p, r)
{
 if (p < r)
 {
  q: <- Partition(A, p, r)
  Quicksort(A, p, q)
  Quicksort(A, q+1, r)
 }
}

使用尾递归的优化版本如下

Quicksort(A, p, r)
{
 while (p < r)
 {
  q: <- Partition(A, p, r)
  Quicksort(A, p, q)
  p: <- q+1
 }
}

Partition根据数据透视表对数组进行排序。

不同之处在于第二个算法只调用Quicksort一次来对LHS进行排序。

有人可以向我解释为什么第一个算法会导致堆栈溢出,而第二个算法不会?或者我误解了这本书。

5 个答案:

答案 0 :(得分:9)

首先让我们从一个简短的,可能不准确但仍然有效的堆栈溢出定义开始。

正如您现在可能知道的那样,有两种不同类型的内存在不同的数据结构中实现:堆和堆栈。

就大小而言,堆大于堆栈,为了保持简单,我们假设每次进行函数调用时,都会在堆栈上创建新环境(局部变量,参数等)。因此,鉴于堆栈的大小是有限的,如果你进行太多的函数调用,你将耗尽空间,因此你将有一个堆栈溢出。

递归的问题在于,由于每次迭代在堆栈上创建至少一个环境,因此您将非常快速地占用有限堆栈中的大量空间,因此堆栈溢出通常与递归调用相关联。

所以有一个名为Tail递归调用优化的东西,它会在每次进行递归调用时重用相同的环境,因此堆栈中占用的空间是不变的,从而防止了堆栈溢出问题。

现在,有一些规则可以执行尾调用优化。首先,每次调用都是完整的,我的意思是,如果你在SICP中断执行,函数应该能够随时给出结果。  即使函数是递归的,这也称为迭代过程。

如果你分析你的第一个例子,你会看到每个迭代都是由两个递归调用定义的,这意味着如果你在任何时候停止执行,你将无法给出部分结果,因为你的结果取决于要完成这些调用,在这种情况下,您无法重用堆栈环境,因为总信息在所有这些递归调用之间分配。

然而,第二个例子没有那个问题,A是常数,p和r的状态可以在本地确定,所以由于所有的信息都在那里,所以可以应用TCO。

答案 1 :(得分:6)

尾递归优化的本质是在程序实际执行时没有递归。当编译器或解释器能够启动TRO时,这意味着它将基本上弄清楚如何将递归定义的算法重写为一个简单的迭代过程,而堆栈不用于存储嵌套的函数调用。
第一个代码段无法进行TR优化,因为其中有2个递归调用。

答案 2 :(得分:2)

嗯,最明显的观察是:

最常见的堆栈溢出问题 - 定义

The most common cause of stack overflow is excessively deep or infinite recursion.

第二个使用较少的深度递归而不是第一个(n每个调用的n^2个分支而不是{{1}})因此它不太可能导致堆栈溢出..

(如此低的复杂性意味着更少的机会导致堆栈溢出)

但是有人必须添加为什么第二个永远不会导致堆栈溢出而第一个可以。

source

答案 3 :(得分:2)

尾部递归本身是不够的。具有while循环的算法仍然可以使用O(N)堆栈空间,在CLRS的该部分中将其减少为O(log(N))左侧作为练习

假设我们正在使用具有数组切片和尾调用优化的语言。考虑这两种算法之间的区别:

为:

Quicksort(arraySlice) {
 if (arraySlice.length > 1) {
  slices = Partition(arraySlice)
  (smallerSlice, largerSlice) = sortBySize(slices)
  Quicksort(largerSlice) // Not a tail call, requires a stack frame until it returns. 
  Quicksort(smallerSlice) // Tail call, can replace the old stack frame.
 }
}

好:

Quicksort(arraySlice) {
 if (arraySlice.length > 1){
  slices = Partition(arraySlice)
  (smallerSlice, largerSlice) = sortBySize(slices)
  Quicksort(smallerSlice) // Not a tail call, requires a stack frame until it returns. 
  Quicksort(largerSlice) // Tail call, can replace the old stack frame.
 }
}

第二个是保证永远不需要超过log2(长度)堆栈帧,因为smallSlice不到arraySlice的一半。但是对于第一个,不等式是相反的,并且它总是需要大于或等于log2(长度)堆栈帧,并且在最坏情况下可能需要O(N)堆栈帧,其中smallerslice总是具有长度1。

如果你没有跟踪哪个切片更小或更大,那么你将遇到与第一个溢出情况类似的最坏情况,即使它平均需要O(log(n))个堆栈帧。如果您始终先对较小的切片进行排序,则永远不会需要超过log_2(长度)堆栈帧。

如果您使用的语言没有尾调用优化,您可以将第二个(不是堆栈)版本编写为:

Quicksort(arraySlice) {
 while (arraySlice.length > 1) {
  slices = Partition(arraySlice)
  (smallerSlice, arraySlice) = sortBySize(slices)
  Quicksort(smallerSlice) // Still not a tail call, requires a stack frame until it returns. 
 }
}

另一件值得注意的事情是,如果您正在实现像Introsort这样的内容,如果递归深度超过与log(N)成比例的某个数字,则更改为Heapsort,您将永远不会遇到O(N)最坏情况下的快速排序堆栈内存使用情况,所以从技术上讲,你不需要这样做。执行此优化(首先弹出较小的切片)仍会改善O(log(N))的常数因子,因此强烈建议这样做。

答案 4 :(得分:0)

那么如果考虑两种方法的复杂性,第一种方法显然比第二种方法更复杂,因为它在 LHS RHS 上调用Recursion因此,有更多机会获得堆栈溢出

注意:这并不意味着绝对没有机会在第二种方法中获得SO