计算机科学之算法——你不得不知的递归
- 2020 年 3 月 6 日
- 笔记
递归
本系列文章在Github:StevenEco以及WarrenRyan同步更新
简介
程序调用自身的编程技巧称为递归 (recursion) 。递归做为一种算法在程序设计语言中广泛应用。 一个过程或函数在其定义或说明中有直接或间接调用自身的一种方法,它通常把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解,递归策略只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量。递归的能力在于用有限的语句来定义对象的无限集合。一般来说,递归需要有边界条件、递归前进段和递归返回段。当边界条件不满足时,递归前进;当边界条件满足时,递归返回。
小例子
看着很抽象?那么我们举一个具体的例子:假设有一天,你正在学校上课,你坐在最后一排,突然你有一件重要的事情需要和第一排的同学进行沟通,你又不能随意走动,那么你应该怎么解决呢?于是你写了一个小纸条,给你前面的同学,并且告诉他转交给第一排的同学,于是前排同学又将小纸条递给了他的前排,循环往复,直到第一排的同学收到小纸条。第一排的同学看完小纸条,写了他要对你说的话,于是他又将纸条递给他的后座,一直递到你为止。这个小例子就是递归的本质思想,你的小纸条就是参数,而传递的过程,事实上都是在执行传递函数的本身。
如果用编程语言来体现刚才的小例子,那么代码就是
string Deliver(int row,string msg) { if(row == 1) { return "Read:" + msg; } return Deliver(--row,msg); }
再举一个例子,斐波那契数列是一个很常见的数列,它的通项公式是 (f(n+2) = f(n) + f(n+1)),我们可以发现,它并没有提及斐波那契数列的表达式,而是给了一个抽象的函数递推式,那么这个时候我们就可以使用递归,将问题简化成一个递推的内容而不是具体的实现。用代码则是
int fib(int n) { if(n == 1 || n == 2) { return 1 } return fib(n-1) + fib(n-2); }
通常,递归必须拥有递推式和跳出条件,因为这可以保证函数不会爆栈,我们要从三个角度去做一个递归:
- 递归的定义:接受什么参数,返回什么值,代表什么意思 。当函数直接或者间接调⽤⾃⼰时,则发⽣了递归
- 递归的拆解:每次递归都是为了让问题规模变⼩
- 递归的出⼝:必须有⼀个明确的结束条件。因为递归就是有“递”有“归”,所以必须又有一个明确的点,到了这个点,就不用“递下去”,而是开始“归来”。
总而言之,递归就是尽可能忽略函数内部的实现,主要关注函数整体需要做的事情。
递归的本质
通过上述的小例子,你可能已经理解了递归的含义,但是为什么通过函数调用函数这种“诡异”的操作可以实现我们的内容呢?如果你在阅读本篇文章之前已经有了一些基础的数据结构和程序语言知识,那么你会知道函数的调用是在栈中实现的,当函数嵌套调用时,系统会将这些函数压入栈中,而栈是先进后出的性质,那么当递归调用时,会一次性将函数压栈到可以return的那个子函数,然后子函数执行完毕返回后,再将返回值带给父函数,再执行父函数。也就是说,递归其实就是一个隐式的栈。
通过这个进栈出栈的过程,一个大的抽象问题就被分解成了若干个嵌套的子问题,子问题一层一层被解决,直到最后一个起始层。
简单的解释就是,递归事实上也是两个问题
递:将问题不断细化直到最小,例如斐波那契数列的问题,fib(5)在程序中的递大致是
fib(5) = fib(4) + fib(3); fib(5) = (fib(3) + fib(2)) + (fib(2) + fib(1)) fib(5) = ((fib(2) + fib(1)) + fib(2)) + (fib(2) + fib(1));
归过程就是将上述递过程的子问题逐步返回到顶层。
整个过程和我们往第一排传纸条再传回来是完全一致的。
递归的用途
我们会发现递归非常的节省代码,而且看起来似乎也没有空间损耗。但真的是这样的吗?答案肯定是否的。诚然,递归会让代码的简洁程度和可读性大幅上涨(可读性上升,但是并不容易被理解和Debug),但是递归也并不是什么时候都是好的。
首先递归最常用的地方就是链表、树、图等含指针的数据结构的操作和计算,因为在这种地方,使用队列、栈等辅助的数据结构会使得代码非常长,并且对于许多算法羸弱的码农并不容易写出来。例如树的中序遍历,对于非递归的方法,你需要借助栈,并且严格的需要保证入栈顺序。而对于后序遍历,你可能还需要借助哈希表来保证左右节点已经被访问,这显然不好。对于递归,只有短短的几行
void InOrder(Tree tree) { if (tree == null) return; InOrder(tree.Left); Console.WriteLine(tree.Value); InOrder(tree.Right); } void PostOrder(Tree tree) { if (tree == null) return; PostOrder(tree.Left); PostOrder(tree.Right); Console.WriteLine(tree.Value); }
相比于普通的代码显得更加简洁明了。
自顶向下与自底向上
但是有时候递归会造成严重的性能问题,尤其会导致栈溢出的问题,事实上函数本身压栈是并不消耗什么空间的,因为本身只是一个指针,并不需要存储任何内容。但是存在返回值的时候,函数需要将返回值保存,因此一同申请空间。当函数栈过深的时候,存储的子函数的返回值也会越来越多,你可以试试将上述斐波那契数列的代码参数设置为一个很大的数字,你会发现程序非常慢,并且有可能会导致栈溢出从而强制退出。因为你从上述分析的递归过程你会发现,有些函数被重复运算了,例如fib(2)就被计算了多次,而这是不需要的。因此浪费了时间和空间。
自顶向下
啥是自顶向下的方法?顶就是顶层任务,也就是我们的预期结果,向下就是指分解成小任务。自顶向下就是讲大任务拆解成若干小任务,随后将小任务组合起来的过程。
通常来说自顶向下有时会造成严重的性能问题,例如我们举的例字,假设你只是想让第一排的同学把橡皮给你,信息却传递了整整一个来回。假设第一排的同学一开始就知道要把橡皮给你,那么就能节省不少时间。
事实上对于斐波那契数列而言,我们并不关心他的前面项的结果,并且在前文的叙述中你也发现了有重复计算的问题。例如fib(10)的值,你完全没有必要关心fib(5)之类的是多少,你只需要关心fib(8)+fib(9)而已,因此对于fib(5)的值你也是完全没有必要压栈的。递归的斐波那契数列时间复杂度达到了惊人的(O(2^n)),空间也用了(S(n))。
假设一个任务可以拆分成互相不干扰,没有直接联系的多个子任务,那么自顶向下的方法则是最优的方法,例如树的遍历,对于一个节点而言,它的兄弟节点必然不会是他的子节点(子函数的结果),那么你就可以大胆的用自顶向下的递归。而对于斐波那契数列,你会发现他的子任务显然会建立联系,那么自顶向下的方法必然会导致重复的运算,甚至爆栈。
自底向上
为了解决子任务相关联导致的自顶向下的性能问题,我们引出自底向上的方法。自底向上则是将最小的子任务往大任务组合,这样就不会有重复计算的过程,因为子任务组合过程是单向的。
对于下面这个改良版的斐波那契数列,尽管代码显得并不是那么可读和方便,但是时间复杂度却降到了(O(n)),并且只使用了常数个的空间。显然我们的复杂度下降了。
int fib(int n) { int rs = 0; int[] temp = new int []{ 1, 1 }; for (int i = 2; i < n; i++) { rs = temp[0] + temp[1]; temp[0] = temp[1]; temp[1] = rs; } return rs; }
并且对于斐波那契数列这种存在通项公式的递归,使用通项公式会使得你的时间复杂度进一步下降至(O(logn))以下。因此可见递归虽好,但可不要滥用。
但是自底向上并不是任何时候都是有效的,例如最小子任务不可知的情况下,树还是一个很好的例子,对于树的叶子结点,在父节点未知的情况下必然无法确定,因此自底向上失效。
小题目
为了加深各位对递归的理解,这里选取了几个使用递归解决的小题目,希望你能独立解决难题,答案将会在文末解析。请使用递归解决嗷!你可以将代码在评论中留下,我会仔细审阅,输入特殊用例来判断你的正确性。
- Code1 – 反转字符串:
//给你一个字符串,请将其反转。 //输入 Hello //输出 olleH public static string Reverse(string str) { }
- Code2 – 三个一组交换单链表
//给你一个单链表,请返回三个一组反转后单链表的表头 //输入:1->2->3->4->5 //返回:3->2->1->4->5 class LinkNode { public int Value { get; set;} public LinkNode Next { get; set;} } public LinkNode Reverse(LinkNode head) { }
- Code3 – 斐波那契数列
//使用递归计算斐波那契数列 //要求时间复杂度降为O(n) //Tip:验证时间复杂度可以输入一个50000去跑 public int Fib(int n) { }
如果我的文章帮助了你,请帮我点个赞,给个star,关注三连走一波。