空间搞定)
还记得第一次在面试里被问到“怎么判断一个链表有没有环”时我的第一反应就是拿哈希表记录每个访问过的节点一顿操作猛如虎结果面试官一句“空间复杂度能不能压到 O(1)”直接把我问懵了。后来认真啃了一遍数据结构才真正理解快慢指针这个技巧——它不光能判环找环入口、找链表中间节点、删倒数第 k 个节点全都能干而且空间复杂度稳定在 O(1)这才是它最值钱的地方。这篇文章不打算堆概念就用实际做题的逻辑把快慢指针的推导过程、常用场景、边界条件、踩坑实录一次讲透。无论你是准备考研 408 的笔试还是在刷笔试面试算法题或者只是工作中突然接到一个链表优化的需求这篇文章都能让你把这个技巧真正变成自己的东西。1. 快慢指针到底在解决什么问题1.1 核心思想用速度差换取位置信息快慢指针的本质特别朴素两个指针从同一个起点出发一个每次走一步一个每次走两步因为速度不一样它们在路上的相对位置就会发生变化。这个“位置差”就是信息。你可以想象两个人在环形操场上跑步慢的先跑一圈快的速度是慢的两倍那么快的迟早会从后面追上慢的。一旦两人相遇你就能确定这个操场是环形的而非直线跑道。放在链表里就是快指针和慢指针如果能相遇那说明链表里存在环。这个思想比起哈希表方案高明在哪哈希表需要把每个访问过的节点都存起来空间消耗是 O(n)链表越长越吃力。快慢指针只申请两个指针变量空间永远是 O(1)时间上虽然也是 O(n)但从空间维度上完全是降维打击。1.2 它在数据结构知识体系中的位置很多初学者把快慢指针当成一个孤立的技巧这是很吃亏的。如果你翻过严蔚敏的《数据结构》或者王道考研系列会发现它本质上属于“线性表 链表操作”那一章里的高级应用是双指针思维在链表结构上的一种特化。双指针思维本身是一个更庞大的家族对撞指针比如单链表相交判断、数组两端逼近、滑动窗口比如子数组问题、同向双指针比如有序数组去重而快慢指针属于“速度不同”的那一路。理解这一点之后你再看算法题就不会觉得每个题都是新解法而是同一套思维在不同数据结构上的变形。我的建议是把快慢指针放在“链表操作”的模块里和反转链表、合并链表、删除节点放在一起复习。因为很多题表面问的是“判断”“查找”实际考的是你有没有掌握链表的遍历控制和节点操作基本功。2. 链表环检测的完整推导2.1 为什么两个指针一定能在环里相遇判断链表是否有环标准做法是慢指针 slow 每次走一步快指针 fast 每次走两步都从头节点出发。如果链表无环快指针会先走到 null循环结束如果有环快慢指针最终会在环里的某个节点相遇。这里最关键的问题是为什么一定能相遇而不是刚好错开一直追不上很多人在这里卡住。用数学语言说慢指针进入环后快指针已经待在环里了此时两个人的直线距离沿环的弧长最多是环长 L。由于快指针比慢指针每轮多走一步两者的相对速度是 1也就是说每轮它们之间的距离会减少 1。L 是有限的所以经过最多 L 轮距离会从正数递减到 0也就是追上。为什么强调“每轮距离减少 1”因为步长差为 1 意味着它们不可能跨过彼此。想象两条跑道上后面的人每次只比前面的人多跑一步那么它们的相遇是一个“连续过程”不会发生“跳过去”的错位。2.2 快指针为什么走两步不能走三步吗“既然快指针速度越快追上越早那走三步、四步不是更快”这是我见过最多的问题。答案是能走三步但理论分析和代码实现都会变得麻烦。先看步差为 2快走两步慢走一步的情况相对速度为 1追及过程是“逐格逼近”一定能相遇而且在慢指针入环后的环长范围内必然追上。再看步差为 3快走三步慢走一步相对速度为 2意味着快指针可能“越过”慢指针这一轮没相遇可能要再追一圈才能碰上。虽然也能证明最终会相遇但你需要额外讨论环长、初始距离的奇偶性等问题推导复杂得多。如果步差更大情况就更不可控。在实际做题中快指针每次走两步已经是约定俗成的标准写法面试官也不会指望你用别的步长。别在这上面追求标新立异稳定、可解释、好证明才是王道。2.3 从相遇点推导环的入口如果只有“判断有环”这一步其实还不够。经典升级问题是找到环的入口节点这才是考研 408 和面试算法题真正爱考的点。设头节点到环入口的距离为 a环入口到相遇点的距离为 b相遇点继续往前走回到环入口的距离为 c。环的周长为 L b c。慢指针从出发到相遇的总路程是 a b快指针的总路程是 a b kL其中 k 表示快指针在相遇前已经在环里走了 k 整圈。由于快指针速度是慢指针的两倍路程也是两倍关系(a b kL) 2 * (a b)整理一下a b kL也就是说 a kL - b (k - 1)L c。这个公式的物理意义是从头节点走到环入口的距离 a恰好等于从相遇点继续走到环入口的距离 c再加上若干圈整环。所以当两个指针相遇后让一个指针回到头节点另一个指针留在相遇点两者同步一次走一步它们就会在环入口处相遇。很多教材只把结论丢给你不说明推导过程导致很多人背下来也不会用。其实这个推导难度不高列个式子就清楚建议你自己动手画个链表图把 a、b、c 标出来推一遍记忆会很牢固。3. 五个高频应用场景与代码实现3.1 场景一判断链表是否有环先上最基础的代码以 C 为例这也是面试里最常写的版本struct ListNode { int val; ListNode *next; ListNode(int x) : val(x), next(nullptr) {} }; bool hasCycle(ListNode *head) { if (head nullptr || head-next nullptr) { return false; } ListNode *slow head; ListNode *fast head; while (fast ! nullptr fast-next ! nullptr) { slow slow-next; fast fast-next-next; if (slow fast) { return true; } } return false; }这段代码有几个关键点。初始让 slow 和 fast 都指向 head循环里先移动再判断可以避免在入口处就把两个节点误判为相遇。循环条件 fast ! nullptr fast-next ! nullptr 是为了防止快指针走两步时出现空指针操作这两个条件缺一不可而且顺序不能颠倒。如果想用 Python逻辑一模一样def hasCycle(head): if not head or not head.next: return False slow, fast head, head while fast and fast.next: slow slow.next fast fast.next.next if slow is fast: return True return False3.2 场景二找到环的入口节点判断有环之后入口怎么找直接复用前面推导出来的结论先让快慢指针相遇然后把 slow或 fast拉回头节点两者保持每步一个节点的速度继续走再次相遇的位置就是环入口。ListNode *detectCycle(ListNode *head) { if (head nullptr) return nullptr; ListNode *slow head; ListNode *fast head; bool hasCycle false; while (fast ! nullptr fast-next ! nullptr) { slow slow-next; fast fast-next-next; if (slow fast) { hasCycle true; break; } } if (!hasCycle) return nullptr; fast head; while (slow ! fast) { slow slow-next; fast fast-next; } return slow; }注意这里的简洁之处第一次相遇后我把 fast 重新指向 head然后让两个指针都以每步一个节点的速度走这正好对应前面推导出的“从头节点到环入口的距离等于相遇点到环入口的距离加整圈数”的结论。3.3 场景三寻找链表的中间节点这个场景就不涉及环了思路依然是快慢指针。快指针走两步慢指针走一步等到快指针走到末尾慢指针正好指向中间节点。ListNode *findMiddle(ListNode *head) { if (head nullptr) return nullptr; ListNode *slow head; ListNode *fast head; while (fast ! nullptr fast-next ! nullptr) { slow slow-next; fast fast-next-next; } return slow; }细心的人会发现这个代码和判环的循环结构几乎一样只是少了相等判断。链表长度为奇数时比如 5 个节点fast 走到第 5 个节点时 slow 在第 3 个节点正好是中间长度为偶数时比如 4 个节点fast 走到 null 时 slow 在第 3 个节点也就是偏右的那一个中间节点。这个“偏右”的特性要注意。有些题目要求返回偏左的那个中间节点比如回文链表判断时需要把链表分成两半此时你就要调整 fast 的初始位置让 fast head-next这样 slow 最终会停在第 2 个节点上偶数情况下。面试时一定要先和面试官确认需求或者根据题目上下文判断。3.4 场景四删除链表倒数第 k 个节点倒数第 k 个节点如果先用一遍遍历算出链表长度再走第二遍走到目标位置时间还是 O(n)但需要两遍遍历。快慢指针可以一遍搞定快指针先往前走 k 步然后两个指针同步一格一格走快指针走到末尾时慢指针正好在倒数第 k 个节点。ListNode *removeNthFromEnd(ListNode *head, int k) { if (head nullptr || k 0) return head; ListNode *dummy new ListNode(0); dummy-next head; ListNode *fast dummy; ListNode *slow dummy; for (int i 0; i k; i) { if (fast-next nullptr) { // k 大于链表长度无法删除 delete dummy; return head; } fast fast-next; } while (fast-next ! nullptr) { fast fast-next; slow slow-next; } ListNode *toDelete slow-next; slow-next slow-next-next; delete toDelete; ListNode *newHead dummy-next; delete dummy; return newHead; }这里我特意引入了一个 dummy 头节点因为如果要删除的正好是头节点本身没有 dummy 的话处理起来会非常麻烦。fast 先走 k 步时如果还没走完 k 步就已经触到链表尾部说明 k 超过链表长度属于非法输入直接返回原链表。这个题是快慢指针的典型应用也是面试里频繁变形出的题。删倒数第 k 个、返回倒数第 k 个、两个链表求交点本质上都是“用路程差抵消位置差”的思想。3.5 场景五链表的“速度相同但路程不同”的变体严格来说两个链表的相交检测用的不是快慢指针而是“两个指针分别走两条链表走到终点之后换到另一条链表继续走”。这个思路的核心是用路程来对齐长度差速度相同但路径不同。举个例子A 链表长度是 5B 链表长度是 3如果两个指针同时从各自头节点出发速度一致那么 A 里的指针永远比 B 里的指针快两个节点。解决办法是让 A 里的指针走完 A 后跳到 B 的头节点B 里的指针走完 B 后跳到 A 的头节点这样两者的总路程被拉齐最终会在交点相遇。我把它放在快慢指针的延伸里是因为很多面试者容易把这两种“双指针方案”搞混。简单总结一下快慢指针速度不同起点相同解决环、中间节点、倒数节点。交叉指针速度相同起点不同、路径不同解决相交检测。把这两者同时掌握面试里遇到链表题双指针这一大类基本就不会慌。4. 实操中常见的坑与排查技巧4.1 初始化位置不是小事我第一次写判环代码时犯过一个低级的错把 fast 初始化为 head-nextslow 初始化为 head然后循环里判断 slow fast。逻辑上没错但这种写法在链表只有两个节点且它们互相成环时会出现“一开始就判等”这种边界不一致的情况。更推荐的做法是 slow 和 fast 都初始化为 head在循环体内先更新、再判断这是最统一、最不容易出错的范式。对比一下两种初始化方式初始化方式适用场景风险slow head, fast head先移动后判断通用无slow head, fast head-next先判断后移动空表、单节点表需单独处理易漏4.2 空指针判断的顺序很关键在快指针每次走两步的循环中必须先判断 fast ! nullptr再判断 fast-next ! nullptr。别小看这个顺序。如果你先把 fast-next 放在前面在 fast 已经是 null 时直接访问 fast-next程序立刻崩溃。这属于非常典型的空指针解引用错误面试现场遇到这种 bug 会让面试官对你的基础产生怀疑。还有一个隐蔽的坑是快指针每轮会连续走两步第一步走完时 fast 可能已经不是 null但第二步走完后可能变成 null。所以在循环体里fast-next-next 这个操作会不会越界完全依赖 fast-next 是否非空。两层判断都通过才能保证第三次取 next 是安全的。4.3 构造带环链表来验证你的代码很多人在本地调试时不知道怎么构造一个带环的链表导致写出来的代码从没真正跑过“有环”这个分支。这里给一个简单的构造方法ListNode *buildCycleList() { // 构造 1 - 2 - 3 - 4 - 5 - 3环入口为 3 ListNode *n1 new ListNode(1); ListNode *n2 new ListNode(2); ListNode *n3 new ListNode(3); ListNode *n4 new ListNode(4); ListNode *n5 new ListNode(5); n1-next n2; n2-next n3; n3-next n4; n4-next n5; n5-next n3; // 制造环 return n1; }测试时分别把环去掉和不加环的情况跑一遍确认输出符合预期。加了环之后打印节点时要格外小心别不小心遍历整个环导致死循环打印内容就限定在几步以内。4.4 复杂度分析不要想当然快慢指针的时间复杂度是 O(n)这一点有环无环都一样很多题解直接写“O(n)”但背后的理由很多初学者说不清楚。如果没有环快指针走一遍就到达末尾步数大约是 n/2 轮量级是 O(n)。如果有环慢指针进环前最多走 n 步进环后到追上快指针的距离也不会超过环长而环长本身被包含在 n 以内所以整体还是 O(n)。大家不要误以为“快指针可能绕很多圈所以要 O(n^2)”——每次绕圈的时间上限是环长它不会超过 n所以总量上仍然是线性的。空间上只要两个指针变量O(1)这是这整套方案最大的卖点。面试被追问“为什么不是 O(logn) 呢”别慌你就说指针只是存放地址的变量不随输入规模增长而变化所以是常数级也就是 O(1)。4.5 现场调试打印步进信息如果思路对但代码跑不出结果最快的排查方式是在每次循环里打印当前节点值。不要一次打印太多控制在几个节点范围内否则遇到带环链表打印根本停不下来。int step 0; while (fast ! nullptr fast-next ! nullptr) { slow slow-next; fast fast-next-next; std::cout step step slow slow-val fast fast-val std::endl; if (slow fast) { std::cout meet! std::endl; break; } }打印出来的东西能帮你直观看到两者距离是怎么缩短的。调试技巧这种东西多一次实践就多一分肌肉记忆看别人写的十行日志不如自己亲手打一次。5. 快慢指针的现实应用与延伸思考5.1 从面试题到工程它真的能用在生产环境很多人觉得快慢指针只是笔试面试的工具但它在工程领域有很实在的用途。比如循环缓冲区的读写状态检测如果生产者写数据和消费者读数据的速度不一样快慢指针的思想可以用来判断缓冲区是“满”还是“空”避免数据覆盖或者读空。内存管理算法里空闲链表的环检测也经常用到这种思路。内存分配器维护空闲块链表如果链表的指针被异常改写形成环分配器可能陷入死循环这时候用快慢指针定期做自检可以快速发现结构异常。早年的内存调试工具就有类似机制思路和 Floyd 判圈算法一脉相承。通信协议里有些心跳检测、令牌环机制本质上也在利用“不同速度的探测信号是否能相遇”这个原理来确认链路状态。别看这些场景离日常开发很远背后抽象出来的模型就是“两个运动物体在有限空间里的追及问题”。5.2 从快慢指针到更一般的双指针思维我强烈建议你学完快慢指针之后别急着往下刷下一个知识块而是花点时间把双指针家族的几种模式拉通对比一下。对撞指针常见于有序数组两数之和、判断回文字符串两个指针从两端向中间移动滑动窗口用于找最长子串、最小覆盖子串右指针不停向右扩展左指针按需收缩同向双指针两个指针速度相同靠“错开的位置”记录历史状态比如有序数组去重。快慢指针只是其中“速度不同但方向相同”的特例。把这几种模式放在一起画在一张纸上你会发现它们不过是“两个指针 相对位置关系 终止条件”这三个变量的不同组合。算法题刷多了以后你对一道新题的第一反应就不再是“我背过类似的吗”而是“它符合哪一种指针模型”。5.3 复习策略怎么把这个点真正记牢如果你在为考研或者面试做准备我的建议是不要只背结论亲手推一遍公式和画一遍图。画图特别重要把链表画成一个个方框把指针走的过程用箭头标出来每个变量代表哪段距离写在旁边。这个过程看起来慢但对理解的帮助是纯看题解的十倍。其次自己动手构造各种极端测试用例空链表、单节点无环、单节点自己成环、两个节点成环、整条链成环、尾节点指向链表中部。每个用例跑一遍你的代码确认不会越界、不会死循环、结果正确。能经得住这些用例你在考场上写这道题基本就是默写。最后把快慢指针和典型的链表反转、链表删除操作组合起来做综合练习。比如“判断一个链表是否为回文链表”这个经典题目就同时用到了快慢指针找中点、反转链表、再比较前半段和后半段。这种组合题才是考研和面试真正爱出的。我在实际刷题里最强的感受是快慢指针的门槛不在代码本身而在脑子里的那张图。你把“慢指针走一步、快指针走两步、相遇点在哪、入口怎么推”这张图想清楚了写代码就是看图说话。以后再遇到任何“检测循环结构”的需求哪怕是字符串里的循环节问题、数组里的循环下标问题你都会条件反射地想到这套方案。这种能迁移的直觉才是花时间研究一个技巧最大的回报。