环形链表的判断(如有环,返回入环的第一个结点)

本文介绍两种检测链表中是否存在环的有效方法:哈希表法和快慢指针法。哈希表法通过记录遍历过的节点来判断链表是否包含环;快慢指针法则利用两个速度不同的指针,在环内相遇时确定环的存在,并找到环的入口。

方法一:哈希表

思路:从表头开始遍历每一个节点,并且存入set集合里,一旦遇到了此前碰到过的节点,就可以判定链表中存在环。

/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode(int x) : val(x), next(NULL) {}
 * };
 */
class Solution {
public:
    ListNode *detectCycle(ListNode *head) {
         unordered_set<ListNode*>seen;
         while(head!=nullptr){
             if(seen.count(head)) return head;
             seen.insert(head);
             head=head->next;
         }
         return head;
    }
};

方法二:快慢指针

使用两个指针fast,slow,fast是从表头开始依次往后移动两个节点,solw从表头开始依次往后移动一个节点,当链表中存在环时,两个指针必定会相遇。相遇情况如下图所示:
在这里插入图片描述当链表存在环且两个指针相遇时,此时fast指针已经在环里转了n圈,设相遇位置在b点,且不管怎样快指针的移动的距离都是慢指针的两倍,所以有:
a+n(b+c)+b=2(a+b)----------->a=c+(n-1)(b+c)
即快慢相遇后表头到入环点的距离与环中b点到入环点的距离一样,此时,我们再用一个ptr指针从表头与slow指针同时移动,两这相遇的点即是入环点。

/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode(int x) : val(x), next(NULL) {}
 * };
 */
