文章

【算法】链表(一):世界观与指针操作——攥住再改、重绑与改对象、哨兵节点

【算法】链表(一):世界观与指针操作——攥住再改、重绑与改对象、哨兵节点 摘要 链表系列第一篇。从数组转场链表,最先撞的不是算法,是世界观:数组是一个连续的"东西",链表是一堆独立节点 + 指针关系——持有头节点不等于持有链表,持有的是入口,链长在指针网络里。本篇两题三课:LC206 反转链表(先写了个"头插法重建"绕过指针操作——功能对但 O(n) 空间,被自己注释里"指针被移动就丢失后续"的诊

【算法】链表(一):世界观与指针操作——攥住再改、重绑与改对象、哨兵节点

文章信息

  • 原文链接:https://jiayq.blog.csdn.net/article/details/164821613
  • 发布时间:2026-09-12 09:00:00
  • 标签:#Golang, #算法, ##开学季·九月创作之星博客挑战赛, ##数据结构, ##算法讲解

【算法】链表(一):世界观与指针操作——攥住再改、重绑与改对象、哨兵节点

摘要

链表系列第一篇。从数组转场链表,最先撞的不是算法,是世界观 :数组是一个连续的”东西”,链表是一堆独立节点 + 指针关系 ——持有头节点不等于持有链表,持有的是入口,链长在指针网络里。本篇两题三课:LC206 反转链表 (先写了个”头插法重建”绕过指针操作——功能对但 O(n) 空间,被自己注释里”指针被移动就丢失后续”的诊断打脸:已精确诊断出断链风险,然后回避了它 ;原地版一行精髓”改指针之前,先攥住会丢的东西 “;递归版两个精髓——”head.Next 不用动,反转让它指着的节点自动从头变成尾 “的头尾互换性质,和”newHead 从头到尾没被动过一行代码,链却越长越长”的指针网络之谜);LC21 合并有序链表 (哨兵节点 dummy——”结构对了特判蒸发”的链表版:选头样板 7 行 → 0 行、循环四分支 → 两分支、空链守卫整个蒸发)。外加一段语言地基:重绑 vs 改对象 ——= 换便签(各是各的),.字段= 改房子(全局可见),链表题的一切指针操作都是这两个动作的组合。

前置阅读:双指针与滑动窗口(一):框架总纲——三类问题、一个原理与判决书。配套代码仓库(按题号分目录):https://github.com/a18792721831/studyleetCode

1. 世界观:链长在指针网络里

数组转链表,最先要换的不是算法是脑子。数组是一段连续内存,下标天然存在、O(1) 能回头看;链表是一堆散落在内存里的节点 + Next 指针织成的网络 ——这个差异派生出链表题的三条公理:

公理一:持有头节点 ≠ 持有链表。 头节点只是一张入口门票 ——“链的内容” = 从入口出发顺着 Next 能走到的一切。节点可以共享(两条链从交点后手拉手)、可以成环(网络里绕回起点)、可以悬空(没有入口指向它的节点等于不存在)。

公理二:没有”回头看”。 Next 单向、下标不存在——想看前一个节点?没有。这就是为什么链表题大量使用双指针(一个替你记着另一个位置)和 dummy(替你虚构一个”头之前的节点”)。

公理三:指针操作不可逆、且瞬时会丢东西。 改掉 curr.Next 的那一刻,后半截链表从 curr 的手里逃走 ——除非提前攥住它。这是链表题第一死因,解药就一行:改指针之前,先攥住会丢的东西next := curr.Next)。

2. 语言地基:重绑 vs 改对象

写链表代码前必须分清两个动作,混了就是灾难:

1
2
3
slow = slow.Next     // ① 重绑变量:换自己便签上的门牌 —— 别的变量看不见
slow.Next = prev     // ② 修改对象:走进门牌那栋房子改结构 —— 所有指向它的变量都看得见

我在 LC160 时产生过一个经典困惑:“head 和 slow 指向同一个节点,改了 slow,head 会不会跟着变?”——不会 。变量是便签,slow := head 只是抄了一份门牌号;slow = slow.Next 是擦掉自己便签上的号换个新的,head 的便签纹丝不动。只有 ②(.字段=)才”联动”——因为那次是真的去了房子里动了东西。

一句话钉死:= 改的是便签,.字段 = 改的是房子。便签各是各的,房子是共享的。

