链表是数据结构学习绕不开的一环,而删除节点又是链表操作中最容易出错的部分。不少人在 Go 语言里写链表删除时,会直接从 head 开始遍历找到目标节点,然后简单地把指针一改就以为完事了,结果一跑测试,要么头节点删不掉,要么删到中间某个节点时整条链断了。这篇文章就来系统地讲讲 Go 语言里删除链表节点的正确姿势,从基本原理到标准库用法,再到几道经典变体题,把容易踩的坑一次性说清楚。

单链表删除的基本原理与虚拟头节点技巧
先明确链表的结构定义。在 Go 里,一个最简单的单链表节点通常这样定义:
type Node struct {
Val int
Next *Node
}
type LinkedList struct {
Head *Node
Size int
}
删除一个节点,本质上做的事情只有一件:让目标节点的前驱节点的 Next 指向目标节点的后继节点。也就是说,删除的是值为 target 的节点时,需要找到它的前一个节点 prev,然后执行 prev.Next = prev.Next.Next。被删除的节点由于不再被引用,Go 的垃圾回收器会自动回收它,这比 C 语言要省心不少,不需要手动 free。
这里有个绕不开的坑:如果要删除的恰好是头节点,它没有前驱节点,处理逻辑就完全不同了,需要直接把 Head 指向 Head.Next。于是代码里就得写两个分支,一旦疏忽就会漏掉头节点的情况。解决这个问题的经典技巧是引入虚拟头节点(dummy node,也叫哨兵节点)。
虚拟头节点的思路是:创建一个额外的节点,让它指向真正的头节点,这样所有真实节点都有前驱了,头节点也不例外。删除操作完成后,返回 dummy.Next 即可。下面是完整实现:
// RemoveByValue 删除第一个值为 val 的节点,返回新的头节点
func RemoveByValue(head *Node, val int) *Node {
dummy := &Node{Next: head}
prev := dummy
for prev.Next != nil {
if prev.Next.Val == val {
// 前驱直接跳过目标节点,完成删除
prev.Next = prev.Next.Next
return dummy.Next
}
prev = prev.Next
}
return dummy.Next // 没找到,原样返回
}
这段代码的健壮性体现在三处:第一,链表为空时 head 为 nil,dummy 照样能工作,不会空指针崩溃;第二,删除头节点和删除中间节点走的是同一条逻辑,不需要特殊分支;第三,目标值不存在时循环自然结束,返回原链表。如果是维护一个 LinkedList 结构体,删除后记得维护 Size 字段,并且当删除的是头节点时同步更新 Head 指针,这是新手常犯的一致性错误。
使用标准库 container/list 还是手写实现
Go 标准库提供了 container/list 包,实现的是双向链表,删除操作开箱即用。如果业务场景只是需要一个能高效增删的序列容器,直接用标准库是最稳妥的选择,没必要重复造轮子。
import "container/list"
func demo() {
l := list.New()
e1 := l.PushBack(10)
l.PushBack(20)
l.PushBack(30)
// 删除指定元素,O(1) 完成
l.Remove(e1)
// 遍历
for e := l.Front(); e != nil; e = e.Next() {
fmt.Println(e.Value)
}
}
标准库的 Remove 接收的是 *list.Element 而不是值,这意味着删除是 O(1) 的,因为双向链表每个元素都持有前后指针,不需要遍历找前驱。但要注意,container/list 的元素值类型是 any,取值时需要类型断言,比如 e.Value.(int),断言失败会触发 panic,使用时要确保类型一致。另外,同一个 Element 被 Remove 两次、或者对一个已不属于该链表的 Element 调用 Next 等操作,行为是未定义的,需要自己保证正确性。
什么时候应该手写链表呢?主要有两种情况:一是面试或算法题中要求操作给定的单链表结构;二是有特殊性能要求,比如嵌入字段做侵入式链表、避免接口装箱带来的分配开销。Go 1.21 之后标准库还多了 container/ring 的环形链表和 container/heap 可配合使用,但绝大多数场景下,切片配合 append 的性能反而比链表好,因为链表节点分散在堆上,缓存不友好。选数据结构之前先想清楚是否真的需要链表,这比怎么写删除更重要。
两个经典变体:删除倒数第 N 个节点与删除重复节点
LeetCode 上的第 19 题「删除链表的倒数第 N 个结点」是高频面试题。常规思路是先遍历一遍统计长度 L,再走 L-N 步找到前驱。更优雅的做法是快慢指针:fast 先走 N+1 步,然后 fast 和 slow 同步前进,fast 到达 nil 时 slow 正好停在前驱位置。结合虚拟头节点,代码非常简洁:
func removeNthFromEnd(head *Node, n int) *Node {
dummy := &Node{Next: head}
fast, slow := dummy, dummy
// fast 先走 n+1 步,保证 slow 最终停在待删节点的前驱
for i := 0; i <= n; i++ {
fast = fast.Next
}
for fast != nil {
fast = fast.Next
slow = slow.Next
}
slow.Next = slow.Next.Next
return dummy.Next
}
这段代码一次遍历就完成删除,时间复杂度 O(L),空间复杂度 O(1)。需要留意 n 大于链表长度的情况,题目通常保证 n 合法,但如果是要写健壮的业务代码,fast 在前进过程中就可能变成 nil,解引用 nil 的 Next 会直接 panic,最好加一层参数校验。
另一个经典变体是删除排序链表中的重复元素。因为链表有序,重复值一定相邻,只需一次遍历比较当前节点和下一个节点的值即可。如果要求的是「删除所有重复过的节点」(LeetCode 82 题),逻辑要稍作调整,需要跳过整段重复区间:
// 83 题版本:重复值保留一个
func deleteDuplicates(head *Node) *Node {
cur := head
for cur != nil && cur.Next != nil {
if cur.Val == cur.Next.Val {
cur.Next = cur.Next.Next
} else {
cur = cur.Next
}
}
return head
}
// 82 题版本:重复值全部删除,同样借助 dummy
func deleteDuplicatesAll(head *Node) *Node {
dummy := &Node{Next: head}
prev := dummy
for prev.Next != nil && prev.Next.Next != nil {
if prev.Next.Val == prev.Next.Next.Val {
val := prev.Next.Val
// 跳过整段重复
for prev.Next != nil && prev.Next.Val == val {
prev.Next = prev.Next.Next
}
} else {
prev = prev.Next
}
}
return dummy.Next
}
两个版本的细节差异值得体会:83 题中即使发生了删除,cur 也不前进,因为新的 cur.Next 可能还是重复值;82 题中一旦整段重复被跳过,同样不移动 prev,要等下一轮判断新的 prev.Next 是否还有重复。这种「删除后指针不轻易前进」的细节正是链表题目容易出错的地方。
常见错误写法与排查思路
最后总结几个高频错误。第一个是忘记处理头节点,没有用 dummy 导致删除头节点失败,症状是链表第一个元素永远删不掉。第二个是循环条件写错,比如写成 for cur != nil 然后判断 cur.Val == val 再操作 cur.Next,当 cur 是尾节点时 cur.Next 已经是 nil,再访问 cur.Next.Next 必然空指针。正确做法是始终站在前驱的位置判断 prev.Next。
第三个错误是删除多个节点时提前移动了指针,导致漏删。比如要删除所有等于 target 的节点,有人在删除后立刻执行 prev = prev.Next,这样新接上来的节点如果是目标值就会被跳过。正确写法是删除分支里不移动 prev,只在未删除时移动。第四个是在并发场景下不加锁直接操作链表,Go 虽然有 GC 不会内存泄漏,但并发读写链表指针会产生数据竞争,用 go run -race 一测就能现形,必要时要加 sync.Mutex 或改用并发安全的数据结构。
排查链表问题最有效的手段是写一个打印函数,把删除前后的链表完整输出对比,再配合边界用例:空链表、只有一个节点、删除头节点、删除尾节点、目标不存在。这五个用例都能通过,删除逻辑基本就稳了。链表操作考验的不是智商,而是对指针指向的每一步是否心里有数,写之前在纸上画一遍节点和箭头,往往比盯着代码看半天更管用。