关于递归的理解

关于递归的理解

1.递归的理解

1)递归的概念

一种计算过程,如果其中每一步都要用到前一步或前几步的结果,称为递归的。用递归过程定义的函数,称为递归函数,例如连加、连乘及阶乘等。凡是递归的函数,都是可计算的,即能行的 。

古典递归函数,是一种定义在自然数集合上的函数,它的未知值往往要通过有限次运算回归到已知值来求出,故称为“递归”。它是古典递归函数论的研究对象 。

通俗来说就是一种形式,不断调用一个函数,如在点外卖时候,会点开炒饭区如果没有想要吃的,就点开煮面区,一直重复到点好外卖为之 。

2)递归的三大要素

①明确函数要干什么

对于递归,我觉得很重要的一个事就是,这个函数的功能是什么,要完成什么样的一件事,而这个,是完全由自己来定义的。也就是说,先不管函数里面的代码什么,而是要先明白,这个函数是要用来干什么。

我们定义了一个算n的阶乘的函数,
传入是一个int型整数,输出也是一个int型整数
功能就是计算n的阶乘
int f(int n){
    计算n的阶乘;
    return n的阶乘;
}

②寻找递归结束条件

所谓递归,就是会在函数内部代码中,调用这个函数本身,所以,我们必须要找出递归的结束条件,不然会一直调用自己,直到栈溢出。也就是说,我们需要找出参数变化为何值时的条件使得递归结束,再把结果返回,这个时候我们必须能根据这个参数的值,能够直接知道函数的结果是什么。

先拆分为几小步
int f(int n){
    //当传入的是1,我们能很清楚知道1的阶乘是1
    if(n == 1){
        return 1;
    }
    //当传入的是2,我们能很清楚知道2的阶乘是2*1=2
    if(n == 2){
        return 2*1;//等价于return 2;
    }
    //当传入的是3,我们能很清楚知道2的阶乘是3*2*1=6
    if(n == 3){
        return 3*2*1;
    }
    //结合一下找到递归结束的条件就是n<=2
    if(n <= 2){
        return n;
    }
}

③找出函数的等价关系式

我们要不断缩小参数的范围,缩小之后,我们可以通过一些辅助的变量或者操作,使原函数的结果不变。

  例如,f(n) 这个范围比较大,我们可以让 f(n) = n * f(n-1)。
这样,范围就由 n 变成了 n-1 了,范围变小了
并且为了原函数f(n) 不变,我们需要让 f(n-1) 乘以 n。
也就是要找到原函数的一个等价关系式
f(n) 的等价关系式为 n * f(n-1),即f(n) = n * f(n-1)。
​
int f(int n){
    //结合一下找到递归结束的条件就是n<=2
    if(n <= 2){
        return n;
    }
    //把f(n)的等价操作写进来
    return f(n-1)*n;
} 

就这样递归三要素已经都写进代码里了,所以这个 f(n) 功能的内部代码我们已经写好了。

这就是递归最重要的三要素,也就是每次做递归的时候,可以试着去寻找这三个要素。

④举些例子

各种数学问题如: 8皇后问题 , 汉诺塔, 阶乘问题, 迷宫问题, 球和篮子的问题(google编程大赛)

各种算法中也会使用到递归,比如快排,归并排序,二分查找,分治算法等.

将用栈解决的问题–>递归代码比较简洁

1.斐波那契数列

问题:斐波那契数列的是这样一个数列:1、1、2、3、5、8、13、21、34…,即第一项 f(1) = 1,第二项 f(2) = 1…,第 n 项目为 f(n) = f(n-1) + f(n-2)。求第 n 项的值是多少。
  
  第一步,写出递归函数和功能
我们定义了一个算n的阶乘的函数,
传入是一个int型整数,输出也是一个int型整数
功能就是求第n项的值
int f(int n){
    计算第n项的值;
    return 第n项的值;
}
​
  第二步找出递归结束的条件
int f(int n){
    //当传入的是1,我们能很清楚知道求第1项的值是1
    if(n == 1){
        return 1;
    }
    //当传入的是1,我们能很清楚知道求第2项的值是1
    if(n == 2){
        return 1;
    }
    //当传入的是3,我们能很清楚知道第3项的值是2
    if(n == 3){
        return 2;
    }
    //结合一下找到递归结束的条件就是n<=2
    if(n <= 2){
        return 1;
    }
}
​
  第三步,找出函数等价条件关系式
