算法学习笔记——函数调用、递归以及栈-part 1


学习算法时整理的一些笔记,篇幅有些大,所以干脆分成几个独立的部分上传了,因为只是简单复制,所以图片和公式不能显示,所以提供Word文档下载地址,Word文档下载地址:

http://download.csdn.net/detail/wearenoth/6022339


1      函数调用、递归以及栈

         调试一个程序,底部最经常看的两个窗口就是“局部变量”与“调用堆栈”,而且平时也经常听到“函数栈”这个名词,递归就是一种特殊的函数调用(因为它是自己调用自己)。栈和函数调用其实有很大的关系。

         例如,表 1是快速排序的递归实现:

表 1 快速排序递归实现

voidquicksort(inta[],intl,intr){
    if(l>=r)return;
    intm=partition(a,l,r);
    quicksort(a,l,m);
    quicksort(a,m+1,r);
}

         使用树来画这个函数的调用过程,就如所示。容易就发现:

Ø  函数执行过程只可能在树的某一个分支上,不可能同时在两个分支上,此时执行的这个分支的所有节点就是函数栈。

Ø  函数的调用顺序其实就是树的一个遍历过程。

Ø  树的深度就是函数栈的最大深度。

程序执行时候,所有函数调用过程中需要的内存空间就在一个特殊的地址空间上进行分配——即平时所说的栈空间。

在函数调用的时候,栈上会保存函数传入参数(也就是常说的形参)、函数返回值、帧寄存器地址以及函数内部申请的局部变量,即使没有参数传入,没有返回值,没有局部变量,也会因为保存帧寄存器地址而耗费空间,即使是最简单的函数调用也是需要耗费几个机器指令的执行周期。

栈空间很小,一般大小就几M,这意味着如果函数调用的深度在超过一定阈值以后,就超出了所能用的空间,即所谓的爆栈。

最后的结论就是:为保证不爆栈,最好保证函数的递归深入不要太深。为保证算法效率,最好减少递归调用。

更多关于栈的内容可以参考《链接、装载与库》这本书,书中很详细的介绍了函数栈的调用过程,更详细的可以参考《Linux内核完全剖析》《深入Linux内核架构》,书中从内核的角度介绍了进程的地址空间分配情况,在那里可以看到栈的一些具体细节。

图 1

         对于函数栈,我们也可以自己手工的去实现,Java虚拟机就是如此做的。但是因为不同的函数传入的参数大小以及函数调用过程中需要的空间大小是不固定的,所以手工实现函数栈是很困难的,因为这时候设计的栈需要支持动态内存管理。

         那如何避免爆栈这个问题,一般可以用三个策略:

Ø  不在函数中声明大的数据对象,也不向函数传入大数据对象,也不返回大数据对象。因为它们会快速消耗栈上空间,此外,它们的构造与析构还会造成运行效率的降低。

Ø  尽可能避免递归调用,递归算法容易产生很深的函数调用,就容易出现爆栈问题。

Ø  调整系统参数,如果是对Java程序,可以调虚拟机的参数。

除此以外,我想不到其他办法。

         不向函数传入、传出大数据对象,以及不在函数中申请大内存,很容易做到,对于C/C++而言,使用new或malloc在堆中进行内存分配,然后使用引用或指针指向这个内存即可。对于Java,这完全不是问题,因为Java只有引用,其他语言就更简单了。

         调系统参数,如果是C/C++,那就要调操作系统参数;如果是Java,就是调Java虚拟机,其他的解释型语言也类似。

         对于避免递归调用,我知道的方法有:

Ø  观察是否是尾递归,尾递归可以借用一些辅助变量求解。例如二分搜索就是一个尾递归。

Ø  观察是否是线性递归,尾递归其实是线性递归的一种特例,线性递归则可以根据情况,转换成尾递归求解,或者转换成递推进行求解。

Ø  如果是树形递归,一般就是通过栈或者队列辅助来实现。典型的如树的遍历。

Ø  其他,肯定还有其他的方法,但是现在我还没见过。

其中,利用栈是解决所有递归问题的最终解决方法,它也是一个重量级的方法,因为我们需要构造出一个用于保存函数调用的栈。入栈,出栈无疑都需要实践,而且它也不可避免的要耗费空间。所以不到最后不要用栈进行优化。

1.1    递归、栈与分治法

         《算法导论》书中在最早的章节就介绍了分治法,分治法并不是一个算法,它只是一种解决问题的思想。在很多地方都可以遇到使用分治法进行求解,如何利用分治法分析问题就是一个case-by-case的问题,如何将分析结果转换成代码则就有一定的模板可循。

         分治法的三个步骤:

Ø  分解(Divide):将原问题分解成一系列子问题。

Ø  解决(Conquer):递归的解各个子问题。若子问题足够小,则直接求解。

Ø  合并(Combine):将子问题结果合并成原问题的解。

这就是《算法导论》上的原话,翻译成代码就是:

Ø  需要确定输入问题的大小,也就是输入参数需要表明问题的边界。

Ø  需要一个分解函数Divide()对当前问题进行问题分解成若干子问题。得到每个子问题的问题边界。

Ø  递归的解决各个子问题,意思就是要使用递归函数,将之前分解得到的子问题边界带入到递归函数中进行求解。

Ø  如果问题足够小,则直接求解,这意味着函数进入的时候需要判断问题规模,然后需要一个计算函数Conquer()最小的规模问题。

Ø  将子问题结果合并成原问题的解,表明需要有一个解的合并函数Combine()进行解的合并操作。

通过这些得到的就是这样一个模板:

表 2 分治法代码模板

Result Algorithm(pro, bound)

if bound达到可以解决的规模

    ret = Conquer();

    return ret;

while newBound = Devide(pro, bound)

    tmpRet[] =Algorithm(pro, newBound);

ret = Combine(tmpRet[]);

         模板是死的,用的时候肯定不会完全套用这个模板,经常需要进行修整,但是如果合理利用这个模板就可以达到不错的效果。而且弄明白这个模板可以很容易理解一些算法代码了,例如在看《算法:C语言实现》这本书的时候就大量见到这个模板。下文中也大量套用了这个模板用于解决具体问题。

         上述的内容就是分治法与递归的关系。这个代码存在的一个隐患就是递归的深度问题,这时候就需要仔细的思考如何避免产生递归调用。

1.2    尾递归及其优化

尾递归就是在函数末尾进行调用递归,更确切的说就是在函数的return语句中调用递归,显然二分搜索的递归实现就是一个尾递归。

尾递归在一些语言中得到支持,如Java、C#。在C/C++则不支持。如果在C#和Java中,这种代码已经不需要优化了,因为编译器会自动识别成尾递归,这样无论递归多少次,函数栈的深度都不会增加。更详细的内容可以参考博文《尾递归与Continuation》

尾递归比较容易优化,而且优化的时候不需要使用栈进行辅助。尾递归的优化则可以参考这篇博文《浅谈尾递归的优化方式》。

1.2.1  二分搜索与插值搜索

         二分搜索就很容易用于解释分治法的这个模板,二分搜索要求输入必须有序。然后查看整个输入问题中间的元素是否达到要求,然后根据比较结果将问题分成两个子问题。

         二分搜索的时间复杂度O(lgn),相对于线性搜索的O(n)可以说快的太多。

         利用之前给的模板就很容易写出一个递归版本的二分搜索:

Ø  函数参数部分需要输入问题边界

Ø  函数最开始部分就是对最小子问题的求解。

Ø  然后开始问题分割

Ø  递归求解子问题。

这样写出来的代码就如下所示。

表 3 二分搜索递归实现

intbinarySearch(inta[],intl,intr,intkey){

    if(l>r)//最小规模子问题

        return-1;

    intm=l+(r-l)/2;//问题分割

    //递归解决每个子问题

    if(a[m]==key)

        returnm;

    if(a[m]>key)

        returnbinarySearch(a,l,m-1,key);

    returnbinarySearch(a,m+1,r,key);

}

         二分搜索的栈递归深度不会太深,因为每次问题都缩小一半的问题规模,基本上几十次递归就可以得到结果,所以使用二分搜索的时候并不会去担心爆栈的风险。编写非递归的二分搜索其实是从效率的角度来考虑(其实也不会差别太多,一个常数因子,但是当算法大量调用的时候,就可以显示出区别了)。

1.2.1.1 利用栈进行实现

         栈可以用于解决所有的递归问题,栈上存储的内容就一个——问题边界。这种改写过程其实也是一个模板,如表 4所示:

表 4

Result Algo(pro, bound)

sk[++top] = bound;

while top >= 0 //即栈不为空

tmpBound = sk[top--]

if tmpBound达到最小问题规模

    tmpRet = conquer();

    ret = Combine(tmpRet);

    continue;

while newBound = Devide(pro, bound)

    sk[++top] = newBound;

return ret