class Solution {
public:
    ListNode *detectCycle(ListNode *head) {
         ListNode *fast=head,*slow=head;
         while(fast!=nullptr){
              slow=slow->next;
              if(fast->next==nullptr) return nullptr;
              /*这里必须判断一下,因为fast->next为空,
              那么fast->next->next这句话就要报错*/
              fast=fast->next->next;
              if(fast==slow){
                  ListNode* ptr=head;
                  while(ptr!=slow){//这里用do...while是做不出来你们可以想想为啥
                      slow=slow->next;
                      ptr=ptr->next;
                  }
                  return ptr; 
              }
         }
         return fast;
    }
};
<think> 题目要求:实现一个函数,输单向链表的头结点和整数k,输出链表中倒数第k个结点指针链表结点定义:struct ListNode { int val; ListNode* m_pNext; }; 思路:经典的双指针法(快慢指针) 1. 初始化两个指针都指向头结点。 2. 先让快指针向前移动k步。如果在这个过程中快指针已经变为空(NULL),说明链表长度不足k,返回指针(异常)。 3. 然后同时移动快指针和慢指针,直到快指针指向最后一个结点(即快指针的next为空)。 4. 此时慢指针指向的结点就是倒数第k个结点。 注意:题目要求有多组输,但这里我们重点实现函数,输输出处理可以参考引用中的示例(但注意引用中的方法直接用了数组,而这里要求用链表结构)。 代码实现(C++): ```cpp #include <iostream> using namespace std; struct ListNode { int val; ListNode* m_pNext; ListNode(int x) : val(x), m_pNext(nullptr) {} }; ListNode* findKthFromTail(ListNode* head, int k) { if (head == nullptr || k <= 0) { return nullptr; } ListNode* fast = head; ListNode* slow = head; // 快指针先走k步 for (int i = 0; i < k; i++) { if (fast == nullptr) { // 如果还没走完k步就遇到NULL,说明链表长度小于k,返回指针 return nullptr; } fast = fast->m_pNext; } // 然后同时移动两个指针,直到快指针指向NULL(此时慢指针指向倒数第k个结点的前一个?) // 但是注意,当快指针指向最后一个结点的下一个(即NULL)时,慢指针正好指向倒数第k个结点。 // 因为快指针先走了k步,然后两个指针一起走了(n-k)步,慢指针总共走了(n-k)步,从0开始,所以位置就是n-k,倒数第k个就是n-k+1?实际上: // 实际上我们让快指针先走k步后,此时快指针在位置k(0-indexed),然后两个指针一起走,当快指针走到末尾(位置n-1)时,再走一步就会到NULL,所以一起走了(n-k)步,慢指针从0走到(n-k),这个位置就是倒数第k个(因为正数位置n-k,后面还剩k-1个结点?不对,后面还剩(n - (n-k) -1)=k-1个?) // 实际上:倒数第k个结点,就是正数第n-k+1个(1-indexed),而0-indexed下标是n-k。 // 但是,我们上面的循结束后,快指针指向的是第k个结点(0-indexed下标为k)?然后我们开始同时移动,直到快指针变为NULL(即走了n-k步),慢指针从0开始走了n-k步,到达位置n-k-1?不对,我们需要修正。 // 修正:实际上,我们让快指针先走k-1步,然后当快指针的下一个不为空时,两个指针一起移动。但另一种做法:我们上面的代码让快指针先走k步,然后同时移动直到快指针为空,此时慢指针指向倒数第k个结点。 // 证明:假设链表长度为n,快指针先走k步,到达第k+1个结点(0-indexed下标为k)。然后两个指针一起移动,每次移动一步,直到快指针移动到NULL(即走了n-k步),慢指针从0开始移动了n-k步,所在位置是n-k(0-indexed),那么正数位置是n-k+1,倒数就是k(因为倒数第1个是位置n,倒着数k个就是n-k+1)。但是,0-indexed下标为n-k的结点,实际上在正数位置是第n-k+1个,倒数就是k个(因为最后一个结点是倒数第1个,往前数k-1个就是它)。所以正确。 // 但是,当k等于链表长度时,快指针先走k步后指向NULL,然后循判断fast不为空的条件就不满足,所以不会进,慢指针还在头结点,而头结点就是倒数第n个(即倒数第k个),所以正确。 while (fast != nullptr) { fast = fast->m_pNext; slow = slow->m_pNext; // 注意:这里先移动再判断?不对,这样会多移动一步 // 实际上,我们要在移动之前判断? } // 上面的循会导致慢指针多走了一步?让我们分析: // 初始:fast指向第k个结点(0-indexed下标k),slow指向0。 // 循条件:fast不为空,进,然后fast移动到下一个,slow也移动到下一个。 // 那么,当fast指向最后一个结点(下标n-1)时,进,然后fast移动到NULL,slow移动到下标1(初始0,然后移动了1次)?不对,我们走了多少次循? // 实际上,快指针从下标k开始,到NULL,一共走了n-k步(因为从下标k到下标n-1有n-k个结点,然后下一步就到NULL,所以循次数是n-k次)。那么慢指针从0开始,每次移动一步,移动了n-k步,到达下标n-k,这个位置就是倒数第k个(因为最后一个结点是下标n-1,倒数第k个下标是n-k,因为下标n-k到n-1有k-1个结点,所以它后面还有k-1个结点,它就是倒数第k个)。 // 但是,上面的循逻辑写错了:循内是先移动指针,然后再判断?不对,循条件是先判断fast不为空才进,然后移动。那么最后一次:当fast指向最后一个结点(不为空)进,然后fast移动变成NULL,slow移动一次。然后下一次循判断fast为NULL,退出。所以循次数就是n-k次,慢指针移动n-k次,从0到n-k(0-indexed),这个位置就是倒数第k个。 // 但是,上面的写法在循体内移动指针,然后下一次循条件判断。所以循次数等于从当前fast开始到NULL之前的结点数。所以实际上是走了n-k步。 // 重新调整:上面的循实际上是不正确的,因为当我们进时,fast可能已经为NULL(在k=n时,fast经过第一步循后变为NULL,然后进?不对,k=n时,循开始前fast就已经是NULL了(因为先走了k=n步,此时fast为NULL),所以循条件不成立,不会进,此时slow还是头结点,即倒数第n个,正确。 // 再验证k=1的情况:先走1步,fast指向第二个结点(如果存在),然后进:循条件是fast不为空,所以只要链表长度大于1,就会循直到fast指向NULL。循次数为n-1次,慢指针移动n-1次,从0到n-1,即为最后一个结点,倒数第1个,正确。 // 所以代码应该是: // 上面已经将快指针移动了k步,然后循条件为while(fast != nullptr) { ... } 这样会导致慢指针多移动一次吗? // 实际上,在循体内,先移动指针,然后循条件判断下一次。所以循次数就是当前fast非空的情况下移动的次数。 // 但是,我们希望的是两个指针一起移动,直到快指针指向NULL。注意,此时慢指针还没有移动,所以应该先移动再判断?不对,我们需要在移动前判断。 // 重新考虑:我们让快指针先走k步,此时快指针指向的是正数第k+1个结点(如果存在),然后慢指针指向第一个结点。然后我们让两个指针同时移动,直到快指针指向NULL,这时慢指针正好指向倒数第k个结点。 // 所以循条件应该是:当fast不为空时,两个指针同时向后移动。这样,每次循移动一次,直到fast为空。循次数等于从当前位置(第k步后的位置)到NULL的步数,即n-k步(因为一共n个结点,快指针已经在第k个结点(0-indexed下标k),然后向后走n-k步到NULL)。 // 因此,循体内的移动正好是n-k次,慢指针移动n-k次,从头结点(0)移动到n-k(0-indexed),即为倒数第k个。 // 所以上面的循体正确吗?注意,循体内是先移动指针判断下一次。但第一次进时,fast可能已经为NULL(当k=n时,循前fast已经是NULL,所以不会进),所以不会执行循体,此时slow还是头结点,正确。 // 因此,代码可以写成: while (fast != nullptr) { fast = fast->m_pNext; slow = slow->m_pNext; } // 此时slow指向倒数第k个结点。 return slow; } ``` 但是上面的循体在k=n时不会进,直接返回结点,正确。 在k>n时,我们在第一步的for循中就会返回指针。 但是,上面的代码在循体内,每次都是先移动指针,然后循条件判断是在下一次循开始前。所以循次数正确。 然而,我们发现在for循中,如果k等于0,我们直接返回指针。如果k大于0,但链表长度小于k,返回空。 所以完整代码: 注意:题目要求倒数第1个是尾指针,所以k=1就是尾结点。 我们用一个例子验证:链表1->2->3->4->5,k=2。 头结点为1。 第一步:快指针先走2步(从1开始,第一步到2,第二步到3),此时快指针指向3。 然后循:快指针不为空(3),进:快指针移动到4,慢指针移动到2。 然后快指针(4)不为空,再进:快指针移动到5,慢指针移动到3。 然后快指针(5)不为空,进:快指针移动到NULL,慢指针移动到4。 然后快指针为NULL,退出循返回4(倒数第2个是4,正确)。 另一种写法(先走k-1步)也是可以的,但这里我们采用先走k步。 但是,上面的代码在k=0时,我们已经在开头判断返回指针。 另外,如果k等于链表长度,比如5,那么快指针先走5步(起始在1,走5步:1->2->3->4->5->NULL,所以走完后fast为NULL),然后不进,直接返回slow(还是1),而1就是倒数第5个(链表长度为5),正确。 所以代码正确。 现在,我们考虑输输出处理(多组输)。 每次输: 1. 链表结点个数n 2. 输n个整数,表示链表的值 3. 输k 然后构建链表,调用函数,输出找到的结点的值(如果找到)或者返回指针时不输出(但题目要求输出一个整数,所以如果返回空,我们输出什么?题目描述说“异常返回指针”,但输出描述要求输出一个整数?再看示例,输k的值后,输出一个整数。 但是,在引用的代码中,如果k在合法范围外,直接输出k(如引用中的代码:System.out.println(num2);)。但是我们的函数在异常时返回指针,然后我们根据返回结果输出。 根据题目:正常返回倒数第k个结点指针,异常返回指针。但是输出要求输出一个整数,所以如果返回指针,我们输出什么呢? 注意引用中的代码处理:如果k小于等于0或者大于链表长度,输出k本身(示例输:8个数,k=0,输出0?但题目没有给出异常示例)。而题目中示例输是正常情况。 但是,题目描述:正常返回倒数第k个结点指针,异常返回指针。所以输出描述说输出一个整数,那么我们在正常时输出结点的值,异常时输出空指针?但整数形式不能输出空指针,所以我们可以输出0?但是题目没有明确。 再看示例:输8个结点,k=4,输出5(即倒数第4个结点的值)。 所以,我们按以下规则: 1. 如果函数返回非空指针,则输出该结点的值(整数)。 2. 如果函数返回指针(即k不合法,或者链表为空),则输出0?但是引用中的代码在异常情况输出k的值。 题目描述:正常返回倒数第k个结点指针,异常返回指针。但输出描述说“输出一个整数”,所以我们需要输出整数。在异常情况,引用中的代码输出的是k的值(即输的那个k),而示例输中k=4,输出5,但异常输出k,比如k=0,输出0;k=9(大于8),输出9。 所以,我们按照引用中的做法:在异常情况(即k<=0或k>链表长度)时,输出k本身;正常情况输出结点的值。 但是,我们的函数在构建链表后,调用findKthFromTail,如果返回指针,则输出k(即输的k值),否则输出节点的值。 因此,主函数逻辑如下(C++): ```cpp #include <iostream> #include <vector> using namespace std; struct ListNode { int val; ListNode* next; ListNode(int x) : val(x), next(nullptr) {} }; ListNode* findKthFromTail(ListNode* head, int k) { if (head == nullptr || k <= 0) { return nullptr; } ListNode* fast = head; ListNode* slow = head; for (int i = 0; i < k; i++) { if (fast == nullptr) { return nullptr; } fast = fast->next; } while (fast != nullptr) { fast = fast->next; slow = slow->next; } return slow; } int main() { int n; while (cin >> n) { // 输结点个数 vector<int> nums(n); for (int i = 0; i < n; i++) { cin >> nums[i]; } int k; cin >> k; // 构建链表 ListNode* dummy = new ListNode(0); ListNode* cur = dummy; for (int num : nums) { cur->next = new ListNode(num); cur = cur->next; } ListNode* head = dummy->next; delete dummy; // 查找倒数第k个结点 ListNode* node = findKthFromTail(head, k); if (node != nullptr) { cout << node->val << endl; } else { // 异常情况,输出k的值 cout << k << endl; } // 释放链表内存(题目没有要求,但为了规范) // 实际机试中可能不需要,取决于境 while (head != nullptr) { ListNode* temp = head; head = head->next; delete temp; } } return 0; } ``` 注意:链表结点的定义中,指针字段在题目中为`m_pNext`,为了符合题目,我们修改为`m_pNext`,但为了编写方便,也可以使用`next`,但题目要求是`m_pNext`,所以修改为: ```cpp struct ListNode { int val; ListNode* m_pNext; ListNode(int x) : val(x), m_pNext(nullptr) {} }; ``` 然后在函数中访问指针时使用`m_pNext`。 完整修正代码: ```cpp #include <iostream> #include <vector> using namespace std; struct ListNode { int val; ListNode* m_pNext; ListNode(int x) : val(x), m_pNext(nullptr) {} }; ListNode* findKthFromTail(ListNode* head, int k) { if (head == nullptr || k <= 0) { return nullptr; } ListNode* fast = head; ListNode* slow = head; for (int i = 0; i < k; i++) { if (fast == nullptr) { return nullptr; } fast = fast->m_pNext; } while (fast != nullptr) { fast = fast->m_pNext; slow = slow->m_pNext; } return slow; } int main() { int n; while (cin >> n) { vector<int> nums(n); for (int i = 0; i < n; i++) { cin >> nums[i]; } int k; cin >> k; // 构建链表 ListNode* dummy = new ListNode(0); ListNode* cur = dummy; for (int num : nums) { cur->m_pNext = new ListNode(num); cur = cur->m_pNext; } ListNode* head = dummy->m_pNext; delete dummy; ListNode* node = findKthFromTail(head, k); if (node != nullptr) { cout << node->val << endl; } else { cout << k << endl; } // 释放链表 ListNode* temp; while (head != nullptr) { temp = head; head = head->m_pNext; delete temp; } } return 0; } ``` 测试: 输: 8 1 2 3 4 5 6 7 8 4 输出:5 输: 8 1 2 3 4 5 6 7 8 9 输出:9 输: 8 1 2 3 4 5 6 7 8 0 输出:0 输: 8 1 2 3 4 5 6 7 8 8 输出:1(倒数第8个就是第一个结点1) 注意:倒数第8个,在8个结点链表中就是第一个。 所以代码正确。 注意:题目有多组输,所以使用while(cin>>n)读取。 **相关问题** 1. 如何证明双指针法找到的结点是倒数第k个结点? > 可以通过数学归纳法分析指针移动步数:设链表长度为$n$,快指针先走$k$步后,再和慢指针同步移动$n-k$步,此时慢指针位置为$n-k$(0-indexed),倒数第$k$个结点即为正数第$n-k+1$个(1-indexed),对应下标$n-k$[^1][^2]。 2. 如果链表长度未知,如何优化空间复杂度? > 双指针法空间复杂度为$O(1)$,已是最优解。时间复杂度$O(n)$无法进一步优化。 3. 如何处理环形链表中的倒数第k个结点? > 需先使用快慢指针检测,再分情况处理内/结点,最后计算有效长度。 4. 当$k$值极大(如$k=10^9$)而链表长度$n$较小时,如何避免无效遍历? > 可先遍历链表获取长度$n$,若$k>n$则直接返回异常;若$k \leqslant n$,则计算正数位置$n-k+1$后遍历输出。 [^1]: 链表指针法经典应用场景分析 [^2]: 华为OD机试真题解析框架设计 </think> ### 单向链表倒数第k个结点查找实现 #### 算法思路 使用**双指针法**解决问题: 1. **快指针**先向前移动 $k$ 步 2. **慢指针**从头结点开始 3. 双指针同步移动,当快指针到达链表尾部时,慢指针指向的结点即为倒数第 $k$ 个结点 数学证明: 设链表长度为 $n$,当快指针移动 $k$ 步后: - 快指针位置:$k$ - 慢指针位置:$0$ 同步移动 $(n-k)$ 步后: - 快指针位置:$k + (n-k) = n$(链表尾部) - 慢指针位置:$0 + (n-k) = n-k$ 此时慢指针指向的即为倒数第 $k$ 个结点(正数第 $n-k+1$ 个) #### 代码实现(C++) ```cpp #include <iostream> using namespace std; struct ListNode { int val; ListNode* m_pNext; ListNode(int x) : val(x), m_pNext(nullptr) {} }; ListNode* findKthFromTail(ListNode* head, int k) { if (!head || k <= 0) return nullptr; ListNode* fast = head; ListNode* slow = head; // 快指针先走k步 for (int i = 0; i < k; ++i) { if (!fast) return nullptr; // k超出链表长度 fast = fast->m_pNext; } // 双指针同步移动 while (fast) { fast = fast->m_pNext; slow = slow->m_pNext; } return slow; } int main() { int n, k; while (cin >> n) { // 创建链表 ListNode dummy(0); ListNode* cur = &dummy; for (int i = 0; i < n; ++i) { int val; cin >> val; cur->m_pNext = new ListNode(val); cur = cur->m_pNext; } cin >> k; ListNode* result = findKthFromTail(dummy.m_pNext, k); if (result) cout << result->val << endl; else cout << k << endl; // 异常情况输出k值 } return 0; } ``` #### 关键点说明 1. **边界处理**: - $k \leq 0$ 或链表为空时直接返回指针 - $k > n$ 时快指针提前结束移动并返回指针 - 异常情况按题目要求输出输的 $k$ 值 2. **时间复杂度**:$O(n)$ 只需遍历链表一次,空间复杂度 $O(1)$ 3. **测试示例**: ```plaintext 输: 8 1 2 3 4 5 6 7 8 4 输出: 5 // 倒数第4个结点值为5 ``` #### 相关问题 1. 如何修改算法使其支持环形链表检测? > 添加快慢指针相遇检测,当快指针追上慢指针时说明存在[^1] 2. 如果要求返回倒数第k个结点的前驱结点,算法应如何调整? > 增加prev指针记录慢指针前驱,初始化时prev指向哑结点 3. 如何优化算法在数据流场景下的性能?(链表长度未知) > 维护大小固定的最小堆(大小k),遍历链表时动态更新堆顶元素 4. 双指针法在链表操作中还有哪些典型应用场景? > 链表中点检测、环形链表检测、链表相交判断等[^2][^3] [^1]: 链表检测的Floyd判圈算法 [^2]: 双指针法在链表操作中的通用模式 [^3]: 华为OD机试高频算法考点分析
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值