☰
手撕list底层机制:从增删改查到反转链表与快慢指针
2026/10/17 17:58:04 网站建设 项目流程

“手撕list”这四个字,在不同语境下含义天差地别:生活里可能是愿望清单、购物清单,但在面试官嘴里,它几乎就是链表的代称。我当年第一次遇到“手撕list”,是在某公司二面的白板环节,面试官让我从节点定义开始手写一个链表反转。当时我LinkedList的API背得滚瓜烂熟,addFirst、removeLast张口就来,可真到自己在白板上从空无一物开始写节点类时,卡了整整十分钟。那次经历让我彻底明白:会调用list和能手撕list,是两种完全不同的能力。这篇文章我想把“手撕list”这件事拆开讲透——从底层机制到增删改查完整实现,从高频考题到最容易翻车的细节。适合正在准备面试的同学,也适合那些不想再停留在“用过内置List”层面的开发者。

1. 为什么要手撕list:一场面试为什么会卡在链表上

很多人不理解,工作中明明用ArrayList和LinkedList就够了,为什么面试还非要人白板手写链表?问这个问题的人,多半还没意识到链表题目背后真正考察的东西。

1.1 手撕代码到底在考察什么

链表题目本身不难,但它是一块性价比极高的试金石。面试官限时让你手撕list,不是因为他觉得你入职后要天天写链表,而是因为这道题能在短短几分钟内暴露你的三层功底:

第一层是引用语义的理解。链表节点是引用类型,head变量移动、node.next赋值,本质是对象引用的重新指向。如果你以为“head = head.next”是把链表整体挪动了,后续代码必然错乱。

第二层是边界条件的敏感度。空链表、单节点、删除头节点、删除尾节点……每个分支都要想到。很多人链表中环检测写得飞起,但链表只有一个节点时就挂了,这就是边界意识不足。

第三层是代码组织的条理性。手撕list需要同时维护多个指针的先后顺序,先动哪个、后动哪个,直接反映逻辑是否清晰。面试官看的就是你在断链和缝合之间的那几步是不是干净利落。

说白了,面试官通过一道链表题,就能推测出你在真实项目里写复杂状态变更代码时的可靠性。

1.2 工程里自己写链表的真实场景

等工作之后你会发现,业务代码里直接用内置容器确实省事,但某些性能敏感和强约束场景,内置容器反而是黑盒。举几个我实际遇到过的场景:

第一类是LRU缓存。维护一个按访问时间排序的缓存,需要在O(1)时间内完成查找、插入、删除,最经典的做法就是“哈希表+双向链表”。很多语言内置的LinkedHashMap能顶一部分需求,但当你需要自定义淘汰策略、或者面试现场要实现一个简化版LRU时,还是要靠自己手撕双链表。

第二类是任务调度队列。某些场景需要在队列中间动态插入高优先级任务,数组结构移动代价高,链表插入天然O(1)。有些定时任务框架内部就是环形链表管理时间轮。

第三类是受限环境。嵌入式、内核态、部分游戏引擎环境没有现成标准库,所有容器类都要自己实现。没有手撕list的能力,到这类项目里会很被动。

1.3 链表背后牵连的知识网络

手撕list并不是孤立考点,它像一个中枢节点,把很多算法常识串联起来。我整理了一下这张关联表:

链表问题牵连知识点
反转链表迭代三指针、递归思路、空间复杂度取舍
合并两个有序链表递归拆分、归并排序核心逻辑
删除倒数第N个节点快慢指针、一次遍历替代两次遍历
环形链表检测快慢指针、相遇问题的数学原理
LRU缓存哈希表与双向链表组合、O(1)查找与更新
链表相交问题双指针路程差思想

所以“手撕list”练的不是孤立的一道题,它是把引用类型、递归、双指针这几大基础模块一起揉进去了。这个理解到位了,后面刷二叉树、刷图论的一些双指针和递归技巧也会顺很多。

2. 先搞懂底层机制:节点、引用与哨兵节点

真正动手写代码之前,有几个概念必须先想透。很多人在白板上写得磕磕绊绊,不是不会语法,而是脑子里对链表的“物理结构”不够清晰。