题目已经把等价关系式给我们了,很容易就能够知道 f(n) = f(n-1) + f(n-2)。
int f(int n){
    //结合一下找到递归结束的条件就是n<=2
    if(n <= 2){
        return 1;
    }
    //加入等价式
    return f(n-1) + f(n - 2);
}

2.小青蛙跳台阶

一只青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个n级的台阶总共有多少种跳法。
  
  第一步,写出递归函数和功能
我们定义了一个算n的阶乘的函数,
传入是一个int型整数,输出也是一个int型整数
功能就是求青蛙跳上一个n级的台阶总共有多少种跳法
int f(int n){
    青蛙跳上一个n级的台阶总共有多少种跳法;
    return 青蛙跳上一个n级的台阶总共有多少种跳法;
}
​
  第二步找出递归结束的条件
int f(int n){
    //当传入的是1,我们能很清楚知道求青蛙跳上一个1级的台阶总共有1种跳法
    if(n == 1){
        return 1;
    }
    //当传入的是1,我们能很清楚知道求青蛙跳上一个2级的台阶总共有2种跳法
    if(n == 2){
        return 2;
    }
    //当传入的是3,我们能很清楚知道求青蛙跳上一个3级的台阶总共有3种跳法
    if(n == 3){
        return 3;
    }
    //结合一下找到递归结束的条件就是n==1
    if(n == 1){
        return 1;
    }
}
​
  第三步,找出函数等价条件关系式
每次跳的时候,小青蛙可以跳一个台阶,也可以跳两个台阶,也就是说,每次跳的时候,小青蛙有两种跳法。
第一种跳法:第一次我跳了一个台阶,那么还剩下n-1个台阶还没跳,剩下的n-1个台阶的跳法有f(n-1)种。
第二种跳法:第一次跳了两个台阶,那么还剩下n-2个台阶还没,剩下的n-2个台阶的跳法有f(n-2)种。
小青蛙的全部跳法就是这两种跳法之和了,即 f(n) = f(n-1) + f(n-2)。
等价关系式就求出来了
int f(int n){
    //结合一下找到递归结束的条件就是n<=2
    if(n == 1){
        return 1;
    }
    //加入等价式
    ruturn f(n-1) + f(n-2);
}
这时候问题来了,n=2时,会有f(2) = f(1) + f(0),f(0)=0,
按道理是递归结束,不用继续往下调用的
但我们上面的代码逻辑中,会继续调用 f(0) = f(-1) + f(-2)。
这会导致无限调用,从而进入死循环。
也就是说,我们要补上n=0的条件
int f(int n){
    //经过分析,f(2)=2也是一个临界条件。
    if(n <= 2){
        return n;
    }
    ruturn f(n-1) + f(n-2);
}

3.反转单链表

问题:反转单链表。例如链表为:1->2->3->4。反转后为 4->3->2->1
  
  第一步,写出递归函数和功能
我们定义了一个算n的阶乘的函数,
传入是一个单链表头节点,输出也是一个单链表头节点
功能就是反转单链表
先来个链表节点
class Node{
    Object date;
    Node next;
}
Node f(Node head){
    反转单链表;
    return 反转后的单链表节点;
}
​
  第二步找出递归结束的条件
容易可以得出链表只有一个节点或者是一张空表时候结束递归
Node f(Node head){
    if(head == null || head.next == null){
        return head;
    }
}
​
  第三步,找出函数等价条件关系式
2->3->4 递归成 4->3->2。1这个节点我们并没有去碰它,所以1的next节点仍然是连接2。
只需要把节点2的next指向1,然后把1的next指向 null就行了。
f(head) 等价于 f(head.next) + 改变一下1,2两个节点的指向。
Node f(Node node){
    if(head == null || head.next == null){
        return head;
    }
    //递归反转子链表 
    Node newList = f(head.next);
    //改变 1,2节点的指向。 
    //通过 head.next获取节点2 
    Node t1 = head.next; 
    //让2的next指向2 
    t1.next = head; 
    //1的next指向null. 
    head.next = null; 
    //把调整之后的链表返回。
    return newList; 
}

2.递归之八皇后问题理解

  八皇后问题,是一个古老而著名的问题,是回溯算法的典型案例。
