详解双指针法
文章目录
- 详解双指针法
- 概念
- 适用场景
- 解题步骤
- 参考例题
- leetcode-209-最小尺寸子数组
- leetcode-881-救生艇
- leetcode-26-移除有序数组中的重复元素
- leetcode-287-寻找重复数字-环形链表的变形
- 两个有序数组差值满足[lower, upper]的个数
- leetcode-930-[Binary Subarrays With Sum](https://leetcode.cn/problems/binary-subarrays-with-sum/)🌟🌟🌟🌟
- leetcode-1004-[Max Consecutive Ones III](https://leetcode.cn/problems/max-consecutive-ones-iii/)🌟🌟🌟🌟🌟
- leetcode-395-Longest Substring with At Least K Repeating Characters🌟🌟🌟🌟🌟
概念
双指针其实是基于暴力解法的优化,在遍历数组的过程中,不是使用单个指针进行访问,而是使用两个相同方向(快慢指针)的或者相反方向的(对撞指针)进行扫描,从而达到相应的目的。
适用场景
在固定一个数找另一个数的过程中,对暴力解法的优化实施的解法即为双指针法。通常用于解决数组或字符串的子元素问题,将嵌套循环改为单循环。
通常情况下,如果无法用双指针来解决,需要考虑下是否可以使用哈希表,在找数的场景中尤为明显。
其次考虑二分查找,分治法。
解题步骤
- 对撞指针:指两个变量在数组上相向移动来解决问题,二分查找即为对撞指针的典型处理方式
- 和二分查找的条件一样:如果题目是有序数组,或能发现有递增或递减的趋势就可以考虑使用二分查找或者说对撞指针法来解决
# 伪代码
left,right = 0, len(nums)-1
while left <= right:
left += 1
# 进行相应的操作、处理
right -= 1
-
快慢指针:指两个变量同向移动解决的问题,常见于链表
- 一个指针每次只 +1,另一个指针每次 +2
- 起始点可以相同也可以隔 x 个位置(不同起始点会在确定环的初始位置上造成偏差,具体可以看环形链表 II)
# leetcode-141-判断环形链表 # leetcode-142-返回环的第一个起点 class ListNode: def __init__(self, head): self.val = head self.next = None class Solution: def hasCycle(self, head: Optional[ListNode]) -> Optional[ListNode]: """ 判断环形链表是用快慢指针解决问题的经典题目(当然也可以使用哈希表来做,空间复杂度高点) 时间复杂度:O(N) 其中 N 是链表中的节点数 当没有环的时候,每个节点至多被访问两次(即快慢指针都访问到它) 当有环的时候,因为快慢指针相差一个位置,所以至多走 N 轮后能相遇 空间复杂度:O(1) 升级:返回环起点的位置 发现相遇的点有时候在环的中间有时候在环的结尾,所以我们需要跟原链进行一次对比即可找到环的起 点 :param head: :return: """ fast, slow = head, head while fast is not None and slow is not None: slow = slow.next if fast.next is not None: fast = fast.next.next else: return None if fast == slow: while head != slow: head, slow = head.next, slow.next return slow return None
这时我们有了一个疑问:为什么从相遇位置开始一步一步走,从开始位置一步一步走,两者可以再相遇到环的起点?
假设环的长度为 L,从起点到环的距离为 a,从环的入口继续走 b 到达相遇位置,从相遇位置继续走 c 即可走回入口,所以 b+c = L,快指针每次走两步,慢指针每次走一步,快慢指针肯定会相遇在环中:
假设 k 是快指针在环里兜的圈数
a +b +kL=2(a+b)
a = kL-b,我们关注 a 和 c 的关系
a = kL-(L-c)=(k-1)L+c
这个等式也就证明了我们从起点走 a 步,在环里走 k-1 圈加上 c 步,根据之前的假设条件可见也走到了环的起点。
当环形链表变成数组,我们该怎么办?(请参考下文中的 leetcode-287)
-
滑动窗口(sliding window algorithm):即数组中被框起来的一部分,当滑动窗口从数组的左边滑到了右边,就可以从所有候选结果中找到最优解。
- window 的大小需要根据题目内容进行确定,有的要求定长有的则不需要
- valid 也需要根据题目的要求来确定
- 没有限制滑动窗口的条件可以创造条件,比如 leetcode-395
# 伪代码
left, right = 0, 0
while right < len(nums):
window.add(right)
while (valid):
window.remove(nums[left])
left += 1
right += 1
参考例题
quickSort 快排
leetcode-209-最小尺寸子数组
当我们使用暴力法解题的时候发现需要嵌套循环解决问题,同时观察数组不是有序的,所以不能用对撞指针,由于是关于子数组的问题,所以我们最终使用滑动窗口来解决问题
时间复杂度:O(N)
空间复杂度:O(1)
class Solution:
def minSubArrayLen(self, target: int, nums: List[int]) -> int:
# 当我们使用暴力法时发现只能双层循环,所以尝试使用双指针法来解决问题
# 我们发现没办法使用对撞指针,只能使用同向的快慢指针
left, right = 0, 0
summary, res = 0, len(nums)+1
while right < len(nums):
summary += nums[right]
while summary >= target:
res = min(res, right-left+1)
summary -= nums[left]
left += 1
right += 1
return res if res < len(nums)+1 else 0
leetcode-881-救生艇
leetcode-26-移除有序数组中的重复元素
题目数组是有序的,因此考虑使用双指针,对撞指针不能满足需求,所以考虑使用快慢指针,也像滑动窗口
所以我们可以套用滑动窗口的模板:更多也可以参考我的题解
时间复杂度:O(N)
空间复杂度:O(1)
以 [0,0,1,1,1,2,2,3,3,4] 为例:
left=0, right=1,tmp = 1 idx = 0
left =2, right=3,right=4 tmp=3,4 idx = 1
left=5, right=6 tmp=6 idx=2
left=7, right=8 tmp=8 idx=3
left=9 idx=4
class Solution:
def removeDuplicates(self, nums: List[int]) -> int:
left, right = 0, 0
total = 1
while right < len(nums):
window = nums[right]
if window != nums[left]:
total += 1
if total - 1 != right:
nums[total-1] = nums[right]
left = total-1
right += 1
return total
leetcode-287-寻找重复数字-环形链表的变形
-
使用快慢指针:题目要求不改变数组且只使用常数的空间,所以不能进行排序,不能使用哈希表。很自然地想到双指针来解决问题,因为数组无序所以无法使用对撞指针,只能是同向的,滑动窗口不太满足需求。
所以这里适用于快慢指针,环形链表是快慢指针的经典问题,我们只需要找到环中的某个值,然后再从头开始遍历,找到第一次相遇的位置即为环的开始位置。这里有两个问题:
- 快慢指针怎么跳动,肯定不是像环形链表那样往后移。实际上任意一个没有重复元素的且值在数组长度范围内的,比如数组长 n,里面的元素都小于 n,slow = nums[slow] 最终会跳出,但有重复元素或者说带环的就不会。
- 因此,
slow = nums[slow]
fast=nums[nums[fast]]
- 因此,
class Solution: def findDuplicate0(self, nums: List[int]) -> int: """ 时间复杂度:O(N) 空间复杂度:O(1) """ fast, slow = 0, 0 while fast != slow or fast == slow == 0: fast = nums[nums[fast]] slow = nums[slow] slow = 0 while fast != slow: slow = nums[slow] fast = nums[fast] return fast
- 如何避免
i==nums[i]
这种场景:题目要求数组包含 n+1 个元素,且值在 [1,n] 之间,所以 i 从 0 开始没任何问题,如果值在 [0,n-1] 之间且 第一个值为 0,那么就会陷入0 == nums[0]
之中。所以开始的关键是避开i==nums[i]
的点
def findDuplicate1(self, nums: List[int]) -> int: length = len(nums) slow, fast = length - 1, length - 1 while fast != slow or fast == slow == length - 1: slow = nums[slow] fast = nums[nums[fast]] slow = length - 1 while fast != slow: slow = nums[slow] fast = nums[fast] return fast
- 快慢指针怎么跳动,肯定不是像环形链表那样往后移。实际上任意一个没有重复元素的且值在数组长度范围内的,比如数组长 n,里面的元素都小于 n,slow = nums[slow] 最终会跳出,但有重复元素或者说带环的就不会。
-
使用对撞指针,二分查找法
我们已经知道题目数组包含 n+1 个元素,且值在 [1,n] 之间,其实已经天然形成了一个有序数组,同时借助 missingNumber 的思路:
当我们知道一个值之后,我们知道小于这个值的应该有几个,大于这个值的应该有几个,由于重复值一定存在,所以一定能找到。因此我们可以使用二分查找:
def findDuplicate(self, nums: List[int]) -> int:
"""
时间复杂度:O(NlogN)
空间复杂度:O(1)
:param nums:
:return:
"""
length = len(nums)
left, right = 1, length - 1
while left <= right:
mid = (left + right) // 2
left_count = mid - 1
real_left_count = 0
real_mid_count = 0
for i in range(length):
if nums[i] < mid:
real_left_count += 1
elif nums[i] == mid:
real_mid_count += 1
if real_left_count > left_count:
right = mid - 1
elif real_mid_count >= 2:
return mid
else:
left = mid + 1
两个有序数组差值满足[lower, upper]的个数
使用同向指针,不算是滑动窗口,但很巧妙
def get(self, nums1: List[int], nums2: List[int], lower: int, upper: int) -> int:
"""
找两个有序数组的差值为 [lower,upper] 之间的对数
:param nums1:
:param nums2:
:param lower:
:param upper:
:return:
"""
res = 0
# for i in range(len(nums2)):
# for j in range(len(nums1)):
# if lower <= nums2[i] - nums1[j] <= upper:
# res += 1
# 我们发现暴力法中其实重复计算了一些我们本来已经知道不符合条件的差值,所以使用两个指针,此时时间复杂度直接降到了 O(m+n)
l = 0
r = 0
for i in range(len(nums1)):
# 如果已知一个有序数组,求在 [lower+nums1[i], upper+nums1[i]] 的值的个数
while l < len(nums2) and nums2[l] - nums1[i] < lower:
# 找到 >=lower 的左指针
l += 1
# 必须是 <= upper 如果用 < upper res += r-l+1 会错误
while r < len(nums2) and nums2[r] - nums1[i] <= upper:
# 找到满足 <=upper 的右指针
r += 1
res += r - l
两个有序数组满足 nums1[i] > nums2[j]*2 的组合
时间复杂度为 O(m+n)
i,j = 0,0
res = 0
while i < len(nums1) and j < len(nums2):
if nums1[i] > nums2[j]*2:
# 相当于固定 j 来求值
res += len(nums1) - i
j += 1
else:
i += 1
# 或使用固定 nums1 中的值
j = 0
for x in range(len(nums1)):
while i < len(nums2) and nums1[x] > nums2[j]*2:
j += 1
res += j - mid - 1
注:上面两题在计算过程中都应该注意指针加减过程中对结果的影响,避免产生错误
leetcode-930-Binary Subarrays With Sum🌟🌟🌟🌟
这道题可以使用前缀和+哈希表来解决,也更容易理解,但也可以使用滑动窗口来解决,空间复杂度更低
符合固定一个值,遍历其他所有元素的特点,所以可以使用双指针,因为数据无序所以可以使用滑动窗口,但由于 0 值的存在导致一次遍历之后不能找到所有符合条件的个数,所以我们可以借助两个 left 两个窗口来完成题目。
class Solution:
def numSubarraysWithSum(self, nums: List[int], goal: int) -> int:
"""
已知暴力法双层循环可以解决问题,同时符合固定一个数遍历的特征,所以可以使用滑动窗口,因为 0 的存在当只使用一个 left 时无法得到答案,所以
使用两个 left,即 right 固定时,有一个区间其到 right 的和满足 goal
时间复杂度:O(N)
空间复杂度:O(1)
:param nums:
:param goal:
:return:
"""
length = len(nums)
left_1, right = 0, 0
left_2 = 0
window_1 = 0
window_2 = 0
res = 0
while right < length:
window_1 += nums[right]
while left_1 <= right and window_1 > goal:
window_1 -= nums[left_1]
left_1 += 1
window_2 += nums[right]
while left_2 <= right and window_2 >= goal:
window_2 -= nums[left_2]
left_2 += 1
res += left_2 - left_1
right += 1
return res
leetcode-1004-Max Consecutive Ones III🌟🌟🌟🌟🌟
已知暴力求解问题的过程中发现,题目符合固定一个值,然后找另一个数或者说遍历剩下的元素的特点,所以可以用双指针来优化,数组无序所以可以使用滑动窗口
class Solution:
def longestOnes(self, nums: List[int], k: int) -> int:
"""
题目符合固定一个数,找另一个数的特征(也就是前文中的暴力解法),所以可以尝试双指针,不符合快慢指针,对向指针参考上文,
这里可以考虑使用滑动窗口来解决问题,
时间复杂度:O(N)
空间复杂度:O(1)
:param nums:
:param k:
:return:
"""
length = len(nums)
left, right = 0, 0
# [1,1,1,0,0,0,1,1,1,1,0]
# 0 1 2 3 4 5
res = 0
while right < length:
if nums[right] == 0:
k -= 1
if k >= 0:
res = max(res, right - left+1)
else:
# k < 0 的状态下不能包含 right
res = max(res, right-left)
while k < 0:
if nums[left] == 0:
k += 1
left += 1
right += 1
return res
leetcode-395-Longest Substring with At Least K Repeating Characters🌟🌟🌟🌟🌟
无法限制窗口的大小时,创造条件限制大小,一段(由小写字符组成的)字符串包含的字符的种类不会超过 26,因此问题得解
class Solution:
def longestSubstring(self, s: str, k: int) -> int:
"""
题解滑动窗口法,在 longestSubstring_0 中固定一侧进行遍历时我们知道可以使用双指针来优化,但我们不知道如何把窗口限制住,
这是最大的难题,但事实上我们可以通过限制窗口中出现的字母的种类来进行限制,假设为 t,我们遍历 26 个数,自然可以 26 次来限制 t
当 t = target 时,我们只需要对窗口内的元素检查是不是每个元素个数都 >= k 即可,然后对比 res
时间复杂度:O(Σ^2*N) 外层循环为 Σ,内部使用滑动窗口时间复杂度为 N, 同时因为每次会遍历 tb 对比每个 key 对应 value 的值
空间复杂度:O(Σ) 指的是 tb 哈希表占用的空间
:param s:
:param k:
:return:
"""
length = len(s)
# 用来记录当前窗口中元素的种类数
res = 0
for target in range(1, 27):
l, r = 0, 0
# 暂存 l~r 区间内收集到的字母分类
tb = {}
while r < length:
# window.add(r)
key = ord(s[r]) - ord('a')
if key in tb:
tb[key] += 1
else:
tb[key] = 1
while len(tb.items()) > target:
# delete item
key = ord(s[l]) - ord('a')
tmp = tb[key] - 1
if tmp == 0:
del tb[key]
else:
tb[key] = tmp
l += 1
if len(tb.items()) == target:
# check every item is >= k
flag = True
for item in tb.items():
if item[1] < k:
flag = False
break
if flag:
# print('=====>', target, l, r)
res = max(res, r - l + 1)
r += 1
return res