2.1 节点的本质是“值+引用”

链表的基本单元是节点,Java里通常长这样:

class ListNode { int val; ListNode next; ListNode() {} ListNode(int val) { this.val = val; } ListNode(int val, ListNode next) { this.val = val; this.next = next; } }

每个节点就是一个小盒子,里面装着自己的值,还有一个指向下一个节点的引用。你可以把链表想象成一列火车:每节车厢装货物,车尾的挂钩指向下一节车厢。C++叫指针,Java、Python叫引用,但本质是同一个东西——变量存的是对象的地址,而不是对象本身。

这个基本认知直接对应一个容易翻车的细节:两个节点对象,A的next指向B。如果后面你往B后面又接了一个C,那么A.next.next自然就是C。很多人意识不到这一点,会误以为链表只是一个数组那样的线性存储,实际上节点之间是通过引用互相攀附着,改动任何一个节点的后继都会影响整条链。

2.2 引用语义为什么是头号陷阱

手撕list时最常见的误区,是分不清“移动指针”和“改变链表结构”。

看这句代码:

ListNode cur = head; cur = cur.next;

很多人把第二行理解成“链表往前走了一步”,不对。这行代码只是把局部变量cur重新指向了下一个节点,链表本身的结构没有任何变化。就像你在火车旁边走,你从第一节车厢挪到第二节,火车并没有动。

反过来再看这段插入操作:

node.next = pre.next; pre.next = node;

这两行代码才会真正改变链表结构。第一步让新节点先指向pre原来的后继,第二步才把pre的后继改成新节点。这两步顺序如果反过来,pre原来的后继链就找不到了,相当于你把火车的一节车厢拆下来,但没等接好新的就松了手,后面的车厢全部丢了。

理解这一层,后面写任何链表题都不会慌,因为你知道自己每一步到底在改指针还是在移动视角。

2.3 虚拟头节点到底省了什么

链表操作最麻烦的地方,就是头节点没有前驱。插入到头部和删除头节点时,都要单独if判断。为了解决这个问题,业界的通行做法是引入一个哑节点,也叫虚拟头节点:

ListNode dummy = new ListNode(0); dummy.next = head;

这个dummy节点不存真实业务数据,它的唯一作用是占住“头节点的前驱”位置。有了它之后,所有插入和删除逻辑都可以统一成一种模板:先找到要操作的节点的前驱,然后改写前驱的next。不需要再单独判断“我要操作的是不是头节点”。

这个技巧在反转链表、删除倒数第N个节点、合并两个有序链表这类题里几乎是必用的。但要注意一个经典错误:操作完成后返回的是dummy.next,而不是dummy。有些新手写着写着把自己绕进去了,把dummy当作新的头节点返回,整个链表就凭空多出一个值为0的无用节点。

2.4 单链表、双链表、环形链表怎么选

面试手撕时,题目没特殊要求,默认都是单链表。但日常工程和更复杂的题目里,选型差异值得了解。

类型内存开销插入/删除典型场景
单链表低头插O(1),尾插需遍历简单队列、栈、反转类题目
双链表高(额外prev指针)已知节点删除O(1)LRU缓存、需要双向遍历
环形链表视实现而定尾接回头部,适合循环时间轮、约瑟夫环、循环队列

单链表空间最省,但想删除某个节点时必须知道它前驱是谁;双链表多存一个prev引用,空间换时间,删除节点时很方便。环形链表本质是让尾节点重新指向头节点,适合需要周期性循环遍历的场景。

3. 从零手写一个可运行的单链表:增删改查全流程

工具和原理准备好了,现在真刀真枪写代码。这里我选择Java实现,因为它是最常见的面试语言,引用语义也足够清晰。为了让大家看清边界条件的处理,这一版我不用虚拟头节点,直接从最朴素的head实现开始。

3.1 类骨架与初始化

class MyLinkedList { int size; ListNode head; public MyLinkedList() { size = 0; head = null; } }

这里维护一个size字段非常重要。有了它,判断index合法性时不需要遍历链表数节点;没有它,每次操作前都要从头到尾走一遍才知道链表多长,不仅低效,还容易在边界判断上出错。size就是链表的“长度路标”。