该问题是国际西洋棋棋手马克斯·贝瑟尔于1848年提出:
在8×8格的国际象棋上摆放八个皇后,使其不能互相攻击,
即任意两个皇后都不能处于同一行、同一列或同一斜线上,
问有多少种摆法。 
  高斯认为有76种方案。1854年在柏林的象棋杂志上不同的作者发表了40种不同的解,
  后来有人用图论的方法解出92种结果。
  计算机发明后,有多种计算机语言可以解决此问题
八皇后问题是典型的回溯法解决的问题。

所谓回溯法,名字高大上,思想很朴素。设想把你放在一个迷宫里,想要走出迷宫,最直接的办法是什么呢?没错,试。先选一条路走起,走不通就往回退尝试别的路,走不通继续往回退,直到找到出口或所有路都试过走不出去为止。这就是暴力破解。盲僧李青说得好:如果暴力不是为了解题,那就毫无意义了。

尽管回溯法也算是暴力方法,但也不是特别暴力

从8*8=64个格子里选8个格子,放皇后,测试是否满足条件,若满足则计数加1,否则换8个格子继续试。

很显然, 64中选8,并不是个小数字,有着十亿级别的尝试次数

稍加分析,我们可以得到一个不那么暴力的办法,显然,每行每列最多只能有一个皇后,如果基于这个事实进行暴力破解,那结果会好得多。安排皇后时,第一行有8种选法,一旦第一行选定,假设选为(1,i),第二行只能选(2,j),其中j!=i,所以有7种选法。以此类推,需要穷举的情况有8!=40320种。

看起来这个结果已经不错了,但尝试的次数是随问题规模按阶乘水平提高的,8皇后太多了,把问题缩成4皇后,4个皇后在4*4的格子里各自安排不打架,一共有多少种安排方法?
关于递归的理解
试着穷举,4!=24次,我们真的需要24次尝试吗?

现在我们把第一个皇后放在第一个格子,被涂黑的地方是不能放皇后的。
关于递归的理解
第二行的皇后只能放在第三格或第四格,比方我们放第三格,则:

关于递归的理解
前两位皇后沆瀣一气,已经把第三行全部锁死了,第三位皇后无论放哪里都难逃被吃掉的厄运。于是在第一个皇后位于1号,第二个皇后位于3号的情况下问题无解。我们只能返回上一步来,给2号皇后换个位置。
关于递归的理解
显然,第三个皇后只有一个位置可选。当第三个皇后占据第三行蓝色空位时,第四行皇后无路可走,于是发生错误,返回上层调用(3号皇后),而3号也别无可去,继续返回上层调用(2号),2号已然无路可去,继续返回上层(1号),于是1号皇后改变位置如下,继续搜索
关于递归的理解
到这,“回溯法”便是谓知易行难

基本思路是:

1.第一行先占一个皇后

2.第二行再占一个且不能与第一个皇后攻击

3.第三行再占一个

n.第n行占一个,当第n行站不下的时候,取消n-1行的皇后,在第n-1皇后的下一个位置重新占一个皇后位置,知道占到最n-1行的最后一个位置,当还不行的时候,就取消第n-2行,当n-2行的皇后在n-2行的最后一个位置的时候,就取消n-3,n-2在最后一个位置,那么n-3行的一定不再最后一个位置

再重新寻找n-2行的皇后的位置

直到找到最后一个皇后

package com.atguigu.queen8;
​
public class Queen8 {
​
// 一共有多少个皇后(此时设置为8皇后在8X8棋盘)
int max = 8;
// 该数组保存结果,第一个皇后摆在array[0]列,第二个摆在array[1]列
int[]  array = new int[max];
static int  count = 0;
public static void main(String[] args) {
​
Queen8 queen8 = new Queen8();
queen8.check(0);
System.out.println("一共有" + count + "种解法");
}
​
/**
     * n代表当前是第几个皇后 [n 是从 0 开始算的,即0 表示第一个皇后, 同时n也表示第几行]
     * 即 第1行是第一个皇后(n=0),第2行是第二个皇后(n=1), 第8行是第8个皇后(n=7),如果遍历到第9行(n=8),说明
     * 皇后全部放置好了, 就相应的得到了一种解法... 
     * 然后回溯 ,又将第一个皇后,放置第1行的第2列...
     * @param n
     * 皇后n在array[n]列
     */
    private void check(int n) {
        //终止条件是最后一行已经摆完,
    //由于每摆一步都会校验是否有冲突,
    //所以只要最后一行摆完,说明已经得到了一个正确解
        if (n == max) {
            print();
            return;
        }
        //将第n个皇后从.第一列开始放值,然后判断是否和本行本列本斜线有冲突,如果OK,就进入下一行的逻辑
        for (int i = 0; i < max; i++) {
            array[n] = i; //先将第一个皇后放置第一行的第一列 array[0] = 0
            if (judge(n)) {  // 如果 该皇后没有和其它皇后冲突
                check(n + 1); // 放第二个皇后,因为是递归,因此大家可以思考,第二个皇后是从 第二行的第1列开始放
            }
        }
    }
    