模板是死的,实际问题是活的,模板只是提供一个指导性的框架,合理套用上述的框架,就不难得到表 5的代码:

表 5 利用栈辅助实现二分搜索

#defineMAXSIZE100

intsklb[MAXSIZE];//左边界

intskrb[MAXSIZE];//右边界

inttop=-1;

intbinarySearch(inta[],intl,intr,intkey){

    top=-1;sklb[++top]=l;skrb[top]=r;

    while(top>=0&&sklb[top]<=skrb[top]){

        intm=sklb[top]+(skrb[top]-sklb[top])/2;

        top--;    //将已经解决的问题边界出栈

        if(a[m]==key)

            returnm;

        if(a[m]>key){          

            skrb[++top]=m-1;//将新的问题边界入栈

        }else{

            sklb[++top]=m+1;

        }

    }

    return-1;

}

1.2.1.2 插值搜索

         二分搜索的速度已经非常快,相对于线性搜索的复杂度O(N),其复杂度已经算是非常的小。

         但在一些情况下,其性能却没有理想中的那么理想。如,当输入数据规模非常大,比如10G的数据,这种情况下,不可能将数据一次性放入内存。此时,内存中只能有一部分的数据,这时候每次搜索都会从外存中读取数据,这无疑会降低一部分性能。

         这种时候可以考虑插值搜索,它可以将算法复杂度降低到O(lglgN),这意味着原本需要32次的数据读入,可以降低到5次。但是插值搜索有一个很强的前置条件:

         排序好的数据在区间范围内尽可能接近均匀分布。

         这时候,只需要将代码中中值m的求解方程写成:

1.2.2  利用尾递归优化二分查找

观察的表 5代码就不难发现,这个栈的深度不会超过1,意思就是其实不需要一个栈来保存,只需要两个临时变量就可以,而在这里甚至可以不需要这两个临时变量。这样就得到一个更加简洁的代码:

表 6 用尾递归优化方法优化递归的二分搜索代码

intbinarySearch(inta[],intl,intr,intkey){

    while(l<=r){

        intm=l+(r-l)/2;

        if(a[m]==key)

            returnm;

        if(a[m]>key)

            r=m-1;

        else

            l=m+1;

    }

    return-1;

}

1.2.3  利用尾递归优化最大公约数代码

         《编程之美》中最后给出了这样一个算法代码:

表 7 最大公约数递归方式实现

#defineISEVEN(x)!(x&1)

intgcd(unsignedintx,unsignedinty){

    if(x<y)

        returngcd(y,x);

    if(y==0)

        returnx;

    if(ISEVEN(x)){

        if(ISEVEN(y))

            return(gcd(x>>1,y>>1)<<1);

        else

            returngcd(x>>1,y);

    }else{

        if(ISEVEN(y)){

            returngcd(x,y>>1);

        }else{

            returngcd(y,x-y);

        }

    }

}

         很容易就发现这个代码的递归调用全部在最后的return语句处,所以这个代码就是一个尾递归(注:不能算纯粹的尾递归)。根据之前优化二分搜索的方法就可以慢慢优化得到下面的这段非递归版本的实现。

表 8 利用尾递归优化优化最大公约数递归代码

#defineISEVEN(x)!(x&1)

intgcd(unsignedintx,unsignedinty){

    intret=1;

    for(;;){

        if(x<y){

            swap(x,y);continue;

        }

        if(y==0){

            return(ret*x);

        }

        if(ISEVEN(x)){

            if(ISEVEN(y)){

                x>>=1;y>>=1;

                ret<<=1;

            }else{

                x>>1;

            }

        }else{

            if(ISEVEN(y)){

                y>>=1;

            }else{

                x=x-y;

            }

        }

    }

}

 

1.3    递归与递推

         与递归相比,递推是一种正向求解问题的思维,而递归则是一种逆向的求解思维。虽然递推总是可以转换成递归,但是递归不一定总能转换成递推,比如对树的遍历就无法将递归转换成递推。不过幸运的是,两者在一些特殊情况下可以相互转换,而这些特殊的情况还是比较好用的。

对于一个递归表达式:

F(n)= {G(k) | 0<=k<=n}

         这种递归表达式很常见,尤其是利用动态规划解决的问题中就更是比比皆是了。

         根据递归表达式可以比较方便的写出一个递归函数的实现。但是此时如果知道递归方程的一个初始解,则可以考虑将其转换成递推方式实现。

1.3.1  斐波那契级数

         斐波那契级数的递归表达式:

