1. 为什么链表是刷题绕不开的基本盘
我这些年帮人改简历、做模拟面试,发现一个很有意思的现象:不管你是准备考研、应付期末考试,还是冲刺大厂算法面,链表几乎是必考的第一道关卡。它不是最难的,却是最能暴露基本功的。很多人觉得链表不就是“节点串节点”吗,真到写代码的时候,指针一乱、边界一错,整个程序就崩了。尤其在面试里,链表题往往被当作“热身题”,但恰恰是这道热身题,最能看出一个人是真的懂数据结构,还是只会背答案。
链表(Linked List)是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。听起来有点绕,用人话说就是:每个节点(Node)不光存数据,还存着“下一个节点在哪”的地址。这种设计带来的最大好处是插入和删除不需要移动大量元素,只要你找到了位置,改几个指针就能完成,时间复杂度是O(1)。但代价也很明显:你不能像数组那样按下标直接随机访问,想找第 k 个元素,必须从头一个个遍历过去,时间复杂度是O(n)。
刷链表题的核心价值,我认为不在于“把题做出来”,而在于逼你建立三种思维:指针操作的精准性、边界条件的敏感度、空间换时间的权衡能力。这三种能力,不管你以后写业务代码、做底层开发,还是搞算法研究,都用得上。而且链表天然就是一个“递归结构”,处理它的时候经常要用到递归思想,这又和树、图这些更复杂的数据结构接上了。所以你会发现,凡是链表学得扎实的人,学二叉树、学图的遍历通常也不会太费劲,因为它们底层都是同一个逻辑:沿着指针/引用走,边走边处理。
这篇文章我就结合自己刷题和带新人的经验,把链表刷题这件事拆开揉碎了讲清楚。从最基础的节点定义,到高频的插入删除逆序合并,再到快慢指针、边界条件这些容易“翻车”的细节,最后聊一聊链表思想在Redis这些真实系统里的应用,以及怎么安排一条合理的刷题路线。看完你至少能摸清链表题的套路,知道每道题在考你什么,也能避开大部分常见的坑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 链表节点的定义与遍历:所有题型的起点
2.1 先写对节点定义,后面的路才顺
链表题的一切,都从“节点怎么定义”开始。不同语言写法不同,但核心结构是一样的:一个数据域,一个指针域(在Python里叫引用)。
以C语言为例,最经典的单链表节点定义长这样:
c复制typedef struct Node {
int data; // 数据域
struct Node *next; // 指针域,指向下一个节点
} Node;
在C++里,用类或者结构体都行,我一般习惯用struct,因为默认成员是公有的,写起来省事:
cpp复制struct ListNode {
int val;
ListNode *next;
ListNode() : val(0), next(nullptr) {}
ListNode(int x) : val(x), next(nullptr) {}
ListNode(int x, ListNode *next) : val(x), next(next) {}
};
看到那个构造函数了吗?这是LeetCode风格的定义,提供了三种初始化方式,方便你在不同场景下快速创建节点。实际面试写代码的时候,我也建议模仿这种写法,默认值都给上,省得每次new出来还要手动赋next,容易忘。
Python就简单很多,一个类就搞定:
python复制class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
Java也类似:
java复制public class ListNode {
int val;
ListNode next;
ListNode() {}
ListNode(int val) { this.val = val; }
ListNode(int val, ListNode next) { this.val = val; this.next = next; }
}
这里有一个很多新手容易困惑的点:为什么节点定义里要写好几个构造函数?直接写一个带val的不就行了吗?我的理解是,多几个重载可以让你在写代码的时候更灵活。比如你想创建一个带头节点的空链表,直接new ListNode()就行,后面创建实际节点再用new ListNode(x),代码读起来干净不少。当然,如果你只是在刷题阶段,不写构造函数也行,手动赋值也完全没问题。但养成好习惯总是好的,面试的时候代码简洁也是加分项。
2.2 遍历链表:刷题的地基操作
只要涉及链表,遍历就是第一件事。不管你是查找、计数、反转,还是找中点,前提都是“能从头到尾把链表走一遍”。
C语言版本的单链表遍历:
c复制void traverse(Node *head) {
Node *cur = head;
while (cur != NULL) {
printf("%d ", cur->data);
cur = cur->next; // 这一步千万别忘
}
printf("\n");
}
Python版本:
python复制def traverse(head):
cur = head
while cur:
print(cur.val, end=' ')
cur = cur.next
print()
这段代码太基础了,基础到很多人一眼扫过去觉得“这有什么好讲的”。但我见过太多人在这个简单操作上翻车,最常见的错误就是:cur = cur->next这行忘写了,结果进入死循环,程序卡死。还有的人喜欢用while (head != NULL)然后head = head->next,这样虽然也能遍历,但会污染原始头指针,后面想再用头节点就找不回来了。所以强烈建议,遍历的时候用一个临时变量cur来游走,不要动head本身。
另外有一个细节,判断条件到底是while (cur != NULL)还是while (cur->next != NULL)?这个取决于你的目的。前者是“遍历所有节点”,适合查找、打印;后者是“停在最后一个节点上”,适合做尾部插入。很多题目的解法差异,根源就在这个判断条件上。你在写代码之前,先想清楚:我要停在哪个位置?是停在当前节点处理它,还是停在它的前一个节点方便做修改?
2.3 为什么头节点是个“特殊存在”
链表里最容易被特殊对待的,就是头节点。很多操作都要单独判断“如果是头节点怎么办”,比如删除头节点、在头部插入。为了统一处理,实际工程里经常用“哑节点(dummy node)”技巧,也就是在真正的头节点前面再加一个虚拟节点,让头节点也变成一个普通节点,这样很多边界判断就消失了。
举个例子,你要删除链表中某个值为target的节点。如果没有哑节点,删除头节点和删除中间节点的逻辑是不同的:删除中间节点需要找到它的前驱,然后把前驱的next指向它的后继;但头节点没有前驱,得单独处理。有了哑节点之后,所有节点都变成“有前驱”的节点了,代码逻辑就统一了。
这个技巧在刷题里用得非常多,尤其是涉及链表增删操作的题目。我后面讲具体题目的时候会反复提到它,你现在先记住一句话:凡是处理链表头部可能变化的场景,先加一个dummy节点再说。能省掉一半的边界条件判断。
3. 链表经典操作的八股现场:插入、删除、逆序、合并
3.1 单链表的插入操作:先接新节点的next,再改前驱的next
插入操作是链表最核心的卖点,也是面试里最基础的手写题。在单链表的第pos个位置插入一个新节点,核心逻辑就四行,但顺序不能错。
假设当前节点是prev,要插入的新节点是newNode,插入到prev后面:
c复制// 第一步:新节点先指向prev原来的后继
newNode->next = prev->next;
// 第二步:prev的next指向新节点
prev->next = newNode;
这两步的顺序是固定的,必须先把newNode的next指好,再修改prev的next。如果你先执行prev->next = newNode,那么prev原来的后继节点就丢了,你再也找不到它了,newNode的next也就不知道该指向谁了。这就像你排队的时候,新来的人要先拉住后面人的手,你再松开前面人的手,队伍才不会断。
头插法(在头部插入)也很常用,和上面的逻辑几乎一样,只是prev变成了哑节点:
c复制newNode->next = head;
head = newNode; // 更新头指针
在刷题的时候,如果需要频繁头插,建议用哑节点来简化:
c复制// 创建一个哑节点
Node *dummy = (Node*)malloc(sizeof(Node));
dummy->next = head;
// 在头部插入newNode
newNode->next = dummy->next;
dummy->next = newNode;
// 最后返回 dummy->next,它才是新的头
3.2 单链表的删除操作:找前驱是关键
删除一个节点,本质上就是“让它的前驱绕过它,直接指向它的后继”。所以删除操作的核心不是找到“被删节点”本身,而是找到它的前驱节点。
c复制// 删除prev后面的节点
Node *toDelete = prev->next;
prev->next = toDelete->next;
free(toDelete); // C语言记得释放内存
这里有几个容易踩的坑:
第一,如果链表是单向的,你无法通过当前节点知道它的前驱。所以要么在遍历时维护一个“前驱指针”,要么用哑节点让头节点也有前驱。前者是常规思路,后者是简化代码的技巧。
第二,LeetCode上有一道经典题“删除链表中的节点”,题目很变态——只给你被删节点的指针,不给你头节点。这时候怎么办?常规的前驱找不到了,但解法很巧妙:把下一个节点的值复制到当前节点,然后删除下一个节点。从效果上看,当前节点的值被抹掉了,等价于删除了当前节点。这种“值复制”的思路,是链表题里的一种重要思维,后面还会用到。
第三,删除操作在C/C++里要记得释放内存,但在Java/Python里不用管,垃圾回收会自动处理。不过如果你是用C刷题,每malloc一次就要对应free一次,这也是基本功。
3.3 链表逆序:面试出镜率最高的一道题
链表逆序(反转)是链表题里的“Hello World”,几乎每一场面试都会遇到。它有好几种解法:迭代、递归、头插法。我推荐你先掌握迭代法,因为最容易理解和记忆。
迭代法的核心思路是:用三个指针,prev(前驱)、cur(当前)、next(后继),每次把cur->next指向prev,然后三个指针整体后移。
c复制struct ListNode* reverseList(struct ListNode* head) {
struct ListNode *prev = NULL;
struct ListNode *cur = head;
while (cur != NULL) {
struct ListNode *next = cur->next; // 先保存后继
cur->next = prev; // 反转指针
prev = cur; // 前驱后移
cur = next; // 当前节点后移
}
return prev; // 遍历完prev刚好指向新头
}
这段代码可以说是链表题里最值得背下来的代码之一。你会发现它其实只用了一个指针操作cur->next = prev,其他都是在移动指针。理解了这一步,逆序就理解了80%。
递归法也值得了解一下,虽然面试时递归写起来更费脑,但它在处理“局部反转”“K个一组反转”这类进阶题时很有用:
python复制def reverseList(head):
if head is None or head.next is None:
return head
new_head = reverseList(head.next)
head.next.next = head
head.next = None
return new_head
递归法的核心就一句:head.next.next = head——让下一个节点的next反过来指向当前节点。这里最需要想清楚的是递归的返回条件:当链表为空或只有一个节点时,直接返回本身。
实际面试的时候,我个人建议先写迭代法,因为迭代法不依赖递归栈,空间复杂度是O(1),而且不容易栈溢出。如果你能在此基础上讲清楚递归法,那是加分项。
3.4 合并两个有序链表:一道经典的“双指针”入门题
合并两个升序链表,是链表题里另一道必刷的题。它不仅考察链表操作,还引入了“双指针”的思想,很多人就是从这道题开始理解双指针的。
c复制struct ListNode* mergeTwoLists(struct ListNode* l1, struct ListNode* l2) {
struct ListNode dummy; // 栈上分配哑节点,不需要malloc
struct ListNode *tail = &dummy;
dummy.next = NULL;
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节点帮我们避免了判断“新链表的头到底是l1还是l2”这个边界问题。你可以试想一下,如果没有哑节点,第一次该把l1还是l2作为头节点?这个判断本身不复杂,但会让代码多出一层嵌套,可读性变差。有了dummy,代码就变成了一路tail->next往前接,清爽很多。
第二是剩余链表的接续。当某个链表遍历完之后,另一个链表剩下的部分可以直接整体接上,不需要一个个遍历。因为链表天然是“已排序”的,剩下的部分也都大于等于之前的元素。很多新手会继续用while循环去逐个接剩下的节点,其实没必要,一条tail->next = l1 ? l1 : l2就够了。
C语言版本里我故意用了struct ListNode dummy;而不是malloc分配,就是想说一个细节:如果你的哑节点只在函数内部使用,不需要返回,完全可以用栈分配,这样连free都省了。面试的时候写这种写法,面试官会看在眼里。
4. 链表题里暗藏的“快慢指针”与边界陷阱
4.1 快慢指针究竟能解决哪几类问题
链表题做到一定量之后,你会发现有一类题目非常依赖一种技巧:快慢指针。具体做法是:两个指针同时从头出发,快指针每次走两步,慢指针每次走一步。当快指针到达末尾时,慢指针恰好停在中间。就是这么简单的思想,能解决好几个高频题。
第一类是求链表中间节点。 上面已经说了原理。如果你想在不知道链表长度的情况下找到中间节点,快慢指针是效率最高的方法。
c复制struct ListNode* middleNode(struct ListNode* head) {
struct ListNode *slow = head, *fast = head;
while (fast != NULL && fast->next != NULL) {
slow = slow->next;
fast = fast->next->next;
}
return slow;
}
这里有个细节要注意:while条件里为什么要同时判断fast != NULL和fast->next != NULL?因为fast每次走两步,如果链表长度是偶数,最后一次fast会走到NULL;如果是奇数,fast会走到最后一个节点。不管哪种情况,你都得确保循环继续下去的条件是“还能再走两步”,所以两个条件缺一不可。这个判断稍不留意就写错。而且两个条件的顺序不能反,要先用fast != NULL保护,再检查fast->next,否则如果fast已经是NULL,你再访问fast->next就直接空指针解引用了。
第二类是判断链表是否有环。 这就是著名的“龟兔赛跑”问题。快指针每次走两步,慢指针每次走一步,如果链表中存在环,快指针迟早会追上慢指针,两者相遇;如果没有环,快指针会先到达空指针。
c复制bool hasCycle(struct ListNode *head) {
struct ListNode *slow = head, *fast = head;
while (fast != NULL && fast->next != NULL) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) {
return true;
}
}
return false;
}
这个算法除了判断是否有环,还衍生出一个经典变体:找到环的入口节点。思路是:先用快慢指针找到相遇点,然后让一个指针从head出发,另一个从相遇点出发,都每次走一步,再次相遇的地方就是环的入口。这个结论背后有严谨的数学推导,这里不展开,但你要知道这个“三步走”套路:判断有环,找到相遇点,从头部和相遇点同步出发找入口。
第三类是寻找链表的倒数第k个节点。 快指针先走k步,然后快慢指针一起走,当快指针到达NULL时,慢指针正好指向倒数第k个节点。
c复制struct ListNode* getKthFromEnd(struct ListNode* head, int k) {
struct ListNode *fast = head, *slow = head;
// 快指针先走k步
while (k-- > 0) {
fast = fast->next;
}
// 一起走
while (fast != NULL) {
slow = slow->next;
fast = fast->next;
}
return slow;
}
很多人在这一步会想不明白:为什么快指针先走k步,再一起走,慢指针就到了倒数第k个?你可以这样理解:快指针领先慢指针k步,当快指针走到链表末尾的NULL处时,它一共走了len步(len是链表长度),慢指针也走了len-k步,那么慢指针距离链表末尾还有k步,它自然就是倒数第k个节点。
这三类问题看起来完全不同,底层却是同一个模型。你把这一个模型吃透了,等于同时会做三道题,性价比极高。
4.2 最容易“翻车”的边界条件
链表题和数组题最大的区别之一,就是链表处理边界条件时特别容易出错。我总结了几个高频“翻车点”,每一个都是我亲自踩过或者看别人踩过的坑。
第一个坑:空链表。 很多操作都需要先判断head == NULL。比如求链表长度,空链表长度为0;反转链表,空链表反转后还是空;合并两个链表,如果两个都为空,返回的也应该是空。初学者最容易写代码时忘掉这个判断,结果整个函数崩溃。
第二个坑:只有一个节点。 链表只有一个节点时,它的next是NULL,很多操作的逻辑会变得不一样。比如反转链表,只有一个节点时不需要反转,直接返回即可;删除节点时,只有一个节点且值匹配时,删除后链表就空了,头指针要置NULL。
第三个坑:头节点操作和中间节点操作的不一致。 前面已经讲过哑节点是解决这个问题的利器。我再强调一次:如果你发现代码里出现了“如果删除的是头节点xxx,否则xxx”这种分情况讨论,优先考虑用dummy节点统一处理。
第四个坑:指针丢失。 链表操作最忌讳的就是修改一个指针之后,另一个你还需要用的节点找不到了。典型的例子就是插入操作那两个步骤的顺序,还有删除操作里要先保存toDelete再修改前驱的next。牢记一个原则:改动指针之前,先确保你想要引用的节点已经用一个变量保存好了。
4.3 链表题的“时间换空间”与“空间换时间”
刷了足够多链表题之后,你会开始遇到一些“看似超纲”的题目:链表排序、链表重排、回文链表判断等。这些题目最好的解法往往不是“硬做”,而是利用辅助空间来简化问题。
我给你举一个例子:判断一个链表是否为回文链表。回文的意思是正着读和倒着读一样。数组的回文判断很容易,两个指针一左一右往中间走就行。链表不行,因为没法从后往前访问。最简单的思路是:把链表的值复制到一个数组里,然后用数组的回文判断法。这就是“空间换时间”,时间O(n),空间O(n)。但面试官往往会追问一句:能O(1)空间吗?这时候你就需要用到前面学的快慢指针+逆序了:先用快慢指针找到链表中间位置,把后半部分逆序,然后前半部分和后半部分逐个比较。这个解法空间复杂度只有O(1),但代码量翻了一倍,逻辑也更绕。
这就是链表题的一个典型特点:同一道题,有“暴力但简单”的解法,也有“优雅但复杂”的解法。 面试时应该怎么选?我的建议是:如果你在面试中,先写简单解法,保证正确性,然后主动说“我还可以优化空间复杂度”,再给优化版。先保底、再加分,是面试的基本策略。如果是平时刷题,那就逼自己把两种解法都写一遍,尤其是优化版,那才是真正提升水平的部分。
再比如链表排序。链表归并排序的思路和数组版本很像,但不需要额外的O(n)空间,因为链表天然就能用指针操作达到“原地排序”的效果。具体做法是:先通过快慢指针找到中点,把链表拆成两半,分别排序,然后合并。这其实是用到了递归+分治的思想。你做这道题的时候,会明显感觉到链表和数组在“分治”这个环节的差异:数组分治需要大量的元素移动,链表分治只是改几个指针就能完成。
5. 从链表刷题到真实世界:Redis里的链表长什么样
5.1 Redis的链表不只是“链表”
有些同学刷题的时候会困惑:链表这种结构,现实中到底有什么用?如果只是考试、面试,那确实可以点到为止。但如果你做个有心人,会发现链表思想在真实系统中的运用远比想象中广泛。
最典型的例子就是Redis。Redis是一个内存数据库,它的底层数据结构中,链表(linked list)曾经是list类型的主要实现方式之一,后来被quicklist和listpack取代,但链表的思想仍然贯穿其中。
在Redis 3.2之前的版本,list的底层是一个双向链表(adlist)。为什么用双向链表?因为Redis的list操作经常需要从头部或者尾部插入/弹出元素,双向链表可以O(1)完成这些操作。但双向链表有一个问题:每个节点都需要额外存储两个指针(prev和next),内存开销比较大,而且左右指针会让缓存局部性变差。所以后来的quicklist用了一个折中方案:双向链表只串起“链表的节点”,每个节点内部是一个压缩列表(ziplist)。这就是一层链表套一层数组的数据结构。
Redis官方文档里有句话:“ziplist是一种特殊编码的双向链表,旨在节省内存。它存储字符串和整数值,其中整数以真正的整数表示,而不是字符串表示。” 这其实就是链表思想的一种变体:与其在一个链表里存很多个小元素(每个元素都有指针开销),不如在链表节点里存一小块连续数组,减少指针数量。
刷题的时候你可能觉得“指针开销”只是理论上的概念,但Redis的演进过程恰恰说明:在实际工程里,每一个指针都是真实的内存占用,当数据量以亿计的时候,你省下的每一个指针都可能意味着几十GB的内存差异。
5.2 双向链表与单向链表的选择逻辑
Redis的adlist是双向链表,而我们刷题时绝大多数题目用的是单向链表。为什么?因为刷题场景下,单向链表已经足够覆盖绝大多数考点了,而且实现简单。但工程上,双向链表的价值非常明显:你可以O(1)时间内访问前驱节点,这在很多场景下是刚需。
比如文本编辑器的撤销操作,就是一个典型的结构:每一步操作都是一个节点,节点之间有正向和反向的关联。反向关联让你可以撤销(回退),正向关联让你可以重做(前进)。这个“撤销/重做”模型本质上就是一个双向链表。
再比如浏览器的前进后退:你访问的每个页面是一个节点,浏览器的“后退”就是沿着prev指针往回走,“前进”就是沿着next指针往前走。
所以我在带新人的时候经常说一句话:别把链表只当成一个“面试数据结构”,它其实是一套“如何用节点+指针表达顺序关系”的思维方式。 当你理解了这一点,你看到的就不再是几道题,而是一整类系统的底层骨架。
5.3 链表思想在内存管理里的应用
除了Redis,操作系统里的空闲内存管理也大量使用了链表。比如Linux内核中,物理页框的管理用到了链表;文件系统的目录项缓存用到了哈希链表;甚至虚拟内存的页表管理也有链表的影子。这些场景里,链表的动态插入删除特性非常契合需求,因为内存块的大小和数量都是动态变化的,数组方式很难应对这种动态性。
你刷链表题时练的“指针修改”“节点插入删除”,本质上就是在练习这种对“动态拓扑结构调整”的敏感度。说得直白一点,你在LeetCode上写的每一个prev->next = next,和内核里修改一个页表链表节点,底层逻辑是一样的。
这也是为什么很多大厂面试喜欢考链表:它不是考你背题,而是考你对“通过指针操作动态结构”这件事的理解深度。你能不能在没有任何辅助工具的情况下,把指针操作想清楚、写正确,很大程度代表了你对系统的理解能力。
6. 一条亲测有效的链表刷题路线
6.1 从基础题到进阶题的推进顺序
刷链表题最忌讳的就是“一上来就做难题”,那样只会摧毁信心。我的建议是分三个阶段走,每个阶段解决一类核心问题。
第一阶段:基本功(掌握指针操作)
先把这些题刷明白:删除链表中的节点、删除链表的倒数第N个节点、反转链表、合并两个有序链表、两两交换链表中的节点。这五道题覆盖了链表最核心的操作:遍历、删除、反转、合并、局部指针修改。刷的时候不用追求速度,慢慢写,写完看官方题解,对比自己的写法有没有可以优化的地方。
第二阶段:快慢指针与进阶操作
接着刷这些:环形链表、环形链表II(找入口)、链表的中间节点、回文链表、排序链表、重排链表。这个阶段你会开始接触快慢指针的灵活运用、递归、分治等更复杂的思路。每道题都值得刷两遍以上,因为很多题都有多种解法,多写几遍才能内化。
第三阶段:综合应用与面试模拟
最后可以挑战一些综合题:K个一组翻转链表、复制带随机指针的链表、相交链表、合并K个升序链表。这些题要么是多种技巧的组合,要么是场景比较特殊(比如带random指针)。能独立做出来,基本代表你的链表水平已经超过大多数候选人了。
6.2 用哪种语言刷链表题最合适
说实话,这个问题没有标准答案,完全取决于你的情况。但我要给出一些基于经验的中肯建议。
如果你是C/C++背景,直接用C/C++刷。C语言对链表的支持最“裸”,你被迫面对指针、内存分配、释放,这些底层细节恰恰能加深你对链表的理解。很多考研的学生用严蔚敏《数据结构》配合C语言刷,这个组合非常经典,但要注意书上的代码是教学风格的,和刷题风格的代码不完全一样,需要自己适应。
如果是为了面试刷题,Python其实是最推荐的语言,尤其是非C++方向的岗位。原因很简单:写起来快,不用管内存释放,代码量少,出错概率低。Python刷链表题时,你不需要考虑malloc/free,只需要关注逻辑本身。但有一个小缺点:Python的引用机制有时候会掩盖“指针”这个概念,你写cur = cur.next和C的cur = cur->next感觉完全不一样,很多Python写多了的人,对“引用”和“对象”的理解容易模糊。如果你打算长期做底层或者高性能开发,建议至少用C/C++过一遍基础题目,把指针概念澄清。
Go语言也是一个不错的选择,它既有指针概念,又不用手动管理内存,语法也挺简洁。很多后端岗位现在都要求Go,用Go刷链表题,性价比很高。
Java刷链表题的体验介于C++和Python之间,有引用概念,但垃圾回收帮你处理内存。如果你主要在Java技术栈,直接用Java刷没任何问题。
6.3 复盘方法:怎么从一道题里挖出更多价值
刷题不在于多,在于精。一道链表题,我建议你用“三遍法”来刷:
第一遍:独立完成,不看任何题解。如果卡住了,最多想15分钟,想不出来就看题解,但要确保理解了再合上题解自己重写一遍。
第二遍:隔一天后再做一遍,要求自己20分钟内完成,并且代码风格干净整洁,不需要debug。这遍的目的是检验你到底记住了没有、理解透了没有。
第三遍:用不同的解法再做一遍。比如反转链表,第一遍用迭代法,第二遍就用递归法;判断回文链表,第一遍用数组辅助法,第二遍就用快慢指针+逆序法。这一步是拉开差距的关键,因为面试中你常常需要知道“有哪些解法,各自有什么优缺点”。
复盘的时候,我习惯用表格记录每道题的关键信息,比如:
| 题目 | 核心考点 | 最优时间/空间复杂度 | 我的易错点 | 第二解法 |
|---|---|---|---|---|
| 反转链表 | 指针修改顺序 | O(n)/O(1) | 忘记保存后继 | 递归法 |
| 环形链表II | 快慢指针+数学推导 | O(n)/O(1) | 忽略空链表 | 哈希表法 |
| K个一组反转 | 递归+局部反转 | O(n)/O(1) | 组内边界混乱 | 栈辅助法 |
这样做的好处是,复习的时候你不用重做所有题,只需要扫一眼表格,回忆当时的思路,就能快速唤醒记忆。
6.4 学习资料怎么搭配着用
关于链表的学习资料,市面上的选择太多了。我给你一个亲测有效的搭配方案:
教材层面:严蔚敏《数据结构(C语言版)》前四章讲线性表的部分,多看几遍。虽然书里的代码风格比较老旧,但原理讲得非常清楚,尤其是链表和顺序表的对比、单链表的各种操作细节,至今仍是考研的必考内容。考研的同学配合王道考研的数据结构辅导书一起看,基本能覆盖考试要求。
在线刷题层面:LeetCode的链表题目列表建议按我前面说的三个阶段去刷。力扣官方有专门的“链表”题单,直接跟着它刷就行。刷题的时候,注意看官方题解和讨论区的高赞解答,有时候别人的一句话能让你少走很多弯路。
视频层面:B站上有很多高校的数据结构公开课,比如清华、浙大、北大的课程都可以看,质量很高。但我建议你刷题遇到卡壳的时候再看视频,不要一上来就看,那样容易形成依赖,一碰到题就想看视频,自己不会思考。
博客层面:多看看“数据结构与算法八股文”这类总结性文章,它们把零散的知识点浓缩成了可以直接背诵的口诀和模板,对面试突击很有用。但同样的问题:别只背模板,得真正理解背后的逻辑。面试官一旦追问,你就知道“背”和“懂”的区别了。
我在实际带人的过程中发现一个规律:链表刷得好的人,往往是“动手型”学习者。 他们不只是看题解,而是真的会自己画图、自己写代码、自己调试。你在刷链表题的时候,强烈建议手边准备一张草稿纸。每道题都先画出链表的结构,标出指针的指向变化,然后再写代码。这个习惯一开始看起来笨拙,但坚持一段时间后,你会发现自己对指针操作的理解会有一个质的飞跃,很多“看一眼就知道怎么处理”的感觉,就是从这种画图练习中建立起来的。
拿我自己来说,链表题我刷了不下两百遍,但每一次重新做,都还能发现一些新的理解角度。比如反转链表这道题,一开始我只会迭代法,后来学了递归,再后来发现它其实还能和“头插法”联系起来——你用哑节点加上遍历,一个个把节点头插到哑节点后面,也能实现反转。这三种解法看起来不一样,但本质都是“改变指针方向”。当你能从多个角度看同一道题,并梳理出它们之间的联系时,数据结构的素养就真正上了一个台阶。
还有一个小技巧分享给大家:刷链表题时,写完代码一定要在脑子里“模拟走一遍”。不是简单地看代码,而是拿着笔在草稿纸上手动执行,比如链表是 1->2->3,你的代码每一行执行后,head在哪、cur在哪、prev在哪、每个节点的next指向谁,都画出来。这个过程看起来慢,但能帮你避免九成以上的低级错误。尤其是那些“越界”“死循环”问题,绝大多数在“模拟走一遍”时就能发现,根本不用等到运行的时候崩溃。
链表学习还有一个很多人忽略的点:考试和面试对链表的考察侧重点不太一样。 期末考试的链表题偏重“概念理解”和“算法设计”,比如叫你写一个单链表逆序的实验报告,重点考察你对每个操作步骤的理解是否清晰;考研的链表题偏重“手写代码”和“时间复杂度分析”,经常会考综合题;而面试的链表题,则偏重“边界条件处理”和“多种解法对比”。你准备的时候,要针对不同的目标做不同的训练。如果是在校学生,建议先从期末考试的角度出发把基础打牢,再用刷题来拓展视野;如果是求职者,直接按面试标准来刷就行,但别忘了回头补一下基础概念,因为面试官有时候也会问“数组和链表的区别是什么”“什么时候用链表,什么时候用数组”这种看似简单、实则考验理解深度的问题。
关于“顺序表和链表的区别”这个问题,几乎是链表相关场合必问的,我这里也顺手说清楚:顺序表(数组)的优点是随机访问O(1)、缓存友好,缺点是插入删除需要大量搬移元素、扩容成本高;链表的优点是插入删除O(1)(前提是已经定位到位置)、不需要连续内存、长度动态变化,缺点是随机访问必须O(n)遍历、每个节点有额外的指针开销、缓存局部性差。实际工程中,两者往往是搭配使用的,比如哈希表解决冲突时常用链表法,但元素数量多到一定程度又会转换为红黑树,这就是一个“不同数据规模用不同结构”的经典例子。
最后再说几句掏心窝的话。链表刷题这条路,没有捷径,但也没有想象中那么难。你不需要把几百道链表题全刷完,只需要把最核心的二三十道题吃透,就能够覆盖绝大多数考点。真正重要的不是刷了多少题,而是你是否从每一道题里提炼出了共性的思维模型——比如“指针修改前先保存”、“需要处理头节点时用哑节点”、“涉及找中点/找环时用快慢指针”、“涉及逆序时考虑递归或头插法”。这些模型会在你以后遇到任何复杂数据结构问题时,成为你分析和解题的底层支撑。
数据结构的世界里,链表只是起点,但它是最好的起点。过了这一关,后面的树、图、堆、哈希表,你都会更有底气。所以别急,慢慢来,把每一步走扎实。刷题愉快。
