尾递归及快排尾递归优化

尾递归

概念

如果一个函数中所有递归形式的调用都出现在函数的末尾,我们称这个递归函数是尾递归的。当递归调用是整个函数体中最后执行的语句且它的返回值不属于表达式的一部分时,这个递归调用就是尾递归。尾递归函数的特点是在回归过程中不用做任何操作,这个特性很重要,因为大多数现代的编译器会利用这种特点自动生成优化的代码。

原理

编译器检测到一个函数调用是尾递归的时候,它就覆盖当前的活动记录而不是在栈中去创建一个新的。编译器可以做到这点,因为递归调用是当前活跃期内最后一条待执行的语句,于是当这个调用返回时栈帧中并没有其他事情可做,因此也就没有保存栈帧的必要了。通过覆盖当前的栈帧而不是在其之上重新添加一个,这样所使用的栈空间就大大缩减了,这使得实际的运行效率会变得更高。

 

内存中的栈

计算机系统中,栈是一个具有以上属性的动态内存区域(虽然与数据结构中的栈有区别,但是它们的思想都是先进后出)。程序可以将数据压入栈中,也可以将数据从栈顶弹出。在i386机器中,栈顶由称为esp的寄存器进行定位。压栈的操作使得栈顶的地址减小,弹出的操作使得栈顶的地址增大。栈在程序的运行中有着举足轻重的作用。最重要的是栈保存了一个函数调用时所需要的维护信息,这常常称之为堆栈帧或者活动记录堆栈帧一般包含如下几方面的信息:

(1)函数的返回地址和参数

(2)临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量。

 

栈在函数调用过程中的工作原理

int main() {
    foo1();
    foo2();
    return 0;
}

上面是一个简单的示例代码,现在简单模拟一下这个 main 函数调用的整个过程,$ 字符用于表示占地:

(1)建立一个函数栈。 $

(2)main 函数调用,将 main 函数压进函数栈里面。$ [main]

(3)做完了一些操作以后,调用 foo1 函数,foo1 函数入栈。$ [main] [foo1]

(4)foo1 函数返回并出栈。$ [main]

(5)做完一些操作以后,调用 foo2 函数,foo2 函数入栈。$ [main] [foo2]

(6)foo2 函数返回并出栈。$ [main]

(7)做完余下的操作以后,main函数返回并出栈。$

上面这个过程说明了栈的作用。就是第 4 和第 6 步,让 foo1 和 foo2 函数执行完了以后能够在回到 main 函数调用 foo1 和 foo2 原来的地方。这就是栈,这种"先进后出"的数据结构的意义所在。

 

尾递归实例

尾递归,要先从递归讲起。最简单的例子——阶乘。

以下是一个用线性递归写的计算 n 的阶乘的函数:

int fact(int n)             //线性递归
{
    if (n < 0)
        return 0;
    else if(n == 0 || n == 1)
        return 1;
    else
        return n * fact(n - 1);
}

普通递归的问题在于展开的时候会产生非常大的中间缓存,而每一层的中间缓存都会占用宝贵的栈的空间,所导致了当这个 n 很大的时候,栈上空间不足则会产生"爆栈"的情况。

当n=5时,线性递归的递归过程如下:

fact(5)
{5*fact(4)}
{5*{4*fact(3)}}
{5*{4*{3*fact(2)}}}
{5*{4*{3*{2*fact(1)}}}}
{5*{4*{3*{2*1}}}}
{5*{4*{3*2}}}
{5*{4*6}}
{5*24}
120

 

n 的阶乘的尾递归函数:

int facttail(int n, int a)   //尾递归
{
    if (n < 0)
        return 0;
    else if (n == 0)
        return 1;
    else if (n == 1)
        return a;
    else
        return facttail(n - 1, n * a);
}

当n=5时,尾递归的递归过程如下:

facttail(5,1)
facttail(4,5)
facttail(3,20)
facttail(2,60)
facttail(1,120)
120

 

误区

跟上面的普通递归函数比起来,貌似尾递归函数因为在展开的过程中计算并且缓存了结果,使得并不会像普通递归函数那样展开出非常庞大的中间结果,所以不会爆栈?答案:当然不是!尾递归函数依然还是递归函数,如果不优化依然跟普通递归函数一样会爆栈,该展开多少层依旧是展开多少层。不会爆栈是因为语言的编译器或者解释器所做了"尾递归优化",才让它不会爆栈的。

 

阶乘函数及gdb调试

将上述2个阶乘代码进行编译,并对两种方法进行调试,观察在程序运行过程中栈帧的使用情况以及程序的运行情况。以下会使用的gdb调试命令:

编译:gcc/g++ test.c -g -o test
运行:gdb test
list+行号      查看程序指定行附近的代码
b +行号        在该行添加断点
r              运行程序
n              逐步运行程序
bt             打印调用栈的使用情况
info frame     查看当前栈帧的情况

代码:

#include <bits/stdc++.h>
using namespace std;
#define M 5