f(n)=f(n-1)+f(n-2)n>1,f(0)=0,f(1)=1

         这玩意在《算法导论》《具体数学》这些书里都很详细的介绍了其性质。现在我们先关注最直接的代码实现:

表 9 斐波那契级数递归代码实现

intfib(intn){
    if(n==1)return1;
    if(n==0)return0;
    returnfib(n-1)+fib(n-2);
}

可以说这个代码非常糟糕,第一,这个算法递归深度很大,到达O(N);第二、这个算法计算了大量的重叠子问题,参考《算法导论》。虽然使用记忆搜索法可以减少重叠子问题的情况,但是却无法解决递归深度的问题,此外,使用记忆搜索法还不可避免的让整个算法的空间复杂度达到O(N)。

认真观察,不难发现这个代码是一个线性递归,可以通过一些技巧先将代码转换为尾递归,然后再转换成非递归的实现。不过这个过程太麻烦了,我们有更加简单的办法,这时候转换成递推,就完全可以避免上述的问题,这个代码的空间复杂度是O(1),时间复杂度是O(N):

表 10 利用递推实现斐波那契级数

intfib(intn){

    intx=0;inty=1;intret=0;

    for(inti=2;i<=n;i++){

        ret=x+y;

        x=y;y=ret;

    }

}

         其实在利用动态规划解决问题的时候,总是要先得到一个递归表达式(也就是所谓的状态转移方程),求解递归表达式比较直接的思路就是通过写递归函数,但是这种总是很难避免上述遇到的两个问题。《算法导论》中给出的两种方法就是:1、记忆搜索,以有效减少重叠子问题,但无法解决递归深度带来的问题,此外,使用这种办法导致空间复杂度比较难以优化,因为需要将所有状态记录下来;2、转换成递推,可以避免递归函数的递归调用问题。所以最后动态规划问题基本上都采用了递推的方式进行求解。

1.3.1.1 斐波那契级数优化

         对于斐波那契级数在时间复杂度上其实还有很大的优化空间,虽然一般情况不会出现太大的N值(毕竟Fib(64)就已经大的不得了)。

第一种优化方法是利用差分方程求解的办法来求解斐波那契级数,得到最后的解表达式,这样就可以在O(1)的时间内得到答案。

         此外还有一个非常有趣的思路。首先将这个方程写成:

(FnFn-1)=(Fn-1 Fn-2)*A 其中A =

         通过推导就可以得到:

(FnFn-1)=(F1 F0)*An-1

         这样,级数的求解就编程了矩阵的幂乘问题。

         先不思考矩阵乘法,先考虑一个整数的乘法,例如:312,利用分治法的思想,就容易得到:312=36*36  35=32*32*3   32=3*3。

         获取的递归表达式就是:

F(n)={F(n-1)*F(n-1)+k*a| k=n&1}

         这个思路很容易得到一个递归方式的代码实现,有之前的方法就可以得到一个使用非递归方式实现的代码:(注意,这里没有对num为0的情况进行判断):

表 11 求解一个数的幂次方

intnumPow(intnum,intn){

    intret=1;

    while(n!=0){

        if(n&0x01)

            ret*=num;

        num*=num;

        n=n>>1;

    }

    returnret;

}

在这个代码模板的基础上,只要定义好矩阵乘法运算,就很容易得到An-1

1.3.1.2 数的溢出与大数运算

斐波那契级数的结果增长是指数级别的,这意味着对于一个32位的系统而言,无符号整数对于F(32)就已经接近溢出边缘了,如果是使用有符号数,可以说已经溢出了。即使换成长整形也无济于事。如果N比较大,也不可能会(long long long …)这样的写下去。

所以无可避免的,斐波那契级数就需要考虑大数情况下的数据溢出问题。

数的溢出一直困扰着我们,很多时候都需要考虑到数的溢出或回绕问题。

网上搜了下,Python的源码gmp.c中就有对大数运算的实现。可以从中找找灵感。

1.3.2  RMQ问题

1.3.2.1 问题描述

         给定一个数据输入,求出区间[s,e]范围内的最值。

1.3.2.2 基本思路

         最简单,最暴力的方法就是在给定的区间范围类顺序搜索,这种方式下的算法复杂度就是O(e-s+1)。如果只是1次查询,这种方法其实还是很不错的,但是如果要经常检查某个区间范围内的数据情况,那这个方法就可以说是最差的算法了。

1.3.2.3 Sparse Table解法

         对于RMQ问题,可以采用ST算法进行求解,ST算法利用的是一种动态规划的思想。定义状态:st[i, j]表示从区间范围[i, 2j]范围内的最大值。则st[i, j]满足状态转移方程:

