关注Java领域相关技术 记录有趣的事情

LeetCode-141. 环形链表

US-B.Ralph
US-B.Ralph
2020-10-09

问题地址

LeetCode每日一题/2020-10-09

LeetCode141. 环形链表


问题描述

规则

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

示例

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

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

  • 示例3
输入:head = [1], pos = -1
输出:false
解释:链表中没有环。

进阶

你能用 *O(1)(即,常量)内存解决此问题吗?

提示

  • 链表中节点的数目范围是 [0, 104]
  • -105 <= Node.val <= 105
  • pos 为 -1 或者链表中的一个 有效索引 。

解析

解题思路

  • 哈希表
    • 遍历链表,使用哈希表存储链表节点,当遇到此前遍历过的节点,说明链表中存在环;
  • 快慢指针

    • 分别定义 fast 和 slow指针,从头结点出发,fast指针每次移动两个节点,slow指针每次移动一个节点,如果 fast 和 slow指针在途中相遇 ,说明这个链表有环。

数据操作分析

  • 见思路分析

复杂度分析

  1. 时间复杂度
  2. 空间复杂度

编码实现

  • 哈希表
public class LeetCode0141_LinkedListCycle {
    public boolean hasCycle(ListNode head) {

        HashSet<ListNode> set = new HashSet();
        while (head != null) {
            if (!set.add(head)) return true;
            head = head.next;
        }
        return false;
    }
}
  • 快慢指针
public class LeetCode0141_LinkedListCycle_1 {
    public boolean hasCycle(ListNode head) {
        if (head == null || head.next == null) return false;
        ListNode fast = head.next;
        ListNode slow = head;
        while (fast != slow) {
            if (fast == null || fast.next == null) return false;
            fast = fast.next.next;
            slow = slow.next;
        }
        return true;
    }
}

官方解法

方法一:哈希表

思路与算法:

  • 最容易想到的方法是遍历所有节点,每次遍历到一个节点时,判断该节点此前是否被访问过。
  • 具体地,我们可以使用哈希表来存储所有已经访问过的节点。每次我们到达一个节点,如果该节点已经存在于哈希表中,则说明该链表是环形链表,否则就将该节点加入哈希表中。重复这一过程,直到我们遍历完整个链表即可。

复杂度分析:

  1. 时间复杂度:O(N)。其中 N 是链表中的节点数。最坏情况下我们需要遍历每个节点一次。
  2. 空间复杂度:O(N)。其中 N 是链表中的节点数。主要为哈希表的开销,最坏情况下我们需要将每个节点插入到哈希表中一次。

编码实现

public class Solution {
    public boolean hasCycle(ListNode head) {
        Set<ListNode> seen = new HashSet<ListNode>();
        while (head != null) {
            if (!seen.add(head)) {
                return true;
            }
            head = head.next;
        }
        return false;
    }
}

方法二:快慢指针

思路及算法:

  • 本方法需要读者对「Floyd 判圈算法」(又称龟兔赛跑算法)有所了解。
  • 假想「乌龟」和「兔子」在链表上移动,「兔子」跑得快,「乌龟」跑得慢。当「乌龟」和「兔子」从链表上的同一个节点开始移动时,如果该链表中没有环,那么「兔子」将一直处于「乌龟」的前方;如果该链表中有环,那么「兔子」会先于「乌龟」进入环,并且一直在环内移动。等到「乌龟」进入环时,由于「兔子」的速度快,它一定会在某个时刻与乌龟相遇,即套了「乌龟」若干圈。
  • 我们可以根据上述思路来解决本题。具体地,我们定义两个指针,一快一满。慢指针每次只移动一步,而快指针每次移动两步。初始时,慢指针在位置 head,而快指针在位置 head.next。这样一来,如果在移动的过程中,快指针反过来追上慢指针,就说明该链表为环形链表。否则快指针将到达链表尾部,该链表不为环形链表。

细节

  • 为什么我们要规定初始时慢指针在位置 head,快指针在位置 head.next,而不是两个指针都在位置 head(即与「乌龟」和「兔子」中的叙述相同)?
  • 观察下面的代码,我们使用的是 while 循环,循环条件先于循环体。由于循环条件一定是判断快慢指针是否重合,如果我们将两个指针初始都置于 head,那么 while 循环就不会执行。因此,我们可以假想一个在 head 之前的虚拟节点,慢指针从虚拟节点移动一步到达 head,快指针从虚拟节点移动两步到达 head.next,这样我们就可以使用 while 循环了。
  • 当然,我们也可以使用 do-while 循环。此时,我们就可以把快慢指针的初始值都置为 head。

