算法课堂——铁索连环(链表)
这是我参与2022首次更文挑战的第5天,活动详情查看:2022首次更文挑战」。
上节课我们介绍了链表的概念、链表与数组的异同,链表的基本api的手写。相信只要认真看完,同学们至少能对链表的基本使用有了一个大概的理解,这节课,我们将用三个比较典型的题目带大家详细的理解算法中链表的使用。
一、环形链表
给你一个链表的头节点 head ,判断链表中是否有环。
如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。如果 pos 是 -1,则在该链表中没有环。注意:pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。
如果链表中存在环,则返回 true 。 否则,返回 false 。
输入:head = [3,2,0,-4], pos = 1 输出:true 解释:链表中有一个环,其尾部连接到第二个节点。
输入:head = [1,2], pos = 0 输出:true 解释:链表中有一个环,其尾部连接到第一个节点。
输入:head = [1], pos = -1 输出:false 解释:链表中没有环。
阅读完题目,对于题目的理解应该不难。判断一个链表中有没有一段是循环的,也就是闭合的,只要走到这里,就永远走不出去了。
好的,这个时候我们举一个生活中的例子,如何判断一段路是闭合的。我们回想一下电视剧里迷路的场景。是不是只要知道我回到了我走过的地方,就证明我的走的路是闭合,如果我一直回到我刚刚路过的地方,是不是等于说我一直在“兜圈子”。
好的,根据这个思路我们很容易想到我们的第一个方法,就是说走过哪个节点我们就记录一下(又要用到糖果罐了哦),如果一直走还能走到这个节点,那么就能判断出链表中有没有环形的部分。代码如下:
public class Solution {
public boolean hasCycle(ListNode head) {
//定义一个不能存重复数据的集合
Set<ListNode> seen = new HashSet<ListNode>();
while (head != null) {
if (!seen.add(head)) { //如果set中已经有这个元素了
return true;
}
head = head.next;
}
return false;
}
}
好了,那么我们再深入思考下,本题我们只需要判断,链表中是否有一段是闭合的,并不需要知道具体哪一部分是闭合的,我们是不是有更简单的思路呢?
我们再来模拟生活中的一个场景,你在马路上跑步和在学校操场上跑步,你跑的很慢,没关系,你慢慢跑,没人管你。但是没有对比就没有伤害,突然来了一个猛男,跑步贼快。假如你们在马路上,那还好,他超过你你再也见不到他了。但是如果在操场上,那你就糗大了,为什么?因为你不一会就会见到他,并且他还回头嘲讽你一句,跑的真慢。这你能受得了吗?这个时候你可以思考下,为什么马路上我们就再也见不到猛男了,在操场上,一会儿就能再见到他呢?
我想你应该明白了,操场是圆的,是闭合的。
那么运用到此题呢?很简单嘛,定义一个“猛男”,每次能走两步,定义一个“肥仔”每次走一步,当“猛男”能遇到“肥仔”,是不是证明链表是有一段是闭合的呢?
这就是我们算法中比较常用的思想之一:快慢指针,代码如下:
public class Solution {
public boolean hasCycle(ListNode head) {
if (head == null || head.next == null) {
return false;
}
ListNode slow = head;
ListNode fast = head.next;
while (slow != fast) {
if (fast == null || fast.next == null) {
return false;
}
slow = slow.next;
fast = fast.next.next;
}
return true;
}
}
很明显,双指针并没有用到和原链表长度相关的外部存储空间,所以此方法的空间复杂度为O(1),确实比方法一优秀一点。
二、删除链表的倒数第 N 个结点
给你一个链表,删除链表的倒数第 n 个结点,并且返回链表的头结点。
示例 1:
输入:head = [1,2,3,4,5], n = 2 输出:[1,2,3,5]
示例 2:
输入:head = [1], n = 1 输出:[]
示例 3:
输入:head = [1,2], n = 1 输出:[1]
简单阅读了一下题目,这是一个关于链表删除的问题。在上一篇算法课堂中我们手写了算法的删除算法,和本题唯一的不同就是,我们的手写的算法,告诉了我们具体要删除的是哪个节点;此题则是告诉我们删除的是倒数第n个节点,所以解答此题的第一个方法就是通过题目中倒数第n个节点推导出我们想要的条件,即删除的是第几个节点。
假设链表的长度是5,倒数第二个即是第四个节点,倒数第三个节点即是第三个节点。
立即推:m = 链表.length - n + 1;
这个时候我们通过我们的删除算法,就可以解答此题了。(删除节点的算法即是断链操作,即将链表的需要删除节点的指针断开,将删除节点的上一个节点的指针指向删除节点先一个节点)
public ListNode removeNthFromEnd(ListNode head, int n) {
ListNode dummy = new ListNode(0, head);
int length = getLength(head);
ListNode cur = dummy;
for (int i = 1; i < length - n + 1; ++i) {
cur = cur.next;
}
//断链操作
cur.next = cur.next.next;
ListNode ans = dummy.next;
return ans;
}
//获得链表的长度
public int getLength(ListNode head) {
int length = 0;
while (head != null) {
++length;
head = head.next;
}
return length;
}
那么同学们思考一下,这个方法的时间复杂度是多少呢?很简单,我我们通过遍历得到链表的长度,又通过一次遍历删除了倒数第n个节点,所以我们时间复杂度是O(n)。
有些同学可能有些疑问,你这个方法也是两次循环,为啥就是O(n)呢?不是O()呢?
因为我们这边的两次循环不是嵌套循环,所以我们最多执行2n次操作即可完成两次遍历,所以时间复杂度是O(n)。 空间复杂度很简单,我们并没有借用其他的存储结构来解答此题,所以本题的空间复杂度是O(1)。
此时我们思考下,我们通过上节课删除链表中节点的方法知道,想要删除链表中的节点,我们不可避免的需要通过遍历找到需要删除的节点的位置,所以优化本题的主要思路是如何找到倒数第n个节点。
同学们可以在课后思考一下,将你的想法写在评论区。(小提示:能不能用到第一题的双指针呢?)
三、总结
通过这两个例子,大家对链表的操作应该有了更深的理解,并且学习了我们算法中比较常用的一个思想:快慢指针。明天我们会给大家介绍解决算法问题中的又一利器:队列,具体它又是怎样的一个数据结构呢?请关注下节: