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

本文是关于算法学习的笔记,主要探讨了函数调用、递归以及栈的概念。作者强调了避免递归调用过深以防止栈溢出的重要性,并介绍了尾递归的概念及优化方法,如利用尾递归优化二分查找和最大公约数的计算。文章还涉及分治法、二分搜索、插值搜索、斐波那契级数的递推优化,以及解决RMQ问题的Sparse Table算法。此外,提到了树形递归、深度优先搜索与栈的使用,指出栈可以作为优化递归问题的工具。
摘要由CSDN通过智能技术生成


学习算法时整理的一些笔记,篇幅有些大,所以干脆分成几个独立的部分上传了,因为只是简单复制,所以图片和公式不能显示,所以提供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;//

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值