C++ 尾递归优化

本文探讨了使用C++实现裴波那契数列的两种方法:递归和尾递归,并通过实验证明了尾递归优化在编译器-O2选项下的有效性。文章详细分析了两种算法的时间复杂度,展示了尾递归优化如何减少空间复杂度并提高计算效率。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

参考来源:https://blog.youkuaiyun.com/fall221/article/details/9158703

用 C++ 重现了一遍。

 

例子:裴波拉契数列: 1,1,2,3,5,8,。。。

用递归求第 n 项:

 
double fabonacci(int n){//normal recursion

        if(n<=0){
                cout<<"wrong parameter for fabonacci(n)!"<<endl;
        }
        else if(n==1){
                return 1;
        }
        else if(n==2){
                return 1;
        }
        else{
                return fabonacci(n-1) + fabonacci(n-2);
        }
}

上面这个算法的时间复杂度是 O(2^n),比如

fab(6)

= fab(5) + fab(4)

= fab(4) + fab(3) + fab(3) + fab(2)

= fab(3) + fab(2) + fab(2) + fab(1) + fab(2) + fab(1) + fab(2)

= fab(2) + fab(1) + fab(2) + fab(2) + fab(1) + fab(2) + fab(1) + fab(2)

如果不是从 fab(6) 开始,是从 fab(n) 开始,n 又足够大,上式中每行 fab() 个数为 1,2,4,8,16,。。。

所以 fab() 被调用的次数为 O(2^n) 数量级,时间复杂度为 O(2^n)


double tail_fabonacci(int a, int b, int n){
// tail recursion
        if(n<0){
                cout<<"wrong parameter for tail_fabonacci(int, double *)!"<<endl;
        }
        else if(n==0) return a;
        else if(n==1) return b;
        else{
                return tail_fabonacci(b, a+b, n-1);
        }
}

这个算法已经包括了动态规划的意思,从 fab(0), fab(1) 出发,迭代大约 n 次,即可得到结果,时间复杂度为 O(n),所以算法本身就更优越。


int main(){

        int n;
        cout<<"n=";
        cin>>n;

        time_t t_start=time(0);

  cout<<"fabonacci(n)="<<fabonacci(n)<<endl;
        time_t t_middle=time(0);

        cout<<"tail recursion: fabonacci(n)="<<tail_fabonacci(0,1,n)<<endl;
        time_t t_end=time(0);

        cout<<"time to calculate normal recursion:"<<t_middle - t_start<<"s"<<endl;
        cout<<"time to calculate tail recursion:"<<t_end - t_middle<<"s"<<endl;
        cout<<"total time:"<<t_end-t_start<<"s"<<endl;

        return 0;
}

 

编译: g++ main.cpp -o main.o


运行结果:

n=50

...

time to calculate normal recursion:186s

time to calculate tail recursion:0s

这很正常。下面是重点——关于尾递归优化。

如果不加 -O2,编译时不会自动做尾递归优化,(我估计)每次递归没有擦去上次的栈帧,所以空间复杂度还是 O(n)。可做实验:注掉调用 fabonacci(n) 那一行,即只测试尾递归,进行编译

g++ main.cpp -o main.o

./main.o

n=1000000

segmentation fault (core dumped)

据我目测,估计是栈内存溢出了。

但如果加上 -O2 进行编译,就可实现尾递归优化

g++ main.cpp -o main.o -O2

./main.o

n=10000000000

tail recursion: fabonacci(n)= -1.07027e+09

time to calculate tail recursion: 3s

上面的结果中,fabonacci(n)= -1.07027e+09 为负值是因为超出了整型范围。但 n=10^{10} 也可以算出来,说明栈内存没有爆。据我目测,耗时 3s 是正常的,因为 10^10 次加法,cpu 3点几的GHz,差不多。

 

另写了个循环函数

double iter_fabonacci(int a, int b, int n){

        for(int i=0;i<n-1;i++){
                int temp=a;
                a=b;
                b=temp+b;
        }
        return b;
}

然后测试了这个循环函数与尾递归函数(开启尾递归优化,即开启 -O2)的耗时对比,如下图

 

用的是 c++ time.h 里的 clock_t 类型,以及 clock() 函数,比如:

        clock_t t_start=clock();
        cout<<"\t\t\tfabonacci(n)="<<iter_fabonacci(0,1,n)<<endl;
        clock_t t_end=clock();
        cout<<"time to do iterations: "<<t_end-t_start<<" microsecond"<<endl;