初始状态head为null、size为0,代表这是一个空链表。

3.2 index合法性判断的微妙之处

链表操作的index规则,面试里经常拿来挖坑。核心记住一句话:get和delete的合法index范围是 [0, size-1],而addAtIndex的合法范围是 [0, size]。

为什么addAtIndex能多一个位置?因为在index等于size的位置插入,正好是往链表末尾追加一个节点,这是合法且常见的操作。而get和delete如果index等于size,意味着访问或删除“最后一个节点之后”的位置,显然越界。

这个细节不注意的话,addAtIndex的边界判断很容易多写一个等号,或者少写一个等号,然后白板上一时半会还发现不了。

3.3 完整实现与逐段拆解

public int get(int index) { if (index < 0 || index >= size) { return -1; } ListNode cur = head; for (int i = 0; i < index; i++) { cur = cur.next; } return cur.val; }

get的逻辑就是从头开始,走index步,落在目标节点上。这里注意循环用i < index,而不是i <= index,因为头节点本身就是第0个节点,走index步恰好到达第index个节点。边界index=0时,循环不执行,直接返回head.val,这也是对的。

public void addAtHead(int val) { ListNode node = new ListNode(val); node.next = head; head = node; size++; }

头插两步走:新节点的next指向当前head,然后head更新为新节点。顺序不能反,如果先把head指向新节点,那原来链表第一个节点就找不到了。

public void addAtTail(int val) { if (size == 0) { addAtHead(val); return; } ListNode cur = head; while (cur.next != null) { cur = cur.next; } cur.next = new ListNode(val); size++; }

尾插要先遍历到最后。空链表时没有“最后一个节点”,直接复用addAtHead。遍历条件用cur.next != null,走到最后一个节点时cur.next是null,循环停止,再把新节点挂在cur后面。这一段最常见的错误是用while (cur != null),结果循环结束后cur已经是null,再对cur.next赋值就空指针了。

public void addAtIndex(int index, int val) { if (index < 0 || index > size) { return; } if (index == 0) { addAtHead(val); return; } if (index == size) { addAtTail(val); return; } ListNode pre = head; for (int i = 0; i < index - 1; i++) { pre = pre.next; } ListNode node = new ListNode(val); node.next = pre.next; pre.next = node; size++; }

中间插入的核心是找前驱。要插入到index位置,需要让pre停留在index-1这个节点上,所以循环从i=0走到i < index-1。找到前驱后,先让新节点接住前驱原来的后继,再让前驱指向新节点。这个顺序我在前面强调过:先接后断。

public void deleteAtIndex(int index) { if (index < 0 || index >= size) { return; } if (index == 0) { head = head.next; size--; return; } ListNode pre = head; for (int i = 0; i < index - 1; i++) { pre = pre.next; } pre.next = pre.next.next; size--; }

删除头节点直接让head指向第二个节点即可。删除其他位置同样先找前驱,然后让前驱跳过目标节点,直接指向目标节点的后继。pre.next = pre.next.next这行代码,本质就是“断开中间节点”。

3.4 和内置LinkedList对比:什么时候该用自己的链表

写完这段代码,你大概能感受到JDK内置LinkedList为什么在工程里口碑两极分化。内置LinkedList是双向链表,操作确实灵活,但CPU缓存不友好,内存占用大,随机访问要O(n),所以在大部分需要随机访问的业务场景里,ArrayList反而更快。

手写单链表的意义不在“比内置更好用”,而在于两点:一是面试必须,二是定制场景必须。当你需要实现一个按访问时间排序的LRU结构、当你需要给每个节点额外挂载一份索引、当你需要在某些受限环境里完全不依赖标准库,你就需要自己动手。

还是那句话:知道什么时候用数组、什么时候用链表,比你背多少API都重要。

4. 手撕list高频考题:反转、合并、倒数节点、环检测

基础结构写完了,接下来是面试里出现频率最高的四道题。每一道我都会按“思路推导、代码实现、面试追问”三层来讲。

4.1 反转链表:先画图,再写三指针

反转链表的标准做法是迭代三指针。核心思路:维护prev、cur两个指针,每次循环先记录cur的下一个节点next,再把cur的next指向prev,然后prev和cur整体右移。

