链表来进阶--小册专项练习笔记

272 阅读12分钟

这是我参与更文挑战的第3天,活动详情查看: 更文挑战

链表

链表的基础操作

通过在算法与数据结构的学习,我们知道了什么是链表,以及链表的一些基础特性val, next等,那么今天我们就来好好的学习一下,链表有哪些基础的操作!

链表的合并

首先,我们怎么去理解链表的合并,链表她是有序的,也就是知道自己下一个节点指向哪里,如果单纯的说合并两个链表,其实很容易,只要把第一个链表的最后一个结点的next指向第二个链表的头结点即可,那么如何合并两个有序链表呢?我们来看下面这道题目👇:

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

首先我们明确,两个链表以及输出后的新的链表都是有序的,那么我们的思路是不是就是如何把她们串起来,此时我们可以想象新链表其实现在只有一根针,然后从现有的链表结点去有序的串起来,就能得到我们想要的链表了,无图不丈夫,来,我们看一下图就能很好的理解了:

合并.png

通过上图我们可以很好的理解,其实就是比大小了,我们用代码来实现一下这个合并链表:

//这里用对象的形式来模拟有序链表,这样可以更直观
const l1 = {
    val: 1,
    next: {
      val: 2,
      next: {
        val: 3,
        next: {
          val: 4,
          next: null
        }
      }
    }
}
const l2 = {
    val: 2,
    next: {
      val: 3,
      next: {
        val: 4,
        next: {
          val: 5,
          next: {
            val: 6,
            next: null
          }
        }
      }
    }
}
// 对链表创建一个类,用来返回我们最后的链表
class ListNode {
    constructor(val) {
      this.val = val
      this.next = null
    }
}
const mergeTwoLists = function (l1, l2) {
// 定义头结点,确保链表可以被访问到
let head = new ListNode();
// cur 这里就是咱们那根“针”
let cur = head;
console.log(head === cur); // 因为是类的实例化所以是引用类型,因此全等
console.log(111,  head, cur, l1, l2); // 111 ListNode {val: undefined, next: null}next: {val: 1, next: {…}}val: undefined__proto__: Object ListNode {val: undefined, next: null}next: {val: 1, next: {…}}val: undefined__proto__: Object {val: 1, next: {…}}next: {val: 2, next: {…}}val: 1__proto__: Object {val: 2, next: {…}}
// “针”开始在 l1 和 l2 间穿梭了
while (l1 && l2) {
  // 如果 l1 的结点值较小
  if (l1.val <= l2.val) {
    // 先串起 l1 的结点
    cur.next = l1;
    // l1 指针向前一步
    l1 = l1.next;
  } else {
    // l2 较小时,串起 l2 结点
    cur.next = l2;
    // l2 向前一步
    l2 = l2.next;
  }

  // “针”在串起一个结点后,也会往前一步
  cur = cur.next;
  console.log(222,cur);
}

// 处理链表不等长的情况
cur.next = l1 !== null ? l1 : l2;
console.log(333,cur);
// 返回起始结点
console.log(444, head, head.next); // 实际上head头的val是undefined,因此比head.next多了一层头
return head.next;
};
mergeTwoLists(l1, l2)

大家可以对代码自行去验证一下,修言大佬的小册已经对这个代码进行过大量验证了,如果有问题,欢迎一起交流!

链表结点的删除

链表结点的删除其实在基础知识那段已经说过了,只要把next指向下一个结点的next就可以了,那我们今天要说的是升级版的删除,是什么呢,我们来看下一道真题: 给定一个排序链表,删除所有含有重复数字的结点,只保留原始链表中 没有重复出现的数字。 示例 1: 输入: 1->2->3->3->4->4->5 输出: 1->2->5 示例 2: 输入: 1->1->1->2->3 输出: 2->3

从题目中我们很明显的发现一个问题,就是这里的删除跟我们之前删除重复结点不一样,就是只要这个值重复出现了,我们就得把所有这个值的结点都删除,注意,这里是排序链表哦,只要掌握到这点就比较有思路了,另外这里再拓展一个点————dummy结点(新知识😭),标配写法呢就是

const dummy = new ListNode()
dummy.next = head// 这里的 head 是链表原有的第一个结点

,类似于什么呢,就是一个虚拟的结点,用来给我们穿针引线用的,实际上不存在,毕竟假如第一个结点重复被删除了,你拿什么直接选择第三个结点呢,起到一个辅助作用,我们来看一下代码实现过程,用例是上一个合并链表的结果:

function deleteMultiple(l) {
    // 假如只有0个结点,或者一个结点就不会重复
    if(!l || !l.next) {
      return
    }
    // new一个dummy结点,始终指向我们的链表
    const dummy = new ListNode()
    dummy.next = l
    // 定一个一个指针
    let cur = dummy

    // 对链表循环,只有当有两个后续结点时才行
    while(cur.next && cur.next.next) {
      // 对两个结点的值判断
      if(cur.next.val === cur.next.next.val) {
        // 记录一下这个相等的值
        const equalVal = cur.next.val
        // 然后对后续的每个结点判断的时候,只要等于这个值,就删除
        while(cur.next && cur.next.val === equalVal) {
          cur.next = cur.next.next
        }
      }else{
        // 如果不相等,就串进去
        cur = cur.next
      }
    }

    // 最后将循环的结果返回
    console.log(dummy.next);
    return dummy.next
}
deleteMultiple( l)
// {
//   val: 1
//   next: {
//     val: 5
//     next: {
//       val: 6
//       next: null
//     }
//   }
// }