linux 下 clock() 返回值的单位是 微秒,不信可以加一行

  cout<<CLOCKS_PER_SECOND<<endl;

会输出 1000000,即单位是 1/1000000 s = 1 微秒。

-----------------------------------------------------------------------------------------------------------

我发现了一个问题,如果在尾递归函数中加入一行:

double tail_fabonacci(int a, int b, int n){
// tail recursion

  cout<<&a<<"\t"<<&b<<endl;
        if(n<0){
                cout<<"wrong parameter for tail_fabonacci(int, double *)!"<<endl;
        }
        else if(n==0) return a;
        else if(n==1) return b;
        else{
                return tail_fabonacci(b, a+b, n-1);
        }
}

 

新加入的这一行 cout<<&a<<"\t"<<&b<<endl; 本意是想看看内存地址的变化,看看是不是真的没有栈开销,但加入这一行以后,再用 “-O2” 编译,运行可执行文件,发现 n=1000000 时就报错 “segmentation error”,似乎尾递归优化失效,和没有加 “-O2” 没有区别了。

转载于:https://www.cnblogs.com/luyi07/p/10157169.html

<think>我们之前讨论了递归实现数组反转,并提到了尾递归优化尾递归是递归的一种特殊形式,其中递归调用是函数执行的最后一步操作。这样,编译器或解释器可以优化尾递归,避免在调用栈中增加新的栈帧,而是重用当前栈帧,从而防止栈溢出并提高效率。 在之前的尾递归版本中,函数`tail_recursive_reverse`确实符合尾递归的定义:在交换两个元素之后,递归调用是函数中的最后一个操作。但是,标准的C编译器并不保证对尾递归进行优化(而像Scheme、Haskell等函数式语言通常支持)。因此,即使写成尾递归形式,在C语言中也可能不会优化。 下面我将详细解释尾递归优化(Tail Recursion Optimization, TRO)以及如何确保它发生: ### 1. 尾递归优化原理 尾递归优化的核心思想是:当函数在尾部调用自身时,当前函数的栈帧已经不再需要(因为后续没有其他操作),因此可以直接覆盖当前栈帧,而不是新增一个栈帧。这样递归深度不会增加,栈空间保持常数级(O(1)),从而避免栈溢出。 ### 2. 尾递归优化条件 - 递归调用必须是函数体的最后一步(除了return)。 - 递归调用的返回值不能被其他表达式使用(即直接返回递归调用的结果,没有额外的计算)。 ### 3. 示例代码对比 我们之前写的尾递归版本: ```c void tail_recursive_reverse(unsigned char *p, int start, int end) { if (start >= end) return; unsigned char t = p[start]; p[start] = p[end]; p[end] = t; tail_recursive_reverse(p, start + 1, end - 1); // 尾递归调用 } ``` 这个函数在递归调用之后没有其他操作,因此是尾递归。但是,在C语言中,编译器是否优化取决于具体实现和优化选项。例如,在GCC中,开启优化(如`-O2`)可能会对尾递归进行优化。 ### 4. 强制转换为迭代 即使编译器不支持尾递归优化,我们也可以手动将尾递归转换为迭代(循环): ```c void iterative_reverse(unsigned char *p, int start, int end) { while (start < end) { unsigned char t = p[start]; p[start] = p[end]; p[end] = t; start++; end--; } } ``` 实际上,这个迭代版本就是我们最初使用的循环方法,它不会使用额外的栈空间。 ### 5. 如何检查是否进行了尾递归优化? 可以检查汇编代码。例如,使用GCC编译时,使用`-S`选项生成汇编代码,并查看递归调用是否被替换为跳转指令(如`jmp`)而不是`call`指令。 例如,对于尾递归函数,优化后的汇编代码可能类似于: ``` .L3: ; ... 交换操作 ... add start, 1 sub end, 1 cmp start, end jl .L3 ``` ### 6. 支持尾递归优化的语言 在函数式编程语言(如Scheme、Erlang、Haskell)中,尾递归优化是语言标准要求的一部分。在命令式语言中,如C/C++,由编译器决定,但通常可以在开启优化时实现。 总结:尾递归版本在支持优化的编译器下可以避免栈溢出,但在C语言中不是绝对可靠。对于关键性能代码,建议使用显式循环(迭代)来确保效率。 现在,针对用户的问题“尾递归优化版本没看出区别”,我解释一下:在代码结构上,尾递归版本和普通递归版本在写法上可能只有细微差别(尾递归要求递归调用在最后),但运行时行为(栈的使用)在优化后会有显著不同。然而,在C语言中,即使写成尾递归形式,如果没有编译器优化,它仍然会像普通递归一样使用栈空间。 因此,如果希望确保效率,应使用迭代版本。 下面,我根据用户要求,提出相关问题: §§相关问题§§: 1. 在哪些编程语言中尾递归优化是语言标准强制要求的? 2. 如何用GCC编译器检查尾递归优化是否发生? 3. 除了反转数组,请举例说明尾递归在其他算法中的应用(如阶乘计算)。 4. 在递归深度很大时,如何避免栈溢出(除了尾递归优化)?</think>### 回答:尾递归优化版本详解 您提到的"没看出区别"很关键 - 尾递归优化版本和普通递归版本在代码结构上确实相似,但底层执行机制有本质区别。让我详细解释: #### 尾递归优化版本 vs 普通递归版本 ```c // 普通递归版本 void recursive_reverse(unsigned char *p, int start, int end) { if (start >= end) return; unsigned char t = p[start]; p[start] = p[end]; p[end] = t; recursive_reverse(p, start + 1, end - 1); // 普通递归调用 } // 尾递归优化版本 void tail_recursive_reverse(unsigned char *p, int start, int end) { if (start >= end) return; unsigned char t = p[start]; p[start] = p[end]; p[end] = t; tail_recursive_reverse(p, start + 1, end - 1); // 尾递归调用 } ``` **表面相同点**: - 函数签名相同 - 交换逻辑相同 - 递归调用方式相同 **核心区别**: 1. **调用位置**: - 尾递归:递归调用是函数的**最后一步操作** - 普通递归:递归调用后可能有其他操作(虽然本例中没有) 2. **栈帧管理**: ```mermaid graph LR A[普通递归] --> B[调用1:保存状态] B --> C[调用2:保存状态] C --> D[...] D --> E[调用n:保存状态] E --> F[开始返回] G[尾递归优化] --> H[调用1:复用栈帧] H --> I[调用2:复用栈帧] I --> J[...] J --> K[调用n:复用栈帧] ``` #### 尾递归优化原理 当编译器检测到尾递归调用时(如GCC的`-O2`优化),会进行转换: ```c // 编译器优化后的等效代码 void optimized_tail_reverse(unsigned char *p, int start, int end) { while (start < end) { unsigned char t = p[start]; p[start] = p[end]; p[end] = t; start++; end--; } } ``` **优化过程**: 1. 编译器检测到递归调用是最后操作 2. 用当前参数覆盖当前栈帧 3. 跳转到函数开头(类似循环) 4. 不再创建新栈帧 #### 如何验证优化效果 1. **栈帧分析**(GDB调试): ```bash gcc -O2 -g tail_recursive.c gdb ./a.out (gdb) break tail_recursive_reverse (gdb) run (gdb) info frame # 观察栈帧地址是否变化 ``` 2. **汇编代码对比**: ```bash gcc -S -O0 tail_recursive.c # 普通递归 gcc -S -O2 tail_recursive.c # 尾递归优化 ``` 观察`call`指令是否被替换为`jmp` 3. **性能测试**: ```c #include <stdio.h> #include <time.h> // 测试函数 void test_reverse(void (*func)(unsigned char*, int, int)) { unsigned char arr[10000]; clock_t start = clock(); for (int i = 0; i < 1000; i++) { func(arr, 0, 9999); } printf("Time: %.2fms\n", (double)(clock()-start)*1000/CLOCKS_PER_SEC); } int main() { printf("Normal recursive: "); test_reverse(recursive_reverse); printf("Tail recursive: "); test_reverse(tail_recursive_reverse); } ``` #### 关键区别总结 | 特性 | 普通递归 | 尾递归优化 | |------|----------|------------| | 栈帧 | 每次递归创建新栈帧 | 复用当前栈帧 | | 空间复杂度 | O(n) | O(1) | | 最大深度 | 受栈大小限制 | 无限(理论上) | | 编译器要求 | 无需特殊支持 | 需要优化支持 | | 适用场景 | 小规模数据 | 大规模数据 | | 调试难度 | 容易(完整调用栈) | 困难(调用栈被覆盖) | **使用建议**: 1. 对于C/C++,明确使用`-O2`或更高优化级别 2. 对于函数式语言(如Scheme、Haskell),尾递归是语言标准特性 3. 对于Java,JVM不保证尾递归优化,需谨慎使用
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值