递归的二三事
斐波那契数列,大家都很熟悉了,从第三项开始,就是把前面两项之和加起来等于第三项,很多人也知道这道题目用递归来可以解,也很多人可以马上顺利的写出递归代码,但是如果你多问一句也许他们就懵逼了,叫他们说一下对递归的理解,或者说斐波那契数列能有什么改进的地方。
(一)递归的理解
很多人都知道斐波那契数列,汉诺塔问题要用递归,也许代码也可以写出来(很多时候都是基于他们记忆写的),下面我就说说我自己对递归的理解。 递归:递就是传递,归就是回来的意思,我们生活中可能也有很多这样的例子,比如你和女朋友去电影院看电影,你想知道自己是坐在第几排(由于太黑了,看不清前面的排树),这个时候,你可能想到一个办法:就是问前面的人是坐第几排(前面的人也不知道自己是做第几排,就再问前面的人,前面的人再问前面的人,直到第一排),这就是递的过程,前面第一排的告诉第二排的,再到第三排...最后到你自己(这就是归的过程),这就是递归。
(二)递归的思想误区
我在刚刚学习递归的时候,我也花了很大心思才搞懂斐波那契数列数列是怎么理解的。我一开始是这么想的,F(40)=F(39)+F(38) F(39)=F(38)+F(37)...一直这样想,想下去头都大了。我这种想法呢,就是很经典的陷入了递归的思维误区。其实正确的思考方式应该是,对于递归来说:如果一个问题A可以分解为多个子问题B,C,D 。然后B,C,D又可以分为子问题B1,B2,C1,C2...。当我们要求A的时候,应该要假设B,C,D都是已知(千万别一层层的往下想子问题和子子问题的关系),要屏蔽掉递归细节,这样理解起来就简单很多了。
(三)什么时候应该用递归
1、可以把一个问题分为子问题,分治法。
2、每个小问题的求解方式和大问题的求解方式应该是一样的(除了数据规模不一样)
3、还要有中止条件,不能无限递归下去。
(四)递归的实践
很多面试官都会问,你在平时工作中,有哪些地方用到了递归,我就举几个例子,1、遍历文件夹里面的所有问题并输出(包含子文件夹和子文件夹)。2、常见的树形结构数据(菜单,地区,部门,这种有层级关系的,一般会用递归来求解子节点)3、斐波那契数列,汉诺塔问题(这个就工作应该问不到用来加深自己对递归的理解)4、快速排序,树的遍历等等都可以用到递归,如果你告诉面试官,你没用过递归,你就GG啦(坏笑)。
问题1:遍历文件夹里面的所有问题并输出(包含子文件夹和子文件夹)
/// <summary> /// 获取文件夹下面的所有文件 /// </summary> /// <param name="dir"></param> /// <returns></returns> static List<string> GetFilesByDir(DirectoryInfo dir,List<string> fileInfos) { // 比如现在要求A目录下的所有文件,找到这个目录下的子目录下的文件夹和这个目录当前存在的文件。先假设上面两个都是已知 if (!dir.Exists) { throw new Exception("目录不存在"); } foreach (var subDir in dir.GetDirectories()) { fileInfos.AddRange(GetFilesByDir(subDir,fileInfos)); } foreach(var subFile in dir.GetFiles()) { fileInfos.Add(subFile.Name); } return fileInfos ; }
问题2:常见的树形结构(地区树)
//上面是模拟数据库的数据。 现在要求的是广东省下的子节点(假设是一个无限层级,虽然案例的数据只有三层) //要解决这个问题,为什么想到用递归呢,因为求一个广东省的子节点和求广东省下面的韶关市的子节点套路是一样的。 //我要返回一棵树形的结构 public List<RegionTree> GetRegions(string parentCode) { var parentRegions = regions.Where(d => d.ParentCode == parentCode).Select(d => new RegionTree() { RegionCode = d.RegionCode, RegionName = d.RegionName, SubRegions = new List<RegionTree>() }).ToList(); foreach (var subRegion in parentRegions) { subRegion.SubRegions.AddRange(GetRegions(subRegion.RegionCode)); } return parentRegions; }
问题3:斐波那契数列
斐波那契数列,从第三项开始,值为前面两项的和。求解方式如下
public int FibonacciOridionary(int n) { if (n == 1 || n == 2) { return 1; } return FibonacciOridionary(n - 1) + FibonacciOridionary(n - 2); }
问题4:汉诺塔问题
汉诺塔问题呢,可以这样考虑,当只有一个盘的时候呢,就是直接从A移动到C。当你有两个盘的时候,先把一个盘从A移动到B,再把一个盘从A移动到C,再把剩下的从B移动到C。当有10个盘的时候,套路也是一样的,你可以假设另外99个盘是已经移动到B了的(把它当成两个盘来看待)。
static void Hannuota(int n,char a,char b,char c) { if (n == 1) { Move(a, c); } else { Hannuota(n - 1, a, c, b); Move(a, c); Hannuota(n - 1, b, a, c); } } static void Move(char a,char b) { Console.WriteLine($"从{a}移动到{b}"); }
问题5:全排列,快排,树的遍历。希望大家来补充了。
上面代码都可以在github这里:https://github.com/gdoujkzz/-RecursionDemo
(五)递归的缺点
说起递归的缺点,可能很多时候,我们调着的时候,可能就会报一个堆栈溢出的错误。其实这个就是因为,我们函数在内存中是堆栈的形式存在的,函数执行的时候,会把临时变量等等压入栈中,执行完毕后,再把他们弹出栈(因为每个函数栈的大小是有限的,所以就导致堆栈溢出了)。
递归还有一点就是很多重复的计算,比如计算斐波那契数列,F(50)=F(49)+F(48) F(49)=F(48)+F(47) ,这其中F(48)就是重复计算了,所以我们要尽可能的避免重复计算。
(六)改进递归的一些做法
斐波那契数列的改进做法如下(加个hash表来存储中间的值,避免重复计算):
public int FibonacciImprove(int n) { if (n == 1 || n == 2) { return 1; } if (_hashDict.ContainsKey(n)) { return _hashDict[n]; } int res = FibonacciImprove(n - 1) + FibonacciImprove(n - 2); _hashDict.Add(n, res); return res; }
至于堆栈溢出的话,只能通过限制递归深度来进行。当递归深度大于指定值的时候,报错。
(七) 替代递归
改进代码在github上了,欢迎大家关注。
(八) 总结
递归在我们编程中十分常见,一般情况下,用递归也不会有什么问题,当然我们也要知道递归的优缺点。大家在平时的编程中,应该还有很多用到递归的地方,欢迎留言。
上面代码都可以在github这里:https://github.com/gdoujkzz/-RecursionDemo