    /**
     * 查看n皇后是否满足约束条件(即:检查皇后n是否会发生冲突) 
     * 如果冲突,返回 false , 如果不冲突返回true
     * 0 4 7 5 2 6 1 3 
     * @param n
     * @return
     */
    private boolean judge(int n) {
        for (int i = 0; i < n; i++) {
        //说明: 
        //1. array[i] == array[n] 判断 是不是在同一列
        //2. Math.abs(n - i) == Math.abs(array[n] - array[i]) 判断是不是在同一条斜线
        //3. 不用判断是不是在同一行,因为我们每放一个皇后,行是递增的.
            if (array[i] == array[n] || Math.abs(n - i) == Math.abs(array[n] - array[i])) {
                return false;
            }
        }
        return true;
    }
    /**
     * 打印这个满足条件的八皇后的放置位置
     */
    private void print()  {
    count++;
        for (int i = 0; i < array.length; i++) {
            System.out.print(array[i] + " ");
        }
        System.out.println();
    }
}
​

3.递归的优化

递归的优点:

书写:简洁
使用:在树的前序,中序,后序遍历算法中,递归的实现明显要比循环简单得多。

递归的缺点:

性能:假设传入的参数值特别大,那么这个调用栈将会非常之大,最终可能超出调用栈的缓存大小而崩溃导致程序执行失败。每一次函数调用会在内存栈中分配空间,而每个进程的栈的容量是有限的,当调用的层次太多时,就会超出栈的容量,从而导致栈溢出。
效率:
递归由于是函数调用自身,而函数调用是有时间和空间的消耗的:每一次函数调用,都需要在内存栈中分配空间以保存参数、返回地址以及临时变量,而往栈中压入数据和弹出数据都需要时间。
递归中很多计算都是重复的,由于其本质是把一个问题分解成两个或者多个小问题,多个小问题存在相互重叠的部分,则存在重复计算,如fibonacci斐波那契数列的递归实现。

1)尾调用

尾递归是一种递归的写法,可以避免不断的将函数压栈最终导致堆栈溢出。通过设置一个累加参数,并且每一次都将当前的值累加上去,然后递归调用。通过尾递归,我们可以把复杂度从O(n)降低到O(1),是指一个函数里的最后一个动作是返回一个函数的调用结果的情形,即最后一步新调用的返回值直接被当前函数的返回结果。
(注意:**JVM仍然不支持尾调用优化,**因此对大多数Java开发人员来说,Java不进行尾调用优化并不是什么大问题。不过我猜或许很多Java程序员都没有听说过尾调用优化这回事。不过当你在JVM上运行函数式语言的话,这就成为一个问题了。递归的代码在自己语言的解释器里运行得好好的,可能一到JVM上就突然把栈给用完了。)

以下用js代码为例

//核心:就是看一个函数在调用另一个函数得时候,本身是否可以被“释放”
function f(x) {
  a(x)
  b(x)
  return g(x) //函数执行的最后调用另一个函数
}

任何的尾调用,不只是尾递归,函数调用本身都可以被优化掉,变得跟goto操作一样。这就意味着,在函数调用前先把栈给设置好,调用完成后再恢复栈的这个操作(分别是prolog和epilog)可以被优化掉。比如说下面的这段代码:

函数调用栈和调用帧为例
函数的调用层数非常多时,调用栈会消耗大量内存,甚至栈溢出,造成程序严重卡顿或意外崩溃。
function f(x) {
  res = g(x)
  return res+1
}
​
function g(x) {
  res = r(x)
  return res + 1
}
​
function r(x) {
  res = x + 1
  return res + 1
}
尾调用解决栈溢出风险
调用g之后,和f就没有任何关系了,函数f就结束了,
所以执行到最后一步,完全可以删除 f() 的调用记录,
只保留 g(30) 的调用记录。
function f() {
  m = 10
  n = 20
  return g(m + n)
}
f()
​
// 等同于
function f() {
  return g(30)
}
f()
​
// 等同于
g(30)

将函数优化为尾调用,那么完全可以做到每次执行时,调用帧为一,这将大大节省内存,提高能效。

说到递归,尾递归=尾调用+递归

function factorial(n, total = 1) {
  // console.trace()
  if (n === 0) {
    return total
  }
​
  return factorial(n - 1, n * total)
}
调用如下
factorial(3, 1) 
factorial(2, 3) 
factorial(1, 6) 
factorial(0, 6) 
// n = 0; return 6

调用栈不再需要多次对factorial进行压栈处理,因为每一个递归调用都不在依赖于上一个递归调用的值。因此,空间的复杂度为o(1)而不是0(n)。

2)memoization

(引自https://www.cnblogs.com/lalalagq/p/9906621.html)

简单来说,memoization 是一种优化技术,主要用于通过存储昂贵的函数调用的结果来加速计算机程序,并在再次发生相同的输入时返回缓存的结果。

不使用 memoization

不假思索,我们会立即写下如下的代码:

const factorial = n =&gt; {
    if (n === 1) {
        return 1
    } else {
        return factorial(n - 1) * n
    }
};

使用memoization

const cache = []
const factorial = n =&gt; {
    if (n === 1) {
        return 1
    } else if (cache[n - 1]) {
        return cache[n - 1]
    } else {
        let result = factorial(n - 1) * n
        cache[n - 1] = result
        return result
    }
};

使用 闭包 和 memoization

常见的方式是 闭包 和 memoization 一起搭配使用:

const factorialMemo = () =&gt; {
    const cache = []
    const factorial = n =&gt; {
​        if (n === 1) {
            return 1
        } else if (cache[n - 1]) {
            console.log(`get factorial(${n}) from cache...`)
            return cache[n - 1]
        } else {
            let result = factorial(n - 1) * n
            cache[n - 1] = result
            return result
        }
    }
    return factorial
};
const factorial = factorialMemo();

memorization 可以把函数每次的返回值存在一个数组或者对象中,在接下来的计算中可以直接读取已经计算过并且返回的数据,不用重复多次相同的计算。是一个空间换时间的方式,这种方法可用于部分递归中以提高递归的效率。

3)函数式编程(Java描述):尾调用及抽象递归

一个有趣的解释,递归就是包子馅的包子,它的极限是个馒头。即便Java不支持尾调用优化,但我们并不想浪费尾递归带来的便利,《Java函数式编程》一书提供了一个非常好的思路,把递归进一步抽象出来,将在栈上的递归改为在堆上的递归。

public static int sum(int n, int acc) {
    if (n == 0){
      return acc;
      }
    else {
      acc = acc + n;
      return sum(n - 1, acc);
      }
}

这是一个符合尾递归的函数

但把参数给个1000,会爆栈

只是因为Java不支持尾调用优化,也就是jvm虚拟机不支持这个。

现在,我们来进一步抽象递归操作。

我们定义一个类TailCall来表示递归操作,其中泛型T表示递归操作的结果类型。递归分为两个部分,一个是中间调用,另一个就是最终结果,因此我们需要两个子类表示这些过程。即用Return表示最后一个调用,它是提供结果的,所以直接持有一个T类型的变量;Suspend表示中间的递归调用,为我们提供调用链上的某一部操作,持有Supplier实例。很明显,这是一个用链表表示的栈结构(LIFO),每一个节点存储每一步调用步骤。

