单链表这个东西,学数据结构的人没有不碰的。你翻开任何一本算法书、任何一套面试题,前几页一定会有它。但很多初学朋友卡在了一个很尴尬的位置:定义看懂了,理论也记住了,一动手写代码就翻车——不是指针乱飞,就是插入后链表直接断掉。这篇博文就是来解决这个问题的,我直接用图文拆解的方式,从内存模型到每个操作的代码细节,把单链表的创建、遍历、插入、删除、反转全部过一遍,并附上我实际调试中踩过的坑。不管你是刚开始学数据结构的在校生,还是准备面试需要快速复习的开发者,跟着这篇把单链表的“手感”练出来,后面再学双向链表、循环链表、栈和队列都会顺很多。
1. 单链表的本质:为什么它跟数组完全不一样
1.1 从数组到链表:内存布局的差异
先想一个问题:数组在内存里长什么样?是一段连续的格子,就像一栋楼的同一层连续房间,每个房间大小一样。你想找第3个房间,直接按门牌号算位置就行,所以数组按下标访问是O(1)的。但代价也很明显——你一开始就要把这层楼整个租下来,哪怕只用了其中几间房,剩下的也空着;如果你要扩容,得整层换到更大的地方去。
单链表就不一样。它像一条用绳子串起来的珠子,每一颗珠子散落在内存的各个角落,珠子之间靠绳子连起来。这个“绳子”就是指针(在高级语言里叫引用)。你不需要预先申请一大块连续空间,来一个数据就申请一颗珠子,用完了就拆掉,内存利用率高,插入删除也灵活。代价是你想找第99颗珠子,必须从第1颗开始顺着绳子一颗一颗摸过去,所以随机访问是O(n)。
这个区别是理解链表所有操作的根基。你后面对链表做的每一步——插入、删除、反转——本质上都是在“穿绳子”和“断绳子”,而不是在挪动数据本身。
1.2 单链表的核心结构:节点与指针/引用
单链表的基本单元叫节点(Node),一个节点就干两件事:存数据,存下一个节点的地址。
图1 单链表节点与节点间关联示意 ┌──────────┐ ┌──────────┐ ┌──────────┐ │ data: 12 │ │ data: 5 │ │ data: 8 │ │ next: ──────────> │ next: ──────────> │ next: null│ └──────────┘ └──────────┘ └──────────┘上面这个图就是单链表最经典的形态。每个格子分成上下两部分,上面是数据域,下面是指针域。最后一个节点的next指向null,表示“后面没有了”。如果你脑子里能随时浮现这个形态,后面所有的代码操作都不会晕。
用C语言定义就是:
typedef struct Node { int data; // 数据域,这里以int为例 struct Node *next; // 指针域,指向下一个节点 } Node;用Java或Python定义思路一样,只是语法不同。Java里next是Node类型的引用,Python里直接是一个对象属性。理解了C语言的指针版,其他语言你自然就会了,因为所有链表操作的难点都在于“怎么改指向”,而不是某种语言的语法细节。
在这里我想强调一个很多人初学时的认知偏差:链表操作的核心不是“移动数据”,而是“修改指针”。很多新手写插入代码时,总想着把后面节点的数据挪一挪,给新节点腾位置——这是把数组的思路硬套到链表上。链表插入一个新节点,做的只是:新节点的next指向原来某个节点,前一个节点的next指向新节点。数据一个都没动,只是把绳子重新穿了一下。
2. 写代码之前的设计:头节点、初始化与内存管理
2.1 为什么需要头节点:一个让代码简洁十倍的设计
很多教材在讲链表时,会区分“带头节点”和“不带头节点”。初学者很容易被这个绕晕,我直接给你结论:自己写练习代码或者做项目,强烈建议使用带虚拟头节点(dummy head)的链表。
不带头节点时,链表为空就是一个NULL指针。这时候如果你要在头部插入一个节点,你得修改头指针本身——也就是你需要传二级指针(C语言)或者返回新的头节点。这种情况下,插入逻辑要分“插入位置是头部”和“插入位置是中间/尾部”两种情况写,代码始终有两个分支,非常容易漏。
带头节点后,头节点本身不存有效数据,它的next才指向第一个真正的数据节点。这样一来:
- 空链表的状态是“头节点的next为NULL”,而不是“头指针为NULL”。
- 无论插入、删除的位置在哪,你都不需要修改头指针本身,只需要修改某个节点的next。
- 插入和删除的逻辑统一,代码分支大幅减少。
代价是多占了一个节点的内存,但这个代价换来的逻辑简化是非常划算的。至少在我带过的项目里,用带头节点的方式,新手写链表的出错率能下降一半以上。
图2对比两种设计在“链表为空”时的差异:
不带头节点: head → NULL 带头节点: dummyHead → NULL别小看这个NULL的位置差异。很多野指针、空指针问题,都源于“链表为空时,头指针本身是NULL”这个特殊状态没有被处理好。带头节点之后,你所有操作都基于“dummyHead一定存在”这个前提,心里踏实很多。
2.2 初始化与三种节点创建方式
初始化的任务就是创建那个虚拟头节点,并把next置空:
Node* initList() { Node* dummy = (Node*)malloc(sizeof(Node)); // 创建虚拟头节点 dummy->next = NULL; // 链表为空 return dummy; }创建新节点的标准模板:
Node* createNode(int data) { Node* newNode = (Node*)malloc(sizeof(Node)); newNode->data = data; newNode->next = NULL; return newNode; }这里有个细节你一定会遇到:很多新手的createNode函数里,newNode->next忘记置NULL。在循环创建节点时可能碰巧没问题,但一旦你用“while (p != NULL)”遍历链表,最后一个节点的next指向一个随机地址,程序直接崩溃。malloc出来的内存是脏的,不是0,所以新节点的next一定要手动置NULL。
内存管理这块,我多说一句:写链表练习时用malloc,用完了一定要free;用new的,用完了一定要delete。我见过太多人练习时图省事,小内存泄漏不在乎,结果面试手写链表时,面试官追问“如果让你把整个链表释放掉,怎么保证不遗漏不重复”直接卡壳。链表的释放要一路顺着next走,先把后面的节点保存下来,再释放当前节点,否则你释放了当前节点,它的next就访问不到了:
void freeList(Node* dummy) { Node* cur = dummy->next; while (cur != NULL) { Node* nextNode = cur->next; // 先保存下一个 free(cur); // 释放当前 cur = nextNode; // 移到下一个 } free(dummy); }这个“先保存后删除”的思路,在删除链表节点的时候也会反复用到,务必形成肌肉记忆。
3. 核心操作逐一拆解:创建、遍历、插入、删除、查找
3.1 遍历和打印:所有操作的地基
所有链表的操作都建立在遍历之上。遍历就是从头节点开始,一路顺着next走到尽头。打印代码长这样:
void printList(Node* dummy) { Node* cur = dummy->next; // 跳过头节点 while (cur != NULL) { printf("%d -> ", cur->data); cur = cur->next; } printf("NULL\n"); }这段代码引出了一个最重要的遍历原则:千万不要直接用头指针或者虚拟头指针去遍历,而是要重新定义一个临时变量cur。新手最常见的错误就是写while (dummy != NULL) { ... dummy = dummy->next; },遍历完发现头指针丢了,链表再也找不回来了。这里我分享一个排查技巧:如果遍历完链表后程序还能正常访问链表,八成是你用了临时变量;如果遍历完链表再想从头走一遍发现走不了,那一定是你把头指针给移动了。
遍历的时间复杂度是O(n),空间复杂度O(1),这个不用多说,后面所有基于遍历的操作都会在这两个复杂度基准上进行。
3.2 头插法和尾插法:两种建链思路
头插法是把新节点插到虚拟头节点的后面,也就是每次插完,新节点都成为第一个数据节点:
void insertAtHead(Node* dummy, int data) { Node* newNode = createNode(data); newNode->next = dummy->next; dummy->next = newNode; }注意这个顺序:先让新节点指向原第一个节点,再让虚拟头节点指向新节点。顺序不能反。如果先把dummy->next改成newNode,那原来的第一个节点地址就丢了,新节点就无法连接到它。图3展示头插两步的顺序:
第一步: newNode->next = oldFirst 第二步: dummy->next = newNode这里有个很多人没意识到的应用:头插法天然实现逆序。你按1、2、3、4的顺序用头插法建链表,最终打印出来是4、3、2、1。后面讲单链表反转时,有一种简洁实现就是重新遍历原链表,用头插法建一个新链表。
尾插法需要先找到当前最后一个节点。判断最后一个节点的条件是cur->next == NULL。找到后,把last->next指向新节点:
void insertAtTail(Node* dummy, int data) { Node* newNode = createNode(data); Node* cur = dummy; while (cur->next != NULL) { cur = cur->next; } cur->next = newNode; }头插法是O(1),尾插法是O(n),因为你必须走到链表末尾才能接上。如果频繁尾插,一个常见的优化是额外维护一个tail指针指向链表末尾。但要注意,tail指针在插入、删除后都必须同步更新,否则就会出现指针指向失效。练习阶段不建议一开始就加tail,先把遍历思路练熟再加优化。
3.3 按位置插入:最考验指针操作的环节
按位置插入是链表操作的分水岭,很多人就是在这里开始迷糊的。假设我们要在第pos个数据节点之前插入新节点(pos从0开始计数),核心步骤是:
- 找到第pos-1个节点,记为prev。
- 新节点newNode的next指向prev的next。
- prev的next指向newNode。
找第prev节点的逻辑就是从头向后走pos步:
void insertAtIndex(Node* dummy, int data, int pos) { Node* cur = dummy; // 从虚拟头节点开始 // 走pos步,找到第pos-1个节点 while (pos > 0 && cur != NULL) { cur = cur->next; pos--; } if (cur == NULL) { printf("位置越界\n"); return; } Node* newNode = createNode(data); newNode->next = cur->next; cur->next = newNode; }这段代码有很多值得讲的地方。为什么要从虚拟头节点开始走pos步而不是从第一个数据节点走pos-1步?因为当pos为0时,也就是要插到链表头部,cur一开始是虚拟头节点,走0步,此时cur正好是虚拟头节点,完美支持头部插入。如果不带头节点,pos为0的情况就得单独写一个分支。这就是前面说的带头节点让代码变简洁的最好例证。
边界条件是另一个重点。位置越界时cur会走到NULL,此时不能继续插入,否则就是对NULL解引用,程序直接崩溃。很多人在写链表插入时只考虑了正常情况,忘了处理边界,一测试就出问题。我建议你写链表代码时,脑子里始终挂着三个位置:头部、中间、尾部,以及一个错误位置:越界。这四种情况都要在逻辑上覆盖住。
图4是中间插入的指针变化:
插入前: prev → nextNode 插入后: prev → newNode → nextNode关键点在于“newNode先指向nextNode,prev再指向newNode”,这个先后顺序在任何链表操作中都不能乱。
3.4 删除节点:必须记住“前一个节点”
删除节点和插入的核心区别是:插入时需要知道插入位置的前一个节点;删除时同样需要知道被删除节点的前一个节点。你要删的是第pos个数据节点,但你的操作对象其实是第pos-1个节点——修改的是它的next。
我直接给出按值删除的代码(删除第一个值为target的节点):
void deleteByValue(Node* dummy, int target) { Node* prev = dummy; // 前一个节点,从虚拟头开始 Node* cur = dummy->next; // 当前节点 while (cur != NULL && cur->data != target) { prev = cur; cur = cur->next; } if (cur == NULL) { // 没找到 return; } // 核心:让prev跨过cur prev->next = cur->next; free(cur); // 释放被删除节点内存 }这段代码里最需要注意的就是free(cur)之后,cur不能再被访问。有些新手会在free之后还想用cur->next去干什么,那已经是未定义行为了。正确姿势是在free之前已经把cur->next取出来赋给了prev->next。
关于“prev还是cur”?这是删除操作里新手最纠结的地方。我这么说吧:你找到cur时,其实你已经知道怎么删了——就是让cur的前一个节点绕过cur。所以遍历时你必须同时保留prev。很多教材里用“快慢指针”“双指针”来称呼这种套路,其实在删除这里就是一个简单的事实:单向链表只能往后走,你想回头找前一个节点,只能提前用一个变量把它记下来。这个思想贯穿整个链表:单向链表做不到的事情,就用额外的变量来记忆。
按位置删除的代码类似,这里不做重复,主要逻辑就是把刚才插入的定位方式拿过来,找到第pos-1个节点,然后跨过去。
3.5 查找与修改:相对简单的两类操作
按值查找非常直观:
Node* findNode(Node* dummy, int target) { Node* cur = dummy->next; while (cur != NULL) { if (cur->data == target) return cur; cur = cur->next; } return NULL; // 没找到 }修改节点的值就更容易了——找到节点直接改data。但这里有个场景值得补充:当你拿到一个Node*指针,想修改它的值,语法上没什么可讲的。不过你需要注意,如果这个值是一个动态分配的内存(比如字符串指针),那就涉及深拷贝和浅拷贝的问题了。链表节点存字符串指针时,很多人的代码释放了一个节点的内存,但里面指向的字符串内存没有释放,又或者两个节点指向同一个字符串,释放时重复释放。这些坑我后面统一说。
3.6 单链表反转:面试高频题的三种解法
反转是整个单链表面试题里的常青树,它的解法也最能体现你对指针操作的理解程度。我讲三种解法,从直观到巧妙。
解法一:迭代反转(三指针法)
准备三个指针:prev、cur、next。初始时prev指向NULL,cur指向第一个数据节点。每次循环中:
- 保存cur的下一个节点到nextNode。
- 将cur的next指向prev。
- prev前移到cur。
- cur前移到nextNode。
Node* reverseList(Node* head) { // 这里传入的是第一个数据节点 Node* prev = NULL; Node* cur = head; while (cur != NULL) { Node* nextNode = cur->next; // 先保住后面的节点 cur->next = prev; // 掉头 prev = cur; // prev前移 cur = nextNode; // cur前移 } return prev; // 新的头节点 }图5展示了反转过程中指针的变化:
第一轮:NULL ← [1] [2] → [3] → [4] 第二轮:NULL ← [1] ← [2] [3] → [4] 第三轮:NULL ← [1] ← [2] ← [3] [4] 第四轮:NULL ← [1] ← [2] ← [3] ← [4]这个解法的核心就是那个nextNode,它必须在一开始就保存好cur的下一个节点,否则cur->next一旦被修改,后面的节点就找不到了。这个思路和前面free链表时的“先保存后操作”如出一辙。
解法二:递归反转
递归写法非常简洁,但理解门槛稍高:
Node* reverseRecursively(Node* head) { if (head == NULL || head->next == NULL) return head; Node* newHead = reverseRecursively(head->next); head->next->next = head; // 让下一个节点指回自己 head->next = NULL; // 当前节点不再指向下一个 return newHead; }这里最核心也最难理解的一行是head->next->next = head;。这句话的意思是:head的下一个节点本来指向更后面的节点,现在让它掉头指向head自己。递归的base case是链表为空或者只有一个节点,这种情况不需要反转。递归的思考方式不要陷入每一层的细节,而是相信函数语义:它返回的是反转后的新头节点。
我见过很多人学递归反转时在纸上推演半天,推完更晕。我的建议是:先记住这个函数的语义和两行关键代码,然后找几个小例子在代码里跑几遍,看输出对不对。数据结构这块,很多东西是靠做、靠跑,而不是靠想通的。
解法三:头插法建新链表
这个就是前面说的“头插法天然逆序”的应用。创建一个新虚拟头节点,遍历原始链表,每取出一个节点就头插到新链表中。代码就不重复了,思路等于把insertAtHead用起来。这种方法逻辑最直观,但面试时面试官往往希望你写出迭代法或递归法,因为头插法需要额外内存。
4. 实操演练:从零构建一个完整单链表
4.1 完整代码走读
这一节我把上面的内容串起来,给一个可以直接跑起来的完整C语言版本。很多人看零散代码以为懂了,但把所有函数拼在一起时又会出问题,所以我强烈建议你跟着这份代码完整敲一遍,然后亲自跑起来。
#include <stdio.h> #include <stdlib.h> typedef struct Node { int data; struct Node *next; } Node; Node* initList() { Node* dummy = (Node*)malloc(sizeof(Node)); dummy->next = NULL; return dummy; } Node* createNode(int data) { Node* n = (Node*)malloc(sizeof(Node)); n->data = data; n->next = NULL; return n; } void insertAtHead(Node* dummy, int data) { Node* n = createNode(data); n->next = dummy->next; dummy->next = n; } void insertAtTail(Node* dummy, int data) { Node* n = createNode(data); Node* cur = dummy; while (cur->next != NULL) cur = cur->next; cur->next = n; } void insertAtIndex(Node* dummy, int data, int pos) { Node* cur = dummy; while (pos > 0 && cur != NULL) { cur = cur->next; pos--; } if (cur == NULL) { printf("插入位置越界\n"); return; } Node* n = createNode(data); n->next = cur->next; cur->next = n; } void deleteByValue(Node* dummy, int target) { Node* prev = dummy; Node* cur = dummy->next; while (cur != NULL && cur->data != target) { prev = cur; cur = cur->next; } if (cur != NULL) { prev->next = cur->next; free(cur); } } void printList(Node* dummy) { Node* cur = dummy->next; while (cur != NULL) { printf("%d -> ", cur->data); cur = cur->next; } printf("NULL\n"); } void freeList(Node* dummy) { Node* cur = dummy->next; while (cur != NULL) { Node* nextNode = cur->next; free(cur); cur = nextNode; } free(dummy); } int main() { Node* list = initList(); insertAtHead(list, 10); insertAtHead(list, 20); insertAtTail(list, 30); insertAtIndex(list, 40, 1); printList(list); // 预期输出:20 -> 40 -> 10 -> 30 -> NULL deleteByValue(list, 10); printList(list); // 预期输出:20 -> 40 -> 30 -> NULL freeList(list); return 0; }这段代码把前面的几个操作整合在一起。你可以在本地编译运行,观察每步输出的变化。调试链表时有个小技巧:每做一次插入或删除就把printList()调出来打一遍,看到底哪个环节指针出了问题。不要指望一口气写好一个链表,然后一次跑通,链表代码一定要边写边验证。
4.2 测试用例设计
自己做练习时,很容易陷入“只测正常情况”的误区。比如插入操作,你只测了插到中间,没测插到头部、尾部、越界位置,结果头部插入有问题你没发现。我列一份链表测试清单,你照着测一遍,基本能把绝大多数边界问题暴露出来:
| 测试项 | 测试操作 | 预期结果 |
|---|---|---|
| 空链表插入头 | 对空链表执行insertAtHead | 链表有一个节点 |
| 空链表插入尾 | 对空链表执行insertAtTail | 链表有一个节点 |
| 头部插入 | 在已有链表头部插入 | 新节点成为第一个数据节点 |
| 尾部插入 | 在已有链表尾部插入 | 新节点成为最后一个节点 |
| 中间插入 | 在pos位置插入 | 链表顺序正确 |
| 越界插入 | 在超过链表长度的位置插入 | 程序不崩溃,提示越界 |
| 删除头节点 | 删除第一个数据节点 | 第二个节点成为新头 |
| 删除尾节点 | 删除最后一个数据节点 | 倒数第二个节点的next变成NULL |
| 删除不存在的值 | 删除一个链表里没有的值 | 链表不变,程序不崩溃 |
| 删除空链表 | 空链表删除 | 程序不崩溃 |
这张表里的每一个“程序不崩溃”都很重要。链表代码最常见的失败不是逻辑算错,而是直接段错误或者空指针异常。很多初学朋友测链表时只跑正常路径,一变态输入就崩,面试官看一眼就知道你边界处理能力不行。你把上面这张表在本地跑一遍,比纯看十篇教程都管用。
5. 常见问题与排查技巧:我踩过的那些坑
5.1 空指针与野指针:链表崩溃的头号元凶
我见过最多的链表错误就是操作了NULL指针。比如在insertAtIndex里,如果函数调用方传了个负数pos,while循环根本不会进入,cur一直是虚拟头节点,这时候插入操作实际上变成了头插,程序不崩但逻辑错了;如果pos大于链表长度,cur最后可能是NULL,这时cur->next就是空指针解引用,程序直接崩。
排查空指针问题有一个非常实用的方法:在解引用指针之前,先打印指针的值,或者用调试器查看它是不是NULL。很多新手一崩了就不知道从哪下手,其实只需要定位到崩溃的那一行,再往上看这个指针是在哪被赋值的,是NULL还是随机地址,很快就能找到原因。
还有一个很容易被忽略的点:malloc失败会返回NULL。在要求严格的生产环境,必须检查malloc的返回值。不过练习阶段内存充足,一般不会走到这一步。但你要知道这个常识,面试被问到“malloc返回NULL怎么办”不能回答不上来。
5.2 修改头节点时的指针传递问题
C语言里,函数传参是值传递。你在函数里修改形参本身(比如让形参指向别的地方),调用方感知不到。很多新手写insertAtHead时,想把新的头节点地址带回去,就写了一个只传Node*头指针的函数,在函数内部把形参指向了新节点,然后欢天喜地回到main函数发现链表根本没变。
解决办法就是我前面推荐的:用虚拟头节点,不动头指针本身,只修改dummy->next。从根源上规避了二级指针问题。如果你非要用不带头节点的链表,那就得用Node**来传参,或者让函数返回新的头节点。这个知识点在面试时经常被拿出来考,理解值传递就明白了为什么必须要这么做。
再说说Java、Python为什么没有这个烦恼:它们传的是引用,但本质上也是传引用的副本。你修改传入的引用让它指向新对象,外部变量依然不受影响,跟C的值传递在“修改形参指向”这个层面上是一样的。所以无论什么语言,带头节点的思路都成立。
5.3 内存泄漏与悬空指针
写链表练习时内存问题主要有三类:忘记释放、重复释放、释放后还在用。
- 忘记释放:只malloc不free,程序内存越用越多。常见于循环创建节点后链表丢了一部分,剩余部分没人释放。
- 重复释放:free同一个指针两次,会触发未定义行为。常见于两个节点指向同一个动态分配的区域,释放第一个时把区域还给系统,释放第二个时系统已经不知道这块内存了。
- 悬空指针:free了一个节点,但还有别的指针指向它。之后再访问这个指针,得到的是已经还给系统的内存,内容可能已经被改写。
对于链表练习,我的建议很简单:分配节点时就想着谁负责释放。虚拟头节点的内存你负责,closeList时释放;每个数据节点在删除时,由删除操作的执行者负责释放;整个链表销毁时,freeList负责。明确了每个节点的“责任人”,内存问题能少一大半。
5.4 链表调试的实用技巧
这里分享几个我实际调试链表代码时反复用、觉得最有效的方法。
第一,纸笔推演。写链表代码不需要电脑,先拿纸画几个方框和箭头,模拟一遍插入、删除过程。我并不是让你养成依赖纸笔的习惯,而是说当你某段代码搞不明白时,画图比盯着代码想有效十倍。图4那种节点示意图,你手画一遍就记住了。
第二,打印大法。在关键位置打印日志,看指针变化。特别是检查cur在循环里的移动方向,以及printList是否正常打印。很多人觉得打印大法土,但这个方法是调试链表最快的手段,远比你抱着代码愣想效率高。
第三,小数据量测试。链表调试不要一上来就用几万个节点,5个节点以内足够了。节点越少,出问题越容易定位。我曾经为了验证反转逻辑,用一个4节点的链表,在纸上迭代了三次,把所有指针的指向都画出来,相当于把程序手动执行了一遍。这个方法对于理解迭代反转特别有效。
第四,多跑极端输入。空链表、只有一个节点的链表、删除不存在的值、插入越界的位置,这些极端输入一定要跑。链表大部分隐性bug只有在极端输入下才会暴露,这也是面试官最爱考察的点。
6. 单链表的进阶方向与实际应用
到这里,单链表的基本操作已经全部覆盖。但真正要用好链表,还需要知道它的边界和后续方向,这里做一个简洁的延伸。
单链表在实际系统里最出名的应用应该是LRU缓存淘汰算法。维护一个按访问时间排序的链表,每次访问一个数据,要么把它移到链表头部(如果已存在),要么直接插入头部;当缓存满时,就删除尾节点。这个算法实现简单、理解直观,是链表实战最好的入门项目。你现在学会了头插法和删除尾节点,直接就能上手写一个简化版LRU。
进阶方向包括:双向链表(多了prev指针,删除时不需要找前驱)、循环链表(尾节点指向头节点,适合环形场景,比如约瑟夫环)、跳表(多层链表加速查找,是Redis有序集合的底层实现之一)。学会了单链表之后,双向链表和循环链表的核心操作几乎可以直接迁移,跳表的思路则会在你熟练掌握链表指针操作后更容易理解。
我个人在实际操作中的体会是:链表这个东西,不要把它当理论去学,一定要当成手艺去练。你画一百遍图,不如亲手敲三遍代码、跑十个测试用例。前面那张测试清单就是我建议你起步的地方。别急着去刷那些“链表反转变体”“K个一组反转链表”之类的进阶题,先把基础操作练到不需要想、手到擒来,再往上走会轻松得多。如果在练的过程中某个操作不符合预期,回到用纸笔画箭头的那一步,画一画,你就知道问题在哪了。