注释都已经写在代码里了,应该还是很明确了,还是修言大佬那句:“只看不写,回家种田!”。

链表的进阶

学完了基础操作,我们现在来学点更有用更常见的一些指针操作吧

快慢指针

快慢指针故名思义,是指有快和慢两个指针,当初在解双循环的题目时,我们只能通过一次又一次的遍历来求的我们想要的东西,但是有了快慢指针,在遍历上面我们就少了很多功夫,两个指针一个走得快一个走得慢,在反复遍历的题目中非常好用,有时候我们甚至会用到三个指针,因此,快慢指针+多指针的学习,对我们解决遍历的题目很有好处,下面我们来看一道真题吧!

真题描述:给定一个链表,删除链表的倒数第 n 个结点,并且返回链表的头结点。 示例: 给定一个链表: 1->2->3->4->5, 和 n = 2. 当删除了倒数第二个结点后,链表变为 1->2->3->5. 说明: 给定的 n 保证是有效的。

其实这个题目放在正常的操作中,我们有点没头绪,因为链表就是要从头开始找到目标元素的,正常的思路是先遍历一遍,求的总共的长度,在遍历一遍求的目标位置,然后删除,是不是很麻烦,我们来看一下快慢指针的做法:定义快慢两个指针,快指针先走n步,然后快慢指针同时走,等快指针走到头的时候,此时慢指针恰好就在目标结点的前一个结点,是不是就能轻易删除了?我们来看一下代码实现👇:

// 用对象的形式代替链表
let l = {
    val: 1 ,
    next: {
      val: 2 ,
      next: {
        val: 3 ,
        next: {
          val: 4 ,
          next: {
            val: 5 ,
            next: null
          }
        }
      }
    }
}
function deleteTarget(l, n) {
// 定一个dummy结点,还是用到了上面的类和dummy结点
const dummy = new ListNode()
// 将dummy的next指向l
dummy.next = l
// 定义两个快慢指针,都指向dummy
let fast = dummy, slow = dummy
// 先让快指针走n步
while(n) {
  fast = fast.next
  n--
}
// 这样当fast走到最后一个结点的时候,慢指针指向的就是要删除结点的前一个结点
while(fast.next) {
  slow = slow.next
  fast = fast.next
}
// 然后让慢结点删除下一个结点
slow.next = slow.next.next
// 最后返回删除后的l
console.log(dummy.next);
return dummy.next
}
deleteTarget(l,2)
// {
//   val: 1 ,
//   next: {
//     val: 2 ,
//     next: {
//       val: 3 ,
//       next: {
//         val: 5 ,
//         next: null
//       }
//     }
//   }
// }

多指针的应用

说完了快慢指针,我们来说一下他的兄弟多指针,上面我们也提到了,他们是为了解决遍历的问题,用空间换时间,那么多指针又有什么用处呢,我们来看下面一道真题: 真题描述:定义一个函数,输入一个链表的头结点,反转该链表并输出反转后链表的头结点。 示例: 输入: 1->2->3->4->5->NULL 输出: 5->4->3->2->1->NULL

这里题目中的要求是让我们反转,反转怎么反转啊,又没有数组的reverse方法,怎么做呢?我们前面学习的链表,链表的前后其实就是通过next指针的指向来做的,这是不是就是说,我们只要把指针掉个个儿就行了呢?没错,确实是这样的思路,那么具体怎么做呢,大家可以看一下我画的图,就能厘清思路了👇:

reverse (1).png

正如图片中我所说的,我们用多指针来做这题真真是极好的(我走错片场了@!@),也就是说我们通过三个指针前指针pre,当前指针cur,后置指针next来代表当前结点的前后关系,我们只需要改变当前指针的next指向,就能完成整个链表的反转了,来看一下代码的实现吧:

// 还是用上面的例子
function reverseListNode(l) {
    // 我们来定义三个指针,从头结点开始
    let pre = null, // 第一个结点的前置结点为空
        cur = l // cur指向头结点
    // 遍历链表,直到没有下一个结点
    while(cur !== null) {
      // 首先记录下一个结点
      let next = cur.next
      // 改变指针指向
      cur.next = pre
      // pre前进一步
      pre = cur
      // cur前进一步
      cur = next
    }
    // 完成遍历之后,我们返回的pre结点就是头结点了
    console.log(pre);
    return pre
}
reverseListNode(l)
// {
//   val: 5 ,
//   next: {
//     val: 4 ,
//     next: {
//       val: 3 ,
//       next: {
//         val: 2 ,
//         next: {
//            val: 1 ,
//            next: null
//       }
//     }
//   }
// }