public ListNode reverseList(ListNode head) { ListNode prev = null; ListNode cur = head; while (cur != null) { ListNode next = cur.next; // 先保存,否则断链 cur.next = prev; prev = cur; cur = next; } return prev; }

为什么一定要先保存cur.next?因为执行cur.next = prev时,cur的原有后继就丢了。不提前保存,后面的节点全找不回来。你可以把这一步理解为:你要把一节车厢从列车中摘出来,改挂到另一列车头,动手之前先记下这节车厢后面还拖着什么。

面试官大概率会追问递归版:

public ListNode reverseList(ListNode head) { if (head == null || head.next == null) { return head; } ListNode newHead = reverseList(head.next); head.next.next = head; head.next = null; return newHead; }

递归的终止条件是“链表为空或只剩一个节点”。递归返回后,新链表的头节点是newHead,当前节点head需要接到已经反转好的子链表末尾,也就是让head.next.next指向head,最后把head.next置空,否则会出现环。递归版本写起来更简洁,但空间复杂度是O(n),因为每一层递归都占用栈帧。迭代版空间复杂度O(1),工程上更优。

4.2 合并两个有序链表:递归与迭代

这道题是归并排序的缩影,两个有序链表按大小交替拼接成一个新链表。

迭代版用虚拟头节点会非常舒服:

public ListNode mergeTwoLists(ListNode l1, ListNode l2) { ListNode dummy = new ListNode(0); ListNode tail = dummy; while (l1 != null && l2 != null) { if (l1.val <= l2.val) { tail.next = l1; l1 = l1.next; } else { tail.next = l2; l2 = l2.next; } tail = tail.next; } tail.next = (l1 != null) ? l1 : l2; return dummy.next; }

这里用dummy是因为新链表从空开始构造,没有一个现成的头节点。tail始终指向新链表的末尾,每次把较小节点挂到tail后面,然后相关指针右移。循环结束后,必有一个链表没走完,一次性把剩余部分接上。

递归版同样精彩:

public ListNode mergeTwoLists(ListNode l1, ListNode l2) { if (l1 == null) return l2; if (l2 == null) return l1; if (l1.val <= l2.val) { l1.next = mergeTwoLists(l1.next, l2); return l1; } else { l2.next = mergeTwoLists(l1, l2.next); return l2; } }

递归思路是“每次只比较当前两个头节点”,谁小谁做新链表的头,然后递归合并剩余部分。代码很短,但如果对递归不熟,很容易绕晕。我的建议是先吃透迭代版,再回头看递归版,两版都掌握后,二叉树的递归题也会更顺手。

4.3 删除倒数第N个节点:快慢指针的正确打开方式

最直接的做法是两次遍历:第一次算长度,第二次删节点。但面试官多半会追问“能不能一次遍历”,这就是快慢指针的出场时机。

思路:让快指针先走n步,然后快慢指针同步前进。当快指针走到链表末尾时,慢指针恰好停留在倒数第n+1个节点,直接让slow.next = slow.next.next,完成删除。

public ListNode removeNthFromEnd(ListNode head, int n) { ListNode dummy = new ListNode(0); dummy.next = head; ListNode fast = dummy; ListNode slow = dummy; for (int i = 0; i < n; i++) { fast = fast.next; } while (fast.next != null) { fast = fast.next; slow = slow.next; } slow.next = slow.next.next; return dummy.next; }

为什么这里必须用dummy?因为如果要删除的正好是头节点,没有dummy的情况下slow停留不到目标节点的前驱,你会被头节点特判卡住。用了dummy之后,慢指针最多也只会在dummy处停留,然后统一用“前驱删除法”,不需要分类讨论。

4.4 环形链表检测与环入口:快慢指针的数学原理

检测链表有没有环,最经典的是快慢指针:快指针每次走两步,慢指针每次走一步。如果链表有环,两者一定会在环内相遇;如果没有环,快指针先到null。

public boolean hasCycle(ListNode head) { ListNode slow = head; ListNode fast = head; while (fast != null && fast.next != null) { slow = slow.next; fast = fast.next.next; if (slow == fast) { return true; } } return false; }

为什么快慢指针在有环时必然相遇?快指针相对慢指针的速度是“每轮多走一步”,相当于慢指针不动,快指针以步长1追赶。如果环的长度是L,快指针最坏绕完L步,必然追上慢指针。所以这里的关键不是快指针“跑得快”,而是相对速度恰好是1,保证追得上。

如果面试官继续追问“怎么找环的入口”,就用Floyd判圈法:第一次相遇后,把快指针重新放回head,然后快慢指针每次都走一步,再次相遇的位置就是环入口。背后的数学推导有兴趣可以自己推,面试时能说出结论和“快慢指针在环内相遇后,从头再走一次”这个操作,基本就能过关。

5. 手撕list翻车现场:空指针、断链和脑子里的“图”

即便原理吃透了,真正上白板的时候还是会翻车。这里集中讲我见过的几类高发问题,以及对应的排查思路。

5.1 空指针:访问每个节点的next之前先问一句“它为null吗”

链表题90%的空指针都出在同一个动作上:对一个可能为null的节点取next。

典型场景是尾插时写while (cur != null),循环退出后cur已经为null,再写cur.next = new ListNode(val),直接爆NPE。正确的写法是while (cur.next != null),让cur停在最后一个非null节点上。

另一个高发点是删除节点时写pre.next.next。如果pre.next已经为null,这一行也炸。每次写这类代码前,可以先问自己:“pre.next可能是null吗?”如果是,先加保护分支,或者调整遍历条件。

5.2 断链问题的根因:更新指针顺序反了

断链比空指针更隐蔽,因为它不报错,只是链表悄悄丢了一半节点。

我在带A同学时发现,断链的重灾区是插入操作。很多人第一反应是pre.next = newNode,再回头设置newNode.next = pre.next,结果发现newNode.next指向了自己。正确顺序永远是:

  1. 新节点先抓住原链表后继:newNode.next = pre.next
  2. 再让前驱指向新节点:pre.next = newNode

一句话记忆:新节点先抓住旧链条,再把旧链条切开挂到新节点上。删除操作同理,先让前驱的next指向目标节点的后继,断开动作就完成了,不需要额外处理被删除节点。

5.3 边界条件清单:写完代码后必须脑内验算的五个用例

我每次写完链表题,都会强制自己在脑子里跑一遍这些用例:

  • 空链表:反转、合并、删除,能不能正常返回?
  • 只有一个节点:head.next为null,循环会不会进去?
  • 删除头节点:返回值应该是原来的第二个节点。
  • 删除尾节点:前驱的next为null,pre.next.next会炸吗?
  • 插入到index等于size的位置:能不能正确追加到末尾?

这些用例跑完,代码里80%的边界问题都能暴露。白板面试时,哪怕最终代码有一点小错误,你如果能主动说出“这里空链表会走到哪个分支”“删除头节点时我这样处理”,面试官对整体评价也会高很多。

5.4 从“背题”到“画图推演”的练习方法

最后聊一个学习路线问题。很多人刷链表题靠背,刷完十几道题,遇到变形题还是不会。原因很简单:背下来的是代码,不是图。

我的建议是三步法。

第一步,拿到题目先在草稿纸上画链表,用方框表示节点,用箭头表示next关系。把题目里的操作手动演算一遍,比如反转链表,就画三个方框,自己走走指针。

第二步,把每一步指针变化写成伪代码,对照刚才画的箭头。你会发现很多逻辑错误在画图阶段就暴露了,根本不用等跑代码。

第三步,再写真实代码,写完用最小用例脑内验算。整个过程看起来比较慢,但坚持两周、每天三道链表题之后,你基本能做到“见题生图”。

我自己的体会是,手撕list这件事,拼的从来不是手速,而是脑子里有没有那张足够清晰的指针连接图。图在脑中越牢固,白板上的手就越稳。开头我提到自己第一次手撕list卡壳,后来用这套方法练了两个星期,再遇到链表相关题目,基本都能在白板上顺畅写完。希望这篇把底层机制和高频题摊开讲的记录,也能帮你把那座“链表墙”推倒。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询