☰
单链表基础三题详解:删除节点、反转链表与找中间节点
2026/10/11 14:44:29 网站建设 项目流程

链表这块内容,大学里第一次接触的时候觉得简单,无非是节点加指针。可真到动手写题的时候,删除节点能删丢一半,反转链表能绕成环,找中间节点还会因为奇偶数量吵半天。单链表综合练习里的“删除指定值节点”“反转链表”“找中间节点”这三题,几乎就是所有链表题的地基。这篇就把它们拆开揉碎过一遍,把解题思路、边界处理和踩坑经验一起讲清楚,适合正在学数据结构的学生,也适合准备算法面试想快速过一遍链表基础的人,只要你懂节点的next指针是干什么的,就能跟下来。

1. 三个题目放在一起练,到底能学到什么

1.1 单链表先过一遍:节点是怎么串起来的

单链表的结构特别直白:每个节点存一个数据,外加一个指向下一个节点的指针,最后一个节点的指针指向空。我们用C++定义一下就是这样:

struct ListNode { int val; ListNode* next; ListNode(int x) : val(x), next(nullptr) {} };

它像什么?像小时候玩的寻宝游戏:每张纸条上写着一条线索,还写着下一条线索藏在哪里。你只能顺着纸条一张张找下去,想回头找上一条?不好意思,没有回头路。这就是链表最核心的特点:单向、不可回退、不支持随机访问。你想拿到第5个节点,必须从第1个节点一步步走过去,没有“下标”可以直接跳。

很多初学者会拿它跟数组比较。数组是一排连续的柜子,你知道第5个柜子在哪就能直接走到;链表是一串散落的纸条,每张只记着下一张的线索。这个差别决定了链表几乎所有算法的思考方式:想对链表做任何操作,基本都逃不开“从头遍历”和“维护指针之间的关系”。

1.2 三个题的内在联系:都是指针操作,但考察点不同

这三道题看起来各做各的,实际上一脉相承,都在练同一种核心能力:在遍历过程中正确地调整指针。

删除指定值节点,考的是“前驱指针”的概念。因为链表删除的本质不是抹掉节点,而是让前一个节点的指针跳过当前节点,直接指向后一个节点。要完成这一步,你必须始终记住当前节点的前驱是谁。

反转链表,考的是“指针方向重定向”。原来每个节点都指向下一个节点,反转之后每个节点要指向前一个节点。这条指针链要全部反过来,稍不留神就会断掉。

找链表中间节点,考的是“快慢指针”的思路。一快一慢两个指针同时走,快的走两步,慢的走一步,快的到终点时,慢的正好在中间。它不涉及前驱,也不涉及反转,但它锻炼的是双指针配合的节奏感。

用一个表格把三道题的考察点拎出来,会更清晰:

题目核心难点关键技巧常用复杂度
删除指定值节点删除头节点时谁来做前驱哑节点法统一逻辑O(n)时间,O(1)空间
反转链表防止断链、指针反向先保存下一个节点再改指向O(n)时间,O(1)空间
找中间节点奇偶数量决定中间点位置快慢指针一趟遍历O(n)时间,O(1)空间

所以别把这三题当成孤立的小练习。它们就像是单链表操作里的“绑腿跑”,每个都让你对指针的流动更敏感,练完以后再碰到“删除倒数第N个节点”“回文链表”这类组合题,你会发现那些变体只是把这三个基本功重新排列组合。

1.3 练习方式和语言选型

刷这个练习,语言不是重点,重点是你要能亲眼看见指针的变化。我个人推荐先用C/C++写一遍,因为它用真正的指针,节点之间的关系看得见摸得着,写错了会直接编译或崩溃给你看。Java和Python里虽然叫“引用”,底层逻辑是一样的,只是隐藏了一些细节。用哪种语言都不影响理解,只要你愿意手动画图。

实际操作中我的建议是:

  • 先把每个题的示意图画出来,标出每一步指针的位置。
  • 再在编辑器里敲代码,不要直接背模板。
  • 自己准备几个小用例跑一遍,比如空链表只有一个节点删除头节点连续重复值等。
  • 在手机上用在线编译器或者电脑本地IDE都行,重点是能快速改、快速跑。

2. 删除链表中等于给定值 val 的所有节点:关键是处理头节点

2.1 先别急着写代码,把删除操作拆明白

