无需栈视角的拆解:用函数分身理解递归

93 阅读6分钟

对于编程初学者来说,递归估计是一道拦路虎,初见如雾里看花,再见还是雾里看花。那么今天我就来用可视化的方式来跟大家把递归掰开揉碎,彻底理解递归的本质。

一句话的概括是:递归是函数调用自身。由于在现实世界中一个人没有办法把自己举起来,所以在初见递归时,很难去理解调用自身的概念,那么我们就把这个概念去掉,不去管调用自身,而仅仅只需要在意函数调用这四个字。

在介绍递归时,最常见的例子就是计算斐波那契数列中第n位的值。该数列的特点是,第零个值(在写代码的时候,我们通常从零开始)固定为0,第一个值固定为1,从第二项开始,每一项的值等于前两项的和。

一个简单的代码实现是这样的 ![[staying-setp-1 (5).jpeg]]

当我们调用 fibonacci(0) 的时候,因为 0 <= 1, 所以函数直接返回 0 当我们调用 fibonacci(1) 的时候,因为 1 <= 1, 所以函数直接返回 1 因此我们得到了最初两项的值

但是当我们调用fibonacci(2)的时候,事情开始变得有点不一样起来了,因为 2 > 1,因此 if 条件会被跳过,然后执行到可怕的递归那一行,那一行不仅有可怕的递归,还有两个可怕的递归!

没有关系,我们可以直接去掉他们,就像这样 ![[staying-setp-1 (4).jpeg]]

我估计有的同学看到这个代码直接就懵了 ![[Pasted image 20250122095552.png]]

别急,我来解释一下发生了什么,首先我们看最下面的那个函数,跟之前一样,还是叫 fibonacci 不同的是,之前两次调用自身的那一行被替换成了调用另外两个函数 fibonacci1fibonacci2, 而前面新多出来的两个函数正是 fibonacci1fibonacci2 ,而这两个函数的内容和本来的函数 fibonacci 完全相同, 我只是将原来的函数 ctrl + c ,ctrl + v 复制了两份,然后改了一下名字。

这样操作之后,我们发现,函数 fibonacci 中的递归消失了,它不再调用自身,而是调用其他的函数,只是恰好,这个调用的其它函数功能跟它自己完全一样而已。 >_<

如果你把这个修改过的版本拿去运行,你会发现他的功能和之前包含递归的那个版本完全一样,也能正确的计算出想要的值。

有敏锐的同学可能立刻就会发现,虽然 fibonacci 中不再有调用自身的代码,但是 fibonacci1fibonacci2 中还是包含调用自身的代码,递归并没有被去除。是的,的确是这样,但是没有关系,因为我们接下来会调用 fibonacci(2) ,在执行过程中,你会发现,在执行到 fibonacci1fibonacci2 时,他们函数内部调用自身的那一行代码并不会被执行到,它虽然在那里,但却永远不会真正的参与,很像你和你心中的那个人呢。 ![[Pasted image 20250122101337.png]]

我们一步一步的来看看具体会发生什么 第一步 调用 fibonacci(2) 第二步 进入 fibonacci 函数,此时 n = 2 ![[staying-setp-2 1.jpeg]]

第三步 开始 if 判断, 因为 2 <= 1 不成立,所以跳出 if

第四步 执行到 fibonacci1(n - 1) + fibonacci2(n - 2) 那一行,先执行加号左边的,即调用 fibonacci1(n - 1) ,这个时候 n = 2 ,所以实际上是调用 fibonacci1(1)

不过不要忘记,这个时候加号右边还有代码,等会我们调用 fibonacci1(1)完成之后还会回到这里继续调用 fibonacci2(n - 2) , 因为 n = 2, 所以实际上我们等会实际上调用的是 fibonacci2(0)。是的,不管我们在 fibonacci1 内部发生了什么,都不会影响 n 的值,在这一行,在这里, 调用 fibonacci1n = 2 ,调用fibonacci2n 还是等于 2

![[staying-setp-4.jpeg]]

第五步 进入 fibonacci1 函数,此时fibonacci1内部 n = 1 ![[staying-setp-5.jpeg]] 第六步 开始 if 判断, 因为 1 <= 1 成立,所以进入 if ,直接返回 n 的值,也就是返回 1 ![[staying-setp-8.jpeg]]

第七步 由于 fibonacci1 已经执行完毕,所以按顺序执行加号右边,也就是 fibonacci2(n - 2) ,还记得吗,我们回到了这里,此时我们的 n = 2, 跟之前说的一样,也就是我们实际上要调用的是 fibonacci2(0) ![[staying-setp-9.jpeg]] 第八步 进入 fibonacci2, 此时 n = 0

![[staying-setp-10.jpeg]]

第九步 开始 if 判断, 因为 0 <= 1 成立,所以进入 if ,直接返回 n 的值,也就是返回 0 ![[staying-setp-13.jpeg]] 第十步 我们又回到了最开始的地方,回到了 fibonacci 函数内部,而这时,加号左右两边的函数我们都执行完了,加号左边的结果是 1 ,加号右边的结果是 0 。因此,我们执行加法运算。

根据高等数学的知识,我们经过计算得知 0 + 1 = 1

所以,这一步的结果我们返回 1 ,也就是 fibonacci 函数最终的运行结果返回 1 ![[staying-setp-14.jpeg]] 最后,我们将返回的结果赋值给 result ![[staying-setp-15.jpeg]]

到这里,我们完成了对 fibonacci(2) 的运算,并得到了我们想要的结果,整个过程中我们没有用到递归。

那么,递归是什么呢?接下来我们回归到递归这个概念,并且将代码还原到最初的模样 ![[staying-setp-1 (1) 1.jpeg]] 然后,再重新执行一遍,我们来看一下最后的结果 ![[staying-setp-15 (1).jpeg]]

仔细看,仔细看 我们发现,最初的版本和我们修改的没有递归的版本除了在函数名称上不同之外,它们之间的所有一切都是相同的,它们调用的方式,它们函数内部 n 的值,它们返回的结果。

仔细想,仔细想 你是不是已经发现,递归就是调用函数,只是那么恰好,它调用的函数是它自己,我们完全可以想象成,它在调用另外一个功能和它自己完全一样的函数,而这个函数恰好又和自己同名。

终止条件 由于调用自身的特殊性,你可以想象一只猫在追逐自己的尾巴,所以,相较于非调用自身的函数,我们在使用递归的时候需要特别关注一个东西,就是终止条件 就像猫追自己的尾巴总会停下,因为它可能是累了,也可能被更有趣的毛线球吸引了,如果失去这些,猫可能就会永远追逐下去。 在使用递归时,尤其要关注终止条件,不然就会让这个调用无限进行下去,最终导致爆栈

fibonacci 函数为例,我们可能看到,在调用自身的地方,参数分别为 n - 1n - 2 ,这两个值都比 n 小,而在 if 判断那里, 我们的判断条件是 if (n <= 1) ,因此,随着调用的不断进行,n 会越来越小, 总有一个时刻,n 的值会小于等于 1 ,此时我们进入 if内部,在这个内部,我们不再调用自身,这个无限进行下去的过程被终止。

希望能帮到你