尾递归

概念

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

原理

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

内存中的栈

在计算机系统中,栈是一个具有以上属性的动态内存区域(虽然与数据结构中的栈有区别,但是它们的思想都是先进后出)。程序可以将数据压入栈中,也可以将数据从栈顶弹出。在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;elsereturn 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;elsereturn 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 5int fact(int n)             //线性递归
{if (n < 0)return 0;else if(n == 0 || n == 1)return 1;elsereturn 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;elsereturn 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

尾递归及快排尾递归优化相关推荐

  1. 【算法】递归:递归优化之尾递归

    [算法]递归:递归优化之尾递归 引言:在以往我发过一篇过于通过分析法去理解递归求解递归的博客文章,那篇文章主要介绍了如何去求解递归问题.而在这篇文章中,我会介绍一下如何去优化递归,顺带还会去分析一下递 ...

  2. 基于单链表快排的优化算法

    快排大法好,不说日常数据处理的巨大优势,面试时能手写快排更是装X一大利器. 不过传统的快排有一大缺陷:当出现大量相同值或数据已经有序时,由于对相同值的重复递归,排序效率会急剧降低乃至O(N^2). 为 ...

  3. 快速排序、快排的优化 及Java实现

    一.快速排序的思想 选取一个比较的基准,将待排序数据分为独立的两个部分,左侧都是小于或等于基准,右侧都是大于或等于基准,然后分别对左侧部分和右侧部分重复前面的过程,也就是左侧部分又选择一个基准,又分为 ...

  4. 排序算法--快排的优化

    排序算法–快排的优化 下面是我写的一种快排: #include <iostream> #include <stdlib.h>using namespace std;void P ...

  5. 快排递归非递归python_Python递归神经网络终极指南

    快排递归非递归python Recurrent neural networks are deep learning models that are typically used to solve ti ...

  6. T31快启图像效果优化

    T31快启图像效果优化 liwen01 20220821 (一)基础方法及概念 参考文档 <Ingenic_Zeratul_T31_快起效果调试说明_20200927_CN> (1)起始E ...

  7. java递归优化_在Java中谈尾递归--尾递归和垃圾回收的比较

    我不是故意在JAVA中谈尾递归的,因为在JAVA中谈尾递归真的是要绕好几个弯,只是我确实只有JAVA学得比较好,虽然确实C是在学校学过还考了90+,真学得没自学的JAVA好 不过也是因为要绕几个弯,所 ...

  8. 八大排序算法之快速排序(下篇)(快排的优化+非递归快排的实现)

    目录 一.前言 1.快速排序的实现: 快速排序的单趟排序(排升序)(快慢指针法实现):​ 2.未经优化的快排的缺陷 二.快速排序的优化 1.三数取中优化 优化思路: 2. 小区间插入排序优化 小区间插 ...

  9. Php斐波那契数列尾递归优化,递归优化的这三种方式你知道吗?

    估计找工作的,都会碰到面试官老是问道"递归算法",感同身受,前段时间面试的时候,就有一家问道这个问题,是非常典型的问题.在前面一篇世界上有哪些代码量很少,但很牛逼很经典的算法或项目 ...

最新文章

  1. 二分法:search insert position 插入位置
  2. mysql如何提高其查询速度的方法
  3. 安全测试-抓包工具BurpSuite
  4. Effective_STL 学习笔记(四十四) 尽量使用成员函数代替同名的算法
  5. 【英语学习】【加州教材】【G2】【科学】Science目录及术语表
  6. Altium AD20常用的操作快捷键,个人总结精炼版,全干货超实用
  7. 10 个步骤让你成为高效的 Web 开发者
  8. 关于sinX与y的大小比较取值范围计算
  9. Swift 数据类型(三)
  10. Asymptotic efficiency of nonparametric tests笔记(正在进行中)
  11. ubuntu freeradius mysql_ubuntu上安装和配置FreeRadius
  12. 在 vmware ESXi上安装mac系统虚拟机
  13. SQL分析在2020年度第一季度的购买人数,销售金额,客单价,客单件人均购买频次(时间函数、分组汇总、常用指标计算)
  14. EurekaCAP原理
  15. mysql mysqlhotcopy_MySQL 备份和恢复 (mysqlhotcopy)
  16. svm手写数字识别python_SVM算法识别手写体数字
  17. OpenGL,GLUT,FreeGLUT,GLFW,GLEW,GLAD,GL3W,GLAD,GLM,GLSL的区别详解
  18. 2022最新酒桌小游戏喝酒小程序源码_带流量主
  19. 蓝牙耳机充电仓单芯片IC解决方案汇总
  20. 单片机点阵 LED 设计显示屏,超详细!

热门文章

  1. Windows 平台编译 WebRTC
  2. C++ 面向对象(一)继承:继承、对象切割、菱形继承、虚继承、继承与组合
  3. leetcode-136. 只出现一次的数字解法
  4. FFmpeg Maintainer赵军:FFmpeg关键组件与硬件加速
  5. 为什么磁盘存储引擎用 b+树来作为索引结构?
  6. 腾讯极客挑战赛邀你“码上种树”
  7. EasyRTMP CPU占用问题调优(一)
  8. 数据存储四种常见方式
  9. ubuntu 16.04 安装MXNet GPU版本
  10. go语言linux环境配置nginx,搭建wss