13、检测链表中的环:快慢指针法、哈希表法、环的入口检测

链表中的环,说白了就是一个节点通过 next 指针又指回了前面某个节点,形成一个闭环。这个问题在面试中出现的频率极高,我当年面试时就被问过不止一次。更关键的是,在实际项目中,如果你不小心让链表产生了环,遍历操作就会陷入死循环——嗯,我确实遇到过这种尴尬的情况。

13.1 什么是链表中的环?

先看一个简单的环结构:

1 → 2 → 3 → 4 → 5 → 6
         ↑              |
         |______________|

节点 6 的 next 指向了节点 3,这就形成了一个环。从节点 1 开始遍历,永远走不到 NULL。

环带来的问题很明显:

  • 遍历会无限循环
  • 内存释放时可能遗漏节点
  • 某些算法(如归并排序)会出错

所以,检测环的存在是链表操作中的基本功。

13.2 快慢指针法(Floyd 判环算法)

这个方法我个人非常喜欢。思路很简单:两个指针,一个每次走一步,另一个每次走两步。如果有环,它们一定会相遇。

为什么?你想想看,如果跑道是环形的,跑得快的人最终会追上跑得慢的人。这就是 Floyd 判环算法的核心思想。

// 检测链表是否有环 - 快慢指针法
int hasCycle(struct ListNode *head) {
    if (head == NULL || head->next == NULL) {
        return 0;  // 空链表或只有一个节点,不可能有环
    }
    
    struct ListNode *slow = head;
    struct ListNode *fast = head;
    
    while (fast != NULL && fast->next != NULL) {
        slow = slow->next;      // 慢指针走一步
        fast = fast->next->next; // 快指针走两步
        
        if (slow == fast) {
            return 1;  // 相遇了,有环
        }
    }
    
    return 0;  // 快指针走到头了,无环
}

时间复杂度:O(n),空间复杂度:O(1)。这是最推荐的方法。

我的经验:快慢指针法不仅用于判环,还可以用来找链表的中间节点。我在做嵌入式系统中的任务调度器时,就用它来检测任务链表是否出现了循环依赖。

13.3 哈希表法

这个方法更直观:遍历链表,把每个节点的地址存到哈希表里。如果某个节点地址已经存在,说明有环。

// 检测链表是否有环 - 哈希表法
#include <stdbool.h>
#include <uthash.h>  // 假设使用 uthash 库

struct hashTable {
    struct ListNode *node;
    UT_hash_handle hh;
};

int hasCycleHash(struct ListNode *head) {
    struct hashTable *set = NULL;
    struct hashTable *tmp = NULL;
    struct ListNode *cur = head;
    
    while (cur != NULL) {
        // 检查当前节点是否已在哈希表中
        HASH_FIND_PTR(set, &cur, tmp);
        if (tmp != NULL) {
            return 1;  // 找到了,有环
        }
        
        // 插入当前节点
        tmp = (struct hashTable *)malloc(sizeof(struct hashTable));
        tmp->node = cur;
        HASH_ADD_PTR(set, node, tmp);
        
        cur = cur->next;
    }
    
    return 0;  // 遍历完,无环
}

注意:哈希表法虽然直观,但空间复杂度是 O(n)。在嵌入式系统中,内存往往有限,我个人更倾向于快慢指针法。我曾经在一个内存只有 64KB 的 MCU 上调试代码,哈希表法直接导致堆溢出——从那以后,我养成了先评估内存再选算法的习惯。

13.4 环的入口检测

找到环还不够,有时候我们需要知道环的入口在哪里。比如在修复链表时,必须找到环的起始点才能断开它。

Floyd 算法不仅能判环,还能找入口。方法分两步:

  1. 用快慢指针找到相遇点
  2. 从相遇点和头节点同时出发,每次走一步,再次相遇的位置就是环的入口

为什么会这样?这里有个数学推导:

  • 设头节点到环入口的距离为 a
  • 环入口到相遇点的距离为 b
  • 相遇点继续走到环入口的距离为 c
  • 慢指针走了 a + b,快指针走了 a + b + k*(b+c)
  • 因为快指针速度是慢指针的 2 倍,所以 2*(a+b) = a + b + k*(b+c)
  • 化简得 a = (k-1)*(b+c) + c
  • 也就是说,从头节点到入口的距离,等于从相遇点绕环 k-1 圈再到入口的距离

嗯,数学上可能有点绕。但代码实现其实很简单:

// 找到环的入口节点
struct ListNode *detectCycle(struct ListNode *head) {
    if (head == NULL || head->next == NULL) {
        return NULL;
    }
    
    struct ListNode *slow = head;
    struct ListNode *fast = head;
    int hasCycle = 0;
    
    // 第一步:找相遇点
    while (fast != NULL && fast->next != NULL) {
        slow = slow->next;
        fast = fast->next->next;
        
        if (slow == fast) {
            hasCycle = 1;
            break;
        }
    }
    
    if (!hasCycle) {
        return NULL;  // 无环
    }
    
    // 第二步:找环入口
    slow = head;
    while (slow != fast) {
        slow = slow->next;
        fast = fast->next;
    }
    
    return slow;  // 返回环入口节点
}

关键点:第二步中,两个指针都只走一步。它们再次相遇的位置,就是环的入口。

13.5 三种方法对比

方法 时间复杂度 空间复杂度 能否找入口 适用场景
快慢指针法 O(n) O(1) 通用,内存受限时首选
哈希表法 O(n) O(n) 能(记录入口) 内存充足,代码可读性优先
标记法(修改节点) O(n) O(1) 允许修改链表结构时

避坑指南:我曾经在调试一个通信协议栈时,用快慢指针法检测到了环,但没找到入口就直接把相遇点断开了——结果链表结构被破坏,数据全乱了。记住:只判环不改链表,除非你明确知道入口在哪。

13.6 知识体系总览

下面这张图总结了链表环检测的核心知识点:

链表环检测知识体系 链表环检测 快慢指针法 哈希表法 标记法(修改节点) 空间 O(1),推荐首选 可同时找环入口 空间 O(n),直观易懂 需要额外哈希表实现 修改节点结构(如加 flag) 破坏原链表,慎用 核心:快慢指针法找环 + 数学推导找入口 面试高频题,嵌入式开发实用技能

13.7 实际项目中的选择建议

说了这么多,到底该用哪种方法?我根据实际经验给点建议:

  • 嵌入式环境(内存小):无脑选快慢指针法。空间 O(1) 是硬道理。
  • PC 端开发(内存充足):哈希表法更易读,调试时也方便打印节点信息。
  • 需要修复环:必须用快慢指针法找入口,然后断开。
  • 不允许修改链表:别用标记法,用前两种。

重要提醒:快慢指针法中,快指针每次走两步,慢指针每次走一步。如果你改成快指针走三步,虽然也能相遇,但可能错过某些情况下的相遇点,而且代码更难理解。我见过有人为了「优化」改成三步,结果在特定环结构下永远不相遇——嗯,调试了一整天。

链表环检测,说白了就是「有没有环」和「环在哪」两个问题。快慢指针法一个算法全搞定,这也是它成为经典的原因。你想想看,一个 O(1) 空间的算法,能同时解决两个问题,还有什么理由不用它呢?

公众号:蓝海资料掘金营,微信 deep3321