这道题要求把链表中所有值等于val的节点都删掉,不是只删第一个。先想清楚删除一个普通节点的动作:假设当前节点是cur,它的前驱是prev,那删除cur只需要一行:

prev->next = cur->next;

这句话的意思是:让前驱节点越过cur,直接连到cur的下一个节点。链表的结构让它天然支持这种操作,不需要移动内存,比数组的删除高效得多。

问题来了。如果cur正好是头节点,它没有前驱,怎么执行上面那句话?很多新手第一版代码里会写一堆特判:先while循环把头节点中所有等于val的删掉,再处理后面的。这样能跑,但是一长串if、else相当容易漏,尤其是当头节点连续好几个都等于val时,特判会把自己绕晕。

解决办法有两个方向:一个是在删除头节点时单独维护head = head->next,一个是用一个哑节点(dummy node)把头节点变成普通节点。后者思路清奇但写起来极稳,也是实际编码里我最推荐的方式。

2.2 哑节点写法:逻辑统一,不特判

哑节点的想法很简单:手动创建一个不存储实际数据的节点,把它放在链表头之前,让它的next指向原链表的头节点。这样一来,原链表的头节点也有“前驱”了,所有删除操作都能用同一套逻辑处理。

ListNode* removeElements(ListNode* head, int val) { ListNode* dummy = new ListNode(0); dummy->next = head; ListNode* prev = dummy; ListNode* cur = head; while (cur) { if (cur->val == val) { prev->next = cur->next; cur = cur->next; } else { prev = cur; cur = cur->next; } } ListNode* newHead = dummy->next; delete dummy; return newHead; }

你看,整个循环里只有两种情况:当前节点要删,那就让prev->next跳过它;当前节点不删,那就把prev更新成当前节点。无论删除的是头节点、中间节点还是尾节点,代码完全不用变。

这一步的关键是理解:为什么prev移动要分情况?因为如果删除了当前节点,prev不该跟着往后走,它要继续指向最新保留下来的前驱,不然下一个节点如果也要删,就会漏掉。如果不删除,prev才应该移动到当前节点。很多人第一次写会直接把prev放在循环外每次都后移,结果重复节点删不干净,就是这个原因。

提示:C++里如果用new创建了节点,记得手动delete。在线编译平台一般不销毁也没事,但养成释放内存的习惯总没坏处。

2.3 递归解法和双指针写法,两种思路作为对比

除了哑节点,这题还有两种常见写法。

第一种是不建哑节点,直接用两个指针迭代。prev初始化为null,循环里如果cur->val == val,那就分两种情况:如果prev为null,说明要删的是头节点,直接把head往后挪;如果prev不为null,执行prev->next = cur->next。这种写法同样能过,但每删一次头节点都要判断一次,代码比哑节点版本啰嗦,也更容易漏逻辑。

第二种是递归写法,代码极其简洁:

ListNode* removeElements(ListNode* head, int val) { if (!head) return nullptr; head->next = removeElements(head->next, val); return head->val == val ? head->next : head; }

递归的思路是“我先把后面的链表处理好,再回头处理当前节点”。每一步都先递归处理子链表,返回的结果是一个已经删干净且不含val的链表,然后判断当前节点要不要被删。三行代码,看着爽,但有一个隐藏问题:递归深度等于链表长度,链表特别长的时候可能栈溢出,工程里不太推荐。

两种方式对比下来,哑节点迭代法是原理直观、代码稳定、空间O(1)的最优选。递归适合用来加深理解,不建议在实际代码里面无脑用。

2.4 边界条件与常见坑:连续重复值和内存释放

这道题最容易翻车的地方就是连续重复值。比如链表是1 -> 2 -> 2 -> 3,要删除2。如果代码里在删了一个2之后把prev也往后移,遇到第二个2时就会变成“删掉第一个2,留下第二个2”,最后结果变成1 -> 2 -> 3。我见过无数人在这上面卡住,甚至一度怀疑选择题库的测试用例有问题。

正确的处理方式就是上面哑节点代码里写的:只有当前节点不等于val时,prev才跟着移动。换句话说,prev永远指向“最后一个没有被删除且已经保留的节点”。

内存释放也是C/C++里绕不开的问题。如果确定要delete被删的节点,顺序要格外小心:

if (cur->val == val) { ListNode* tmp = cur; prev->next = cur->next; cur = cur->next; delete tmp; }

