再没对递归了解以前,递归一直个人噩梦,对于写递归代码一直都是无从下手,但当理解了递归以后,才惊叹到,编程真的是一门艺术。在01
世界里,递归是极其重要的一种算法思想,不可能绕的开。这一章咱们从调用栈、图解、调试、用递归写链表的方式,再进一步巩固上一章链表的同时,也更进一步理解递归这种算法思想。node
《盗梦空间》你们应该都看过,那么你能够把递归想象成电影里的梦境,当在这一层没有获得答案时,就进入下一层的梦境,直到在最后一层找到了答案,而后返回到上一层梦境,逐层返回直到现实世界,递归结束。因此递归二字描述的实际上是解决问题的两个过程,首先是递,而后是归。而递与归之间的临界点,又能够叫作递归终止条件,意思是咱们告诉计算机:行了,别递了,开始归的过程吧您嘞。git
为了更好的理解递归,函数调用栈这个前提仍是得先弄明白了,咱们首先来看下这段程序`:github
function a() { b(); console.log('a') } function b() { c(); console.log('b') } function c() { console.log('c'); } a(); // c b a
简单先说下JavaScript
的执行机制,当遇到函数执行时,会为其建立一个执行上下文,而后压入一个栈结构内,当这个函数执行完成以后,就会从栈顶弹出,这是引擎追踪函数执行的一个机制。再看上述代码时,执行a
函数,就将a
推入调用栈,可是a
函数还没执行完时又遇到了b
函数的执行,因此又将b
函数推入调用栈,再b
函数里又执行了c
函数,因此就向调用栈里推入c
函数。在c
函数里打上断点后,咱们能够在浏览器的调用栈里看到三个函数最终入栈的顺序:
出栈的时机是当前栈顶的函数执行完毕时,就弹出,因此最终打印的顺序是c b a
。算法
其实递归函数的调用是相同的,只要没到递归的终止条件,就一直将相同的函数压入栈,这也就是递的过程。当遇到了终止条件后,就开始从栈顶弹出函数,当递归函数的系统栈所有弹出,归的过程结束后,整个递归也就结束。编程
举一个例子,求解字符串的逆序,如abcd
返回dcba
,请使用递归。
既然是求abcd
的逆序,拆解后那就是求解d
加上剩下abc
的逆序;求解abc
的逆序,那就又是求解c
加上ab
的逆序,直到问题被拆解到不能拆解为止。浏览器
没有终止条件的递归会无限递归下去,直至爆栈,因此咱们要给递归函数设置一个终止条件,知足条件后,就不要再递下去了。很明显这个题目的终止条件是当字符串长度为1
时就不用拆解了,为了兼容传入空字符串,能够将终止条件设置为字符串为空时。函数
递归也是有套路的,若是勤加练习,并无太难,这里再附上一个编写递归函数的基本步骤:学习
function recursion(params) { 1. 递归终止条件 (避免无限递归) 2. 当前函数层逻辑处理 (递归函数的主要处理逻辑) 3. 进入下一层函数 (再次执行递归函数) 4. 处理当前层函数其余逻辑 (可能有这一步,也可能没有) }
因此代码以下,稍微详细些↓:spa
function reverseStr(s) { if (s === "") { // 终止条件 return ""; } const lastC = s[s.length - 1]; // 字符串的最后一位 const otherC = s.slice(0, -1); // 除去最后一位的其余字符串 return lastC + reverseStr(otherC); // 进入递归下一层 }
仍是使用画图的方式,更方便的一目了然其内部执行逻辑。debug
人的大脑是习惯平铺直叙的,因此这也是递归代码难理解的地方。而计算机擅长的确是重复,那么如何调试递归程序就很重要,这里分享几个我常常会使用到小技巧。
debugger
法例如求解的字符串的逆序,就代入abc
,而后在递归的函数的内部打上断点,一层层去看当前层的变量变化是否符合处理逻辑。
console.log
大法在递归函数的内部直接在每一个关键节点输出须要观测的变量变化,看是否符合逻辑。
借助debugger
,在进入的递归函数层数足够深了以后,切换系统栈Call Stack
里的递归层数,并经过Local
查看该层变量的值,查看对应的参数的状况。
链表和树都是很是适合学习并理解递归算法的示例,因此以后所有都会使用递归,也是为以后更难理解的回溯打好基础。
再解决链表问题时,若是没有思路,能够用纸和笔把指针指向的过程画下来,而后再尝试从里面找到重复子问题会颇有帮助。
反转一个单链表 输入: 1->2->3->4->5->NULL 输出: 5->4->3->2->1->NULL
上一章使用循环,此次尝试使用递归解决。由于是链表,因此思路是改变指针的指向。子问题就是让最后一个节点指向它以前的节点。首先仍是递的过程,咱们须要递到最后一个节点。而后开始归,让它的指针指向倒数第二个节点便可,因此要知道倒数第二个节点,然而原先倒数第二个节点正指着倒数第一节点了,此时它们就会造成一个互指的环,最后再让倒数第二个节点指向空便可,断开环。
代码以下:
var reverseList = function (head) { if (head === null || head.next === null) { return head } const node = reverseList(head.next) // 最后一个节点 head.next.next = head // 让3指向二、让2指向1。 head.next = null // 让2指向空、让1指向空。 return node };
给定一个排序链表,删除全部重复的元素,使得每一个元素只出现一次。 输入: 1->1->2 输出: 1->2 输入: 1->1->2->3->3 输出: 1->2->3
有了链表反转的技巧后,再解这个题目就很容易了,仍是递归到底,由于咱们知道倒数一个节点和倒数第二个节点,因此再归的过程里,若是倒数两个节点的值相同,则倒数第二个指向它的下下个节点便可。这个相对简单,再理解了反转后,使用以前的递归调试法去理解相信一点都不难,就不画图了。
var deleteDuplicates = function (head) { if (head === null || head.next === null) { return head } const ret = deleteDuplicates(head.next) // 递归到底去,由于递归的终结条件,ret就是最后一个节点 // 而此时head就是倒数第二个节点 if (ret.val === head.val) { head.next = ret.next // 倒数第二个节点指向它的下下个节点 } return head };
将两个升序链表合并为一个新的升序链表并返回。新链表是经过拼接给定的两个链表的全部节点组成的。 输入:1->2->4, 1->3->4 输出:1->1->2->3->4->4
首先仍是拆解子问题,子问题是最小值的节点拼接上剩余已经合并好的两个链表。例如1
指向的就是23
与124
拼接好的结果;剩下的最小节点仍是1
,那么剩下的1
指向的就是23
与24
拼接好的结果。继续拆解直达问题不能拆解为止,若是某一个节点已经到头,那么说明另外一个链表全部的值都比这个链表大,直接返回便可。代码以下:
var mergeTwoLists = function (l1, l2) { if (l1 == null) { // l1到了头,说明l2接下来都比l1最大值还大 return l2 } if (l2 == null) { // 同理 return l1 } if (l1.val > l2.val) { l2.next = mergeTwoLists(l1, l2.next) // 则l2换下一个更大节点来比较 return l2 // 当前小节点指向拼接好的结果后,返回 } else { l1.next = mergeTwoLists(l1.next, l2) // 同理换更大的节点来比较 return l1 // 同理 } };
给定一个链表,两两交换其中相邻的节点,并返回交换后的链表。 你不能只是单纯的改变节点内部的值,而是须要实际的进行节点交换。 1->2->3->4, 返回 2->1->4->3.
若是尝试用纸和笔画出过程,就很容易发现子问题,让第一个节点指向第二个节点以后已经交换好的链表,而后让第二个节点指向以前的节点。
var swapPairs = function (head) { if (head === null || head.next === null) { //判断head.next是为了防止链表节点个数是奇数 return head } const firstNode = head // 链表的第一个节点 const secondNode = head.next // 链表的第二个节点 firstNode.next = swapPairs(secondNode.next) // 第一个节点指向第二个节点以后已经交换好的链表 secondNode.next = firstNode // 第二个节点指向第一个节点 return secondNode // 返回第二个节点做为新的头节点 };
有些复杂的链表问题内的子问题可能须要用上,也是解决部分链表问题的一种小技巧。
给定一个带有头结点 head 的非空单链表,返回链表的中间结点。 若是有两个中间结点,则返回第二个中间结点。 1->2->3->4->5 返回3->4->5
设置两个指针,慢指针一次走一步,快指针一次走两步,当快指针走完时,正好慢指针在链表的中间。
var middleNode = function (head) { let slow = head // 慢指针 let fast = head // 快指针 while (fast !== null && fast.next !== null) { fast = fast.next.next // 走两步 slow = slow.next // 走一步 } return slow // 走完后慢指针指向中间节点 };
给定一个链表,判断链表中是否有环。 为了表示给定链表中的环,咱们使用整数 pos 来表示链表尾链接到链表中的位置(索引从 0 开始)。 若是 pos 是 -1,则在该链表中没有环。 输入:3->2->0->-4,pos=1。 输出:true。 尾部连接到下标1的位置,为有环。 输入:3->2->0->-4,pos=-1。 输入:false。 尾部没有连接,没环。
快指针一次走两步,慢指针一次走一步,若是这个链表是循环的,快慢指针总会相遇;若是是直线行驶,没有环的话,快指针就会走到空。
var hasCycle = function (head) { let slow = head let fast = head while(fast !== null && fast.next !== null) { slow = slow.next fast = fast.next.next if (slow === fast) { // 相遇了 return true } } return false };
给定一个链表,删除链表的倒数第 n 个节点,而且返回链表的头结点。 给定一个链表: 1->2->3->4->5, 和 n = 2. 当删除了倒数第二个节点后,链表变为 1->2->3->5. 说明:给定的 n 保证有效。 进阶尝试:你能尝试使用一趟扫描实现吗?
首先删除链表第n
个节点,则须要找到它以前的节点,让它以前的节点跨过要删除的节点便可。
而后问题是怎么一趟扫描找到倒数第n
个节点以前的节点?咱们仍是可使用快慢指针的方式,须要删除倒数第几个,就让快指针多走几步,快指针把先走的几步走完后,快慢指针一块儿走,快指针到了头,慢指针停留的位置正好就是待删除节点以前的节点。
最后删除该节点,返回链表头节点便可。代码以下:
var removeNthFromEnd = function (head, n) { const dummy = new ListNode() // 设置一个虚拟节点,统一边界处理 dummy.next = head let slow = dummy // 由于要找的是待删除以前节点的缘故 let fast = dummy.next // 让快指针事先就领先一步 while (fast !== null) { if (n > 0) { // 先把领先的走完 n-- fast = fast.next } else { // 而后一块儿走 fast = fast.next slow = slow.next // 走完后慢指针正好在待删除节点以前 } } const delNode = slow.next slow.next = delNode.next // 移除待删除节点 delNode.next = null return dummy.next // 返回头节点 };
写递归也没什么其余的技巧,无非就是多练、多画、多想、多调试。下一章将开始介绍树结构,正好有一个与链表和树都相关的问题,你们能够尝试解决。本章github源码
力扣 109. 有序链表转换二叉搜索树 给定一个单链表,其中的元素按升序排序,将其转换为高度平衡的二叉搜索树。