当前位置:   article > 正文

c++ 链表_前端学数据结构与算法(四):理解递归及拿力扣链表题目练手

c++ 链表_前端学数据结构与算法(四):理解递归及拿力扣链表题目练手

前言

再没对递归了解之前,递归一直是个人的噩梦,对于写递归代码一直都是无从下手,但当理解了递归之后,才惊叹到,编程真的是一门艺术。在01世界里,递归是极其重要的一种算法思想,不可能绕的开。这一章我们从调用栈、图解、调试、用递归写链表的方式,再进一步巩固上一章链表的同时,也更进一步理解递归这种算法思想。

什么是递归?

《盗梦空间》大家应该都看过,那么你可以把递归想象成电影里的梦境,当在这一层没有得到答案时,就进入下一层的梦境,直到在最后一层找到了答案,然后返回到上一层梦境,逐层返回直到现实世界,递归结束。所以递归二字描述的其实是解决问题的两个过程,首先是递,然后是归。而递与归之间的临界点,又可以叫做递归终止条件,意思是我们告诉计算机:行了,别递了,开始归的过程吧您嘞。

函数调用栈

为了更好的理解递归,函数调用栈这个前提还是得先弄明白了,我们首先来看下这段程序`:

  1. function a() {
  2. b();
  3. console.log('a')
  4. }
  5. function b() {
  6. c();
  7. console.log('b')
  8. }
  9. function c() {
  10. console.log('c');
  11. }
  12. a(); // c b a

简单先说下JavaScript的执行机制,当遇到函数执行时,会为其创建一个执行上下文,然后压入一个栈结构内,当这个函数执行完成之后,就会从栈顶弹出,这是引擎追踪函数执行的一个机制。再看上述代码时,执行a函数,就将a推入调用栈,但是a函数还没执行完时又遇到了b函数的执行,所以又将b函数推入调用栈,再b函数里又执行了c函数,所以就向调用栈里推入c函数。在c函数里打上断点后,我们可以在浏览器的调用栈里看到三个函数最终入栈的顺序:

e1c4aabc382ed686a95f9e335f827a04.png

出栈的时机是当前栈顶的函数执行完毕时,就弹出,所以最终打印的顺序是c b a

其实递归函数的调用是相同的,只要没到递归的终止条件,就一直将相同的函数压入栈,这也就是递的过程。当遇到了终止条件后,就开始从栈顶弹出函数,当递归函数的系统栈全部弹出,归的过程结束后,整个递归也就结束。

如何写递归代码

举一个例子,求解字符串的逆序,如 abcd返回 dcba,请使用递归。
  1. 拆解重复逻辑子问题

既然是求abcd的逆序,拆解后那就是求解d加上剩下abc的逆序;求解abc的逆序,那就又是求解c加上ab的逆序,直到问题被拆解到不能拆解为止。

  1. 找到递归终止条件

没有终止条件的递归会无限递归下去,直至爆栈,所以我们要给递归函数设置一个终止条件,满足条件后,就不要再递下去了。很明显这个题目的终止条件是当字符串长度为1时就不用拆解了,为了兼容传入空字符串,可以将终止条件设置为字符串为空时。

  1. 使用套路

递归也是有套路的,如果勤加练习,并没有太难,这里再附上一个编写递归函数的基本步骤:

  1. function recursion(params) {
  2. 1. 递归终止条件 (避免无限递归)
  3. 2. 当前函数层逻辑处理 (递归函数的主要处理逻辑)
  4. 3. 进入下一层函数 (再次执行递归函数)
  5. 4. 处理当前层函数其他逻辑 (可能有这一步,也可能没有)
  6. }

所以代码如下,稍微详细些↓:

  1. function reverseStr(s) {
  2. if (s === "") { // 终止条件
  3. return "";
  4. }
  5. const lastC = s[s.length - 1]; // 字符串的最后一位
  6. const otherC = s.slice(0, -1); // 除去最后一位的其他字符串
  7. return lastC + reverseStr(otherC); // 进入递归下一层
  8. }

还是使用画图的方式,更方便的一目了然其内部执行逻辑。

c20fd80dd788046aa78e41dfffbe5d19.png

如何调试递归代码

人的大脑是习惯平铺直叙的,所以这也是递归代码难理解的地方。而计算机擅长的确是重复,那么如何调试递归程序就很重要,这里分享几个我经常会使用到小技巧。 1. 最小示例代入debugger

例如求解的字符串的逆序,就代入abc,然后在递归的函数的内部打上断点,一层层去看当前层的变量变化是否符合处理逻辑。

  1. console.log大法

在递归函数的内部直接在每个关键节点输出需要观测的变量变化,看是否符合逻辑。

  1. 借助浏览器调用栈

借助debugger,在进入的递归函数层数足够深了之后,切换系统栈Call Stack里的递归层数,并通过Local查看该层变量的值,查看对应的参数的情况。

d9df260468a87aa96be88f3158663b08.png

使用递归解决链表问题

链表和树都是非常适合学习并理解递归算法的示例,所以之后全部都会使用递归,也是为之后更难理解的回溯打好基础。

再解决链表问题时,如果没有思路,可以用纸和笔把指针指向的过程画下来,然后再尝试从里面找到重复子问题会很有帮助。

206. 反转链表↓

  1. 反转一个单链表
  2. 输入: 1->2->3->4->5->NULL
  3. 输出: 5->4->3->2->1->NULL

上一章使用循环,这次尝试使用递归解决。因为是链表,所以思路是改变指针的指向。子问题就是让最后一个节点指向它之前的节点。首先还是递的过程,我们需要递到最后一个节点。然后开始归,让它的指针指向倒数第二个节点即可,所以要知道倒数第二个节点,然而原先倒数第二个节点正指着倒数第一节点了,此时它们就会形成一个互指的环,最后再让倒数第二个节点指向空即可,断开环。

642dbbddcebe94d075aba7962646d502.png

代码如下:

  1. var reverseList = function (head) {
  2. if (head === null || head.next === null) {
  3. return head
  4. }
  5. const node = reverseList(head.next) // 最后一个节点
  6. head.next.next = head //3指向2、让2指向1
  7. head.next = null //2指向空、让1指向空。
  8. return node
  9. };

3be5d680cddc77de5aa930ab43847fc2.png

83. 链表去重↓

  1. 给定一个排序链表,删除所有重复的元素,使得每个元素只出现一次。
  2. 输入: 1->1->2
  3. 输出: 1->2
  4. 输入: 1->1->2->3->3
  5. 输出: 1->2->3

有了链表反转的技巧后,再解这个题目就很容易了,还是递归到底,因为我们知道倒数一个节点和倒数第二个节点,所以再归的过程里,如果倒数两个节点的值相同,则倒数第二个指向它的下下个节点即可。这个相对简单,再理解了反转后,使用之前的递归调试法去理解相信一点都不难,就不画图了。

  1. var deleteDuplicates = function (head) {
  2. if (head === null || head.next === null) {
  3. return head
  4. }
  5. const ret = deleteDuplicates(head.next)
  6. // 递归到底去,因为递归的终结条件,ret就是最后一个节点
  7. // 而此时head就是倒数第二个节点
  8. if (ret.val === head.val) {
  9. head.next = ret.next // 倒数第二个节点指向它的下下个节点
  10. }
  11. return head
  12. };

21. 合并两个有序链表↓

  1. 将两个升序链表合并为一个新的升序链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。
  2. 输入:1->2->4, 1->3->4
  3. 输出:1->1->2->3->4->4

首先还是拆解子问题,子问题是最小值的节点拼接上剩余已经合并好的两个链表。例如1指向的就是23124拼接好的结果;剩下的最小节点还是1,那么剩下的1指向的就是2324拼接好的结果。继续拆解直达问题不能拆解为止,如果某一个节点已经到头,那么说明另一个链表所有的值都比这个链表大,直接返回即可。代码如下:

  1. var mergeTwoLists = function (l1, l2) {
  2. if (l1 == null) { // l1到了头,说明l2接下来都比l1最大值还大
  3. return l2
  4. }
  5. if (l2 == null) { // 同理
  6. return l1
  7. }
  8. if (l1.val > l2.val) {
  9. l2.next = mergeTwoLists(l1, l2.next) // 则l2换下一个更大节点来比较
  10. return l2 // 当前小节点指向拼接好的结果后,返回
  11. } else {
  12. l1.next = mergeTwoLists(l1.next, l2) // 同理换更大的节点来比较
  13. return l1 // 同理
  14. }
  15. };

5248f4d947259564a110fedc1e87e9b7.png

24. 两两交换链表中的节点↓

  1. 给定一个链表,两两交换其中相邻的节点,并返回交换后的链表。
  2. 你不能只是单纯的改变节点内部的值,而是需要实际的进行节点交换。
  3. 1->2->3->4, 返回 2->1->4->3.

如果尝试用纸和笔画出过程,就很容易发现子问题,让第一个节点指向第二个节点之后已经交换好的链表,然后让第二个节点指向之前的节点。

f3f2aa04a60b243f5b4e8b2c4c2c665c.png
  1. var swapPairs = function (head) {
  2. if (head === null || head.next === null) {
  3. //判断head.next是为了防止链表节点个数是奇数
  4. return head
  5. }
  6. const firstNode = head // 链表的第一个节点
  7. const secondNode = head.next // 链表的第二个节点
  8. firstNode.next = swapPairs(secondNode.next)
  9. // 第一个节点指向第二个节点之后已经交换好的链表
  10. secondNode.next = firstNode // 第二个节点指向第一个节点
  11. return secondNode // 返回第二个节点作为新的头节点
  12. };

cc1e461d9b18b82803e3724f4481ac09.png

快慢指针解决链表问题

有些复杂的链表问题内的子问题可能需要用上,也是解决部分链表问题的一种小技巧。

876. 链表的中间结点↓

  1. 给定一个带有头结点 head 的非空单链表,返回链表的中间结点。
  2. 如果有两个中间结点,则返回第二个中间结点。
  3. 1->2->3->4->5
  4. 返回3->4->5

设置两个指针,慢指针一次走一步,快指针一次走两步,当快指针走完时,正好慢指针在链表的中间。

  1. var middleNode = function (head) {
  2. let slow = head // 慢指针
  3. let fast = head // 快指针
  4. while (fast !== null && fast.next !== null) {
  5. fast = fast.next.next // 走两步
  6. slow = slow.next // 走一步
  7. }
  8. return slow // 走完后慢指针指向中间节点
  9. };

141. 环形链表↓

  1. 给定一个链表,判断链表中是否有环。
  2. 为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。
  3. 如果 pos 是 -1,则在该链表中没有环。
  4. 输入:3->2->0->-4,pos=1
  5. 输出:true
  6. 尾部链接到下标1的位置,为有环。
  7. 输入:3->2->0->-4,pos=-1
  8. 输入:false
  9. 尾部没有链接,没环。

快指针一次走两步,慢指针一次走一步,如果这个链表是循环的,快慢指针总会相遇;如果是直线行驶,没有环的话,快指针就会走到空。

  1. var hasCycle = function (head) {
  2. let slow = head
  3. let fast = head
  4. while(fast !== null && fast.next !== null) {
  5. slow = slow.next
  6. fast = fast.next.next
  7. if (slow === fast) { // 相遇了
  8. return true
  9. }
  10. }
  11. return false
  12. };

19. 删除链表的倒数第N个节点↓

  1. 给定一个链表,删除链表的倒数第 n 个节点,并且返回链表的头结点。
  2. 给定一个链表: 1->2->3->4->5, 和 n = 2.
  3. 当删除了倒数第二个节点后,链表变为 1->2->3->5.
  4. 说明:给定的 n 保证有效。
  5. 进阶尝试:你能尝试使用一趟扫描实现吗?

首先删除链表第n个节点,则需要找到它之前的节点,让它之前的节点跨过要删除的节点即可。

然后问题是怎么一趟扫描找到倒数第n个节点之前的节点?我们还是可以使用快慢指针的方式,需要删除倒数第几个,就让快指针多走几步,快指针把先走的几步走完后,快慢指针一起走,快指针到了头,慢指针停留的位置正好就是待删除节点之前的节点。

最后删除该节点,返回链表头节点即可。代码如下:

  1. var removeNthFromEnd = function (head, n) {
  2. const dummy = new ListNode() // 设置一个虚拟节点,统一边界处理
  3. dummy.next = head
  4. let slow = dummy // 因为要找的是待删除之前节点的缘故
  5. let fast = dummy.next // 让快指针事先就领先一步
  6. while (fast !== null) {
  7. if (n > 0) { // 先把领先的走完
  8. n--
  9. fast = fast.next
  10. } else { // 然后一起走
  11. fast = fast.next
  12. slow = slow.next // 走完后慢指针正好在待删除节点之前
  13. }
  14. }
  15. const delNode = slow.next
  16. slow.next = delNode.next // 移除待删除节点
  17. delNode.next = null
  18. return dummy.next // 返回头节点
  19. };

最后

写递归也没什么其他的技巧,无非就是多练、多画、多想、多调试。下一章将开始介绍树结构,正好有一个与链表和树都相关的问题,大家可以尝试解决。

huxiaocheng/be-a-programmer​github.com
9f308e9f1e44619ef9fa5d3889a3f790.png
  1. 力扣 109. 有序链表转换二叉搜索树
  2. 给定一个单链表,其中的元素按升序排序,将其转换为高度平衡的二叉搜索树。
声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/cp456/article/detail/62823
推荐阅读
相关标签
  

闽ICP备14008679号