st[i,j] = max{st[i, j-1], st[i+2j-1, j-1}

st[i, j]就是Sparse Table,F[s, e]表示区间范围[s, e]内的最大值,则通过Sparse Table就很容易得到查询过程的状态转移方程:

F[s,e] = max{st[s, s+m-1], F[s+m, e] | m = 2k, k = lg(e-s)}

1.3.2.3.1      求最邻近的阶

         在ST算法中需要计算一个数最近邻的阶,如k=lg(n)。如何计算这个k值,方法其实很容易,一个数表示成二进制数时,最高位1的位置其实就是这个k值,所以很容易得到代码:

表 12 获取一个数的最近邻阶

intgetMaxodr(unsignedintnum){

    intodr=0;num=num>>1;

    while(num!=0){

        num=num>>1; odr++;

    }

    returnodr;

}

1.3.2.3.2      Sparse Table的初始化

         根据状态转移方程,再套用之前的模板,不难得到ST建表过程的递归实现版本:

表 13 ST建表过程的递归实现

intsparseTable(inta[],ints,intodr){

    if(st[s][odr]!=0){//记忆搜索,避免重叠子问题

        returnst[s][odr];

    }

    if(odr==0){//最小子问题求解

        st[s][odr]=a[s];

        returnst[s][odr];

    }

intmid=s+(1<<(odr-1));//划分子问题

//递归求解子问题并合并答案

st[s][odr]=max(sparseTable (a,s,odr-1),sparseTable (a,mid,odr-1));

returnst[s][odr];

}

voidinitTable(inta[],intn){

    for(inti=0;i<n;i++){

        intodr=getMaxodr(n-i);

        sparseTable(a,i,odr);

    }

}

         这里的代码并不是尾递归或者是线性递归,所以没办法用尾递归的方式进行优化。当然,这里肯定可以使用栈辅助进行实现。不过,现在还不用请出栈这个重量级的方法。

         动态规划基本上都可以使用递推的方式进行实现,这个问题也不例外。利用递推方式写出的代码比之前的代码简洁许多,不过比之前的代码难理解一些。ST建表过程的时间复杂度是O(N lgN)。

表 14 ST建表过程的递推实现

voidsparseTable(inta[],intn){

    for(inti=0;i<n;i++){// 初始化初始解

        st[i][0]=a[i];

    }

    intmaxOdr=getMaxodr(n);

    for(intodr=1;odr<=maxOdr;odr++){

        intstep=1<<odr;intmxn=n-step+1;

        for(inti=0;i<=mxn;i++){

            st[i][odr]=max(st[i][odr-1],st[i+step/2][odr-1]);

        }

    }

}

1.3.2.3.3      ST算法查询实现

         ST算法的查询过程还是比较容易实现,根据状态转移方程,不难得到如下的递归实现:

表 15 RMQ ST算法的查询过程递归实现

intrangeMaxQuery(inta[],intl,intr){

    intodr=getMaxodr(r-l+1);

    if(odr==0){

        returnst[l][odr];

    }

    returnmax(st[l][odr],rangeMaxQuery(a,l+(1<<odr),r));

}

         这个代码可以利用尾递归进行优化,也可以利用递推的方式进行优化。

         对于RMQ的查询过程,算法的时间复杂度是O(M),M为e-s二进制表示中1的个数。

1.4    树形递归、深度优先搜索与栈

         当递归无法利用尾递归或递推方式进行优化的时候,可以考虑利用栈进行优化,可以说栈就是优化递归问题的终极武器。所有的递归问题都可以利用栈来辅助实现。利用栈辅助实现树形递归的模板如表 4所示。

         如树的遍历都是利用深度优先的搜索思路进行编写的,图的深度优先搜索都可以写成为树形递归的方式,这时候如果希望改成非递归实现,都可以利用栈辅助进行转化。

 

1.5    小结

         本节内容从函数调用开始讨论,简单说明了栈与函数调用间的关系;然后简单讨论了分治法的基本内容,给出了一个分治法解决问题可以使用的递归模板框架;接着讨论这个模板框架带来的缺点——效率问题与递归深度问题;然后,讨论了如何避免上述问题,介绍了一些优化递归问题的方法,并给出具体的示例;此外,还针对一些具体问题的细节实现问题进行了一些深入的讨论。

你可能感兴趣的:(算法学习笔记——函数调用、递归以及栈-part 1)