public abstract class TailCall<T> {
    private TailCall() {
    }
    public static class Return<T> extends TailCall<T> {
    private final T t;
    public Return(T t) {
       this.t = t;
    }
 }
 public static class Suspend<T> extends TailCall<T> {
    private final Supplier<TailCall<T>> resume;
    public Suspend(Supplier<TailCall<T>> resume) {
         super();
         this.resume = resume;
    }
 }
}
​
​
我们抽象出几个方法来表示这两个子类的操作:
    public abstract TailCall<T> resume();
    //返回调用链上的某一中间步骤
    public abstract T eval();
    //返回结果
    public abstract boolean isSuspend();
    //是否是一个中间操作
    对于Return类来说:
    @Override
    public TailCall<T> resume() {
      throw new IllegalStateException("return has no resume!");
    }
    //我表示的是一个最终调用产生的结果,所以我没必要支持此操作,直接抛出异常。
    @Override
    public T eval() {
      return t;
    }
    //返回储存的结果
    @Override
    public boolean isSuspend() {
      return false;
    }
    //我不是一个中间调用过程,返回false。而对于Suspend类:
    @Override
    public TailCall<T> resume() {
      return resume.get();
    }
    //返回调用链的下一个TailCall.
    @Override
    public T eval() {
      TailCall<T> tailCall = this;
      while (tailCall.isSuspend()) {
         tailCall = tailCall.resume();
      }
      return tailCall.eval();
    }
    //通过不断在调用链上遍历,找到最终的结果。
    @Override
    public boolean isSuspend() {
       return true;
    }
   //Suspend表示我是一个中间操作

完整代码

public abstract class TailCall<T> {
    public abstract TailCall<T> resume();
    public abstract T eval();
    public abstract boolean isSuspend();
    private TailCall() {
    }
    public static <T> Return<T> ret(T t) {
        return new Return<T>(t);
    }
    public static <T> Suspend<T> sus(Supplier<TailCall<T>> s) {
        return new Suspend<>(s);
    }
    public static class Return<T> extends TailCall<T> {
        private final T t;
    public Return(T t) {
        this.t = t;
    }
    @Override
    public TailCall<T> resume() {
        throw new IllegalStateException("return has no resume!");
    }
    @Override
    public T eval() {
        return t;
    }
    @Override
    public boolean isSuspend() {
        return false;
    }
 }
 
 public static class Suspend<T> extends TailCall<T> {
       private final Supplier<TailCall<T>> resume;
       public Suspend(Supplier<TailCall<T>> resume) {
           super();
           this.resume = resume;
       }
       @Override
       public TailCall<T> resume() {
           return resume.get();
       }
       @Override
       public T eval() {
           TailCall<T> tailCall = this;
           while (tailCall.isSuspend()) {
               tailCall = tailCall.resume();
           }
           return tailCall.eval();
       }
       @Override
       public boolean isSuspend() {
           return true;
       }
      }
}
public static int sum(int n, int acc) {
      return sum0(n, acc).eval();
}
public static TailCall<Integer> sum0(int n, int acc) {
      return n == 0 ? ret(acc) : sus(() -> sum0(n - 1, acc + n));
      //将两个工厂方法静态导入
}

尽管可以,当使用函数式语言在JVM上进行开发的时候,也要避免使用过深的递归调用。

4.递归的调用机制(简单版)

关于递归的理解
如图所示:当调用test(4)方法时,会在栈内存中独立开辟一个新的栈,接着调用test(4)方法时会再独立开辟一个新的栈直到不需要再次调用。

​ 可以看到,一个函数每次被调用时,都会在内存中创建一个帧,来包含函数的局部变量和参数,对于递归函数,栈上可能同时存在多个函数帧。当每调用一次函数f(n)时,栈顶指针就会往栈顶移动一个位置,直到满足退出递归的条件(n==1).之后再依次返回当前的结果直接,栈顶指针往下移动。

递归,也就是先“递”后“归”。

这时要说到递归的调用规则

  1. 程序执行一个函数时,就创建一个新的受保护的独立空间(新函数栈) 。

  2. 函数的局部变量是独立的,不会相互影响 。

  3. 递归必须向退出递归的条件逼近,否则就是无限递归 。

  4. 当一个函数执行完毕,或者遇到return,就会返回,遵守谁调用,就将结果返回给谁 。

例:

求函数值:
已知 f(1)=3; f(n) = 2*f(n-1)+1; 请使用递归的思想编程,求出f(n)的值.
用递归来做的话便是:
f(4)=2 * f(3) + 1
f(4)=2 * (2 * f(2) + 1) + 1
f(4)=2 * (2 * (2 * f(1) + 1) + 1) + 1
f(4)=2 * (2 * (2 * 3 + 1) + 1) + 1
f(4)=2 * 15 + 1
f(4)=31

