Recursion 快速排序和尾部递归优化

Recursion 快速排序和尾部递归优化,recursion,language-agnostic,quicksort,tail-recursion,Recursion,Language Agnostic,Quicksort,Tail Recursion,在p169中,它讨论了对快速排序使用尾部递归 本章前面的原始快速排序算法是(在伪代码中) 快速排序(A、p、r) { if(p如果你考虑这两种方法的复杂性,第一种方法显然比第二种方法更复杂,因为它在 LHS 和 RS< 下调用递归< /代码>,因此有更多的机会获得堆栈溢出 注意:这并不意味着绝对没有机会获得,所以在第二种方法中尾部递归优化的本质是当程序实际执行时没有递归。当编译器或解释器能够启动TRO时,这意味着它将基本上明白如何将递归定义的算法重写为一个简单的迭代过程,堆栈不用于存储嵌套函数调

p169
中,它讨论了对
快速排序使用尾部递归

本章前面的原始快速排序算法是(在伪代码中)

快速排序(A、p、r) { if(p问:最明显的观察结果是:

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

堆栈溢出的最常见原因是太深或无限递归。

第二种方法使用的递归深度小于第一种方法(
n
每次调用分支,而不是
n^2
),因此不太可能导致堆栈溢出

(因此,较低的复杂性意味着导致堆栈溢出的可能性较小)

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


< P>如果你考虑这两种方法的复杂性,第一种方法显然比第二种方法更复杂,因为它在<强> LHS 和<强> RS< <强>下调用<代码>递归< /代码>,因此有更多的机会获得堆栈溢出


注意:这并不意味着绝对没有机会获得
,所以在第二种方法中
尾部递归优化的本质是当程序实际执行时没有递归。当编译器或解释器能够启动TRO时,这意味着它将基本上明白如何将递归定义的算法重写为一个简单的迭代过程,堆栈不用于存储嵌套函数调用。

第一个代码段无法进行TR优化,因为其中有两个递归调用。

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

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

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

递归的问题在于,由于每次迭代都要在堆栈上至少创建一个环境,因此很快就会占用有限堆栈中的大量空间,因此堆栈溢出通常与递归调用相关

因此,有一种称为尾部递归调用优化的方法,它将在每次进行递归调用时重用相同的环境,因此堆栈中占用的空间是恒定的,从而防止堆栈溢出问题

现在,有一些规则来执行尾部调用优化。首先,每个调用都必须是完整的,我的意思是,如果您中断执行,函数应该能够在任何时候给出结果,例如 即使函数是递归的,这也被称为迭代过程

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


然而,第二个例子没有这个问题,A是常数,p和r的状态可以局部确定,因此,既然所有要继续的信息都在那里,那么TCO就可以应用了。

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

假设我们使用数组切片和尾调用优化的语言,考虑这两种算法之间的差异:

坏的:

好:

第二个保证永远不需要超过log2(长度)的堆栈帧,因为smallerSlice的长度小于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,您将永远不会达到quicksort的O(N)最坏情况堆栈内存使用率,因此从技术上讲,您不需要这样做仍然改进了O(log(N))的常数因子尽管如此,还是强烈建议这样做。

复杂性明显不同。这本书有没有提到这一点?这是否意味着第二种算法更有效,但不一定能阻止溢出请检查我下面的答案。我很难看到第二种版本是尾部递归的。递归函数是尾部递归的如果执行的最后一次计算是递归调用(或者更一般地说,调用后不需要保留任何本地数据),则为草书。这就是允许新堆栈帧覆盖在现有堆栈帧之上的原因。但是,在上面的第二个版本中,递归调用后会进行计算,并且
Quicksort(A, p, r)
{
 while (p < r)
 {
  q: <- Partition(A, p, r)
  Quicksort(A, p, q)
  p: <- q+1
 }
}
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.
 }
}
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. 
 }
}