本篇题目
链表基础概念
链表 vs 数组
链表的每个节点在内存里是独立的对象,彼此通过 next 指针相连,不像数组那样连续存储。
数组:[1][2][3][4] ← 内存连续,下标直接访问
链表:[1]→[2]→[3]→[4]→None ← 靠指针串联,只能顺序访问TIP链表里所有变量都是指针(引用)
list1、list2、dummy、cur这些变量存的都是节点的地址,不是节点本身。修改cur.next就是改那个节点里存的地址,不会复制节点。
虚拟头节点(dummy node)
链表题几乎必用的技巧。创建一个值无意义的节点放在结果链表最前面,让所有节点都能用统一的方式处理,不需要对”第一个节点”做特殊判断。
dummy = ListNode(0)cur = dummy# ... 中间构建链表 ...return dummy.next # dummy 本身不算,从 dummy.next 开始才是真正的结果链表通用模板
模板① 构建 / 拼接链表
适用:合并链表、删除节点、重排链表
dummy = ListNode(0) # 虚拟头节点,值无意义,只是占个位cur = dummy # cur 是"建造指针",始终指向结果链表的最后一个节点
while <根据题目决定终止条件>: cur.next = <某个节点或 ListNode(val)> # 把新节点接到链表尾部 cur = cur.next # cur 往后移,准备接下一个
# 注意:while 的终止条件每道题不同,不一定是 while head# 例:合并两个链表 → while list1 and list2# 例:合并K个(堆版)→ while h(堆不空)# 例:遍历一条链表 → while head
return dummy.next # dummy 是占位符,真正的结果从 dummy.next 开始 # 顺着 next 指针走就能访问整条链表模板② 快慢指针
适用:找中点、找倒数第 N 个、判断环
slow = head # 慢指针,每次走一步fast = head # 快指针,每次走两步
while fast and fast.next: # fast and fast.next 两个条件都要判断: # fast → 防止 fast 本身为 None 时报错 # fast.next → 防止 fast.next 为 None,下一步 fast.next.next 报错 slow = slow.next # 慢指针走一步 fast = fast.next.next # 快指针走两步
# 循环结束后:# 奇数个节点 → slow 在正中间# 偶数个节点 → slow 在中间偏右的那个模板③ 反转链表
适用:反转整条或部分链表
prev = None # prev 指向已反转部分的头,初始为 None(反转后末尾接 None)cur = head # cur 是当前处理的节点
while cur: nxt = cur.next # 先保存 cur 的下一个节点,否则反转后就找不到了 cur.next = prev # 把 cur 的 next 指向前一个节点,完成反转 prev = cur # prev 往前移到 cur cur = nxt # cur 往前移到原来保存的下一个节点
# 循环结束时 cur 为 None,prev 指向原链表最后一个节点,即新链表的头return prev模板④ 双指针(记录前驱)
适用:删除节点时需要记录前一个节点
dummy = ListNode(0) # 虚拟头节点,防止删除的是 head 节点时没有前驱dummy.next = headprev = dummy # prev 始终指向 cur 的前一个节点cur = head # cur 是当前检查的节点
while cur: if <满足删除条件>: prev.next = cur.next # 跳过 cur,直接把 prev 连到 cur 的下一个 # prev 不动,因为下一个节点还没检查 else: prev = cur # 不删除,prev 跟着往前移 cur = cur.next # cur 每轮都往前移
return dummy.nextIMPORTANT模板选择原则
本篇三道题都用模板①,区别只在中间逻辑:双指针比大小 / 排序后建链表 / 拆成两条链表再拼接。先把框架写出来,再想中间怎么填。
LeetCode 21 · 合并两个有序链表
思路
双指针同时扫两条链表,每次把较小的节点接到结果链表后面,直到其中一条走完,把剩下的直接接上。
TIP不需要创建新节点
直接把原节点重新串联,只改指针,不
new节点。空间复杂度 O(1)。
代码
class Solution: def mergeTwoLists(self, list1: Optional[ListNode], list2: Optional[ListNode]) -> Optional[ListNode]: cur = dum = ListNode(0) while list1 and list2: if list1.val < list2.val: cur.next, list1 = list1, list1.next else: cur.next, list2 = list2, list2.next cur = cur.next cur.next = list1 if list1 else list2 return dum.next关键细节
cur.next, list1 = list1, list1.next — Python 多重赋值,两侧同时求值,等价于:
cur.next = list1 # 把 list1 当前节点接到结果链表list1 = list1.next # list1 往后移一步不需要手动断开旧连接:下一轮 cur.next = ... 写入新值时,旧的 cur.next 自然被覆盖,不需要额外操作。
cur.next = list1 if list1 else list2 — 循环结束时必有一条链表先走完,剩下那条直接整体接上,因为它本身已经有序。
总结
- 核心技巧:模板① + 双指针,复用原节点
- 复杂度:时间 O(m+n),空间 O(1)
LeetCode 23 · 合并K个升序链表
思路一:排序(暴力)
把所有节点的值倒进数组,排序,再逐个建新节点。
class Solution: def mergeKLists(self, lists: List[Optional[ListNode]]) -> Optional[ListNode]: nums = [] dummy = ListNode(0) curr = dummy
for p in lists: # p 拿到的是每条链表的头节点 while p: nums.append(p.val) p = p.next
nums.sort() # 注意:不是 nums = nums.sort(),后者返回 None
for i in nums: curr.next = ListNode(i) # 每个整数都要包装成新节点 curr = curr.next
return dummy.nextTIP为什么要
ListNode(i)而不是直接用i
nums是普通 Python 列表,里面装的是整数。链表需要ListNode对象,所以必须把每个整数重新包装成节点。这和 21 题不同——那题直接复用原节点,这里是全新建节点。
TIP常见 bug
nums = nums.sort()会把nums赋值为None,因为.sort()原地排序,返回值是None。 正确写法是nums.sort(),直接修改原列表。
- 复杂度:时间 O(N log N),空间 O(N),N 为所有节点总数
思路二:最小堆(进阶)
堆里始终只保留 K 个节点(每条链表的当前头),每次 pop 最小值、push 它的下一个节点,堆大小始终 ≤ K。
from heapq import heapify, heappop, heappush
ListNode.__lt__ = lambda a, b: a.val < b.val # 让堆可以比较节点大小
class Solution: def mergeKLists(self, lists: List[Optional[ListNode]]) -> Optional[ListNode]: cur = dummy = ListNode() h = [head for head in lists if head] # 把所有非空链表的头节点入堆 heapify(h) # 原地堆化,O(K) while h: node = heappop(h) # 取出当前最小节点 if node.next: heappush(h, node.next) # 把它的下一个节点补充进堆 cur.next = node # 直接复用原节点,不建新节点 cur = cur.next return dummy.nextIMPORTANT
ListNode.__lt__ = lambda a, b: a.val < b.val的作用堆需要比较元素大小,Python 对整数天然支持
<,但对ListNode对象不知道怎么比,会报错:TypeError: '<' not supported between instances of 'ListNode' and 'ListNode'
__lt__是 Python 的魔法方法(less than),给ListNode打补丁,告诉 Python 比节点就是比val。 因为ListNode是题目给的无法修改,所以用这种从外部打补丁的方式加上去。
TIPheapq 三个核心函数
函数 作用 时间复杂度 heapify(h)把普通列表原地变成最小堆 O(K) heappush(h, val)插入一个元素,自动维护堆结构 O(log K) heappop(h)取出并返回最小值,自动调整 O(log K) Python 的堆是最小堆,
h[0]可以直接查看堆顶(最小值)。
两种思路对比
| 排序暴力 | 最小堆 | |
|---|---|---|
| 堆/数组大小 | O(N),全部节点 | O(K),K 条链表 |
| 时间复杂度 | O(N log N) | O(N log K) |
| 是否建新节点 | 是,ListNode(i) | 否,复用原节点 |
| 适用场景 | K 较大,N 较小 | K 远小于 N 时更优 |
LeetCode 86 · 分隔链表
思路
同时维护两条链表:small 收集所有小于 x 的节点,big 收集大于等于 x 的节点,最后把两条拼起来。相当于模板①用了两次。
代码
class Solution: def partition(self, head: Optional[ListNode], x: int) -> Optional[ListNode]: small_curr = small_dummy = ListNode(0) big_curr = big_dummy = ListNode(0)
while head: if head.val < x: small_curr.next = head small_curr = small_curr.next else: big_curr.next = head big_curr = big_curr.next head = head.next
small_curr.next = big_dummy.next # 把 big 链接到 small 尾部 big_curr.next = None # 切断 big 链末尾的旧指针
return small_dummy.next关键细节
big_curr.next = None 为什么必须写?
节点是从原链表直接摘过来复用的,它们的 next 还保留着原来的指向。比如原链表是 1→4→3→2→5→2,节点 5 原来指着节点 2,如果不手动切断,结果链表末尾就会出现一个环,导致死循环。
不切断的错误情况:... → 4 → 3 → 5 → 2 → (又回到了前面的节点) ← 形成环!
切断后正确:... → 4 → 3 → 5 → None ✓总结
- 核心技巧:模板①用两次,拆分再合并
- 复杂度:时间 O(n),空间 O(1)
三题横向对比
| 题目 | 中间逻辑 | 是否建新节点 | 特殊处理 |
|---|---|---|---|
| 21 合并两个链表 | 双指针比大小 | 否,复用原节点 | 无 |
| 23 合并K个(排序版) | 收集所有值排序 | 是,ListNode(i) | nums.sort() 别写成 nums = nums.sort() |
| 23 合并K个(堆版) | 最小堆动态取最小 | 否,复用原节点 | 需要给 ListNode 打补丁支持 < 比较 |
| 86 分隔链表 | 拆成两条再拼 | 否,复用原节点 | 末尾必须 big_curr.next = None 防止成环 |
IMPORTANT链表题万能框架
dummy = ListNode(0)cur = dummy# ... 中间各种操作 ...return dummy.next不管中间逻辑怎么变,头尾这个框架几乎不变。先把框架写出来,再想中间填什么。