面试手撕链表题最怕的往往不是题目难而是明明思路对了写出代码却绕不出来。前几天有同学在交流群里问LeetCode 203 移除链表元素和 206 反转链表到底该怎么练这两道题一个考删除一个考反转几乎是模拟面试必碰的基础题。今天我把它们放在一起整理出 4 种核心解法虚拟头节点法、递归删除法、三指针迭代反转法、递归反转法。这篇文章不只是贴代码还会把每一步为什么这么写、坑在哪里、面试官到底想问什么全部拆开讲一遍。准备校招、跳槽的朋友可以直接拿来参考刚学链表的同学也能借这两道题把指针操作彻底弄明白。1. 链表题的面试地位与两个题目的考点定位1.1 为什么面试官总喜欢链表题链表题在算法面试中的出现频率高得离谱。核心原因很现实链表题代码通常很短能在几分钟内手写完但足够考察候选人对引用和指针的理解、对边界条件的敏感度、对迭代和递归两种编程范式的熟悉程度。数组题还可以靠下标缩小犯错范围链表题没有任何随机访问能力所有操作都必须沿着 next 一步一步走这就逼着你想清楚每一步执行完之后指针到底站在哪里。我见过不少候选人算法题刷了很多二叉树也讲得头头是道一到链表就开始在空指针上翻车。原因很简单链表题的逻辑并不难难的是把逻辑翻译成代码时必须同时维护好“前一个节点”“当前节点”“后一个节点”三层关系。LeetCode 203 和 206 恰好是把这种能力训练到骨子里的两道题所以它们才会成为高频里的高频。1.2 203 和 206 到底在考什么LeetCode 203 移除链表元素考的是“删除节点时如何重接 next”。它需要你判断当前节点的下一个节点是否等于目标值等于就跳过他不等于就走一步。这个动作看起来简单但有一个核心陷阱如果头节点正好等于目标值你不做额外处理删除后整个链表就找不到了。所以它实际在考你有没有“头节点也可能被删”的全局意识。LeetCode 206 反转链表考的是“把所有 next 指向倒过来”。它需要你从左到右扫一遍把每一个节点的 next 指向它的前驱。这里最危险的操作是你改掉 cur-next 之前必须先保存它原本的 next否则后面的节点全部丢失。206 表面上在考反转实际上在考“修改指针之前先备份现场”的工程习惯。两道题一个是删除一个是反转正好覆盖了链表最基础的两类指针重排操作。把这两题吃透后面做删除倒数第 N 个节点、两两交换节点、合并链表、环形链表都会顺手很多。1.3 复杂度分析是面试里的“第二答案”很多同学写完代码就停下来等面试官说话这是一个很容易扣分的习惯。链表题写完面试官几乎一定会追问时间复杂度多少空间复杂度多少能不能优化LeetCode 203 虚拟头节点法和 LeetCode 206 迭代反转法都是 O(n) 时间、O(1) 空间这里的 n 是链表长度。如果你使用递归写法时间同样 O(n)但空间会变成 O(n)因为每层递归都要占用调用栈。这不是递归写法不好而是你必须知道它的代价。面试时如果要求额外空间必须是 O(1)优先写迭代如果面试官想看你对递归的理解可以主动写递归并说明栈空间问题。把复杂度说清楚比多写一种解法更能加分。接下来我们就一道题一道题拆。2. LeetCode 203 移除链表元素两种核心解法精讲2.1 题目读法与边界条件逐个拆解先明确题意给一个链表头节点 head 和一个整数 val删除链表中所有值等于 val 的节点返回新链表的头节点。示例是 1→2→6→3→4→5→6val 6返回 1→2→3→4→5。注意不是只删一个而是删所有不是删除后保留一个而是全部都删掉。看到这道题先别急着写循环把边界条件列出来链表为空head nullptr直接返回 nullptr。头节点就是要删的值删掉之后新头节点可能是下一个节点下一个也可能是要删的所以“找到新头”这个动作不能被忽略。连续两个相同值的节点删除前一个之后后一个可能也等于 val所以删除后不能立刻移动 cur要留在原地继续判断。尾节点等于 val删除时 cur-next 会变成 nullptr循环体要能正确处理。面试中如果能把上述边界条件提前说出来会显得你经验很老练。很多时候代码正确与否就取决于这些边界是否被覆盖。如果你只在 LeetCode 上靠提交反馈来修错到了白板手写环境很容易慌所以一定要形成“先想边界再写主体”的习惯。2.2 解法一虚拟头节点法最稳写法虚拟头节点dummy node / sentinel node是链表题里性价比最高的技巧。它的作用是让原链表的头节点退化成普通节点这样删除任何一个节点的逻辑都统一了不需要单独处理“删除头节点”的情况。直接看代码ListNode* removeElements(ListNode* head, int val) { ListNode dummy(0); dummy.next head; ListNode* cur dummy; while (cur-next ! nullptr) { if (cur-next-val val) { cur-next cur-next-next; } else { cur cur-next; } } return dummy.next; }这段代码的逻辑cur 始终站在“被删除节点的前一个节点”上。只要 cur-next 存在就检查它的值如果等于 val让 cur-next 指向 cur-next-next相当于把中间这个坏节点跳过去如果不等于cur 往前走一步。这里有两个关键细节。第一个删除节点时 cur 不要移动。因为删除之后新的 cur-next 可能是下一个待删除节点如果此时 cur 往前走就会漏判。我见过很多人在这里写 else { cur cur-next; }删除分支也习惯性加了移动结果连续相等节点只删一半。第二个dummy 可以直接定义成栈上的局部变量 ListNode dummy(0)不需要 new这样返回时不需要手动 delete也不会让判题产生内存泄漏的困扰。如果你在真实工程里删除节点一般还需要保存被删除节点的指针然后 free 或 delete不过刷题场景可以先不纠结。虚拟头节点法的复杂度很干净时间 O(n)空间 O(1)。这也是面试中最推荐的主写方案。很多链表面试题比如删除倒数第 N 个节点、合并链表、partition 类题目都会用到 dummy 的统一化思路所以一定要练成条件反射。2.3 解法二递归逐步剥离训练递归思维递归解法写出来通常只有几行但理解起来比迭代更绕。它的核心思路是把问题缩小先处理头节点之后的子链表得到一个删干净的子链表再回头看当前头节点要不要删。ListNode* removeElements(ListNode* head, int val) { if (head nullptr) return nullptr; head-next removeElements(head-next, val); if (head-val val) { return head-next; } return head; }可以这样理解递归removeElements(head, val) 的返回值是“以 head 开头的一整条链表删掉 val 之后的新头”。假设 head-next 那一串已经递归处理完head-next 已经被替换成删干净的链表头接下来要判断 head 自己。如果 head-val val说明当前节点不应该留下直接返回已经清理过的 head-next否则当前节点要留下把 head 接到已经清理过的子链表前面返回 head。这个解法最大的价值不是效率而是让你熟悉“递归就是处理子问题”的思考方式。很多解释会说“递归调用会一直走到链表末尾然后从后往前处理”其实你把它想成“每一层只做两件事处理子链表判断当前节点”更准确。时间 O(n)空间 O(n)因为递归深度等于链表长度。面试中如果主动写递归务必在解释复杂度的时候点出栈空间的问题否则容易让面试官觉得你只背了代码没理解代价。2.4 不设虚拟头的“硬删写法”为什么容易错也许你已经想到不引入 dummy先处理头部再遍历删除中间节点也能实现。写法大致是while (head ! nullptr head-val val) head head-next; ListNode* cur head; while (cur ! nullptr cur-next ! nullptr) { if (cur-next-val val) { cur-next cur-next-next; } else { cur cur-next; } } return head;这种写法在逻辑上没错但它有两个问题。第一头部单独处理那一段很容易漏写循环条件中的 head-val val导致连续两个 head 都需要删除时第一个删完第二个还留在头上。第二后面的遍历中 cur 和 cur-next 的组合容易空指针如果 cur 非空而 cur-next 为空判断 cur-next-val 之前必须先判断 cur-next ! nullptr。虚拟头节点法统一了处理逻辑把这些零零碎碎的条件都收敛了所以我建议你在面试和日常刷题中优先用虚拟头节点法。如果面试官特意问能不能不用 dummy你可以写出第二种并指出它的边界风险这反而能展示你的思考深度。3. LeetCode 206 反转链表两种核心解法精讲3.1 反转题的核心切断和重连的指针顺序反转链表这个动作很多人第一反应是把链表读进数组倒序再串起来。这个思路没问题但空间变成了 O(n)面试官大概率会追问能不能原地反转。真正需要掌握的原地反转核心只有一句话每次处理一个节点把它的 next 改成指向前一个节点同时记住它原本的 next方便继续往后走。为了做到这一点你必须维护三个指针prev 指向当前节点的前一个节点cur 指向当前正在处理的节点next 保存当前节点的原始后继。顺序绝对不能乱先保存 next再把 cur-next 改成 prev然后 prev 和 cur 一起往后移动。如果先改 cur-next那么原本的后继就丢了链表后半段直接断裂。这个“先保存、再修改、后移动”的顺序是所有指针重排类题目的通则。3.2 解法一三指针迭代反转迭代反转是最容易一次写对的方案也是面试中的首选。代码如下ListNode* reverseList(ListNode* head) { ListNode* prev nullptr; ListNode* cur head; while (cur ! nullptr) { ListNode* next cur-next; cur-next prev; prev cur; cur next; } return prev; }用一个小链表 1 → 2 → 3 来走读初始 prev 为空cur 指向节点 1。第一次循环next 指向 2把 1-next 改成空prev 变成 1cur 变成 2。第二次next 指向 3把 2-next 改成 1prev 变成 2cur 变成 3。第三次next 为空把 3-next 改成 2prev 变成 3cur 变成空。循环结束返回 prev也就是节点 3反转后的链表是 3 → 2 → 1正确。这里有一个非常容易犯的错误有人会把 while (cur ! nullptr) 写错成 while (next ! nullptr)。你可以根据空链表情况来判断——如果 head 为空cur 初始为空应该直接跳过循环返回 prev为空没问题如果用 next 判断cur 为空时 next 还没定义直接崩溃。写循环时一定要保证 cur 是遍历指针next 只是在循环内部临时保存的现场值。时间 O(n)空间 O(1)这是反转链表的标准答案也是面试中最稳妥的写法。3.3 解法二递归反转从子链表视角理解递归反转链表是很多人觉得难啃的地方但其实它和迭代是同一个逻辑的镜像版本。递归的核心想法把链表拆成第一个节点 head 和剩余子链表 head-next先递归反转子链表得到新的头 newHead然后把 head 放到整个链表的末尾也就是让 head-next-next 指向 head同时把 head-next 置空。ListNode* reverseList(ListNode* head) { if (head nullptr || head-next nullptr) return head; ListNode* newHead reverseList(head-next); head-next-next head; head-next nullptr; return newHead; }base case 是 head 为空或者只剩一个节点这时不需要反转直接返回 head。递归最深的一层比如节点 3返回 3 作为 newHead。回到上一层节点 2执行 2-next-next 2也就是把节点 3 的 next 指向节点 2同时 2-next nullptr。回到最外层节点 1执行 1-next-next 1即 2 的 next 指向 11-next 置空。最后返回一开始从最底层传上来的 newHead也就是节点 3。递归写法很漂亮容易背但如果你没理解下一次换个题目照样不会用。建议你画一个长度为 3 的链表手动走一遍每一层递归的返回值走完你就发现它只是换了个姿势做反转。时间 O(n)空间 O(n)这是递归写法的固有代价。如果面试官问你“递归反转会不会爆栈”你要能答出链表特别长时递归深度等于链表长度可能导致栈溢出而迭代则没这个问题。3.4 头插法与栈辅助两种进阶写法补充除了上面两种核心解法反转链表还有两种常见思路可以作为拓展了解。第一种是头插法维护一个 dummy 节点遍历原链表每次把当前节点插入到 dummy 的后面也就是新的链表头部。以 1→2→3 为例第一次插入后 dummy→1第二次把 2 插入首位得到 dummy→2→1第三次得到 dummy→3→2→1最后返回 dummy.next。它同样做到 O(n) 时间和 O(1) 空间逻辑上更像是“拆一个、往前面放一个”。ListNode* reverseList(ListNode* head) { ListNode dummy(0); while (head ! nullptr) { ListNode* next head-next; head-next dummy.next; dummy.next head; head next; } return dummy.next; }第二种是栈辅助遍历原链表把所有节点指针压入栈然后依次弹出并重连 next。这种写法思想简单但需要额外的栈空间空间复杂度 O(n)相当于绕了一圈。真正面试时栈辅助只是作为思路展示不会作为推荐主写方案。我在给同学做模拟面试时经常建议大家主写三指针迭代法然后在追问环节主动提到递归反转法并说明空间代价。这不只是在炫技而是让面试官看到你掌握的不只是单一模板。4. 面试追问与代码细节实战整理4.1 从提交代码到面试手写差别在哪里LeetCode 上做题写完点 Run 提交错了会告诉你样例编译器帮你检查语法这是“在线刷题”模式。面试手写链表题完全不一样没有编译器、没有测试用例、面试官就坐在对面看你写。这时候考察的是你能否先讲清楚思路再一次性写出逻辑正确的代码。我建议你培养一个固定节奏先说边界空链表、单节点、头节点删除或反转再说解法思路和复杂度最后落笔。落笔时在代码里留注释或者口头同步说明“这里保存 next 是因为下一步要修改 cur-next”。面试官能看到你在思考每一步的意图这不是多余而是加分项。写完之后主动用一个小例子在脑子里走一遍比如 1→2→3确认返回值是预期结果。整个过程会显得非常专业。4.2 空指针与丢节点最常见的两种翻车现场链表题写错翻来覆去就是两个原因空指针访问、丢节点。空指针访问常见于删除题在遍历时判断 cur-next 是否存在直接取 cur-next-val 就会崩。解决办法是每一次循环都先问 cur-next 是不是 nullptr。反转题里要注意 cur 为空的结束条件不要用 next 去判断循环是否继续。丢节点常见于反转题把 cur-next 指向 prev 之前忘了先保存 cur-next。想象你手里拿着一条铁链现在要把链环的方向拧反如果不先把后一环记住你拧完这一环后面整条链就掉地上了。所以链表题有个铁律所有改变 next 的语句都必须确保原 next 已经保存或者在当前步骤不再需要。下面这个表可以贴在笔记里错误类型典型场景解决套路空指针崩溃删除时直接判断 cur-next-val先判 cur-next ! nullptr空指针崩溃反转时用 next 判断循环结束用 cur 作为循环条件丢节点反转时先改 cur-next 后保存 next先保存 next 再修改漏删连续节点删除后 cur 继续前移删除分支保持 cur 不动头节点被删没有用 dummy 或单独处理头部优先使用虚拟头节点4.3 面试官最爱的复杂度问答模板面试官问复杂度时不能只丢一个结论。你可以按“时间 空间 为什么”的结构回答。比如虚拟头节点法时间复杂度 O(n)因为每条链最多遍历一次空间复杂度 O(1)因为只用了常数个指针变量。递归写法就补一句每次递归压栈最坏情况深度 n所以空间 O(n)。把两道题的常见姿势整理成一张表题目与解法时间复杂度空间复杂度推荐指数203 虚拟头节点法O(n)O(1)主推203 递归法O(n)O(n)进阶理解用206 三指针迭代法O(n)O(1)主推206 递归法O(n)O(n)进阶理解用206 头插法O(n)O(1)思路拓展206 栈辅助O(n)O(n)不推荐主写背下这张表不是目的目的是你回答时要能对应上题目和实现。面试官如果追问“递归的空间复杂度为什么不是 O(1)”你要能说出来调用栈每一层保存了当前参数、局部变量和返回地址链表长度为 n所以递归栈深度也是 n。5. 两道题的横向对比与刷题建议5.1 203 和 206 在代码结构上的关键差异203 的核心是“删除”需要让前一个节点意识到“我要绕过当前节点”所以实际上你是在维护一个 cur让它始终站在目标节点的前面然后判断 cur-next 的值。206 的核心是“反转”需要让当前节点指向它的前驱所以你要维护 prev 和 cur 两个指针让它们形成一个能交替前进的组合。一个共同点是两道题都极度依赖 dummy 或 prev 这种“额外一针”否则头节点就是最尴尬的位置。删除题里 dummy 让头节点不再特殊反转题里 prev 最初为空代表反转后链表的尾节点应该指向空。理解了这一点你会发现在做链表面试题时各种巧妙手法的本质都是“给头节点找个前驱”或“给尾节点补个结尾”。另外两道题都可以用递归写出非常短的代码。这说明链表这种线性递归结构天然适合用“当前节点 剩余子链表”的视角来拆解。如果你能同时掌握迭代和递归两种写法遇到面试官换着花样问“你能不能换个方式实现”时就能从容应对。5.2 链表题通用套路哑节点、画图、循环不变量如果你刷链表题时经常改错我的建议很简单先画图再写代码。画图时把每个节点的 val 和 next 写清楚然后标明 prev、cur、next 三个指针的位置。操作结束后去检查链表是否断成两截、是否有环、是否丢了节点。这个习惯慢则慢矣但能救回你大量的试错时间。还有一个非常好用的概念是“循环不变量”定义好循环开始时 cur 站在哪里然后保证每次循环结束时 cur 重新回到相同的语义位置。例如 203 虚拟头节点法的循环不变量就是cur 始终指向“已经处理完的部分的最后一个节点”所以每次循环都检查 cur-next。206 迭代法的循环不变量是prev 始终指向“已经反转好的新链表的头”。有了这两句话你在循环体内部就不会乱。哑节点则是在很多题目里都能复用的招式比如删除倒数第 N 个节点、合并两个有序链表、链表 partition。你可以把虚拟头节点理解成“安全垫”它让你不用为头节点的特殊情况写 if-else。每次刷链表题先想一想能不能引入 dummy往往思路就顺了。5.3 刷题顺序与自测清单这两道题做完不要急着继续往下刷。先按下面的清单自测一遍空链表head 为 nullptr对应对空指针分支。单节点链表反转后还是自身删除时如果值等于 val 返回空。头节点就是要删或反转的对象检查是否正确处理。连续相同值节点删除时注意 cur 不能提前移动。删除所有节点虚拟头节点法的返回值最终是 nullptr。反转很长链表确认 prev 作为最终返回值。再把这两道题用不同的写法各做一遍203 分别用虚拟头节点法和递归法206 分别用迭代和递归。之后你可以马上挑战几道后续题删除链表倒数第 N 个节点、两两交换链表中的节点、合并两个有序链表、环形链表。你会发现这些题里到处都能用上哑节点和指针重排的思路。说到底LeetCode 203 和 206 并不难真正难的是你在面试高压下能不能稳定地把它们写对。把今天这 4 种解法吃透多练几遍手写链表面试的第一关就算踏踏实实过去了。