ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

LeetCode链表题核心攻略:双指针、虚拟头节点与边界条件实战

LeetCode链表题核心攻略:双指针、虚拟头节点与边界条件实战 链表的题目说难不难说简单也真不简单。LeetCode上链表章节的题我做了一圈下来最大的感受就是只要把“指针操作”和“边界条件”这两件事搞明白链表题基本就拿下了。Day4安排的三道题——24. 两两交换链表中的节点、19. 删除链表的倒数第N个节点、142. 环形链表II——恰好把链表题最核心的三种考法都覆盖了直接改指针、双指针配合、快慢指针加数学推导。把这三道题吃透后面的链表题基本都是一路平推。这篇文章不打算罗列答案而是把我刷这三道题时的思考过程、踩过的坑、以及面试时怎么讲清楚全部拆开写明白。适合正在刷LeetCode的新手、准备面试的开发者以及想系统梳理链表题型的读者。看完你不仅能写出AC代码还能把每一步“为什么这么做”讲清楚这才是面试真正考察的东西。1. 链表题的核心思路与解法选型1.1 为什么我把这三道题安排在同一天链表题有个特点代码量不大但指针逻辑非常绕。很多能刷完二叉树的人反而在链表上翻车原因就在于链表操作太依赖“顺序感”了——先改哪个指针、后改哪个指针顺序错了整条链就断了。这三道题是一个很好的递进组合24题考察最底层的指针交换能力。两两交换节点本质上就是控制了4个节点之间的连接关系强制你去思考“改指针前先保存谁”。19题引入双指针思想。一次遍历找到倒数第N个节点把“空间换时间”和“双指针配合”的思维建立起来。142题上难度要求先用快慢指针判断链表是否有环再用数学推导找到环的入口。把这三题连起来看其实是一个完整的技能路线先能把指针改对再能灵活控制遍历节奏最后能结合数学规律解题。一天刷完链表里的“硬骨头”基本都啃到了。1.2 解题前必须养成的三个习惯刷链表题我强烈建议先建立起三个习惯否则代码写出来很容易改到怀疑人生。第一个习惯是虚拟头节点。很多链表题目会遇到“要删除或修改头节点”的情况如果不加dummy节点就需要单独写if判断处理头节点非常容易漏。dummy节点的作用很简单让头节点变成一个普通节点这样操作逻辑就能统一了。三道题里有两道都必须用到这个技巧后面代码会反复看到。第二个习惯是画图推导。链表题尤其需要画图每次操作前先在纸上画出当前链表状态再标出要修改哪几个指针最后再写代码。实际刷题时你会发现90%的bug都是漏画了一步导致改指针的时候把后继节点丢了。第三个习惯是先想边界条件再写代码。空链表怎么处理只有一个节点怎么处理只有两个节点怎么处理链表长度是奇数还是偶数这些边界条件必须在动手写代码前就在脑中过一遍。这三道题恰好覆盖了各种边界场景刷完会对“边界敏感度”有质的提升。2. 三道题逐题拆解与实现要点2.1 24. 两两交换链表中的节点核心是保住“后继指针”这道题要求把链表中相邻的两个节点交换位置比如1-2-3-4变成2-1-4-3。题目不难但第一次做的人非常容易绕晕因为涉及到的指针操作太多了。我用的是迭代法核心思路是维护一个pre指针它始终指向当前要交换的两个节点的前一个节点。假设当前要交换的是cur和nxt这两个节点那要做的操作就是先把nxt的下一个节点保存起来因为交换之后需要让cur指向它把pre的next指向nxt把nxt的next指向cur把cur的next指向之前保存的那个节点更新pre为cur继续处理下一对。这一步最关键的地方在于保护现场——交换前必须把nxt-next也就是下一对节点的起始位置保存好否则一旦nxt的next被修改后面的链表就找不到了。类比起来这就像排队时两人互换位置你先把前面人的肩膀搭住让后面的人走到前面再把原来前面的人推过去。如果一开始就把手撒开整个队伍就乱了。这道题也可以用递归来做代码会简洁很多递归函数接收一个头节点如果头节点为空或者只有一个节点就返回否则先把第二段链表递归交换好再交换当前两个节点。但从面试角度看迭代法是基本功最好先把迭代法写得滚瓜烂熟再考虑递归写法。2.2 19. 删除链表的倒数第N个节点快慢指针的精髓做这道题最容易想到的办法是先遍历一遍链表求出长度然后删除正数第len - n 1个节点。这个方法当然没问题但要遍历两遍链表。面试时如果只给出这个方案面试官大概率会追问一句“能不能只遍历一遍”这里就是快慢指针大展身手的时候了。思路是这样的定义一个快指针fast和一个慢指针slow先让fast往前走n步然后fast和slow同时往前走。当fast走到链表末尾时slow正好指向倒数第N个节点。但这里有个细节我们真正要删除的是节点本身而删除一个节点必须拿到它的前驱节点。所以slow应该停在倒数第N1个节点上而不是倒数第N个节点。怎么做到让fast先走n步还不够应该让fast先走n步之后再检查一下——其实更准确的做法是让fast先走n步此时如果fast为空说明要删的就是头节点否则再让fast和slow同时前进直到fast走到链表最后一个节点此时slow就是倒数第N1个节点。这里我习惯给链表加一个dummy节点理由特别直接如果要删除的是头节点没有dummy的话需要单独写逻辑而有dummy之后删除头节点和删除中间节点就变成完全一样的操作了。为了一个if判断额外写一堆代码完全没必要。这个双指针思路的精髓在于两个指针之间的距离是N。只要保证这个固定距离不变当快指针到达终点时慢指针自然就停在目标节点的前一个位置。这种“先拉开距离再一起移动”的思想在后面很多滑动窗口、链表题目里都会反复用到。2.3 142. 环形链表II从“有环”到“找入口”这道题是三道题里最考验综合能力的。它分两个问题第一判断链表有没有环第二如果有环找出环的入口。判断有没有环快慢指针是经典方案慢指针每次走一步快指针每次走两步如果链表有环快指针最终会和慢指针相遇。这个逻辑很直觉——两个人绕圈跑步跑得快的人总有一天会追上跑得慢的人。但要注意这里如果快指针走三步、慢指针走一步反而不一定能保证相遇因为步长差为1才能确保每次拉近距离都能遍历所有整数距离。所以快指针走两步、慢指针走一步是经过验证的最优组合。难的是第二个问题怎么找出环的入口这里需要做数学推导。假设链表头到环入口的距离是a环入口到快慢指针第一次相遇点的距离是b环的周长是L。当快慢指针相遇时慢指针走了ab步快指针走了abkL步k表示快指针比慢指针多绕了k圈由于快指针速度是慢指针的两倍所以2(ab) abkL推得ab kL再推得a kL - b (k-1)L (L-b)这个式子意味着什么呢如果一个指针从头节点出发走a步到达环入口另一个指针从相遇点出发走a步等价于它绕了k-1圈后再从相遇点走L-b步——而L-b恰恰是“相遇点到环入口”的距离。所以从头节点和相遇点同时出发、速度相同的两个指针必然会在环入口相遇。这就是找环入口的完整方案快慢指针第一次相遇记录相遇点一个新指针从链表头出发另一个指针从相遇点出发速度相同两个指针相遇的位置就是环入口。顺便说一下这道题还有更简单的哈希表解法遍历链表把每个节点放进Set如果遇到重复节点该节点就是环入口。这个解法易懂易写但空间复杂度是O(n)。面试时可以先说哈希表的解法再讲快慢指针的数学推导展现你对优化空间复杂度的思考。3. 实操过程完整代码、复杂度分析与边界测试3.1 完整可运行的代码实现刷题建议用自己最熟练的语言。我平时用Python和CPython写起来快C对指针理解更深。这里分别给出核心实现附上逐行注释方便直接参考。先看Python版本# 24. 两两交换链表中的节点迭代法 # 时间复杂度O(n)空间复杂度O(1) def swapPairs(head): dummy ListNode(-1, head) pre dummy while pre.next and pre.next.next: cur pre.next # 第一个待交换节点 nxt pre.next.next # 第二个待交换节点 # 保存nxt的下一个节点防止交换后丢链 temp nxt.next # 三步完成交换 pre.next nxt nxt.next cur cur.next temp # pre移动到下一对的前驱位置 pre cur return dummy.next# 19. 删除链表的倒数第N个节点快慢指针 # 时间复杂度O(n)空间复杂度O(1) def removeNthFromEnd(head, n): dummy ListNode(-1, head) fast dummy slow dummy # fast先走n步 for _ in range(n): fast fast.next # 如果fast为空说明要删的是头节点这里dummy让处理统一了 while fast.next: # 注意条件让slow停在目标节点的前驱 fast fast.next slow slow.next # 删除目标节点 slow.next slow.next.next return dummy.next# 142. 环形链表II快慢指针数学推导 # 时间复杂度O(n)空间复杂度O(1) def detectCycle(head): slow head fast head # 判断是否有环 while fast and fast.next: slow slow.next fast fast.next.next if slow fast: # 有环进入第二阶段找入口 ptr head while ptr ! slow: ptr ptr.next slow slow.next return ptr return None再看C版本重点注意指针操作的顺序和空指针保护// 24. 两两交换链表中的节点C ListNode* swapPairs(ListNode* head) { ListNode* dummy new ListNode(0, head); ListNode* pre dummy; while (pre-next pre-next-next) { ListNode* cur pre-next; ListNode* nxt pre-next-next; ListNode* temp nxt-next; pre-next nxt; nxt-next cur; cur-next temp; pre cur; } return dummy-next; } // 19. 删除链表的倒数第N个节点C ListNode* removeNthFromEnd(ListNode* head, int n) { ListNode* dummy new ListNode(0, head); ListNode* fast dummy; ListNode* slow dummy; for (int i 0; i n; i) fast fast-next; while (fast-next) { fast fast-next; slow slow-next; } slow-next slow-next-next; return dummy-next; } // 142. 环形链表IIC ListNode* detectCycle(ListNode* head) { ListNode* slow head; ListNode* fast head; while (fast fast-next) { slow slow-next; fast fast-next-next; if (slow fast) { ListNode* ptr head; while (ptr ! slow) { ptr ptr-next; slow slow-next; } return ptr; } } return nullptr; }3.2 复杂度分析与边界测试这三道题的时间复杂度和空间复杂度需要记清楚面试时会被直接问到题目时间复杂度空间复杂度备注24. 两两交换链表中的节点O(n)O(1)迭代法只使用常数级指针19. 删除链表的倒数第N个节点O(n)O(1)快慢指针一次遍历142. 环形链表IIO(n)O(1)快慢指针法哈希表解法为O(n)空间刷题时不能只跑通默认测试用例就完事。这三道题我建议至少在本地多测几个特殊的输入我把常用的测试用例整理成了一张速查表场景24题的预期行为19题的预期行为142题的预期行为空链表返回空返回空返回null单个节点返回原链表n1时返回空无环返回null两个节点交换一次删除头节点/尾节点均正确无环返回null奇数长度链表最后一个节点不交换正常删除——偶数长度链表完整成对交换正常删除——链表尾部形成环————返回尾节点指向的环入口头节点就是环入口————返回头节点全链表成环————返回头节点这个表是很好的自查工具。刷完代码对着表把每个用例在脑子里跑一遍比直接看题解更有收获。3.3 我踩过的三个坑这一节分享一下我实际写这些题时踩过的坑都是不看一眼答案根本发现不了的问题。第一个坑是24题中交换后忘了把cur的next指回来。我第一次写的时候完成了pre.next nxt和nxt.next cur这两步但忘了cur.next temp结果链表直接从第二个节点断掉了后面的节点全丢。这个问题光靠眼睛看代码很难发现最好的办法就是把链表一步步画出来每次修改指针后标注当前状态一画就露馅了。第二个坑是19题里while循环的终止条件。我一开始写的是while fast而不是while fast.next这样就导致slow多走了一步删除的变成了倒数第N1个节点。后来我总结出一个规律要让slow停在目标节点的前驱必须保证fast结束时指向最后一个节点所以条件是fast.next ! null而不是fast ! null。这两个条件的差别就是“停在待删节点”和“停在待删节点的前驱”的本质区别。第三个坑是142题里快慢指针的初始化。我一开始让fast head.next结果在单节点链表和双节点链表上直接空指针异常。正确的做法是slow和fast都从head出发然后用while fast and fast.next来控制循环这样空指针问题就自然规避了。4. 常见问题与排查技巧实录4.1 高频报错场景与排查思路链表题常见的报错就那么几类我总结一下症状、原因和排查方法遇到问题可以按表搜索现象可能原因排查方法运行报空指针异常访问了null节点的next检查while条件是否覆盖空链表、单节点场景查看是否在循环外调用了可能为null的节点链表输出少了一截交换节点时丢失了后继引用画出四个节点的交换过程确认每步是否保存了temp出现死循环/超时链表成环且没有快指针跳出条件或修改指针时把前驱指向了错误的节点检查142题的while条件把循环次数打印出来定位输出结果错一位边界条件判断错误比如fast和fast.next用混手动模拟两个节点的链表逐步走一遍代码142题找不到入口数学推导理解有偏差或第二阶段两个指针没有同步移动重新推导 a(k-1)L(L-b)确信相遇点指针继续走a步必然到达入口我个人的经验是链表题调试时不要只靠IDE的断点把每次指针变化手动写下来往往更高效。写下来之后你会立刻发现问题要么出在循环条件要么出在操作顺序。4.2 刷这类题的两个高效调试技巧第一个技巧是写一个打印链表的工具函数。刷链表题之前我先把一个能打印链表全部值的函数准备好def print_list(head): res [] seen set() while head: # 防止打印环形链表导致死循环 if id(head) in seen: res.append(cycle) break seen.add(id(head)) res.append(head.val) head head.next print( - .join(map(str, res)) if res else empty)有了这个函数每次操作完打印一次立刻能看到链表状态是否符合预期。代码里加两三行调试打印定位速度比纯看代码快得多。第二个技巧是用最小用例手动走查。链表题最容易出错的就是长度很短的情况。比如24题我固定先用 [1,2] 和 [1,2,3] 两个用例走一遍19题用 [1], n1 和 [1,2], n2 走一遍142题构造一个尾部成环的用例走一遍。这些小用例跑通了大用例基本不会翻车。4.3 面试时怎么讲解这三道题才加分刷题和面试是两回事。AC了代码只是基本盘面试官更看重你讲解思路的能力。以我的经验这三道题在面试里可以按下面的套路讲24题先讲递归或迭代的整体思路然后重点强调“交换过程中必须保存后继节点”主动提出来“这里有一个细节就是当链表长度为奇数时最后一个节点不需要交换”这句话一说出口面试官就会觉得你边界条件想得很周全。19题一定要从暴力解法讲起第一遍算出链表的长度第二遍找到要删除的位置。然后再引出快慢指针优化。这个“先暴力后优化”的节奏很重要直接讲最优解反而会显得背书痕迹太重。讲快慢指针时必须说明白为什么用fast.next作为循环条件来控制slow的位置这是判断你有没有真正吃透的关键点。142题先给哈希表的简单解法然后说“如果要求O(1)空间我们可以用快慢指针加数学推导”。接着把头到入口的距离设为a、入口到相遇点设为b、环长为L写两步推导公式。公式不需要写得多严谨但一定要让面试官听懂“两个指针速度相同、从头和相遇点同时出发必然在入口相遇”的直觉。三个题讲下来核心要传达的是你知道每一步在干什么而不是背答案。这也是我反复强调“为什么”的原因。我自己前期刷链表题浪费了很多时间后来发现最高效的方式就是“三件套”组合训练写代码前先画出所有指针变化运行后用打印函数快速定位再对着边界用例表格逐个检查。这样一遍流程走下来一道题的知识点才真正沉淀下来。这三道链表题吃透之后后面再做反转链表、排序链表、合并链表这类进阶题手感会顺畅很多。刷题不在于数量而在于每做一道题都把它的原理和边界嚼碎了咽下去这个习惯比多刷十道题都有用。
返回列表