5.递归的调用机制(复杂版)

1)基于栈的本质

(引自:https://blog.csdn.net/dashuniuniu/article/details/50347149)

例如执行”a = b + c”,在基于栈的虚拟机上字节码指令如下所示:

I1: LOAD C 
I2: LOAD B 
I3: ADD 
I4: STORE A

由于操作数都是隐式地,所以指令可以做的很短,一般都是一个或者两个字节。但是显而易见就是指令条数会显著增加。而基于寄存器虚拟机执行该操作只有一条指令:

I1: add a, b, c

其中a,b,c都是虚拟寄存器。操作数栈上的变化如下图所示:

首先从符号表上读取数据压入操作数栈,
关于递归的理解
然后从栈中弹出操作数执行加法运算,这步操作有物理机器执行,如下图所示:
关于递归的理解

2)递归的工作栈

(引自https://www.cnblogs.com/yangyuliufeng/p/9211412.html)

IA-32使用栈来支持过程的嵌套调用。每个过程都有自己的栈区,称为栈帧(stack frame) 。因此,一个栈由若干栈帧组成,每个栈帧用专门的帧指针寄存器EBP指定起始位置,当前栈帧的范围在其和栈指针寄存器ESP指向区域之间。

IA-32规定,寄存器EAX、ECX和EDX是调用者保存寄存器。当过程P调用过程Q时,Q 可以直接使用这三个寄存器,不用将它们的值保存到栈中,这也意味着,如果P在从Q返回后还要用这三个寄存器的话,P应在转到Q之前先保存它们的值,并在从Q返回后先恢复它们的值再使用。寄存器EBX、ESl、EDI是被调用者保存寄存器,Q必须先将它们的值保存到栈中再使用它们,并在返回P之前先恢复它们的值。

(1)每次递归调用前,先将参数n~参数1按序复制到调用过程栈帧中

(2)执行call指令:首先将返回地址(call指令要执行时EIP的值,即call指令下一条指令的地址)压入栈顶,然后将程序跳转到当前调用的方法的起始地址,相当于执行了push和jump指令。

递归调用时,每一层调用过程栈帧中存放的返回地址都是相同的。

(3)每次递归,必定要先push %ebp(把原帧指针保存在栈顶)和mov %esp,%ebp(把存放原帧指针的栈顶,设置为新栈底)

被调用者定义的非静态局部变量仅存放于当前栈帧,调用结束后就被释放了。

最后往往通过EAX寄存器将结果返回给调用者。

(4)执行leave指令:将栈指针指向帧指针,然后pop备份栈顶存放的原帧指针到EBP。

(5)最后执行ret指令:将栈顶的返回地址弹出到EIP,然后按照EIP此时指示的指令继续执行程序。
关于递归的理解
如图所示,Q的过程体执行时,入口参数1的地址总是R[ebp]+8,入口参数2的地址总是R[ebp]+12……(在栈中传递的参数若是基本类型,则都被分配4个字节)

与IA-32不同,x86-64最多可有6个整型或指针型参数通过寄存器传递,超过6个入口参数时,后面的通过栈来传递。在栈中传递的参数若是基本类型,则都被分配8个字节。栈中的地址也变为了8个字节。

RAX、R10和R11为调用者保存寄存器。RBX、RBP、R12、R13、R14和R15为被调用者保存寄存器,需要将它们先保存在栈中再使用,最后返回前再恢复其值。
关于递归的理解
过程调用中使用的栈机制和寄存器使用约定,使得可以进行过程的嵌套调用和递归调用。
关于递归的理解

3)递归的底层