int fact(int n)             //线性递归
{
    if (n < 0)
        return 0;
    else if(n == 0 || n == 1)
        return 1;
    else
        return n * fact(n - 1);
}
 
int facttail(int n, int a)   //尾递归
{
    if (n < 0)
        return 0;
    else if (n == 0)
        return 1;
    else if (n == 1)
        return a;
    else
        return facttail(n - 1, n * a);
}

int facttail1(int n, int a)  //尾递归转化为循环
{
    while(n > 0)
    {
        a = n * a;
        n--;
	}
	return a;
}
 
int main()
{
    //printf("%p", facttail);
    int a = fact(M);
    int b = facttail(M, 1);
    cout << "A:" << a <<endl;
    cout << "B:" << b <<endl;
}

 

非尾递归阶乘的调试情况:

(1)使用 b 设置断点并运行

(2)使用 bt 命令查看栈的使用情况

(3)递归层层返回

 

尾递归阶乘的调试情况:

上述的尾递归阶乘函数并未优化,所以两个阶乘函数展开的层数还是一样的。但是两者还是有不一样的地方,从上图中可以看出,尾递归阶乘函数在运行到最后时,它是直接返回相应的值。而非尾递归阶乘函数是层层深入然后再一层层地返回,最后得到结果。在这一过程中可以使用info frame命令查看更为详细的栈帧信息。

所有递归都能等效于循环+栈(例如:数据结构中的非递归前、中、后序遍历),尾递归只是只是恰好是那种没有找的最简单的情况递归之所以能写出比循环可读性高的代码是因为递归隐含了一个栈,而用循环实现的时候需要手动维护一个栈导致代码长,但是尾递归恰好就是那个不需要这个栈的特殊情况,也就是说这个时候递归相对于循环完全没有任何优势了。对于无栈循环不能等效的递归函数,转化成尾递归比转化成有栈循环更难看并且还更慢。

 

快排尾递归优化及gdb调试

以下将使用两种快排的方法,即尾递归优化的快排和普通快排。通过对两种方法的调试,观察程序运行过程中栈的使用情况。将尾递归优化成迭代的关键

1.代码主体是根据基准值完成排序后再递归调用函数。

2.将参数 low 提取出来,使其成为迭代变量。

3.将原来函数的里面所代码在一个 while (true) 里面。

4.递归终止的 return 不变,这里当low >= high时递归终止

代码:

#include <stdio.h>

int Partition(int a[], int low, int high)
{
	int i,j,k,temp;
	i = low;
	j = high+1;
	k = a[low];
	while(1)
	{
		while(a[++i] < k && i < j);
		while(a[--j] > k);
		if(i >= j) break;
		else
		{
			temp = a[i];
			a[i] = a[j];
			a[j] = temp;
		}

	}
	a[low] = a[j];
	a[j] = k;
	return j;
}

void QuickSort(int a[], int low, int high)
{
	if(low < high)
	{
		int q = Partition(a, low, high);
		QuickSort(a, low, q-1);
		QuickSort(a, q+1, high);
	}
}

void QuickSort1(int a[], int low, int high)
{
	int pivotPos;
	while(low < high)
    {
        pivotPos = Partition(a,low,high);
        QuickSort(a,low,pivotPos-1);
        low = pivotPos + 1;
    }
}

int main()
{
	int i;
	int a[10] = {3,4,5,6,1,2,0,7,8,9};
	int b[10] = {3,4,5,6,1,2,0,7,8,9};
	QuickSort(a, 0, 9);
	QuickSort1(b, 0, 9);
	for(i = 0; i < 10; ++i){
		printf("[%d]", a[i]);
	}
	printf("\n");
	
	for(i = 0; i < 10; ++i){
		printf("[%d]", b[i]);
	}
	printf("\n");
	return 0;
}

 

普通快排调试情况:

(1)使用 b 设置断点并运行,在这一过程中注意参数 low 、high 和栈的变化

(2)运行过程中参数的变化以及栈最深的情况

 

尾递归优化的快排调试情况:

(1)使用 b 设置第42行代码为断点并运行,在这一过程中注意参数 low 、high 和栈的变化

(2)接下来都是逐步运行并观察参数和栈的使用情况

(3)最后一步运行完,返回 main 函数

从上图中可以明显看出,尾递归优化的快排使用的栈空间很少,因为该方法使用迭代代替了递归操作。当数据量足够大时,使用尾递归优化后,可以缩减堆栈的深度,由原来的O(n)缩减为O(logn)。

 

总结

关于尾递归的问题,网上有许多资料,但大多都是将问题叙述了一遍,也没有提及优化的过程。百度百科中以阶乘函数的尾递归为例向大家介绍了这个问题,但是结论中有一个表述是:可以减少栈的深度。(1)这个表述是有问题的,经过对代码的调试(没有进行优化),发现两种阶乘方法递归的深度是一样的。(2)需要对代码进行尾递归优化才能达到减少栈的深度的目的。如果发现类似的问题,建议大家调试相应的程序,查看栈的使用情况。

参考:https://zhuanlan.zhihu.com/p/36587160

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章