最有说服力的证据是我自己跑对的代码:hasCycleslow = slow.Next 若真会带动 fast,快慢指针早永远同步了,速度差根本无从谈起。怀疑假设,就用调试器看实际地址 ——这比概念争论快一万倍(LC142 我用断点看到三个变量同地址,当场揭穿一个误诊)。

3. 第一课 LC206:反转链表,三种姿势

1→2→3→4→5 反转为 5→4→3→2→1

3.1 头插法:正确的答案,回避的题

我的第一版:

1
2
3
4
5
6
var newHead *ListNode
for head != nil {
    newHead = &ListNode{Val: head.Val, Next: newHead}   // 每 new 一个新节点
    head = head.Next
}

功能全对,但这是头插法重建 ——O(n) 空间 new 了整条新链,一次指针操作都没碰 。讽刺的是我注释里写着”当 head.next 的指针被移动,就丢失后续的了”——已经精确诊断出了断链风险,然后用 new 新节点回避了它,而不是解决它 。LeetCode 会 accept(它只看输出),面试官会追问”能原地吗”——而原地版才是这题的教学目标。头插法本身是正经技巧(后面某些题真有用),但用它回避指针操作,恐惧还在,只是没面对。

3.2 原地版:攥住再改

1
2
3
4
5
6
7
8
9
10
11
12
func reverseList(head *ListNode) *ListNode {
	var prev *ListNode          // 已反转段的头(初始空)
	curr := head
	for curr != nil {
		next := curr.Next       // ① 先攥住后半截 —— 公理三的解药
		curr.Next = prev        // ② 反转指向(后半截已在 next 手里,敢改了)
		prev = curr             // ③ prev 前进
		curr = next             // ④ curr 前进
	}
	return prev                 // curr 走到 nil,prev 停在原尾 = 新头
}

循环不变量(判决书):prev 左边(含)永远是已反转好的段,curr 右边永远是未动的段 ——①②③④每步之后性质保持。正确性靠不变量,效率靠单向性(每个节点只路过一次)。

3.3 递归版:头尾互换 + 指针网络之谜

递归版的两个精髓,一个比一个反直觉:

精髓一:head.Next 不用动,它指着的节点在反转中自动”从头变成尾”。 子链表 2→3 反转成 3→2 后,头是 3、尾是 2——而 1.Next 从下沉到回卷一直指着 2 没动过 ,含义却从”子链表的头”变成了”子链表的尾”。所以接线的位置不用找:head.Next 就是尾,head.Next.Next = head 把原 head 挂到尾巴上:

1
2
3
4
5
6
7
8
9
10
func reverseList(head *ListNode) *ListNode {
	if head == nil || head.Next == nil {
		return head
	}
	newHead := reverseList(head.Next)   // 相信它能反转后面,拿回新头
	head.Next.Next = head               // head.Next 已从"头"变"尾"——接在尾上
	head.Next = nil                     // head 成为新尾(不断就是双向环)
	return newHead
}

我第一次写的递归版栽在这两行上:head.Next = prev(接到了新头 后面而不是新尾 后面),结果 1→2→3 反转成 3→1 加一个环 1→2→3→1——实测当场抓包。原 head 是反转后的最后一个节点,当然挂到尾巴上——挂到头上,旧线没人清理,环就诞生了。

精髓二:newHead 为什么是完整的链? 从触底那一刻起,newHead 没有被动过一行代码——每层只是 return newHead 原样上传。那 4 后面的 3、2、1 是怎么”长”出来的?它们不是长在 newHead 身上,是长在指针网络里

1
2
3
4
5
触底后:  从 4 出发能走到:4
第 3 层后:从 4 出发能走到:4→3
第 2 层后:从 4 出发能走到:4→3→2
第 1 层后:从 4 出发能走到:4→3→2→1   ← "完整的链"诞生

newHead 这个变量从头到尾没变,变的只是”从它出发能走到的范围” ——每层回卷往网络的尾部(被上一层清空的 nil 槽位)接一个节点。断→接→断→接,像接力棒。这就是公理一的实战形态。

4. 第二课 LC21:哨兵节点 dummy

合并两个有序链表。[1,2,4] + [1,3,4][1,1,2,3,4,4]

我的第一版(无 dummy)有两处痛点,都是 dummy 的靶子:

