基本概念
在定义一个函数时,出现调用自身函数的,称为递归(recursion)。
如果一个递归函数,最后一条语句是递归调用语句,则称这种递归调用为尾递归(tail recursion)。
一个递归模型通常有两部分构成:初值(递归出口)和递归体。
递归的使用条件
递归的数学定义,比如斐波那契数列:F(1)=F(2)=1,F(n)=F(n−1)+F(n−2),n≥3F(1)=F(2)=1,F(n)=F(n−1)+F(n−2),n≥3。
递归的数据结构,出现了指向自身的指针或者引用,如链表、树、图等。
递归的求解方法。比如经典的汉诺塔问题。
递归函数的时间空间
求解递归函数的时间通常需要根据问题解出相应的递归式。
对于形如归并排序的分治算法,其递归式通常形如T(n)=aT(bn)+f(n)T(n)=aT(bn)+f(n),通常可以使用主定理(《算法导论》 p53)来求解。
一般情况的递归算法的时间分析可能比较困难,需要详细了解递归的执行过程。比如动态规划法和暴力算法都可以使用递归,但是他们的时间复杂度有显著差异。
递归的空间复杂度除了要考虑分配的临时变量之外,还需要考虑递归的深度(虽然使用的是栈空间,也要将其计算在内。)
递归程序非递归化
对于递归的实现机理,需要理解现代CPU的栈帧模型。栈帧保存了当前函数状态的相关信息。当函数调用另一个函数时,它将保存临时变量等信息,同时为被调用的函数开辟另一个帧。因此递归函数调用,每一层是不会相互影响的。
通常情况下递归是由编译器自动实现,然而系统的栈空间是固定的,对于递归深度较大的情况,可能会出现栈溢出(stack overflow),因此这时候必须用栈的数据结构手动模拟栈帧,来实现递归程序非递归化。具体操作来说,就是在原来递归出现之前,利用栈保存前一层的环境,然后切换到下一层;在原来递归返回到调用函数时,将栈顶元素出栈,得到被保存的环境。
一个例子可以参考之前第3章综合练习的一道题目字符串解码(Decode String)。
需要注意的是,编译器在自动实现递归的过程中,它能够自动将传值的参数进行恢复,但是不能将传地址(引用)的参数(尤其是定义在全局变量、堆空间的)进行恢复。这种情况下,需要手动将其恢复。这个问题可以思考DFS遍历迷宫时,用int [][]和vector<vector<int>>的异同:因为vector是一个类对象,每次自我调用的压栈都会将其复制一份,在返回时出栈也要调用析构函数销毁。这样保证了每次使用的vector<int>都是相互独立的,自然不需要手动恢复。而int [][]则是直接传地址,在一个地方修改了,其他地方也是修改的。虽然使用vector<vector<int>>能够省去恢复的麻烦,但是其反复复制元素造成的效率问题也是不容忽视的。
递归算法的一般设计步骤
对于一般的递归问题,通常需要先转化成一个包含有初始状态和递推状态的模型。
递归一般是将较复杂的大问题,利用递推关系转化为一个或者多个相对较小的问题。直到最后每个子问题都满足初始条件(达到递归出口)。
对于递归定义问题的求解,直接利用定义进行递推就可以了。某些较为复杂的,非简单的数学问题,就需要抽象出递推关系。
抽象递推关系也是非常重要的,动态规划算法就是基于递推关系来确定最优解的。
如何具体设计递归算法,包括简单的回溯法(back-tracking)、动态规划算法(dynamic programming),会在习题里结合具体的例子说明。
---------------------
作者:_g63
来源:CSDN
原文:https://blog.csdn.net/jsxyg63/article/details/78306061
版权声明:本文为博主原创文章,转载请附上博文链接!