算法总结7 双指针
一、双指针的概念
1.1、什么是双指针?
顾名思议,双指针就是两个指针,但是该指针不同于 C,C++中的指针地址,而是一种记录两个索引的算法思想。
实际上,在很多简单题目中,我们经常使用单指针,比如我们通过索引来遍历某数组:
# 可以这样
for i in range(n):
print(nums[i])
# 当然也可以这样
i = 0
while i<n:
print(nums[i])
i+=1
# 这样写为了引申出双指针,因为双指针一般用while来遍历
那么双指针实际上就是有两个这样的指针,最为经典的就是二分法中的左右双指针。
left, right = 0, len(nums)-1
while left<right:
if 一定条件:
return 合适的值,一般是 l 和 r 的中点
elif 一定条件:
l+=1
else:
r-=1
# 因为 l == r,因此返回 l 和 r 都是一样的
return l
其实双指针是一个很宽泛的概念,就好像数组,链表一样,其类型会有很多很多, 比如二分法经常用到左右端点双指针。滑动窗口会用到快慢指针和固定间距指针。 因此双指针其实是一种综合性很强的类型,类似于数组,栈等。 但是我们这里所讲述的双指针,往往指的是某几种类型的双指针,而不是“只要有两个指针就是双指针了”。
有了这样一个算法框架,或者算法思维,有很大的好处。它能帮助你理清思路,当你碰到新的问题,在脑海里进行搜索的时候,双指针这个词就会在你脑海里闪过,闪过的同时你可以根据双指针的所有套路和这道题进行穷举匹配,这个思考解题过程本来就像是算法。
1.2、常见类型
指针一般情况下将分为三种类类型,分别是:
类型 | 特点 |
---|---|
快慢指针 | 两个指针步长不同,一般情况下,快的走两步,慢的走一步 |
左右端点指针 | 两个指针分别指向头尾,并往中间移动,步长不确定,一般为1 |
区间指针 | 一般为滑动窗口,两个指针及其间距视作整体,窗口有定长有变长,每次操作窗口整体向右滑动 |
不管是哪一种双指针,只考虑双指针部分的话 ,由于最多还是会遍历整个数组一次,因此时间复杂度取决于步长,如果步长是 1,2 这种常数的话,那么时间复杂度就是 O(N),如果步长是和数据规模有关(比如二分法),其时间复杂度就是 O(logN)。并且由于不管规模多大,我们都只需要最多两个指针,因此空间复杂度是 O(1)。下面我们就来看看双指针的常见套路有哪些。
1.2.1、快慢指针
本方法需要我们对「Floyd 判圈算法」(又称龟兔赛跑算法)有所了解。
假想「乌龟」和「兔子」在链表上移动,「兔子」跑得快,「乌龟」跑得慢。当「乌龟」和「兔子」从链表上的同一个节点开始移动时,如果该链表中没有环,那么「兔子」将一直处于「乌龟」的前方;如果该链表中有环,那么「兔子」会先于「乌龟」进入环,并且一直在环内移动。等到「乌龟」进入环时,由于「兔子」的速度快,它一定会在某个时刻与乌龟相遇,即套了「乌龟」若干圈。
我们可以根据上述思路来解决本题。具体地,我们定义两个指针,一快一慢。慢指针每次只移动一步,而快指针每次移动两步。初始时,慢指针在位置 head,而快指针在位置 head.next。这样一来,如果在移动的过程中,快指针反过来追上慢指针,就说明该链表为环形链表。否则快指针将到达链表尾部,该链表不为环形链表。
具体的的示意图如下,同时也可以参考相似思路的,且比较简单的例题 141. 环形链表。
1.开始,乌龟slow在起始点,兔子fast在起点的下一个点。
2.乌龟走得慢每次走一步,兔子走得快,每次走两步。
继续走,兔子先进入环。
继续走,兔子一圈环快走完了,而乌龟刚进入环
最后乌龟走第一圈的时候,兔子第二圈刚好遇上。
注意:
当然具体第几圈遇上是不确定的,根据步长与环的大小相关,但是乌龟与兔子在圈中循环跑时,只要步长不一致,他们之间的最近距离会不断减少,总会相遇。
但是一般情况下会设置slow走一步,fast走两步,这个设定会产生很多有规律的数学推导,比如:142. 环形链表 II 中的快慢指针做法。
细节:
为什么我们要规定初始时慢指针在位置 head,快指针在位置 head.next,而不是两个指针都在位置 head(即与「乌龟」和「兔子」中的叙述相同)?
观察下面的代码,我们使用的是 while 循环,循环条件先于循环体。由于循环条件一定是判断快慢指针是否重合,如果我们将两个指针初始都置于 head,那么 while 循环就不会执行。因此,我们可以假想一个在 head 之前的虚拟节点,慢指针从虚拟节点移动一步到达 head,快指针从虚拟节点移动两步到达 head.next,这样我们就可以使用 while 循环了。
当然,我们也可以使用 do-while 循环或者其他方法。此时,我们就可以把快慢指针的初始值都置为 head。(所以,从这里可以得知,快慢指针初始化的值,可以相同也可以不同,具体取决于后面的判断条件)
复杂度分析:
时间复杂度: O ( N ) O(N) O(N),其中 N N N 是链表中的节点数。 | 空间复杂度: O ( 1 ) O(1) O(1)。 |
---|---|
当链表中不存在环时,快指针将先于慢指针到达链表尾部,链表中每个节点至多被访问两次;当链表中存在环时,每一轮移动后,快慢指针的距离将减小一。而初始距离为环的长度,因此至多移动 N N N 轮。 | 我们只使用了两个指针的额外空间。 |
题目类型:
问题 | 例题 | |
---|---|---|
1 | 判断链表是否有环;寻找入环节点 | 141. 环形链表 | 142. 环形链表 II | 287. 寻找重复数 |
2 | 读写指针。将快指针的内容记录到慢指针的位置,典型的题目是原地删除(前置移动)重复元素。 | 26. 删除有序数组中的重复项 | 80. 删除有序数组中的重复项 II | 202. 快乐数 |
伪代码模板:
# 1.fast与slow初始化不同
fast, slow = head, head.next
# 有环则一定相遇 退出循环后,后面return True
while fast!=slow :
if not fast or not fast.next:
return False
slow=slow.next
fast=fast.next.next
return True
# 2.fast与slow初始化相同
# fast = slow = head
fast = head
slow = head
while fast and fast.next:
slow=slow.next
fast=fast.next.next
# 有环则一定相遇 return True
if slow == fast:
return True
return False
1.2.2、左右端点指针(相向双指针)
问题 | 例题 | |
---|---|---|
1 | 二分查找 | 33. 搜索旋转排序数组 | 875. 爱吃香蕉的珂珂 |
2 | 有序数组暴力枚举。区别于上面的二分查找,这种算法指针移动是连续的,而不是跳跃性的 | 1. 两数之和 | 15. 三数之和 | 18. 四数之和 | 881. 救生艇 |
3 | 其他暴力枚举。比如:双边比较从大到小枚举,双边按条件枚举,无需排序或者已经有序(当然2和3其实可以归为一类) | 977. 有序数组的平方 | 75. 颜色分类(Dutch National Flag Problem) |
这一节,我们主要要讲的是1.二分查找,其他两种类型很单一,做几题便能理解。而二分法的模板形式很多,比如:循环上是否取等号,循环内left和right是否+1等等,不同的题型很多的人有不同的写法,我们能否按照自己的思维,整理出一个通用的,便于自己理解的模板呢?
* 详解二分法(二分查找 / 二分答案)
(1)二分查找:
概念:
在计算机科学中,二分查找算法也称折半搜索算法,对数搜索算法,是一种在有序数组中查找某一特定元素的搜索算法。搜索过程从数组的中间元素开始,如果中间元素正好是要查找的元素,则搜索过程结束;如果某一特定元素大于或者小于中间元素,则在数组大于或小于中间元素的那一半中查找,而且跟开始一样从中间元素开始比较。如果在某一步骤数组为空,则代表找不到。这种搜索算法每一次比较都使搜索范围缩小一半,时间复杂度是log(n)。
二分查找用于在多条记录中快速找到待查找的记录,它的思想是:每次将查找的范围缩小一半,直到最后找到记录或者找不到记录返回
二分法的使用条件:
二分法是适用于解决具有“二段性”(单调性)的问题的方法,通常表现为求解满足某一条件的最大值或者最小值问题。
- 上下界确定。 我们可以通过上下界的折半来优化查找。
- 二段性: 对某一范围内的数据,存在一个临界点,使得临界点某一侧的所有数据都满足某一性质,另一侧的所有数据都不满足这一性质,就称这一范围内的数据具有二段性。
二段性包括单调性,即区间内有序,这样二分出来的结果是严格大于或者小于或等于target的。
但是,二段性也体现在非单调性上,也称为局部有序,可以参考 162. 寻找峰值 和 33. 搜索旋转排序数组。由这些题我们可以得知,二分法的奥义(本质)不在于单调性,而是二段性。也就是我们能对整体无序但局部有序的序列进行二分法。
举例理解(有序):
给定一个数组,返回有序数组中第一个 ≥ \geq ≥ target的数的位置,如果所有数都 < \lt <target,则返回数组长度。
暴力做法:从头开始遍历每一个数,判断是否 ≥ \geq ≥ target
高效做法:
L 和R分别指向左右边界,即闭区间。
M指向当前正在访问的数。
三种模板,比较常用第一种,其他类型也可转化为第一种。
# 左闭右闭
def bisect_search(nums, target):
left, right = 0, len(nums)-1
while left<=right:
# 循环不变量:
# nums[left-1] < target
# nums[right+1] >= target
mid = (left+right)//2
# bisect_left 加上=是bisect_right
if nums[mid]<target:
left = mid+1 # 范围缩小到 [mid+1, right]
else:
right = mid-1 # 范围缩小到 [left, mid-1]
return left # right+1
# 左闭右开
def bisect_search1(nums, target):
left, right = 0, len(nums)
while left<right:
mid = (left+right)//2
if nums[mid]<target:
left = mid+1
else:
right = mid
return left # right
# 左开右开
def bisect_search2(nums, target):
left, right = -1, len(nums)
while left+1<right:
mid = (left+right)//2
if nums[mid]<target:
left = mid
else:
right = mid
return right # left+1
输入:
nums = [1, 1, 2, 2 ,3 ,3 ,4, 4, 5]
target = 3
下面列出了四种不同类型的目标:
有序数组的二分查找分为这四种类型,这四种实际上是可以相互转换的,但为了方便,一般都转换成类型一的 lower bound 形式,即 ≥ \geq ≥ target以及它的变体。
注意:下面是以1为单位,即是在数组中都是整数时可以用的。
nums = [1, 1, 2, 2 ,3 ,3 ,4, 4, 5]
target = 3
序号 | 类型 | 目标 | 转换 | 输入 | 结果 | 值 |
---|---|---|---|---|---|---|
类型一 | 返回第一个大于等于 target的数的位置 |
x ≥ \geq ≥target | x ≥ \geq ≥target | bisect_search2(nums, target) | 4 ;nums = [1, 1, 2, 2 ,3 ,3 ,4, 4, 5] |
值为从左向右第一个target的位置 |
类型二 | 返回第一个大于 target的数的位置 |
x > \gt >target | x ≥ \geq ≥(target+1) | bisect_search2(nums, target+1) | 6;nums = [1, 1, 2, 2 ,3 ,3 ,4 , 4, 5] |
值为最后一个target的下一位;比第一个比target大1的位置 |
类型三 | 返回第一个小于 target的数的位置 |
x < \lt <target | (x ≥ \geq ≥target)-1 | bisect_search2(nums, target)-1 | 3;nums = [1, 1, 2, 2 ,3 ,3 ,4, 4, 5] |
第一个target的前一位;最后一个比target小1的位置 |
类型四 | 返回第一个小于等于 target的数的位置 |
x ≤ \leq ≤target | (x ≥ \geq ≥target+1)-1 | bisect_search2(nums, target+1)-1 | 5;nums = [1, 1, 2, 2 ,3 ,3 ,4, 4, 5] |
最后一个target的位置 |
有人问,怎么不讲左开右闭模板?
左开右闭这种写法一般是处理最大值的,或者说是 < 和 ≤。下面视频中讲了,这两都可以转换成 ≥,所以可以用左闭右开模板来解决。
换句话说,视频中讲的这三种二分模板,都只需关注 left right 的初始值,以及二分判定条件的写法,其余的代码都是固定的。
参考:
二分法 [bilibili]
算法—二分法详解 [cnblogs]
一个视频讲透二分查找![leetcode]
二分法总结 [csdn]
(2)二分答案:
二分查找是一种在有序数组中查找给定目标值的算法,而二分答案是一种用于求解最优化问题的算法。
二分答案通常用于求解具有单调性质的最优化问题,例如求解最大值、最小值等。根据题目要求,我们可以将二分答案分为两类:最大化答案和最小化答案。以下模板,我们可以根据实际问题进行修改。
最大化答案的二分答案模板,可行区在左侧:
def binary_answer_max():
l, r = ... # 定义搜索范围的左右边界
while l <= r:
mid = (l + r) // 2
if check(mid): # 定义一个 check 函数来检查当前的假设是否成立
l = mid + 1 # 如果成立,则更新左边界
else:
r = mid - 1 # 否则更新右边界
return r # 返回最终答案
最小化答案的二分答案模板,可行区在右侧:
def binary_answer_min():
l, r = ... # 定义搜索范围的左右边界
while l <= r:
mid = (l + r) // 2
if check(mid): # 定义一个 check 函数来检查当前的假设是否成立
r = mid - 1 # 如果成立,则更新右边界
else:
l = mid + 1 # 否则更新左边界
return l # 返回最终答案
在这两个模板中,我们都需要定义一个 check 函数来检查当前的假设是否成立。具体地,check 函数接受一个参数 mid,表示当前二分答案的中间值。check 函数需要根据实际问题进行实现,以判断当前的假设是否成立。
1.2.3、区间指针 - 滑动窗口(同向双指针)
区间指针 | 例题 | |
---|---|---|
1 | 定长滑动窗口 | 1456. 定长子串中元音的最大数目 | 剑指 Offer 22. 链表中倒数第k个节点 |
2 | 变长滑动窗口 | 713. 乘积小于 K 的子数组 |
伪代码模板:
l = 0
r = k
while 没有遍历完:
自定义逻辑
l += 1
r += 1
return 合适的值
或者for循环
left = 0
for right in range(n):
自定义逻辑
while 满足某条件:
自定义逻辑
left+=1
自定义逻辑
汇总
快慢指针 | 左右端点指针 | 区间指针-滑动窗口 |
---|---|---|
判断链表是否有环;寻找入环节点 | 二分查找 | 定长滑动窗口 |
读写指针。将快指针的内容记录到慢指针的位置,典型的题目是原地删除(前置移动)重复元素。 | 有序数组暴力枚举。区别于上面的二分查找,这种算法指针移动是连续的,而不是跳跃性的 | 变长滑动窗口 |
其他暴力枚举。比如:双边比较从大到小枚举,双边按条件枚举,无需排序或者已经有序(当然2和3其实可以归为一类) |
二、经典例题
2.1、快慢指针
问题 | 例题 | |
---|---|---|
1 | 判断链表是否有环;寻找入环节点 | 141. 环形链表 | 142. 环形链表 II | 287. 寻找重复数 |
2 | 读写指针。将快指针的内容记录到慢指针的位置,典型的题目是原地删除(前置移动)重复元素 | 26. 删除有序数组中的重复项 | 80. 删除有序数组中的重复项 II | 202. 快乐数 |
(1)、链表判环
141. 环形链表
解法1:哈希表
最容易想到的方法是遍历所有节点,每次遍历到一个节点时,判断该节点此前是否被访问过。
具体地,我们可以使用哈希表来存储所有已经访问过的节点。每次我们到达一个节点,如果该节点已经存在于哈希表中,则说明该链表是环形链表,否则就将该节点加入哈希表中。重复这一过程,直到我们遍历完整个链表即可。
注意:Python中的哈希表为字典和集合。
# Definition for singly-linked list.
# class ListNode:
# def __init__(self, x):
# self.val = x
# self.next = None
class Solution:
def hasCycle(self, head: Optional[ListNode]) -> bool:
seen = set()
# 如果有环,虽然while死循环,但一定能在while中return True
while head:
if head in seen:
return True
seen.add(head)
head = head.next
# 没有环则head的最后一个next会None而退出循环
return False
解法2:快慢指针
# Definition for singly-linked list.
# class ListNode:
# def __init__(self, x):
# self.val = x
# self.next = None
class Solution:
def hasCycle(self, head: Optional[ListNode]) -> bool:
slow = head
fast = head
while fast and fast.next:
slow=slow.next
fast=fast.next.next
if fast==slow:
return True
return False
142. 环形链表 II
解法1:哈希表
思路同上
# Definition for singly-linked list.
# class ListNode:
# def __init__(self, x):
# self.val = x
# self.next = None
class Solution:
def detectCycle(self, head: Optional[ListNode]) -> Optional[ListNode]:
seen = set()
# 不允许修改表,用一个临时的指针来操作
cur = head
while cur:
if cur in seen:
return cur
seen.add(cur)
cur=cur.next
return None
解法2:快慢指针
找数学规律:当快慢指针在环中相遇,链表的起点到入环点=快慢指针相遇点到入环点的距离。
所以相遇之后,定义新的游标在链表起点,此时该游标和慢指针一起以相同步长走,相遇即到了入环点。
# Definition for singly-linked list.
# class ListNode:
# def __init__(self, x):
# self.val = x
# self.next = None
class Solution:
def detectCycle(self, head: Optional[ListNode]) -> Optional[ListNode]:
slow = fast = head
while fast and fast.next:
fast = fast.next.next
slow = slow.next
if fast==slow:
cur = head
while cur!=slow:
cur=cur.next
slow=slow.next
return cur
return None
287. 寻找重复数
287. 寻找重复数
这题比较巧妙的一点是将nums的每个值当做下一个点的坐标,从而进行连接起来。我们来看看这个例子:
1 4 6 6 6 2 3
值为6时会指向索引6值为3的点,再以3为索引,又指向索引为3值为6的索引。
这道题同上一题 环形链表 II 的解法一致,重复元素即表示入环点
class Solution:
def findDuplicate(self, nums: List[int]) -> int:
fast = slow = nums[0]
# 至少存在一个重复的数,说明不会死循环,一定存在slow==fast的情况
# 不同判断是否有环,因为一定有
while True:
slow = nums[slow]
fast = nums[nums[fast]]
# 同环形链表的解法
# 1. 先记录第一次相遇
if slow == fast:
# 记录一个起点与slow一同移动直到相遇,即为入环点
cur = nums[0]
while cur!=slow:
cur = nums[cur]
slow = nums[slow]
return cur
return None
当然也有哈希表解法,同上,但时间复杂度高。
876. 链表的中间结点
慢指针走一步,快指针走两步,当快指针走到结尾,慢指针会走到链表中间。
# Definition for singly-linked list.
# class ListNode:
# def __init__(self, val=0, next=None):
# self.val = val
# self.next = next
class Solution