痛点一:开头 7 行”选头”样板。 res = 较小头; 该链前进; index = res——存在理由只有一个:结果链的头还不存在,第一个被接上的节点没有”前任”可接 ,只能特判。

痛点二:循环里两个”逐节点接”分支。 list1 空了以后逐个接 list2 的节点——但剩余段本来就有序 ,一条 tail.Next = list2 能整段接上,逐个比较是白干(判决书:一链空 ⟹ 另一链剩余段有序 ⟹ 无需再比)。

dummy 版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func mergeTwoLists(list1, list2 *ListNode) *ListNode {
	dummy := &ListNode{}          // 哨兵:站在结果链头【前面】的假节点
	tail := dummy                  // tail 永远指向结果链的尾(此刻尾=dummy)
	for list1 != nil && list2 != nil {
		if list1.Val <= list2.Val {
			tail.Next = list1
			list1 = list1.Next
		} else {
			tail.Next = list2
			list2 = list2.Next
		}
		tail = tail.Next
	}
	if list1 != nil { tail.Next = list1 }   // 剩余段整段接
	if list2 != nil { tail.Next = list2 }
	return dummy.Next              // 真头 = 假节点的下一个
}

清点收益:选头样板 7→0 行、循环四分支→两分支+两个整段接、空链守卫整个蒸发 (list1 为 nil 时循环不进、直接整段接、返回 dummy.Next——全 nil 也正确)。

dummy 的本质一句话:在”头还不存在”的位置预先放一个假节点,让”第一个节点”永远不再是特例 ——每个真节点(含第一个)都执行同一动作”接到 tail 后面”。假节点挡在前面吃掉所有边界,return dummy.Next 交出真头。这是”结构对了特判蒸发”的链表版——和双指针系列里”l=i+1 消灭 idx 特判”(LC15)、“l<=r 循环覆盖单元素”(LC33)是同一条规律在三个家族的化身。

顺带一提:这个技巧我其实天天在用——每道链表题的测试代码里build 函数就是 dummy 写法,没有它建链函数也得写头特判。工具一直都在,只是做题时没想到主函数也该用。

5. 速查表

问题判据/口诀出处
改指针之前先攥住会丢的东西next := curr.Next)——不攥就是断链LC206
重绑 vs 改对象= 换便签(各是各的);.字段= 改房子(全局可见)LC160 困惑 / 全系列
指针联动疑问用调试器看地址,别概念争论LC142 误诊事件
递归反转head.Next 反转后自动”从子链表头变尾”——接线写在尾巴上(head.Next.Next = headLC206
递归的世界观返回的头节点 = 入口门票;链长在指针网络里,回卷每层往尾部接一个节点LC206
头节点特判用 dummy:让”第一个节点”不再是特例;return dummy.NextLC21/LC19
一链耗尽剩余段有序,整段接 ,不逐个比LC21
变量命名名字与语义一致(fast 配快速度);变量名不换含义LC141 命名倒置
遇到”功能对但绕过了考点”问自己:是在解决问题还是在回避问题?空间/时间复杂度是不是偷偷变差了LC206 头插法

总结

两道基础题,三个层次的收获:

世界观先于算法。 “链长在指针网络里”这一条,解释了递归反转里 newHead 的不动之谜、解释了 LC160 的 nil 会师、也预示了下一篇环和交点的所有操作——链表题的一半错误(Val 比较代替指针比较、以为改 slow 会联动 head)都是拿”连续数组”的直觉套”指针网络”的现实。

回避不是解决。 头插法那版最值得记:诊断出了断链风险,然后用 O(n) 空间绕过去了——恐惧被租金覆盖,但还在 。原地版的一行”先攥住”才是把恐惧变成理解的那一刻。写”正确的答案”和”做对题”不是一回事,尤其当题目考的就是你绕过的那部分。

dummy 是链表题的默认开场。 只要涉及”改链/接链/删链”,先立 dummy 再说——它把头节点从”特例”降级成”普通节点”,让循环体对所有节点一视同仁。结构对了,特判蒸发——这条规律已经在三个算法家族反复验证。

下一篇:链表上的双指针——变速(找环)、异链(找交点)、定距(删倒数第 N),和一份贯穿三题的路程账本。

参考资料


版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

本文由作者按照 CC BY 4.0 进行授权