链表的算法题,可以说是程序员面试里的常青树了。不管你是准备校招、社招,还是想系统地提升自己的数据结构和算法功底,链表算法题基本是绕不开的一关——它不像动态规划那样需要很强的数学底子,也不像图论那样知识点繁杂,但它对“指针操作”“边界处理”“递归思维”的考察非常精准,能很直接地反映出一个人的基本功是否扎实。我见过不少能轻松写出回溯、动态规划的候选人,一碰到链表题却卡在原地,核心原因就是没建立起“改指针方向”的直觉。
这篇文章我打算聚焦几道最经典的链表算法题,包括反转链表、环形链表检测、合并两个有序链表、删除链表倒数第N个节点、回文链表。这些题表面上各自独立,实际上背后有一套通用的方法论——画图理解、虚拟头节点、双指针/快慢指针,把这些东西吃透了,你再去刷更高阶的链表题会发现不过是套模板而已。我会用手写C++代码的方式逐题拆解,把每一步的意图、边界条件和容易踩的坑都讲清楚,既适合刚接触链表的同学入门,也适合准备面试的人做快速复盘。
我的建议是,看到题目后你先自己在纸上画一画链表结构,再看我给的思路和代码,这样吸收效率会高很多。
1. 刷链表题之前,先把这几个基础概念和套路搞清楚
1.1 链表的本质与数组的本质差异
链表和数组在内存布局上的区别,是最基础也是最容易被忽略的知识点。数组在内存里是一段连续的空间,你可以通过下标直接计算出某个元素的内存地址,所以在任何位置上访问元素的时间都是O(1)。但链表不是,它的每个节点是独立分配的,节点之间靠指针/引用串联,内存地址并不连续,所以你想访问第n个节点,只能从头节点一个一个next过去,时间复杂度是O(n)。
这个差异决定了链表的适用场景:频繁插入和删除、随机访问较少。链表在中间插入节点的操作是O(1)——只要你能拿到目标节点的前驱,改变两个指针指向就好——而数组在中间插入元素需要把后面的元素全部后移,时间复杂度O(n)。代价是你无法快速找到某个位置的节点,只能老老实实遍历。
用人话说,数组像电影院的连排座位,你去哪一排都很快,但中间加一个座位几乎等于重新装修;链表像火车车厢,每节车厢后面拖着下一节,你可以在两节车厢之间随时挂上新车厢,但你想找第50节车厢,只能一节一节数过去。链表的算法题,本质上就是在玩“车厢之间怎么挂、怎么解”的游戏。
1.2 链表题的三大核心套路
我做了几年算法培训相关的工作,也面过不少人,发现链表题虽然千变万化,但核心技巧就三招。第一招是“画图先行”,打开LeetCode先不要急着写代码,在纸上画出链表的结构,标清楚每个指针的来源和去向,尤其是反转链表这类需要同时操作多个指针的题目,画图能帮你避免90%的指针顺序错误。
第二招是“使用虚拟头节点”。很多链表题都要处理“删除头节点”这个特殊场景,如果不加一个dummy节点,你需要单独写一个if分支来判断,代码容易变得臃肿而且容易出错。dummy节点其实就是一个额外的哨兵节点,它的next指向原始头节点,操作完再返回dummy->next,这样头节点的删除就和普通节点一样,完全不需要特殊处理。这招在合并链表、删除倒数第N个节点、两两交换节点等题目里极其好用。
第三招是“双指针/快慢指针”。链表的很多问题——判断环、找中间节点、找倒数第k个节点——用两个指针一快一慢就能优雅解决。为什么要用快慢指针?因为链表没有长度信息,也不能从尾部往前遍历,常规思路是先把链表遍历一遍拿到长度,再重新走一遍找目标位置,但快慢指针能让你在单次遍历里就完成类似的操作,省去一次完整遍历,时间效率和代码简洁度都更高。
这三个套路单独看都不难,但组合起来能解决大量链表问题。下面我选几道代表性的题目,一道一道拆给你看。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 反转链表:一题吃透指针操作
反转链表,LeetCode 206题,可以说是链表题里的“Hello World”。题目很简单:给你单链表的头节点head,请你反转链表,并返回反转后的链表头。这道题虽然简单,但它能帮你建立最重要的指针操作直觉——你是在改变节点的next指向,不是在移动节点本身。
2.1 迭代法:三指针的经典范式
迭代版本的核心思想,就是逐个把节点的next指向前一个节点。这里需要用三个指针:prev(前驱)、cur(当前)、next(暂存下一个),顺序绝对不能乱。
cpp复制ListNode* reverseList(ListNode* head) {
ListNode* prev = nullptr;
ListNode* cur = head;
while (cur != nullptr) {
ListNode* next = cur->next; // 先保存下一个节点,不然后面就丢链了
cur->next = prev; // 反转指针方向
prev = cur; // prev 前移
cur = next; // cur 前移
}
return prev; // cur 为 nullptr 时,prev 正好是新的头
}
第一次写这个代码,最容易犯的错误就是顺序不对。很多人把cur->next = prev写在了保存下一步节点之前,结果链表直接断了,后面的节点全丢了。我在教学的时候经常打一个比方:你站在一条绳子的某个节点上,想改变这段绳子的方向,必须先用手抓住下一段绳子,再松开当前这一段,否则你就失去对后面的控制权了。
为什么prev初始是nullptr?因为新链表的末尾节点指向nullptr。原来链表的头节点反转后成了尾节点,它的next必须置空,所以第一个节点的next指向nullptr正好。最后返回prev也是很自然的——循环退出时,cur已经走到链表末尾的nullptr,prev还停留在最后一个非空节点上,这个节点就是新的头。
时间复杂度是O(n),一次遍历搞定,空间复杂度O(1),只用了几个指针变量。这也是迭代解法在工程中最常用的原因。
2.2 递归法:换个角度理解反转
递归的代码更短,但理解起来需要一个思维转变。
cpp复制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;
}
递归的设计思路是:假设reverseList(head->next)已经帮我完成了“反转以head->next为头节点的链表”这件事,并且返回了新的头节点。那我现在要做的只是把head这个节点接到新链表的尾部。怎么接?head->next本来指向原链表的第二个节点,反转过之后它变成了新链表的尾节点,所以head->next->next = head就是把head链到它的后面,然后head->next = nullptr,因为head现在在末尾,它的next应该指向空。
这里有一个很重要的思维习惯叫“递归的信任原则”——你不需要逐层展开思考,只要你写的递归在n-1规模上是正确的,并且你当前层的逻辑是正确的,那整个递归就是正确的。很多同学看递归代码会卡在“想得太深”,我建议先接受“函数按定义完成它的任务”这个假设,再去看当前层做了什么,这样反而容易理解。
不过,递归解法有它的代价:递归栈的深度等于链表的长度,如果链表很长(比如超过10000个节点),递归栈可能溢出。而迭代法只用了O(1)的额外空间,所以工程和面试里如果有限制空间复杂度,选迭代更合适。
2.3 面试的变体延伸
反转链表是很多题目的基础。比如LeetCode 92题“反转链表II”,要求反转指定区间内的节点;LeetCode 25题“K个一组反转链表”,要求每K个节点反转一次。这两道题都是在206题的基础上增加了区间拆分的逻辑,但核心的指针翻转操作没变。我在面试准备阶段建议先把206题刷到能闭着眼写出来的程度,再去挑战变体。
还有一个高频的Python版本写法也值得一提。Python里没有指针概念,所以处理链表的next引用更直观,但逻辑和C++完全一致,核心也是三个引用的循环更新。如果你熟悉Python,建议自己动手把C++版本翻译成Python,这对理解引用赋值和对象别名非常有帮助。
3. 环形链表:快慢指针的教科书级例子
环形链表的检测,LeetCode 141题和142题,是快慢指针思路的经典应用。题目背景是:给你一个链表,让你判断这个链表里是否存在环。如果你已经会了哈希表解法,那很简单——遍历一遍,用一个set记录访问过的节点,如果某个节点出现在set里,说明存在环。但这样做的空间复杂度是O(n),面试官通常会追问一句:能不能不用额外空间?
这就轮到快慢指针登场了。
3.1 为什么快指针一定能追上慢指针
快慢指针的核心思路是:快指针每次走两步,慢指针每次走一步。如果链表里存在环,那么两个指针进入环之后,就相当于在一个环形跑道上跑,快指针相对于慢指针的速度是1步/次,所以它一定能追上慢指针。你不需要担心快指针会在追击中“跳过”慢指针——因为相对速度是1,它不会跳过任何一个节点,它进入环后每走一次移动,两者距离减少1,最终会在某个节点相遇。
为什么快指针的步长选2而不是3呢?如果快指针步长是3,相对速度就是2,有可能恰好每次都“跨过”慢指针的位置,虽然在环上多转几圈后仍然可能相遇,但数学上分析和证明相对复杂。而且步长2时,两个指针如果同时出发,遇到慢指针需要的步数越少,实现在边界条件下更好判断。所以“快二慢一”是环形链表题里最常用的设定。
cpp复制bool hasCycle(ListNode *head) {
ListNode* slow = head;
ListNode* fast = head;
while (fast != nullptr && fast->next != nullptr) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) {
return true;
}
}
return false;
}
循环条件里为什么是fast != nullptr && fast->next != nullptr?因为fast每次走两步,如果fast已经是nullptr或者fast->next是nullptr,说明链表已经走到尽头,下一个循环里fast->next->next会访问空指针的next,直接导致崩溃。这个判空顺序是这道题最容易翻车的细节。
3.2 进阶:找到环的入口节点
141题只需判断有没有环,142题进一步要求返回环的入口节点。这个数学推导很有意思,值得细看。
假设从头节点到环入口的距离是a,入口到相遇点的距离是b,相遇点再走到入口的距离是c,那么环的周长就是b+c。慢指针从出发到相遇走的总距离是a+b。而快指针走的总距离是慢指针的两倍,即2(a+b),它比慢指针多走的距离是a+b。由于两个指针在环内相遇,快指针比慢指针多走了若干个环的周长,也就是a+b = k(b+c)。化简后可以得到a = k(b+c) - b = (k-1)(b+c) + c。也就是说,从相遇点走c步,再加上若干圈环,能回到入口;而从head走a步,也能到入口。所以,当两个指针相遇后,让其中一个回到head,另一个留在相遇点,然后两个指针都改成每次走一步,它们一定会在环的入口处再次相遇。
听起来有点绕,我建议你在纸上画一个带环的链表,标出a、b、c,验证一遍就非常清楚了。这个推导是142题的灵魂,面试时很可能会被问到“为什么第二次相遇会在入口”。
cpp复制ListNode *detectCycle(ListNode *head) {
ListNode* slow = head;
ListNode* fast = head;
bool hasCycle = false;
while (fast != nullptr && fast->next != nullptr) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) {
hasCycle = true;
break;
}
}
if (!hasCycle) {
return nullptr;
}
fast = head;
while (slow != fast) {
slow = slow->next;
fast = fast->next;
}
return slow;
}
3.3 边界情况一定不能漏
环形链表题我最常看到的问题是边界测试没做够。空链表、只有一个节点的链表、只有一个节点且自己成环的情况、整条链表就是一个环的情况,都要跑一遍。尤其是“只有一个节点且自己成环”的case,很多人的循环条件写错了直接死循环。我自己的习惯是写完代码后先把这些特殊case在脑子里过一遍,再提交,能省不少时间。
另一个容易忽略的点是:判断是否有环时,如果链表很长但环很小,快指针会先在环里转很多圈,慢指针才进入环,然后在环内追及。这个过程中代码不需要做任何特殊处理,因为两个指针最终一定会在环内相遇——只要你循环条件写对,就不会出问题。
4. 合并两个有序链表:递归与迭代的双解法
合并两个有序链表,LeetCode 21题,面试频率极高。题目要求把两个升序排列的链表合并成一个新的升序链表,返回合并后的新头节点。这道题非常适合用来练习两个重要技能:一是虚拟头节点的使用,二是对递归的理解。
4.1 迭代法:虚拟头节点帮你省掉一半边界判断
迭代法最直接的想法是用两个指针分别指向两个链表,每次比较两个指针指向的节点值,把较小的节点接到结果链表的末尾,然后指针后移。但问题来了,结果链表的初始头是谁?如果不用dummy,你需要单独判断第一次接入时结果链表是否为空,代码会多出很多分支。
cpp复制ListNode* mergeTwoLists(ListNode* list1, ListNode* list2) {
ListNode* dummy = new ListNode(-1);
ListNode* cur = dummy;
while (list1 != nullptr && list2 != nullptr) {
if (list1->val < list2->val) {
cur->next = list1;
list1 = list1->next;
} else {
cur->next = list2;
list2 = list2->next;
}
cur = cur->next;
}
if (list1 != nullptr) {
cur->next = list1;
}
if (list2 != nullptr) {
cur->next = list2;
}
return dummy->next;
}
dummy节点在这里的妙处是,不管结果链表当前有没有节点,cur->next = xxx都可以直接执行,你不需要为“第一次插入”单独编逻辑。最后返回dummy->next,干净利落地绕过空结果链表的处理。
为什么循环退出后需要把剩余链表直接拼上?因为两个链表不一定等长,一个链表遍历完了,另一个可能还有剩余节点。而且剩余部分一定都是升序且比之前接入的节点都大,所以直接接到尾部即可。这一步不写或者写错,会导致部分节点丢失。
时间复杂度O(n+m),n和m分别是两个链表的长度,空间复杂度O(1),虽然有new了一个dummy节点,但它的大小是常数级的,可以忽略。
4.2 递归法:一个return搞定
递归解法非常简洁,但需要一点抽象能力:
cpp复制ListNode* mergeTwoLists(ListNode* list1, ListNode* list2) {
if (list1 == nullptr) return list2;
if (list2 == nullptr) return list1;
if (list1->val < list2->val) {
list1->next = mergeTwoLists(list1->next, list2);
return list1;
} else {
list2->next = mergeTwoLists(list1, list2->next);
return list2;
}
}
这个递归的思路是:每次选出较小值的节点作为结果链表的当前节点,然后把“剩余的两个链表”继续合并,结果挂到当前节点的next上。边界条件是,如果一个链表为空,直接返回另一个链表——这天然处理了“一个链表比另一个长”的情况。
递归的缺点是空间复杂度是O(n+m),因为递归调用的栈深度等于结果链表的长度。在极端情况下,比如两个链表各有10万个节点,递归栈会非常大,容易栈溢出。所以实际工程里我建议用迭代版本,面试里如果两种都能写出来,能体现你对递归和迭代优劣的清晰认识。
4.3 扩展:合并K个有序链表
LeetCode 23题是合并两个有序链表的进阶版,要求合并K个有序链表。思路通常有两种:第一种是两两合并,用分治的方式,先把第1、2个链表合并,再把结果和第3个合并,这样做的时间复杂度是O(k*n),如果k很大效率不高;更好的方式是优先队列/堆,每次从K个链表的当前头节点里取出最小值,接到结果链表尾部,时间复杂度是O(n log k),k是链表个数。我从20题跳到23题的过程里最大的体会是,21题能熟练用dummy和递归之后,23题的两种解法写起来都会顺畅很多,所以基础题的扎实程度决定了进阶题的上限。
5. 删除链表倒数第N个节点:双指针的经典应用
这题是LeetCode 19题:给你一个链表,删除链表的倒数第n个节点,并返回链表的头节点。你可能会想,这有什么难的?先遍历一遍拿到链表长度,再从头走len-n步不就行了?确实能解,但算法题的一个常见考核点就是“能不能用一趟扫描完成”。双指针在这里就派上了用场。
5.1 要让快指针先走n步
思路是这样的:设两个指针fast和slow,都从头节点出发。先让fast向前走n步,这样fast和slow之间就拉开了n个节点的距离。然后两个指针一起每次走一步,当fast走到链表末尾的nullptr时,slow正好停在倒数第n+1个节点上——也就是要删除节点的前驱。接着执行slow->next = slow->next->next,就把倒数第n个节点摘掉了。
cpp复制ListNode* removeNthFromEnd(ListNode* head, int n) {
ListNode* dummy = new ListNode(-1);
dummy->next = head;
ListNode* fast = dummy;
ListNode* slow = dummy;
for (int i = 0; i < n; ++i) {
fast = fast->next;
}
while (fast->next != nullptr) {
fast = fast->next;
slow = slow->next;
}
slow->next = slow->next->next;
return dummy->next;
}
为什么这里要连dummy也一起走?因为我们要处理的可能是删除头节点的场景。假设链表长度是n,要删除倒数第n个节点,也就是头节点。如果fast和slow都从头开始走,fast走n步后已经到了nullptr,while循环的条件fast->next != nullptr就不满足,slow停在head上,此时删掉slow->next是做不到的——因为slow->next指向的是第二个节点,而不是要删除的头节点。但如果让fast和slow都从dummy出发,fast走n步后指向第n个节点,while循环会继续往下走,直到fast到达最后一个节点,slow正好在头节点的前驱dummy上,这时slow->next就是头节点,删除操作就统一起来了。
这也是dummy节点最经典的使用场景——它让“删除头节点”这一特殊情况不再特殊。
5.2 边界与细节的坑
这题有几个常见的坑。第一,n的取值必须大于0且小于等于链表长度,题目一般会保证;但如果你自己测试,最好写上防御性判断。第二,fast先走的那n步,用for循环写会比while循环更直观,不容易出错。第三,删除后记得返回dummy->next,而不是head,因为head可能已经被删掉,dummy->next才是新的头节点。
还有个小细节:释放内存。在C++里,delete掉被删除节点是良好习惯,很多算法题的答案为了简洁会省略这一步。但在工程实践中,或者你在面试时和面试官聊到这个话题,主动提到“这里应该delete掉被移除的节点”会是加分项。
5.3 这类题的双指针变体
删除倒数第N个节点是快慢指针的“间隔固定距离”用法,不是追逐用法。快慢指针的追逐式用法在环形链表题里见过,间隔式用法就是这类“找倒数第k个节点”的问题。LeetCode 876题“求链表中间节点”也是类似的思路——快指针走两步,慢指针走一步,快指针到末尾时,慢指针正好在中间。理解这两种用法之后,很多题都能套用。
6. 回文链表:综合题,检验你前面的功底
回文链表,LeetCode 234题:给定一个链表,判断它是不是回文的。回文的意思就是正着读和倒着读一样,比如1->2->2->1是回文,1->2->3->1不是。这题乍看很简单,直接把链表转成数组,然后双指针夹逼比较,但这样空间复杂度是O(n)。面试官往往会要求你能不能只用一个链表来做,把空间压到O(1)。这才是这题真正的考点。
6.1 三步走:找中点、反转、比较
O(1)空间的思路分三步:第一步用快慢指针找到链表的中间节点;第二步把链表后半部分反转;第三步从头部和中部同时开始逐个比较节点的值。
为什么找中点和反转后半段能结合起来?因为回文链表的特点是关于中点对称,前半段和反转后的后半段在数值上完全一致。快慢指针找中点的实现很简单——快指针每次两步、慢指针每次一步,快指针到末尾时,慢指针自然落在中间。偶数长度的链表,慢指针会落在两个中间节点的右端;奇数长度时,慢指针正好落在正中间。
代码大致长这样:
cpp复制bool isPalindrome(ListNode* head) {
ListNode* slow = head;
ListNode* fast = head;
// 1. 快慢指针找中点,slow 最终指向后半段起点
while (fast != nullptr && fast->next != nullptr) {
slow = slow->next;
fast = fast->next->next;
}
// 2. 反转后半部分
ListNode* prev = nullptr;
ListNode* cur = slow;
while (cur != nullptr) {
ListNode* next = cur->next;
cur->next = prev;
prev = cur;
cur = next;
}
// 3. 比较前半段和反转后的后半段
ListNode* left = head;
ListNode* right = prev;
while (right != nullptr) {
if (left->val != right->val) {
return false;
}
left = left->next;
right = right->next;
}
return true;
}
整体时间O(n),空间O(1),既不用数组也不用递归,是这道题的标准最优解。
6.2 几个容易漏掉的细节
反转后半段之前,slow指的位置需要确认。在链表节点数为偶数时,比如1->2->2->1,快慢指针跑完后slow指向第二个2。反转这个节点以及后面的节点,得到1->2 和 2->1反转后的1->2,比较就发现相等。奇数时,比如1->2->3->2->1,slow指向3,反转后是1->2->3,然后比较right链表1->2(因为3也会被反转进去,但其实中点节点不需要参与比较),代码里right != nullptr会包括3,但前半段的left此时是3吗?等等——这里有个细节:奇数长度时,left会走到中点的3,right也会走到反转后的起点3,它们恰好相等,不影响结果。但更严谨的做法是反转后半段时不包括中点节点,用slow->next作为起点反转,这样奇数长度时中点被自然跳过。上面的代码把中点也纳入反转,虽然值相等不会出错,但理解上说明白更好。
我的习惯是统一用“反转slow->next到结尾”的方案,这样奇数偶数的行为更符合直觉:
cpp复制ListNode* secondHalf = slow->next;
slow->next = nullptr; // 断开前半段和后半段
// 反转 secondHalf
断开链接能避免误判吗?在某些边界case下,断开后比较会更清晰。不过上面的实现也能通过全部测试,面试时你把思路讲清楚就行。
最后一个常见问题是:比较完之后要不要把链表恢复原样?LeetCode的判题系统不关心,但实际工程里如果你在复用一个链表,最好把反转后的后半段再反转回去,避免破坏原始数据结构。我一般会在面试里主动提一句“如果需要不影响原始链表,我们可以再复原”,然后问面试官是否需要。
写在最后的一点实战体会
链表的算法题,说难不难,说简单也不简单。难在它考察的是一种“图式思维”——你必须先在脑海里或纸上把链表的形态画出来,看清楚每一个指针在操作前后的走向,代码才能不出错。我的经验是,写链表题80%的时间应该花在画图和设计步骤上,真正写代码往往几分钟就完成了。如果你做链表题老是在编译运行之后才发现bug,大概率不是代码能力问题,而是没养成先画图、先想清楚边界条件再动手的习惯。
如果你正在准备面试,我建议把上面这几道题按顺序刷,顺序是:反转链表 -> 环形链表 -> 合并两个有序链表 -> 删除倒数第N个节点 -> 回文链表。这个顺序基本遵循了由简到繁、互相递进的关系。每一道题都做到能手写、能讲清楚复杂度、能说出边界条件,再考虑刷进阶题。链表题是性价比很高的投资,因为一旦建立了“指针操作”的直觉,后面很多树、图、递归的问题思考方式也会顺带打开。