这道题目确实是不难,而且思路一说会感觉,哦原来是这样~,但是呢,我觉得还是要自己手写一遍,看看能不能实现,因为里面的逻辑顺序你不能弄错,如果觉得理解不了,你就自己画个图,有了图就很好判别了。

完成了完整链表的反转,我们再来看看他的升级版,如何做到局部反转,直接上真题👇:真题描述:反转从位置 m 到 n 的链表。请使用一趟扫描完成反转。 说明: 1 ≤ m ≤ n ≤ 链表长度。 示例: 输入: 1->2->3->4->5->NULL, m = 2, n = 4 输出: 1->4->3->2->5->NULL

我们的思路还是不变的,就是我们还是要按照前面一样来考虑,改变结点的指向,只不过我们看这道题目,其实主要是找到m的前置结点和n的后置结点,这样我们再反转m到n之间的结点就可以了,来看一下代码实现👇:

function reversePart(l, m, n) {
    // 首先我们还是需要定义前置和当前结点,并定义一个辅助结点来记录起始结点
    let pre, cur, handle
    // 初始化一个dummy
    let dummy = new ListNode()
    dummy.next = l
    // 定义一个用来遍历的p
    let p = dummy
    // 循环找到前置结点
    for(let i = 0; i < m-1; i++) {
      p = p.next
    }
    // 循环结束的这个p就是起始结点的前置结点,我们记录一下
    handle = p
    // 记录一下起始结点
    let start = p.next
    // 接下来就要遍历反转了
    pre = start
    cur = pre.next
    // 然后我们再遍历m到n之间,将他们反转
    for(let i = m; i < n; i++) {
      let next = cur.next
      cur.next = pre
      pre = cur
      cur = next
    }
    // 遍历结束之后,将m的前置结点指向遍历后的pre
    handle.next = pre
    // 此时cur正好指向n的后置结点
    start.next = cur
    // 全部结束,返回
    console.log(dummy.next);
    return dummy.next
}
reversePart(l, 2, 4)
// {
//   val: 1 ,
//   next: {
//     val: 4 ,
//     next: {
//       val: 3 ,
//       next: {
//         val: 2 ,
//         next: {
//            val: 5 ,
//            next: null
//       }
//     }
//   }
// }

姿势特别的环形链表

对于环形链表,我们首先应该解决下面的这个问题:

如何判断链表是否成环

我们从一个真题入手: 真题描述:给定一个链表,判断链表中是否有环。 示例 1: 输入:[3,2,0,4] 输出:true 解释:链表中存在一个环这里题目中是有一张图的,描述一下就是4这个结点的next指向了2从而形成了环形。

从解题思路上来说,就是好比我们目前在其中一个结点上,假如遍历整个链表,我们能重新回到刚才我们所在的结点,那么必定成环了,对于这个所在的结点,我们可以用一个flag来表示,只要接下来我们还能在别的结点上找到这个flag,那么肯定是成环了,我们来看一下代码实现👇:

function hasCycle(l) {
     // 只要结点存在,那么就继续遍历
    while(l){
        // 如果 flag 已经立过了,那么说明环存在
        if(l.flag){
            return true;
        }else{
            // 如果 flag 没立过,就立一个 flag 再往
            下走
            l.flag = true;
            l = l.next;
        }
    }
    return false;
}

同样还有一题是作为上面这题的衍生,也就是定位出一个链表入环的第一个结点,如果有返回该结点,如果没有返回null,这道题目起始对于我们已经了解了如何判断是否环状的思维来说,是简单的,因为我们所找到的第一个带有flag属性的结点,就是入环结点,具体的大家自己去思考一下吧!

成环的另一种思路

起始我们用之前学过的知识也能够判断是否成环,没错,那就是快慢指针,怎么判断呢,慢指针每次走一步,快指针每次走两步,假如成环那么快慢指针终究有一天会相遇,类似于我们在操场跑步,跑的快的又时候会碰到还在跑上一圈的跑的慢的同学,我们俗称“套圈”,因为我们也是在环状的跑到跑步,所以同样的思想也能用在环形链表中,我们用代码来实现一下看看:

function detectCycle(l) {
    // 定义一个dummy结点
    let dummy = new ListNode()
    dummy.next = l
    // 定义一个p用来遍历
    let p = dummy.next
    // 定义一个计数器,和一个map空分对象
    let count = 0, map = {}
    // 遍历
    while( p && p.next ) {
      // 将每一个结点存到map中
      if(!map[p]) {
         map[p] = count
      }else {
      // 如果map中出现了已经存过的count,那么证明已经回来了
        return true
      }
      p = p.next
      count++
    }
    // 如果能跳出循环说明不是环状的
    return false
}

小册的评论区有大家各种思路,我也只是把我的思路贴一下,具体是否正确还是欠缺考虑都还需要完整验证

结语

到这里,我们链表进阶的学习就结束了,这一次学了很多,链表其实主要是思想,因此我们关键是掌握他的解题思路,因为写了这么多,我们发现其实每次代码就是在围绕next做文章,因此希望大家好好消化,一定要亲手动手写写才知道和理解!