(以下转载于https://www.cnblogs.com/syw-casualet/p/5223595.html)

以c和汇编的程序为例
关于递归的理解


pushl %ebp  //压栈,压入一个栈顶元素并存到寄存器ebp中
movl %esp, %ebp  //将ebp内存单元中的数据传给esp,让esp = ebp,栈顶指针等于栈底指针
subl $4, %esp  //esp=esp-4,即esp向下移动4个单位,相当于开辟了1个局部int,同时默认了ebp为栈底

这三条用于保存栈的信息,ebp寄存器指向栈底,esp寄存器指向栈顶,栈底是高地址而栈底是低地址。执行完这三行后,栈就为main开辟了一个新空间,新空间从ebp开始到esp结束。

开辟前与开辟后寄存器的位置关系如下图:
关于递归的理解
当main执行完后,我们需要消除它的栈,并返回原来的状态,如何返回呢?通过保存ebp=100这个信息。所以我们返回ebp=100,esp=88。这也就是为什么要做上面这三个步骤。然后继续,movl  $8,(%esp) ,将数值8放在esp所指向的位置。
关于递归的理解
接下来进入被调用函数f,利用到call指令,这里讲一下call指令的作用。常见的CPU的CALL指令(“调用”指令)的功能,就是以下两点:

(1)将下一条指令的所在地址(即当时程序计数器PC的内容)入栈

(2)将子程序的地址送入PC(即开始执行子程序)

这时候会将EIP寄存器压入栈(EIP用来存储CPU要读取指令的地址), eip指向的是call的下一条指令,,addl $1, %eax(eax是X86汇编语言上cpu通用的寄存器名称,是32位寄存器,用来暂时存储数值或地址),随后进入函数并执行头三行:

pushl %ebp
movl %esp, %ebp
subl $4, %esp

关于递归的理解


movl    8(%ebp) , %eax
movl    %eax , (%esp)
call    g

继续看调用函数f的代码,第一行表示将ebp+8地址所在位置的值放入eax中,而由图解可知ebp+8的值实际上是8。这儿的8又正好是C语言里f(int x)的参数传递。所以,在32位X86的情况下,函数的参数传递是通过栈来实现的。我们在用call命令调用函数之前,先把需要的参数压入栈,然后再使用call命令将eip压栈,然后进入新的函数后,旧的ebp压栈,新的ebp指向的位置存了这个刚压栈的旧的ebp,所以我们可以通过新的ebp指向的位置,通过计算得到函数需要的参数值。接下来,movl  %eax, (%esp) ,会把eax的值放入esp所指向的内存的位置,然后调用 g函数,,又可以压入call指令的下一条指令的地址。
关于递归的理解
关于递归的理解
进行g函数,执行前两条指令,得到的结果如下:
关于递归的理解
被调用函数g的第三条指令,movl  8(%ebp), %eax ,与之前的代码一致,将ebp+8位置的值存储在eax中。第四条指令,将eax+3,此时eax = 11。第五条指令,popl  %ebp,将栈顶的那个数取出并存入到ebp寄存器中,ebp变成了72,因为这个时候esp执行的位置存放的值就是72,而这个值也是上一个函数中ebp的值。所以得到下图:
关于递归的理解
然后ret执行,ret执行时会把栈顶元素弹到eip中,即把在这里leave的地址弹到eip中,就可以执行leave 指令了,得到的图是:
关于递归的理解
leave 指令类似一条宏指令, 等价于movl %ebp, %esp  popl %ebp。由已知,ebp=72中存取的值是84,这又是上一个的旧ebp的值,所以继续leave,弹出,得到下图:
关于递归的理解
这一步后,又遇到了一次ret,开始执行addl  $1,%eax,由于之前的eax=11,所以现在变成了12。然后又碰到了leave指令,弹出,达到清栈的目的。效果图如下:
关于递归的理解
于是栈恢复了状态。此时main中2还剩下一条ret指令,由于之前一开始我们没考虑过main的地址压栈,所这部分问题留给操作系统。

总结:

一个函数的执行过程,会有自己的一段从ebp 到esp的栈空间。对于一个函数,ebp指向的位置的值是调用这个函数的上一个函数的栈空间的ebp的值, 这种机制使得leave指令可以清空一个函数的栈、达到调用之前的状态。由于在这个栈设置之前,有一个eip压栈的过程,所以leave 以后的ret正好对应了上一个函数的返回地址,也就是返回上一个函数时要执行的指令的地址,另外,由于对于一个函数的栈空间来说,ebp指向的位置存了上一个ebp的值, 再往上是上一个函数的返回地址,再往上是上一个函数压栈传参数的值,所以我们知道了自己的当前ebp,就可以通过栈的机制来获得参数。

上一篇:C++ 底层分析 2.构造-析构,继承


下一篇:C语言笔记——番外篇 函数栈帧的创建