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的节点。如果没有
