几个月以前就想写这样一篇文章分享给你们,因为本身有心而力不足,没有把真正的学到的东西沉淀下来,因此一直在不断的自学。多是由于在一所三流大学,资源也比较少,只能本身在网搜索相关资料,在互联网上遇到了一些朋友的帮助下去深刻理解,而后本身抽出大量时间作题总结、概括,才会把已有的知识概念所被本身吸取和理解,造成了本身的技术思想体系。javascript
而后本身又用了一个星期的时间去整理、分类,才有了这篇 8000 字有关递归知识的分享,但愿可以帮助正在学习递归的小伙伴们。并且有了这篇文章的支撑和动力,日后还会写出关于数据结构与算法一些难懂的概念简单化。若是文章中有错误的地方,但愿你们指正,可以为他人分享出更有质量的内容!java
看了不少关于递归的文章,也总结了不少递归的文章,也看了多篇文章下方读者的评论。有的读者评论到文章清晰易懂,有的却喷做者写的存在不少错误,埋怨做者写出来很垃圾,还不如不写。我想从理性的角度说一下,创做者写文章的最初好意是可以帮助别人对此知识点有进一步的了解,并不表明必定可以知足每一个人的要求。git
另外一方面,每篇文章的做者可能理解的不够透彻,不少地方可能存在许多错误,包括理解上的错误,笔误等,这也是写文章的第二个目的,可以让别人挑出本身文章中的不足,可以达到与别人共同进步的目的,一箭双雕,一箭双鵰。github
接下来分享的文章是关于递归的,这篇文章不仅仅分享递归的一切,我以为更重要的是向每位读者传递一个思想。思想?对的,没错!这篇文章不能说包含递归的边边角角,可是经过本身的理论上的学习和实践,有了本身的一套递归思想。算法
什么问题该用递归,什么问题用递归简洁,什么问题就不能使用递归解决,以及对于特定的问题用递归解决的陷阱,能不能进一步对递归进行二次优化,这些都是今天小鹿分享的内容。编程
递归,顾名思义,有递有归才叫递归,有递无归,有归无递那叫 “耍流氓” 。
咱们学习一门技术也好,编程语言也好,首先学习以前咱们知道它将能给咱们带来什么,能帮助咱们解决什么样的问题,这也是激励咱们去学习它的动力所在。数组
从数组到链表、散列表,再到基本算法等,直到遇到递归以后,感受很是的难理解。我相信每一个人都有这种感受,一开始以为很是难,经历了九九八十一难以后,仍是没有弄懂递归里边的猫腻,而后就天然而然的跳过了。数据结构
后来我就开始刷了一个月的 LeetCode 题,发现递归在数据结构与算法中有着一席之地,统治着江山。大部分的题均可以用递归去解决,如:二叉树的遍历、回溯算法、0-1 背包问题、深度优先遍历、回溯算法等等,我整理了至少二三十到关于递归的题,才发现递归的重要性,因此不得不从新深刻递归学习,全部有了今天这篇文章。编程语言
上方我对递归“耍流氓”式的定义并不能让你准确的理解递归是什么,那么咱们就来活生生的举个生活中的例子。
好比你和小鹿我同样,在大学里喜欢插队打饭(做为一个三好学生,我怎么能干这种事呢?哈哈),那么队伍后边的同窗本数着本身前边还有 5 个同窗就改轮到本身了,因为前边同窗不断的插队,这时他发现,怎么以为本身离着打饭的窗口愈来愈远呢?这时若是他想知道本身在队队列中的的第几个(前提是前边再也不有人插队),用递归思想来解决,咱们怎么作呢?函数
因而他问前边的同窗是第几位,前边的同窗也不仅到呀,因而前边的同窗问他前边的同窗是第几位,直到前边第二个同窗问到第一个正在打饭的同窗是队伍的第几个(有点小尴尬)。打饭的同窗不耐烦的说,没看到我是第一个正在打饭吗?这个过程实际上是就是一个递归中“递”的过程。
而后前边打饭的第二个同窗不耐烦的又告诉第三个同窗,我是第二个,没看单我前边有个家伙正在打饭吗?而后第三个传给第四个,之后日后传,直到那位逐渐远离窗口的同窗的前一我的告诉他是第几个以后,他知道了本身目前在队伍中的第几个位置。这个过程咱们能够理解为递归中“归”的过程。
“打饭的同窗不耐烦的说,没看到我是第一个正在打饭吗?”,在递归中,咱们称为终止条件。
1)问题虽然是层层递归的分析,可是用程序表示的时候,不要层层的在大脑中调用递归代码去想,这样可能会使你彻底陷入到 “递” 的过程当中去,“归” 的时候,归不出来了,这些都是咱们交给计算机干的事情。
2)那咱们在写程序的时候怎么理解递归呢?咱们只找问题之间存在的关系,屏蔽掉递归的细节,具体看(五)分析。
经过上方的例子,咱们能够很容易的总结出知足递归的三个条件。
想知道本身在队伍中的位置,将其问题分解为“每一个人所处队伍中的位置”这样的多个子问题。
想要知道本身当前的位置,就要问前边人所处的位置。那么前边人想要知道本身所处的位置,就要知道他前边人的位置。因此说,该问题和子问题的解决思路相同,知足第二个条件。
第一个正在打饭的同窗说本身是队伍中的第一人,这就是所谓的终止条件,找到终止条件以后就开始进行“归”的过程。
若是你对递归有了必定的了解,上边的例子对你来讲小菜一碟,下边还有更大的难度来进行挑战。那么问题分析清楚了,怎么根据问题编写出递归代码来呢?
写递归公式最重要的一点就是找到该问题和子问题的关系,怎么找到之间存在的关系呢?这里我要强调注意的一点就是不要让大脑试图去想层层的递归过程,毕竟大脑的思考方式是顺势思考的(一开始学习递归老是把本身绕绕进去,归的时候,就彻底乱套的)。那怎么找到每一个子问题之间存在的某种关系呢?
咱们只想其中一层(第一层关系),以上述为例,若是我想知道当前队伍的位置,因此我要以前前一我的的位置,而后 +1
就是个人位置了。对于他在什么位置,我丝绝不用关系,而是让递归去解决他的位置。咱们能够写出递推公式以下:
// f(n) 表明当前我在队伍中的位置 // f(n-1) 表明我前边那我的的位置 // 递推公式 f(n) = f(n-1) + 1
※ 注意:这个式子的含义就是f(n)
求当前 n 这我的的位置,f(n-1) + 1
表明的就是前一我的的位置+ 1
就是n
的位置。
递推公式咱们很轻松的写出来了,可是没有终止条件的递推公式会永远的执行下去的,因此咱们要有一个终止条件终止程序的运行。那么怎么找到终止条件呢?
所谓的终止条件就是已知的条件,好比上述的排队打饭的例子中,第一我的正在窗口打饭,他的前边是没有人的,因此他是第一个。第一我的的位置为 1,咱们应该怎么表示呢?
// 终止条件 f(1) = 1;
※ 注意:有的问题终止条件不止一个哦,好比:斐波那契数列。具体问题具体分析。
递推公式和终止条件咱们分析出来了,那么将递推公式转化为递归代码很是容易了。
function f(n){ // 终止条件 if(n == 1) retun 1; // 递推公式 return f(n-1) + 1; }
经过作大量的题,根据递归解决不一样的问题,引伸出来的几种解决和思考的方式。之因此将其分类,是为了可以更好的理解递归在不一样的问题下起着什么做用,如:每层递归之间存在的关系、计算,以及递归枚举全部状况和面临选择性问题的递归。虽然分为了几类,可是递归的本质是一成不变的。
将哪一类用递归解决的问题做为计算型呢?我简单总结了为两点, 层层计算和并列计算。
层层计算,顾名思义,可以用递归解决的问题均可以分为多个子问题,咱们把每一个子问题能够抽象成一层,子问题之间的关系能够表示为层与层之间的关系。咱们经过层与层之间的计算关系用递推公式表达出来作计算,通过层层的递归,最终获得结果值。
▉ 例子:
咱们再那上方排队打饭的例子来讲明,咱们的子问题已经分析出来了,就是我想知道当前在队伍中的位置,就是去问我前边人的位置加一就是我当前队伍的位置,这为一层。而前边这我的想知道当前本身的位置,须要用一样的解决思路,做为另外一层。
层与层之间的关系是什么(我当前队伍中的位置与前边人的位置存在什么样的关系)?这时你会说,当前是 +1
。这个大部分人都很容易找出,既然关系肯定了,而后经过递推公式很容易写出递归代码。
// f(n) 为我所在的当前层 // f(n-1) 为我前边的人所在的当前层 // + 1 是层与层之间的计算关系 f(n) = f(n-1) + 1
▉ 总结:
我将以上一类递归问题命名为「递归计算型」的「层层计算类型」。
▉ 触类旁通:
求年龄的问题也是层层计算类型的问题,本身尝试分析一下(必定要本身尝试的去想,动手编码,才能进一步领悟到递归技巧)。
问题一:有 5 我的坐在一块儿,问第 5 我的多少岁,他说比第 4 我的大 2 岁。问第 4 我的多少岁,他说比第 3 我的大2岁。问第 3 人多少岁,他说比第 2个 人大 2 岁。问第2我的多少岁,他说比第 1 我的大 2 岁。最后问第 1 我的,他说他是 10 岁。编写程序,当输入第几我的时求出其对应的年龄。
问题二:单链表从尾到头一次输出结点值,用递归实现。
并列计算,顾名思义,问题的解决方式是经过递归的并列计算来获得结果的。层与层之间并无必定的计算关系,而只是简单的改变输入的参数值。
▉ 例子:
最经典的题型就是斐波那契数列。观察这样一组数据 0、 一、一、二、三、五、八、1三、2一、34...,去除第一个和第二个数据外,其他的数据等于前两个数据之和(如:2 = 1 + 1
,8 = 3 + 5
,34 = 21 + 13
)。你能够尝试着根据「知足递归的三个条件」以及「怎么写出递归代码」的步骤本身动手动脑亲自分析一下。
我也在这里稍微作一个分析:
1)第一步:首先判断能不能将问题分解为多个子问题,上边我也分析过了,除了第一个和第二个数据,其余数据是前两个数据之和。那么前两个数据怎么知道呢?一样的解决方式,是他们前两个数之和。
2)第二步:找到终止条件,若是不断的找到前两个数之和,直到最前边三个数据 0、一、1
。若是递归求第一个 1 时,前边的数据不够,因此这也是咱们找到的终止条件。
3)第三步:既然咱们终止条件和关系找到了,递推公式也就不难写出 f(n) = f(n-1) + f(n-2)
(n 为要求的第几个数字的值)。
4)转化为递归代码以下:
function f(n) { // 终止条件 if(n == 0) return 0; if(n == 1) return 1; // 递推公式 return f(n-1) + f(n-2); }
▉ 总结:
我将上方的问题总结为并列计算型。也能够归属为层层计算的一种,只不过是 + 1 改为了加一个 f 函数自身的递归(说白了,递归的结果也是一个确切的数值)。之所谓并列计算 f(n-1)
和 f(n-2)
互不打扰,各自递归计算各的值。最后咱们将其计算的结果值相加是咱们最想要的结果。
▉ 触类旁通:
青蛙跳台阶的问题也是一种并列计算的一种,本身尝试着根据上边的思路分析一下,实践出真知(必定要本身尝试的去想,动手编码,才能进一步领悟到递归技巧)。
问题:
一只青蛙一次能够跳上 1 级台阶,也能够跳上2 级。求该青蛙跳上一个n 级的台阶总共有多少种跳法。
递归枚举型最多的应用就是回溯算法,枚举出全部可能的状况,怎么枚举全部状况呢?经过递归编程技巧进行枚举。那什么是回溯算法?好比走迷宫,从入口走到出口,若是遇到死胡同,须要回退,退回上一个路口,而后走另外一岔路口,重复上述方式,直到找到出口。
回溯算法最经典的问题又深度优先遍历、八皇后问题等,应用很是普遍,下边以八皇后问题为例子,展开分析,其余利用递归枚举型的回溯算法就很简单了。
在 8 X 8 的网格中,放入八个皇后(棋子),知足的条件是,任意两个皇后(棋子)都不能处于同一行、同一列或同一斜线上,问有多少种摆放方式?
▉ 问题分析:
要想知足任意两个皇后(棋子)都不能处于同一行、同一列或同一斜线上,须要一一枚举皇后(棋子)的全部摆放状况,而后设定条件,筛选出知足条件的状况。
▉ 算法思路:
咱们把问题分析清楚了以后,怎么经过递归实现回溯算法枚举八个皇后(棋子)出现的全部状况呢?
1)咱们在 8 X 8 的网格中,先将第一枚皇后(棋子)摆放到第一行的第一列的位置(也就是坐标: (0,0))。
2)而后咱们在第二行安置第二个皇后(棋子),先放到第一列的位置,而后判断同一行、同一列、同一斜线是否存在另外一个皇后?若是存在,则该位置不合适,而后放到下一列的位置,而后在判断是否知足咱们设定的条件。
3)第二个皇后(棋子)找到合适的位置以后,而后在第三行放置第三枚棋子,依次将八个皇后放到合适的位置。
4)这只是一种可能,由于我设定的第一个皇后是固定位置的,在网格坐标的(0,0) 位置,那么怎么枚举全部的状况呢?而后咱们不断的改变第一个皇后位置,第二个皇后位置...... ,就能够枚举出全部的状况。若是你和我同样,看了这个题以后,若是还有点懵懵懂懂,那么直接分析代码吧。
▉ 代码实现:
虽然是用
javascript
实现的代码,相信学过编程的小伙伴基本的代码逻辑均可以看懂。根据上方总结的递归分析知足的三个条件以及怎么写出递归代码的步骤,一步步来分析八皇后问题。
一、将问题分解为多个子问题
在上述的代码分析和算法思路分析中,咱们能够大致知道怎么分解该问题了,枚举出八个皇后(棋子)全部的知足状况能够分解为,先寻找每一种知足的状况这种子问题。好比,每一个子问题的算法思路就是上方列出的四个步骤。
二、找出终止条件
当遍历到第八行的时候,递归结束。
// 终止条件 if(row === 8){ // 打印第 n 种知足的状况 console.log(result) n++; return; }
三、写出递推公式
isOkCulomn()
函数判断找到的该位置是否知足条件(不能处于同一行、同一列或同一斜线上)。若是知足条件,咱们返回 true
,进入 if
判断,row
行数加一传入进行递归下一行的皇后位置。直至递归遇到终止条件位置,column ++
,将第一行的皇后放到下一位置,进行继续递归,枚举出全部可能的摆放状况。
// 每一列的判断 for(let column = 0; column < 8; column++){ // 判断当前的列位置是否合适 if(isOkCulomn(row,column)){ // 保存皇后的位置 result[row] = column; // 对下一行寻找数据 cal8queens(row + 1); } // 此循环结束后,继续遍历下一种状况,就会造成一种枚举全部可能性 }
// 判断当前列是否合适 const isOkCulomn = (row,column) =>{ // 左上角列的位置 let leftcolumn = column - 1; // 右上角列的位置 let rightcolumn = column + 1; for(let i = row - 1;i >= 0; i--){ // 判断当前格子正上方是否有重复 if(result[i] === column) return false; // 判断当前格子左上角是否有重复 if(leftcolumn >= 0){ if(result[i] === leftcolumn) return false; } // 判断当前格式右上角是否有重复 if(leftcolumn < 8){ if(result[i] === rightcolumn) return false; } // 继续遍历 leftcolumn --; rightcolumn ++; } return true; }
四、转换为递归代码
// 变量 // result 为数组,下标为行,数组中存储的是每一行中皇后的存储的列的位置。 // row 行 // column 列 // n 计数知足条件的多少种 var result = []; let n = 0 const cal8queens = (row) =>{ // 终止条件 if(row === 8){ console.log(result) n++; return; } // 每一列的判断 for(let column = 0; column < 8; column++){ // 判断当前的列位置是否合适 if(isOkCulomn(row,column)){ // 保存皇后的位置 result[row] = column; // 对下一行寻找数据 cal8queens(row + 1); } // 此循环结束后,继续遍历下一种状况,就会造成一种枚举全部可能性 } } // 判断当前列是否合适 const isOkCulomn = (row,column) =>{ // 设置左上角 let leftcolumn = column - 1; let rightcolumn = column + 1; for(let i = row - 1;i >= 0; i--){ // 判断当前格子正上方是否有重复 if(result[i] === column) return false; // 判断当前格子左上角是否有重复 if(leftcolumn >= 0){ if(result[i] === leftcolumn) return false; } // 判断当前格式右上角是否有重复 if(leftcolumn < 8){ if(result[i] === rightcolumn) return false; } // 继续遍历 leftcolumn --; rightcolumn ++; } return true; } // 递归打印全部状况 const print = (result)=>{ for(let i = 0;i < 8; i++){ for(let j = 0;j < 8; j++){ if(result[i] === j){ console.log('Q' + ' ') }else{ console.log('*' + ' ') } } } } // 测试 cal8queens(0); console.log(n)
▉ 总结
上述八皇后的问题就是用递归来枚举全部状况,而后再从中设置条件,只筛选知足条件的选项。上述代码建议多看几遍,亲自动手实践一下。一开始解决八皇后问题,我本身看了好长时间才明白的,以及递归如何发挥技巧做用的。
▉ 触类旁通:
若是你想练练手,能够本身实现图的深度优先遍历,这个理解起来并不难,能够本身动手尝试着写一写,我把代码传到个人 Github
上了。
所谓的递归选择型,每一个子问题都要面临选择,求最优解的状况。有的小伙伴会说,求最优解动态规划最适合,对的,没错,可是递归经过选择型「枚举全部状况」,设置条件,求得问题的最优解也是能够实现的,全部我呢将其这一类问题归为递归选择型问题,它也是一个回溯算法。
0 - 1
背包问题,了解过的小伙伴也是很熟悉的了。其实这个问题也属于回溯算法的一种,废话很少说,直接上问题。有一个背包,背包总的承载重量是 Wkg
。如今咱们有 n
个物品,每一个物品的重量不等,而且不可分割。咱们如今指望选择几件物品,装载到背包中。在不超过背包所能装载重量的前提下,如何让背包中物品的总重量最大?
▉ 问题分析:
若是你对该问题看懵了,不要紧,咱们一点点的分析。假如每一个物品咱们有两种状态,总的装法就有 2^n
种,怎么才能不重复的穷举这些可能呢?
▉ 算法思路:
咱们能够把物品依次排列,整个问题就分解为了 n 个阶段,每一个阶段对应一个物品怎么选择。先对第一个物品进行处理,选择装进去或者不装进去,而后再递归地处理剩下的物品。
▉ 代码实现:
这里有个技巧就是设置了条件,自动筛选掉不知足条件的状况,提升了程序的执行效率。
// 用来存储背包中承受的最大重量 var max = Number.MIN_VALUE; // i: 对第 i 个物品作出选择 // currentw: 当前背包的总重量 // goods:数组,存储每一个物品的质量 // n: 物品的数量 // weight: 背包应承受的重量 const f = (i, currentw, goods, n, weight) => { // 终止条件 if(currentw === weight || i === n){ if(currentw > max){ // 保存知足条件的最大值 max = currentw; } return ; } // 选择跳过当前物品不装入背包 f(i+1, currentw, goods, n, weight) // 将当前物品装入背包 // 判断当前物品装入背包以前是否超过背包的重量,若是已经超过当前背包重量,就不要就继续装了 if(currentw + goods[i] <= weight){ f(i+1 ,currentw + goods[i], goods, n, weight) } } let a = [2,2,4,6,3] f(0,0,a,5,10) console.log(max)
虽然递归的使用很是的简洁,可是也有不少缺点,也是咱们在使用中须要额外注意的地方和优化的地方。
你可能会问,递归和系统中的堆栈有什么关联?不要急,听我慢慢细说。
1)递归的本质就是重复调用自己的过程,自己是什么?固然是一个函数,那好,函数中有参数以及一些局部的声明的变量,相信不少小伙伴只会用函数,而不知道函数中的变量是怎么存储的吧。不要紧,等你听我分析完,你就会了。
2)函数中变量是存储到系统中的栈中的,栈数据结构的特色就是先进后出,后进先出。一个函数中的变量的使用状况就是随函数的声明周期变化的。当咱们执行一个函数时,该函数的变量就会一直不断的压入栈中,当函数执行完毕销毁的时候,栈内的元素依次出栈。仍是不懂,不要紧,看下方示意图。
3)咱们理解了上述过程以后,回到递归上来,咱们的递归调用是在函数里调用自身,且当前函数并无销毁,由于当前函数在执行自身层层递归进去了,因此递归的过程,函数中的变量一直不断的压栈,因为咱们系统栈或虚拟机栈空间是很是小的,当栈压满以后,再压时,就会致使堆栈溢出。
// 函数 function f(n){ var a = 1; var b = 2; return a + b; }
那么遇到这种状况,咱们怎么解决呢?
一般咱们设置递归深度,简单的理解就是,若是递归超过咱们设置的深度,咱们就退出,再也不递归下去。仍是那排队打饭的例子,以下:
// 表示递归深度变量 let depth = 0; function f(n){ depth++; // 若是超过递归深度,抛出错误 if(depth > 1000) throw 'error'; // 终止条件 if(n == 1) retun 1; // 递推公式 return f(n-1) + 1; }
有些递归问题中,存在重复计算问题,好比求斐波那契数列,咱们画一下递归树以下图,咱们会发现有不少重复递归计算的值,重复计算会致使程序的时间复杂度很高,并且是指数级别的,致使咱们的程序效率低下。
以下图递归树中,求斐波那契数列 f(5)
的值,须要屡次递归求 f(3)
和 f(2)
的值。
重复计算问题,咱们应该怎么解决?有的小伙伴想到了,咱们把已经计算过的值保存起来,每次递归计算以前先检查一下保存的数据有没有该数据,若是有,咱们拿出来直接用。若是没有,咱们计算出来保存起来。通常咱们用散列表来保存。(所谓的散列表就是键值对的形式,如 map )
// 斐波那契数列改进后 let map = new Map(); function f(n) { // 终止条件 if(n == 0) return 0; if(n == 1) return 1; // 若是散列表中存在当前计算的值,就直接返回,再也不进行递归计算 if(map.has(n)){ return map.get(n); } // 递推公式 let num = f(n-1) + f(n-2); // 将当前的值保存到散列表中 map.set(n,num) return num; }
由于递归时函数的变量的存储须要额外的栈空间,当递归深度很深时,须要额外的内存占空间就会不少,因此递归有很是高的空间复杂度。
好比: f(n) = f(n-1)+1
,空间复杂度并非 O(1)
,而是 O(n)
。
咱们一块儿对递归作一个简单的总结吧,若是你仍是没有彻底明白,不要紧,多看几遍,说实话,我这我的比较笨,前期看递归还不知道看了几十遍才想明白,吃饭想,睡觉以前想,相信最后总会想明白的。
不要用大脑去想每一层递归的实现,记住这是计算机应该作的事情,咱们要作的就是弄懂递归之间的关系,从而屏蔽掉层层递归的细节。
最后可能说的比较打鸡血,不少人一遇到递归就会崩溃掉,好比我,哈哈。不管之后遇到什么困难,不要对它们产生恐惧,而是当作一种挑战,当你通过长时间的战斗,突破层层困难,最后突破挑战的时候,你会感激曾经的本身当初困难面前没有放弃。这一点我深有感触,有时候对于难题感到很无助,虽然本身没有在一所好的大学,没有好的资源,更没有人去专心的指导你,可是我一直相信这都是老天给我发出的挑战书,我会继续努力,写出更多高质量的文章。
若是以为本文对你有帮助,点个赞,我但愿可以让更多处在递归困惑的人看到,谢谢各位支持!下一篇我打算出一篇完整关于链表的文章,终极目标:将数据结构与算法每一个知识点写成一系列的文章。
做者:小鹿
座右铭:追求平淡不平凡,一辈子追求作一个不甘平凡的码农!
本文首发于 Github ,转载请说明出处:https://github.com/luxiangqiang/Blog/blob/master/articel/数据结构与算法系列/数据结构与算法之递归系列.md
我的公众号:一个不甘平凡的码农。