本文参考labuladong算法笔记[双指针技巧秒杀七道链表题目 | labuladong 的算法笔记]
概述
毫不夸张地说,双指针法可以解决80%的链表题目,剩下的20%变变样子也可以用双指针解~
尤其是快慢指针fast & slow,堪称链表大杀器!
21. 合并两个有序链表 | 力扣 | LeetCode |
将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。
示例 1:
输入:l1 = [1,2,4], l2 = [1,3,4] 输出:[1,1,2,3,4,4]
示例 2:
输入:l1 = [], l2 = [] 输出:[]
示例 3:
输入:l1 = [], l2 = [0] 输出:[0]
提示:
- 两个链表的节点数目范围是
[0, 50]
-100 <= Node.val <= 100
l1
和l2
均按 非递减顺序 排列
【python】
class Solution:
def mergeTwoLists(self, list1: Optional[ListNode], list2: Optional[ListNode]) -> Optional[ListNode]:
# dummy_head用于承接新链表
dummy_head = ListNode(0)
# p用于构造新节点,p1和p2各自遍历原始链表
p, p1, p2 = dummy_head, list1, list2
# 当p1或p2为None则停止遍历,后续只用将不为None的节点接在p后即可
while p1 and p2:
if p1.val <= p2.val:
p.next = ListNode(p1.val)
p1 = p1.next
else:
p.next = ListNode(p2.val)
p2 = p2.next
p = p.next
if p1:
p.next = p1
if p2:
p.next = p2
return dummy_head.next
【算法思路】
我们的 while 循环每次比较 p1
和 p2
的大小,把较小的节点接到结果链表上,看如下 GIF:
形象地理解,这个算法的逻辑类似于拉拉链,l1, l2
类似于拉链两侧的锯齿,指针 p
就好像拉链的拉索,将两个有序链表合并;或者说这个过程像蛋白酶合成蛋白质,l1, l2
就好比两条氨基酸,而指针 p
就好像蛋白酶,将氨基酸组合成蛋白质。
代码中还用到一个链表的算法题中是很常见的「虚拟头结点」技巧,也就是 dummy
节点。你可以试试,如果不使用 dummy
虚拟节点,代码会复杂一些,需要额外处理指针 p
为空的情况。而有了 dummy
节点这个占位符,可以避免处理空指针的情况,降低代码的复杂性。
Tips:
当你需要创造一条新链表的时候,可以使用虚拟头结点简化边界情况的处理。
86. 分隔链表(单链表的分解)
给你一个链表的头节点 head
和一个特定值 x
,请你对链表进行分隔,使得所有 小于 x
的节点都出现在 大于或等于 x
的节点之前。
你应当 保留 两个分区中每个节点的初始相对位置。
示例 1:
输入:head = [1,4,3,2,5,2], x = 3 输出:[1,2,2,4,3,5]
示例 2:
输入:head = [2,1], x = 2 输出:[1,2]
提示:
- 链表中节点的数目在范围
[0, 200]
内 -100 <= Node.val <= 100
-200 <= x <= 200
【python】
class Solution:
def partition(self, head: Optional[ListNode], x: int) -> Optional[ListNode]:
dummy1 = ListNode(0)
dummy2 = ListNode(0)
p1, p2, p = dummy1, dummy2, head
# 一次遍历原始链表即可
while p:
# 小于x的节点接到dummy1,大于等于x的则接到dummy2
if p.val < x:
p1.next = p
p1 = p1.next
else:
p2.next = p
p2 = p2.next
# 每遍历一个节点就断开其指针,方便后续直接用该节点指向其他节点
# 不用新建ListNode,可节省内存
tmp = p.next
p.next = None
p = tmp
# p1指向的节点小于x,最后要指向dummy2.next,而不是指向p2
p1.next = dummy2.next
return dummy1.next
【算法思路】
在合并两个有序链表时让你合二为一,而这里需要分解让你把原链表一分为二。具体来说,我们可以把原链表分成两个小链表,一个链表中的元素大小都小于 x
,另一个链表中的元素都大于等于 x
,最后再把这两条链表接到一起,就得到了题目想要的结果。
我知道有很多读者会对这段代码有疑问:
# 每遍历一个节点就断开其指针,方便后续直接用该节点指向其他节点
# 不用新建ListNode,可节省内存
tmp = p.next
p.next = None
p = tmp
总的来说,如果我们需要把原链表的节点接到新链表上,而不是 new 新节点来组成新链表的话,那么断开节点和原链表之间的链接可能是必要的。那其实我们可以养成一个好习惯,但凡遇到这种情况,就把原链表的节点断开,这样就不会出错了。
23. 合并 K 个升序链表
给你一个链表数组,每个链表都已经按升序排列。
请你将所有链表合并到一个升序链表中,返回合并后的链表。
示例 1:
输入:lists = [[1,4,5],[1,3,4],[2,6]] 输出:[1,1,2,3,4,4,5,6] 解释:链表数组如下: [ 1->4->5, 1->3->4, 2->6 ] 将它们合并到一个有序链表中得到。 1->1->2->3->4->4->5->6
示例 2:
输入:lists = [] 输出:[]
示例 3:
输入:lists = [[]] 输出:[]
提示:
k == lists.length
0 <= k <= 10^4
0 <= lists[i].length <= 500
-10^4 <= lists[i][j] <= 10^4
lists[i]
按 升序 排列lists[i].length
的总和不超过10^4
【python】
import heapq
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
# 重载比较运算符,方便将 ListNode 加入最小堆
def __lt__(self, other):
return self.val < other.val
class Solution:
def mergeKLists(self, lists):
if not lists:
return None
# 虚拟头结点
dummy = ListNode(-1)
p = dummy
# 优先级队列,最小堆
pq = []
# 将 k 个链表的头结点加入最小堆
for i, head in enumerate(lists):
if head:
heapq.heappush(pq, (head.val, i, head))
while pq:
# 获取最小节点,接到结果链表中
val, i, node = heapq.heappop(pq)
p.next = node
if node.next:
heapq.heappush(pq, (node.next.val, i, node.next))
# p 指针不断前进
p = p.next
return dummy.next
【算法思路】
合并 k
个有序链表的逻辑类似合并两个有序链表,难点在于,如何快速得到 k
个节点中的最小节点,接到结果链表上?
这里我们就要用到优先级队列这种数据结构,把链表节点放入一个最小堆,就可以每次获得 k
个节点中的最小节点。关于优先级队列可以参考 优先级队列(二叉堆)原理及实现,本文不展开。
这个算法是面试常考题,它的时间复杂度是多少呢?
优先队列 pq
中的元素个数最多是 k
,所以一次 poll
或者 add
方法的时间复杂度是 O(logk)
;所有的链表节点都会被加入和弹出 pq
,所以算法整体的时间复杂度是 O(Nlogk)
,其中 k
是链表的条数,N
是这些链表的节点总数。
19. 删除链表的倒数第 N 个结点
给你一个链表,删除链表的倒数第 n
个结点,并且返回链表的头结点。
示例 1:
输入:head = [1,2,3,4,5], n = 2 输出:[1,2,3,5]
示例 2:
输入:head = [1], n = 1 输出:[]
示例 3:
输入:head = [1,2], n = 1 输出:[1]
提示:
- 链表中结点的数目为
sz
1 <= sz <= 30
0 <= Node.val <= 100
1 <= n <= sz
【python】
我们又使用了虚拟头结点的技巧,也是为了防止出现空指针的情况,比如说链表总共有 5 个节点,题目就让你删除倒数第 5 个节点,也就是第一个节点,那按照算法逻辑,应该首先找到倒数第 6 个节点。但第一个节点前面已经没有节点了,这就会出错。
但有了我们虚拟节点 dummy
的存在,就避免了这个问题,能够对这种情况进行正确的删除。
class Solution:
def removeNthFromEnd(self, head: Optional[ListNode], n: int) -> Optional[ListNode]:
dummy_head = ListNode(-1, head)
fast = slow = dummy_head
# fast先走n步
for _ in range(n):
fast = fast.next
# slow和fast一起走,直到fast走到最后一个节点
while fast.next:
fast = fast.next
slow = slow.next
# slow直接指向下下个节点即可
slow.next = slow.next.next
return dummy_head.next
【算法思路】
从前往后寻找单链表的第 k
个节点很简单,一个 for 循环遍历过去就找到了,但是如何寻找从后往前数的第 k
个节点呢?
那你可能说,假设链表有 n
个节点,倒数第 k
个节点就是正数第 n - k + 1
个节点,不也是一个 for 循环的事儿吗?
是的,但是算法题一般只给你一个 ListNode
头结点代表一条单链表,你不能直接得出这条链表的长度 n
,而需要先遍历一遍链表算出 n
的值,然后再遍历链表计算第 n - k + 1
个节点。
也就是说,这个解法需要遍历两次链表才能得到出倒数第 k
个节点。
那么,我们能不能只遍历一次链表,就算出倒数第 k
个节点?可以做到的,如果是面试问到这道题,面试官肯定也是希望你给出只需遍历一次链表的解法。
这个解法就比较巧妙了,假设 k = 2
,思路如下:
首先,我们先让一个指针 p1
指向链表的头节点 head
,然后走 k
步:
现在的 p1
,只要再走 n - k
步,就能走到链表末尾的空指针了对吧?
趁这个时候,再用一个指针 p2
指向链表头节点 head
:
接下来就很显然了,让 p1
和 p2
同时向前走,p1
走到链表末尾的空指针时前进了 n - k
步,p2
也从 head
开始前进了 n - k
步,停留在第 n - k + 1
个节点上,即恰好停链表的倒数第 k
个节点上:
这样,只遍历了一次链表,就获得了倒数第 k
个节点 p2
。
上述逻辑的代码如下:
# 返回链表的倒数第 k 个节点
def findFromEnd(head: ListNode, k: int) -> ListNode:
p1 = head
# p1 先走 k 步
for i in range(k):
p1 = p1.next
p2 = head
# p1 和 p2 同时走 n - k 步
while p1 != None:
p2 = p2.next
p1 = p1.next
# p2 现在指向第 n - k + 1 个节点,即倒数第 k 个节点
return p2
876. 链表的中间结点
给你单链表的头结点 head
,请你找出并返回链表的中间结点。
如果有两个中间结点,则返回第二个中间结点。
示例 1:
输入:head = [1,2,3,4,5] 输出:[3,4,5] 解释:链表只有一个中间结点,值为 3 。
示例 2:
输入:head = [1,2,3,4,5,6] 输出:[4,5,6] 解释:该链表有两个中间结点,值分别为 3 和 4 ,返回第二个结点。
提示:
- 链表的结点数范围是
[1, 100]
1 <= Node.val <= 100
【python】
class Solution:
def middleNode(self, head: ListNode) -> ListNode:
# 快慢指针初始化指向 head
slow, fast = head, head
# 奇数个节点,快指针走到末尾时停止;偶数个节点,fast走到None停止
while fast and fast.next:
# 慢指针走一步,快指针走两步
slow = slow.next
fast = fast.next.next
# 慢指针指向中点
return slow
【算法思路】
力扣第 876 题「链表的中间结点」就是这个题目,问题的关键也在于我们无法直接得到单链表的长度 n
,常规方法也是先遍历链表计算 n
,再遍历一次得到第 n / 2
个节点,也就是中间节点。
如果想一次遍历就得到中间节点,也需要耍点小聪明,使用「快慢指针」的技巧:
我们让两个指针 slow
和 fast
分别指向链表头结点 head
。
每当慢指针 slow
前进一步,快指针 fast
就前进两步,这样,当 fast
走到链表末尾时,slow
就指向了链表中点。
需要注意的是,如果链表长度为偶数,也就是说中点有两个的时候,我们这个解法返回的节点是靠后的那个节点。若想返回靠前那个中间节点,可将循环终止条件改成
while fast.next and fast.next.next:
另外,这段代码稍加修改就可以直接用到判断链表成环的算法题上。
141. 环形链表(判断链表是否成环)
给你一个链表的头节点 head
,判断链表中是否有环。
如果链表中有某个节点,可以通过连续跟踪 next
指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos
来表示链表尾连接到链表中的位置(索引从 0 开始)。注意:pos
不作为参数进行传递 。仅仅是为了标识链表的实际情况。
如果链表中存在环 ,则返回 true
。 否则,返回 false
。
示例 1:
输入:head = [3,2,0,-4], pos = 1 输出:true 解释:链表中有一个环,其尾部连接到第二个节点。
示例 2:
输入:head = [1,2], pos = 0 输出:true 解释:链表中有一个环,其尾部连接到第一个节点。
示例 3:
输入:head = [1], pos = -1 输出:false 解释:链表中没有环。
提示:
- 链表中节点的数目范围是
[0, 104]
-105 <= Node.val <= 105
pos
为-1
或者链表中的一个 有效索引 。
进阶:你能用 O(1)
(即,常量)内存解决此问题吗?
【python】
class Solution:
def hasCycle(self, head: ListNode) -> bool:
# 快慢指针初始化指向 head
slow, fast = head, head
# 快指针走到末尾时停止
while fast and fast.next:
# 慢指针走一步,快指针走两步
slow = slow.next
fast = fast.next.next
# 快慢指针相遇,说明含有环
if slow == fast:
return True
# 不包含环
return False
【算法思路】
判断链表是否包含环属于经典问题了,解决方案也是用快慢指针:
每当慢指针 slow
前进一步,快指针 fast
就前进两步。
如果 fast
最终能正常走到链表末尾,说明链表中没有环;如果 fast
走着走着竟然和 slow
相遇了,那肯定是 fast
在链表中转圈了,说明链表中含有环。
只需要把寻找链表中点的代码稍加修改就行了。
142. 环形链表 II
给定一个链表的头节点 head
,返回链表开始入环的第一个节点。 如果链表无环,则返回 null
。
如果链表中有某个节点,可以通过连续跟踪 next
指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos
来表示链表尾连接到链表中的位置(索引从 0 开始)。如果 pos
是 -1
,则在该链表中没有环。注意:pos
不作为参数进行传递,仅仅是为了标识链表的实际情况。
不允许修改 链表。
示例 1:
输入:head = [3,2,0,-4], pos = 1 输出:返回索引为 1 的链表节点 解释:链表中有一个环,其尾部连接到第二个节点。
示例 2:
输入:head = [1,2], pos = 0 输出:返回索引为 0 的链表节点 解释:链表中有一个环,其尾部连接到第一个节点。
示例 3:
输入:head = [1], pos = -1 输出:返回 null 解释:链表中没有环。
提示:
- 链表中节点的数目范围在范围
[0, 104]
内 -105 <= Node.val <= 105
pos
的值为-1
或者链表中的一个有效索引
进阶:你是否可以使用 O(1)
空间解决此题?
【python】
class Solution:
def detectCycle(self, head: ListNode) -> ListNode:
# 长度小于等于1的链表无环,直接返回即可
if not head or not head.next:
return None
# 经典快慢指针
fast = slow = head
# 若无环fast走到最后一个节点就停止,有环则fast和slow必会相遇
while fast and fast.next:
# fast相对于slow的速度是1
fast = fast.next.next
slow = slow.next
# 快慢指针相遇则跳出循环
if slow == fast:
break
# 先判断快慢指针有没有相遇,没有则说明无环
if fast != slow:
return None
# 若有环,slow重新指向head,此后slow与fast速度相同,必然会在环起点相遇
slow = head
while slow != fast:
slow = slow.next
fast = fast.next
return slow
【算法思路】
可以看到,当快慢指针相遇时,让其中任一个指针指向头节点,然后让它俩以相同速度前进,再次相遇时所在的节点位置就是环开始的位置。
为什么要这样呢?这里简单说一下其中的原理。
我们假设快慢指针相遇时,慢指针 slow
走了 k
步,那么快指针 fast
一定走了 2k
步:
fast
一定比 slow
多走了 k
步,这多走的
k
步其实就是 fast
指针在环里转圈圈,所以 k
的值就是环长度的「整数倍」。
假设相遇点距环的起点的距离为 m
,那么结合上图的 slow
指针,环的起点距头结点 head
的距离为 k - m
,也就是说如果从 head
前进 k - m
步就能到达环起点。
巧的是,如果从相遇点继续前进 k - m
步,也恰好到达环起点。因为结合上图的 fast
指针,从相遇点开始走k步可以转回到相遇点,那走 k - m
步肯定就走到环起点了:
所以,只要我们把快慢指针中的任一个重新指向 head
,然后两个指针同速前进,k - m
步后一定会相遇,相遇之处就是环的起点了。
160. 相交链表
给你两个单链表的头节点 headA
和 headB
,请你找出并返回两个单链表相交的起始节点。如果两个链表不存在相交节点,返回 null
。
图示两个链表在节点 c1
开始相交:
题目数据 保证 整个链式结构中不存在环。
注意,函数返回结果后,链表必须 保持其原始结构 。
示例 1:
输入:intersectVal = 8, listA = [4,1,8,4,5], listB = [5,6,1,8,4,5], skipA = 2, skipB = 3
输出:Intersected at '8'
解释:相交节点的值为 8 (注意,如果两个链表相交则不能为 0)。
从各自的表头开始算起,链表 A 为 [4,1,8,4,5],链表 B 为 [5,6,1,8,4,5]。
在 A 中,相交节点前有 2 个节点;在 B 中,相交节点前有 3 个节点。
— 请注意相交节点的值不为 1,因为在链表 A 和链表 B 之中值为 1 的节点 (A 中第二个节点和 B 中第三个节点) 是不同的节点。换句话说,它们在内存中指向两个不同的位置,而链表 A 和链表 B 中值为 8 的节点 (A 中第三个节点,B 中第四个节点) 在内存中指向相同的位置。
示例 2:
输入:intersectVal = 2, listA = [1,9,1,2,4], listB = [3,2,4], skipA = 3, skipB = 1 输出:Intersected at '2' 解释:相交节点的值为 2 (注意,如果两个链表相交则不能为 0)。 从各自的表头开始算起,链表 A 为 [1,9,1,2,4],链表 B 为 [3,2,4]。 在 A 中,相交节点前有 3 个节点;在 B 中,相交节点前有 1 个节点。
示例 3:
输入:intersectVal = 0, listA = [2,6,4], listB = [1,5], skipA = 3, skipB = 2 输出:null 解释:从各自的表头开始算起,链表 A 为 [2,6,4],链表 B 为 [1,5]。 由于这两个链表不相交,所以 intersectVal 必须为 0,而 skipA 和 skipB 可以是任意值。 这两个链表不相交,因此返回 null 。
提示:
listA
中节点数目为m
listB
中节点数目为n
1 <= m, n <= 3 * 104
1 <= Node.val <= 105
0 <= skipA <= m
0 <= skipB <= n
- 如果
listA
和listB
没有交点,intersectVal
为0
- 如果
listA
和listB
有交点,intersectVal == listA[skipA] == listB[skipB]
进阶:你能否设计一个时间复杂度 O(m + n)
、仅用 O(1)
内存的解决方案?
【python】
class Solution:
def getIntersectionNode(self, headA: ListNode, headB: ListNode) -> ListNode:
a = headA
b = headB
# 只要a != b 两个指针就会持续向后遍历
while a != b:
# 关键是当a本身节点存在时,a才会右移
# 旨在若链表未相交,则最后a = b = None,可跳出循环
a = a.next if a else headB
b = b.next if b else headA
# 有相交则返回相交节点,无相交则遍历完两个节点后直接返回None
return a
【算法思路】
给你输入两个链表的头结点 headA
和 headB
,这两个链表可能存在相交。
如果相交,你的算法应该返回相交的那个节点;如果没相交,则返回 null。
比如题目给我们举的例子,如果输入的两个链表如下图:
那么我们的算法应该返回 c1
这个节点。
这个题直接的想法可能是用 HashSet
记录一个链表的所有节点,然后和另一条链表对比,但这就需要额外的空间。
如果不用额外的空间,只使用两个指针,你如何做呢?
难点在于,由于两条链表的长度可能不同,两条链表之间的节点无法对应:
如果用两个指针 p1
和 p2
分别在两条链表上前进,并不能同时走到公共节点,也就无法得到相交节点 c1
。
解决这个问题的关键是,通过某些方式,让 p1
和 p2
能够同时到达相交节点 c1
。
所以,我们可以让 p1
遍历完链表 A
之后开始遍历链表 B
,让 p2
遍历完链表 B
之后开始遍历链表 A
,这样相当于「逻辑上」两条链表接在了一起。
如果这样进行拼接,就可以让 p1
和 p2
同时进入公共部分,也就是同时到达相交节点 c1
:
那你可能会问,如果说两个链表没有相交点,是否能够正确的返回 null 呢?
这个逻辑可以覆盖这种情况的,相当于 c1
节点是 null 空指针嘛,可以正确返回 null。
这样,这道题就解决了,空间复杂度为 O(1)
,时间复杂度为 O(N)
。
以上就是单链表的所有技巧,希望对你有启发。