复杂度分析:

  1. 时间复杂度:O(N),其中 N 是链表中的节点数。
    • 当链表中不存在环时,快指针将先于慢指针到达链表尾部,链表中每个节点至多被访问两次。
    • 当链表中存在环时,每一轮移动后,快慢指针的距离将减小一。而初始距离为环的长度,因此至多移动 N 轮。
  2. 空间复杂度:O(1)。我们只使用了两个指针的额外空间。

编码实现

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;
    }
}

精彩评论

跳转地址1:一文搞定常见的链表问题 (欢迎交流

链表和数组

作为线性表的两种存储方式 —— 链表和数组,这对相爱相杀的好基友有着各自的优缺点。接下来,我们梳理一下这两种方式。
– 数组,所有元素都连续的存储于一段内存中,且每个元素占用的内存大小相同。这使得数组具备了通过下标快速访问数据的能力。
– 但连续存储的缺点也很明显,增加容量,增删元素的成本很高,时间复杂度均为 O(n)。
– 增加数组容量需要先申请一块新的内存,然后复制原有的元素。如果需要的话,可能还要删除原先的内存。
– 删除元素时需要移动被删除元素之后的所有元素以保证所有元素是连续的。增加元素时需要移动指定位置及之后的所有元素,然后将新增元素插入到指定位置,如果容量不足的话还需要先进行扩容操作。
– 总结一下数组的优缺点:
– 优点:可以根据偏移实现快速的随机读写。
– 缺点:扩容,增删元素极慢。


  • 链表,由若干个结点组成,每个结点包含数据域和指针域。结点结构如下图所示:

    • 一般来讲,链表中只会有一个结点的指针域为空,该结点为尾结点,其他结点的指针域都会存储一个结点的内存地址。链表中也只会有一个结点的内存地址没有存储在其他结点的指针域,该结点称为头结点。

    • 链表的存储方式使得它可以高效的在指定位置插入与删除,时间复杂度均为 O(1)。

    • 在结点 p 之后增加一个结点 q 总共分三步:

      • 申请一段内存用以存储 q (可以使用内存池避免频繁申请和销毁内存)。
      • 将 p 的指针域数据复制到 q 的指针域。
      • 更新 p 的指针域为 q 的地址。
    • 删除结点 p 之后的结点 q 总共分两步:
      • 将 q 的指针域复制到 p 的指针域。
      • 释放 q 结点的内存。

链表的主要代码

#include <bits/stdc++.h>

using namespace std;

//定义一个结点模板
template<typename T>
struct Node {
    T data;
    Node *next;
    Node() : next(nullptr) {}
    Node(const T &d) : data(d), next(nullptr) {}
};

//删除 p 结点后面的元素
template<typename T>
void Remove(Node<T> *p) {
    if (p == nullptr || p->next == nullptr) {
        return;
    }
    auto tmp = p->next->next;
    delete p->next;
    p->next = tmp;
}

//在 p 结点后面插入元素
template<typename T>
void Insert(Node<T> *p, const T &data) {
    auto tmp = new Node<T>(data);
    tmp->next = p->next;
    p->next = tmp;
}

//遍历链表
template<typename T, typename V>
void Walk(Node<T> *p, const V &vistor) {
    while(p != nullptr) {
        vistor(p);
        p = p->next;
    }
}

int main() {
    auto p = new Node<int>(1);
    Insert(p, 2);
    int sum = 0;
    Walk(p, [&sum](const Node<int> *p) -> void { sum += p->data; });
    cout << sum << endl;
    Remove(p);
    sum = 0;
    Walk(p, [&sum](const Node<int> *p) -> void { sum += p->data; });
    cout << sum << endl;
    return 0;
}

面试问题总结

  • 无法高效获取长度,无法根据偏移快速访问元素,是链表的两个劣势。然而面试的时候经常碰见诸如获取倒数第k个元素,获取中间位置的元素,判断链表是否存在环,判断环的长度等和长度与位置有关的问题。这些问题都可以通过灵活运用双指针来解决。
    • Tips:双指针并不是固定的公式,而是一种思维方式~
  • 先来看”倒数第k个元素的问题”。设有两个指针 p 和 q,初始时均指向头结点。首先,先让 p 沿着 next 移动 k 次。此时,p 指向第 k+1个结点,q 指向头节点,两个指针的距离为 k 。然后,同时移动 p 和 q,直到 p 指向空,此时 q 即指向倒数第 k 个结点。可以参考下图来理解:
class Solution {
public:
    ListNode* getKthFromEnd(ListNode* head, int k) {
        ListNode *p = head, *q = head; //初始化
        while(k--) {   //将 p指针移动 k 次
            p = p->next;
        }
        while(p != nullptr) {//同时移动,直到 p == nullptr
            p = p->next;
            q = q->next;
        }
        return q;
    }
};
  • 获取中间元素的问题。设有两个指针 fast 和 slow,初始时指向头节点。每次移动时,fast向后走两次,slow向后走一次,直到 fast 无法向后走两次。这使得在每轮移动之后。fast 和 slow 的距离就会增加一。设链表有 n 个元素,那么最多移动 n/2 轮。当 n 为奇数时,slow 恰好指向中间结点,当 n 为 偶数时,slow 恰好指向中间两个结点的靠前一个(可以考虑下如何使其指向后一个结点呢?)。

    • 下述代码实现了 n 为偶数时慢指针指向靠后结点。
class Solution {
public:
    ListNode* middleNode(ListNode* head) {
        ListNode *p = head, *q = head;
        while(q != nullptr && q->next != nullptr) {
            p = p->next;
            q = q->next->next;
        }
        return p;
    } 
};
  • 是否存在环的问题。如果将尾结点的 next 指针指向其他任意一个结点,那么链表就存在了一个环。
  • 上一部分中,总结快慢指针的特性 —— 每轮移动之后两者的距离会加一。下面会继续用该特性解决环的问题。
  • 当一个链表有环时,快慢指针都会陷入环中进行无限次移动,然后变成了追及问题。想象一下在操场跑步的场景,只要一直跑下去,快的总会追上慢的。当两个指针都进入环后,每轮移动使得慢指针到快指针的距离增加一,同时快指针到慢指针的距离也减少一,只要一直移动下去,快指针总会追上慢指针。

  • 根据上述表述得出,如果一个链表存在环,那么快慢指针必然会相遇。实现代码如下:

编码实现
class Solution {
public:
    bool hasCycle(ListNode *head) {
        ListNode *slow = head;
        ListNode *fast = head;
        while(fast != nullptr) {
            fast = fast->next;
            if(fast != nullptr) {
                fast = fast->next;
            }
            if(fast == slow) {
                return true;
            }
            slow = slow->next;
        }
        return nullptr;
    }
};
  • 最后一个问题,如果存在环,如何判断环的长度呢?方法是,快慢指针相遇后继续移动,直到第二次相遇。两次相遇间的移动次数即为环的长度。

跳转地址2:五种解题思路记录:hash表、快慢指针、链表计数、链表反转、标记val值

hash表

思路
  • 使用hash表记录已经访问过的节点
编码实现
class Solution:
    def hasCycle(self, head: ListNode) -> bool:
        # 1. python map
        m = {}
        while head:
            if m.get(head):
                return True
            m[head] = 1
            head = head.next
        return False

快慢指针(龟兔赛跑,空间复杂度O(1))

思路
  • 枚举时添加一个慢一拍的指针,如果有环最终两个指针会相遇
编码实现
class Solution:
    def hasCycle(self, head: ListNode) -> bool:
        # 2. slow fast point
        sp, fp, si = head, head, False
        while fp:
            if si:
                sp = sp.next
                si = False
            else:
                si = True
            fp = fp.next
            if sp == fp:
                return True
        return False

链表计数(空间复杂度O(1))

思路
  • 链表中最多10000个节点,超过10000就是有环
编码实现
class Solution:
    def hasCycle(self, head: ListNode) -> bool:
        # 3. 计数 10000
        count = 0
        while head and count <= 1000:
            count += 1
            head = head.next
        return count > 10000

链表反转(空间复杂度O(1),变更原链表结构)

思路
  • 按顺序反转链表,由于环之前的部分已经反转,最后会返回到head节点
编码实现
class Solution:
    def hasCycle(self, head: ListNode) -> bool:
        # 4. 列表倒置
        if not head or not head.next:
            return False
        p, q = None, head
        while q:
            u, p, q = p, q, q.next
            p.next = u
        return head == q

标记val值(空间复杂度O(1),变更原链表数值)

思路
  • 将原链表val值变为特殊值,若访问的节点val已经被变更过就是有环
  • python中可以给节点添加新属性不改变原链表,则空间复杂度变为O(n)
编码实现
class Solution:
    def hasCycle(self, head: ListNode) -> bool:
        # 5. 标记val
        while head:
            # s = getattr(head, 's', None)  # 获取s属性
            # if s:  # 判断s属性
            if head.val == '1':
                return True
            # head.s = 1  # 添加s属性
            head.val = '1'
            head = head.next
        return False
US-B.Ralph
LeetCode数据结构与算法算法

One Pingback

Leave a Comment

邮箱地址不会被公开。 必填项已用*标注