算法刷题总结 (七) 双指针

算法总结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)。

二分查找用于在多条记录中快速找到待查找的记录,它的思想是:每次将查找的范围缩小一半,直到最后找到记录或者找不到记录返回

二分法的使用条件:
二分法是适用于解决具有“二段性”(单调性)的问题的方法,通常表现为求解满足某一条件的最大值或者最小值问题。

  1. 上下界确定。 我们可以通过上下界的折半来优化查找。
  2. 二段性: 对某一范围内的数据,存在一个临界点,使得临界点某一侧的所有数据都满足某一性质,另一侧的所有数据都不满足这一性质,就称这一范围内的数据具有二段性。
    二段性包括单调性,即区间内有序,这样二分出来的结果是严格大于或者小于或等于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. 环形链表

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

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
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值