递归中隐藏的陷阱 stack overflow
在设计程序的时候我们常常使用递归函数,它常常能够使我们的代码简洁明了。但是,这其中也隐藏着一个陷阱——那就是递归深度是有限制的,这点非常容易被初学者忽略。
我们知道函数的每一次调用,参数和返回地址都要入栈,其实函数中的局部变量也是要占用栈空间的(不信可以在函数体内声明一个1000000大小的数组,运行程序直接崩溃,如果把他改成全局变量就没事了)。而栈空间又是比较小的(相对于堆),因此我们的程序如果执行了一个很深的递归调用,就有可能发生stack overflow这样的错误。初学ACM的同学可能会经常发现自己的程序runtime error了,但是又没有数组越界访问,一种很有可能的原因就是你递归函数的深度太深了,将系统的栈空间消耗光了。
因此,当我们写一个递归函数的时候一定要对这个函数递归的最大深度有个估计,要确保这个最大深度不会太大,一般来说1000次以内的递归是不会溢出的(我写了一个不停递归的函数,在我机器上大约调用4000-5000次左右会栈溢出,当然如果你的函数中用了许多很大的局部变量,可能用不了1000次就溢出了)。
你可能写过很多递归程序,但是从来没有注意过递归的最大深度,也从没有发生过错误,这是因为我们常用的递归递归函数深度都不会太大。下面举几个例子分析一下。
1.归并排序:递归不会超过32次
因为每次递归都将数组分成几乎相等的2部分,所以递归深度为logN,如果调用32次话
那么数组的长度大约是2的32次方,显然在32位机器上是不可能开出这么大的数组的。
2.辗转相除法:递归次数不会超过50次
辗转相除的最坏情况就是两个连续的斐波那契数辗转相除,这样每一次模除后相当于求出了前一个斐波那契数,递归深度也就是这两个数字在斐波那契序列中的序号数。而第50个斐波那契数是12586269025,已经超过32位整数的表示范围。所以两个32位的整数进行辗转相除运算,递归深度不会超过50。
下面举2个会出错的例子。
1.快速排序
快速排序大家都很熟悉,下面是快速排序一个非常朴素的实现。它取最后一个元素来划分数组。可能你也写过类似的快速排序。但是,这个快速排序是有隐患的,在比较极端的输入下会出现栈溢出。我们知道当快速排序的输入是一个有序序列的时候,快速排序会退化成冒泡排序,它的时间复杂度为O(n^2),但是很少有人关注过此时递归的深度,仔细观察一下不难发现,每次划分实际上都只减少了一个元素,因此此时的最大递归深度为N,也就是数组的长度。假如我们以10000个已经排好序的数组作为此程序的输入的话,那么我们得到的不是一个非常慢的排序算法,而是一个stack overflow。由此可见对快速排序的优化是很有必要的,不仅是出于效率的考虑,也是出于程序稳定性的考虑(如果将程序中rand()一行的注释取消,生成一个随机序列,程序将不会溢出)。
#include<iostream> #include<stdio.h> #include<string.h> using namespace std; /////////////////////////////////////////////////////// //QuickSort template<class T> void QuickSort(T v[],int s,int e) { if( s >= e ) return ; int ts = s , te = e; T t = v[e]; while( s < e ) { while( v[s] <= t && s < e) s++; v[e] = v[s]; while( v[e] >= t && s < e) e--; v[s] = v[e]; } v[e] = t; QuickSort(v,ts,e-1); QuickSort(v,e+1,te); } /////////////////////////////////////////////////////////// int v[100000]; int main() { for(int i = 0; i < 100000; i++ ) v[i] = i; //for(int i = 0; i < 100000; i++ ) v[i] = rand(); QuickSort(v,0,100000); for(int i=0;i<100000;i++) printf("%d ",v[i]); printf("\n"); return 0; }
2.树的深度优先遍历
假如树是比较平衡的话,递归深度大约是LogN。
极端情况下,树退化成链表,递归深度为N。
如何解决上述问题?
我们可以把任何一个递归程序转化成非递归的形式。最简单的方法就是模拟系统入栈的操作,我们人为的将参数“入栈”,这里说的“栈”和操作系统的栈不是一个概念,它指的是一种数据结构。虽然这个栈也需要很大的空间来保存,但是我们可以利用堆上的空间来保存它(比方说用new来分配的内存就是在堆上),从了避免占用系统的栈。下面是我使用stl中的stack(栈)实现的一种非递归的快速排序。这个实现中我基本没有该变原来的逻辑,只是将函数调用改成了参数入“栈”,然后每次计算从“栈”中取所需要的值。可以试验,这种情况下不会出现栈溢出,不过排序确实需要挺长时间,要耐心等待一小会儿,毕竟是10^8的复杂度啊。
#include<iostream> #include<stdio.h> #include<stack> using namespace std; /////////////////////////////////////////////////////// //QuickSort struct RANGE { RANGE(int a,int b):s(a),e(b){} int s,e; }; template<class T> void QuickSort(T v[],int s,int e) { T t; int ts = s , te = e; stack<RANGE> stk; stk.push(RANGE(s,e)); while(!stk.empty()) { ts = s = stk.top().s; te = e = stk.top().e; stk.pop(); if( s >= e ) continue; t = v[e]; while( s < e ) { while( v[s] <= t && s < e) s++; v[e] = v[s]; while( v[e] >= t && s < e) e--; v[s] = v[e]; } v[e] = t; stk.push(RANGE(ts,e-1)); stk.push(RANGE(e+1,te)); } } /////////////////////////////////////////////////////////// int v[100000]; int main() { for(int i = 0; i < 100000; i++ ) v[i] = i; //for(int i = 0; i < 100000; i++ ) v[i] = rand(); QuickSort(v,0,100000); for(int i=0;i<100000;i++) printf("%d ",v[i]); printf("\n"); return 0; }
总结:
使用递归可以使程序简洁,易懂。但是,在使用前一定要对递归的深度有一个估计。如果递归深度很大,那么出于程序稳定性,效率的考虑,应该将递归转成非递归。