算法课堂——铁索连环二(链表)

126 阅读6分钟

算法课堂——铁索连环(链表)

这是我参与2022首次更文挑战的第5天,活动详情查看:2022首次更文挑战」。

上节课我们介绍了链表的概念、链表与数组的异同,链表的基本api的手写。相信只要认真看完,同学们至少能对链表的基本使用有了一个大概的理解,这节课,我们将用三个比较典型的题目带大家详细的理解算法中链表的使用。


一、环形链表

给你一个链表的头节点 head ,判断链表中是否有环。

如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。如果 pos 是 -1,则在该链表中没有环。注意:pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。

如果链表中存在环,则返回 true 。 否则,返回 false 。

image.png

输入:head = [3,2,0,-4], pos = 1 输出:true 解释:链表中有一个环,其尾部连接到第二个节点。

image.png

输入:head = [1,2], pos = 0 输出:true 解释:链表中有一个环,其尾部连接到第一个节点。

image.png

输入: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(n2n^2)呢?

因为我们这边的两次循环不是嵌套循环,所以我们最多执行2n次操作即可完成两次遍历,所以时间复杂度是O(n)。 空间复杂度很简单,我们并没有借用其他的存储结构来解答此题,所以本题的空间复杂度是O(1)。

此时我们思考下,我们通过上节课删除链表中节点的方法知道,想要删除链表中的节点,我们不可避免的需要通过遍历找到需要删除的节点的位置,所以优化本题的主要思路是如何找到倒数第n个节点。

同学们可以在课后思考一下,将你的想法写在评论区。(小提示:能不能用到第一题的双指针呢?)

三、总结

通过这两个例子,大家对链表的操作应该有了更深的理解,并且学习了我们算法中比较常用的一个思想:快慢指针。明天我们会给大家介绍解决算法问题中的又一利器:队列,具体它又是怎样的一个数据结构呢?请关注下节:

算法课堂--历史重现(队列)