一定要先把cur->next保存好(或者通过prev->next拿到),再delete,否则删完你连下一个节点都不知道去哪了。

3. 反转链表:从迭代到递归一次讲透

3.1 反转到底反转的是什么

反转链表,就是把1 -> 2 -> 3 -> 4 -> 5变成5 -> 4 -> 3 -> 2 -> 1。从结果上看,链表的头变成尾,尾变成头,所有箭头方向反过来。但这里要特别强调:链表节点本身物理位置没变,变的只是每个节点的next指针指向。

想象一下一串项链,每个珠子只有一根线连着下一颗。要反转它,你必须把每一颗珠子的线头拆下来,重新缠到前面那颗珠子上。问题是拆线的时候,下一颗珠子的位置你得记在心里,否则拆完就找不到了。

这正是反转链表核心的“先保存、再修改、后移动”策略。

3.2 迭代反转的固定三步

迭代法三个指针搞定:prev代表当前节点的前一个节点,初始化是nullptr;cur代表当前要处理的节点;next用来保存当前节点的下一个节点。

ListNode* reverseList(ListNode* head) { ListNode* prev = nullptr; ListNode* cur = head; while (cur) { ListNode* next = cur->next; // 先保存下一个节点 cur->next = prev; // 当前节点指向前一个 prev = cur; // prev向后移动 cur = next; // cur向后移动 } return prev; }

循环里的每一步,我建议你对着图走一遍。拿两个节点举例:1 -> 2。初始prev=null,cur=1。第一步,next=2;然后cur->next=null,这让1的指针断开,成为新链表的尾巴;接着prev=1,cur=2。第二轮,next=null;cur->next=1,2指向1;prev=2,cur=null。循环结束,返回2,链表变成2 -> 1。

为什么第一步一定先保存next?因为第三步cur->next=prev会把cur原本的next覆盖掉。如果你没有提前保存,执行完这行代码后,你就再也拿不到原链表的下一个节点了,整个链表在后面全是断的。这一步就是防止“断链逃生通道被自己堵死”。

3.3 递归反转:从后往前推,理解newHead怎么传回来

递归反转没有迭代那么直观,需要一点“假设法”的勇气。递归代码是这样的:

ListNode* reverseList(ListNode* head) { if (!head || !head->next) return head; ListNode* newHead = reverseList(head->next); head->next->next = head; head->next = nullptr; return newHead; }

它的思想是:不要想怎么一次性反转整条链表,而是先假设从当前节点的下一个节点开始,后面的链表已经反转好了。那head->next就成了反转后链表的最后一个节点,而newHead是反转后链表的头。接下来要做的很简单:让原来在head后面的那个节点(现在是尾部)回头指向head,再把head的next置空。

用1 -> 2 -> 3推演。reverseList(1)会调用reverseList(2),再调用reverseList(3)。到了3,它的next为空,直接返回3。回到reverseList(2):newHead是3,此时head=2,head->next是3,执行head->next->next = head,也就是3->next=2,再让2->next=null,返回3。此时子链表是3 -> 2。回到reverseList(1):head=1,head->next是2,执行2->next=1,1->next=null,返回3,整条链表变成3 -> 2 -> 1。

递归的本质是“相信子问题已经解决”,但很多人在这一步会卡在“为什么head->next->next = head不会破坏子链表”。其实不会,因为此时head->next还是原链表的下一个节点,而它是子链表反转后的最后一个节点,最后一个节点的next现在是null,正好可以安全地指向head。写过几遍之后你会发现,递归反转更像是一个从尾巴倒推回来的过程。

3.4 反转的边界、复杂度与常见误区

边界情况很简单:空链表返回nullptr,只有一个节点的链表直接返回它自己。这两种情况在递归的基线条件里天然覆盖了。

迭代法时间O(n),空间O(1);递归法时间O(n),空间O(n)(调用栈)。对绝大多数场景来说,迭代法是首选,递归法更适合作为思想训练。实际工程中,处理超长链表用递归有爆栈风险。

常见误区我列一下:

  • 忘记保存next就修改cur->next,链表直接断掉,程序输出只剩一两个节点。
  • 循环结束后没有把原头节点的next置空,反转后的链表尾可能还指向原来的第二个节点,造成环形结构或错误输出。
  • 返回值搞错。迭代法最后返回prev,不是cur(因为cur已经变成nullptr了),也不是原来的head。
  • 如果使用C++并在反转过程中释放节点内存,别在修改指针前释放,不然整个链表就丢了。

4. 找链表中间节点:快慢指针两步覆盖

4.1 常规做法和快慢指针思路对比

先想一个最朴素的办法:遍历一遍链表,数出总长度n,然后再从头走n/2步,找到的节点就是中间节点。这个思路完全正确,但需要两趟遍历。第一趟数数,第二趟定位,时间依然是O(n),空间O(1)。对于单链表题来说,两趟遍历并不是不能接受,但面试官一般会追问“能不能一趟搞定”。这时候快慢指针就该登场了。

快慢指针的思路特别像两个人同向走路,一个人一步走一米,另一个人一步走两米。当快的人到达终点时,慢的人刚好走了全程的一半,站的位置就是中间。

ListNode* middleNode(ListNode* head) { ListNode* slow = head; ListNode* fast = head; while (fast && fast->next) { slow = slow->next; fast = fast->next->next; } return slow; }

一开始slow和fast都指向头节点。每轮循环,慢指针走一步,快指针走两步。快指针能走的前提是当前节点和下一个节点都不为空。循环退出时,slow就是我们要的中间节点。

4.2 奇偶数量不同,中间点的返回也不同

链表节点个数是奇数还是偶数,会直接影响中间点的位置,这也是这道题最容易出细节问题的地方。

奇数个节点,比如1 -> 2 -> 3 -> 4 -> 5。过程是这样的:初始都在1,第一轮slow到2、fast到3,第二轮slow到3、fast到5,第三轮检查条件fast && fast->next,此时fast指向5,但fast->next是null,循环退出,slow正好停在3。3就是唯一的中间节点,没问题。

偶数个节点,比如1 -> 2 -> 3 -> 4。第一轮slow到2、fast到3,第二轮slow到3、fast到null,循环退出,slow停在3。4个节点的链表,中间其实有争议,按很多题目默认的“返回两个中间节点中的后一个”,3是对的。

这里你可能会问:如果我想返回两个中间节点中的前一个,该怎么办?比如1 -> 2 -> 3 -> 4时想返回2。做法也很简单,把循环条件改成while (fast->next && fast->next->next)。这样快指针在倒数第二步就停,慢指针停在偏左的中间位置。这个细节在“回文链表判断”里特别重要,因为分割链表的位置偏左还是偏右,会直接影响后半段从哪里开始反转。

4.3 快慢指针的边界与延伸用途

边界处理先记住两条:空链表直接返回nullptr;只有一个节点时,循环条件不满足,直接返回head,天然正确。

快慢指针不止能找中间节点,它还有几个经典的延伸:

  • 检测链表是否有环。让快慢指针从头出发,如果链表有环,快指针早晚会追上慢指针;没环的话快指针会先到达null。
  • 找链表倒数第k个节点。可以让快指针先走k步,然后快慢指针同步走,快指针到终点时,慢指针正好在倒数第k个位置。这个思路和找中间节点其实是同一个“距离差”思想的变体。
  • 配合反转链表判断回文结构。这是三道题的综合应用,后面详细说。

提示:写while (fast && fast->next)时,两个条件的顺序不要反过来。如果写成while (fast->next && fast),在空链表上会先访问fast->next,直接空指针崩溃。条件是“短路的”,必须先把fast不为空放在前面。

5. 三个题能串出多少种面试变体

5.1 变体一:删除链表倒数第N个节点

这道题是“删除指定值节点”和“快慢指针思想”的结合。思路是:先用一个快指针往前走n步,然后快慢指针同步往后移动。当快指针到达链表末尾时,慢指针的next正好是倒数第n个节点,删掉它就行。

这里也强烈建议用哑节点。因为如果要删除的节点正好是头节点,也就是n等于链表长度时,没有哑节点会多一堆特判。用哑节点可以让删除操作统一,代码不容易出错。

这个变体练完会让你明白一件事:删除问题的核心从来都是“找到被删节点的前驱”,而不是“找到被删节点本身”。前面删除指定值,是通过遍历找前驱;这里则是通过快慢指针制造固定距离,让慢指针停在正确的前驱位置。

5.2 变体二:回文链表判断

回文链表是快慢指针加反转链表的经典组合题。判断一个链表是不是回文,比如1 -> 2 -> 2 -> 1就是回文,1 -> 2 -> 3 -> 2 -> 1也是回文。

常规流程分三步:

  1. 用快慢指针找到链表的中间位置。
  2. 从中间位置(或者中间位置的下一个节点)开始,反转后半段链表。
  3. 把原链表前半段和反转后的后半段逐一比较,全部相等就是回文。

这里面最容易错的就是第二步的起点。偶数长度和奇数长度的分割点不一样,反转起点选错,比较时就会出现错位。建议初学者在纸上把两种长度的链表各画一个,标清slow最终停在哪个节点,再决定反转从哪个节点开始。这一步完全依赖前面的基本功,所以“找中间节点”这个看似简单的题,其实是回文判断的地基。

5.3 链表适合这么练的原因与工程启发

链表在工程里的应用不如数组那么密集,但它的思想无处不在。操作系统的内核链表、内存池里的空闲块管理、浏览器的历史记录栈、LRU缓存淘汰算法里的双向链表,都是链表结构。单向链表虽然简单,但它是理解指针、引用、动态内存的一把钥匙。

算法题为什么这么爱考链表?我觉得核心原因是:链表题最不依赖数学技巧,最依赖逻辑严谨性。没有取模、没有动态规划,有的就是一步步改变指针方向、处理边界情况。你写错了,它不会像数学题那样“算不出来”,而是会编译通过但运行崩溃,或者静默地输出一个错得离谱的结果。这种特性让它成为检验代码细节掌控力的经典题型。

所以练这三道题,不要只满足于“AC了”,而是要把每道题的两个解法和每个边界都想明白。到能闭着眼把三种操作串起来分析的程度,链表类题目基本就通了。

6. 常见问题与调试实录:我踩过的坑

6.1 容易出现空指针访问的几个位置

链表题最常见的运行时错误就是空指针访问。我总结几个高频位置:

第一个是while (fast && fast->next)里的顺序。条件判断是从左到右短路的,你要是先写fast->next,空链表直接崩溃。同理,在循环里访问cur->val之前,一定要确认cur不为null。

第二个是反转链表里,有些人会在循环里顺手写cur = cur->next,但此时cur->next已经被改成prev了,再往后走就把链表彻底走断。正确的做法是用提前保存的next变量来更新cur。

第三个是删除节点时,prev->next = cur->next执行完之后,有些人会习惯性地再让prev = cur,这在普通情况下没问题,但在连续删除场景下会漏删,前面已经解释过。在代码里如果不确定,可以加一行注释标记当前节点的去留。

6.2 调试链表题的小技巧

很多新手在链表题出错时不会调试,只能干瞪眼。我的经验是,先写一个辅助打印函数,把每个节点的值和地址都打出来:

void printList(ListNode* head) { ListNode* cur = head; while (cur) { std::cout << cur->val << " "; cur = cur->next; } std::cout << std::endl; }

这个函数看起来不起眼,但能救很多命。如果打印时发现节点数量不对或者出现环形链表导致死循环,你基本就能定位到是指针修改顺序的问题。

另外,准备一套固定的“标准测试用例”,每次调试都跑一遍:

  • 空链表:[]
  • 单节点:[1]
  • 两个节点:[1, 2]
  • 三个节点:[1, 2, 3]
  • 含连续重复值的链表:[1, 2, 2, 3]
  • 全部都是目标值的链表:[1, 1, 1]

这套用例覆盖了绝大多数边界条件,跑一遍,出了问题就能立刻看出来是哪一类场景崩了。我发现只用一个例子调通,不代表题目真会,这些边界用例才是真正检验代码是否健壮的标准。

6.3 最后说两句练习心得

这三道题我反复写过很多遍,每一次都还有新体会。刚开始练的时候,我习惯背模板,看起来会写,但碰到变体就懵。后来改成“先画图,再写代码”,每道题都在纸上把指针一步步标出来,突然就通了。链表算法不是靠记忆,是靠对指针流动的直觉。

我的建议是:把这三个题当成一套组合拳来练。先做删除指定值,理解前驱的重要性;再做反转链表,理解指针重定向的顺序;最后做中间节点,理解双指针的节奏。练到这步,很多链表题的骨架你已经掌握了一大半。以后哪怕忘了某道具体题的写法,只要还记得这三板斧,就能顺着思路把解法推出来。链表这东西,多画图,多手写,慢慢就不会再怕它了。

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

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

立即咨询