什么样的问题适合用双指针技巧?当问题是从一个有序的数组或链表中,找到一个元素的子集,该子集需要满足某种限制。 这时候就特别适合用双指针。这个子集可能是某两个元素,某三个元素,甚至是一个子数组。
1 举个栗子
给你一个升序排列的数组和一个目标值,从该数组中找出两个元素,使它们的和等于目标值
看到这个问题,你会不会觉得:
这不就是两层for循环嘛。你轻松写下了如下代码:
def twoSum(nums: List[int], target: int) -> List[int]:
for i in range(len(nums)):
for j in range(i+1, len(nums)):
if nums[i] + nums[j] == target:
return [i, j]
这个解法,问题在于时间复杂度是 。在实际中有点难以接受。这个时候就可以用上我们的双指针技巧。
在滑动窗口技巧中,我曾经反复强调,滑动窗口不过是对暴力搜索算法的优化。 同理,双指针技巧也是这样的。网上很多讲双指针技巧的,我还没有看到谁从这个角度来讲的,如果有麻烦加微信(milter007)告诉我。而我认为只有从这个角度来讲,才能减少认知的跳跃,更容易理解双指针的本质所在。
2 问题分析
分析上面的暴力算法,我们看到外层循环的目的,是一个一个元素看,尽量找到和该元素之和等于目标值的元素。这个过程是不可能省略的,因为省略就意味着没有查找全面彻底。
但是,内层循环是可以进行优化的。并不需要那么傻乎乎地一个一个查找。举例来说,如图所示:
Pointer1就代表上面的外层循环中的 ,Pointer2就代表上面的内层循环中的 。只不过内层循环是从数组尾部开始而已。目标值是6。
我们看到,此时 ,于是,我们的Pointer2代表的内层循环就会往前走,如图所示:
此时, ,此时,Pointer2代表的内层循环没必要继续了,因为数组是升序排列的,越往前,元素值越小,和Pointer1的和自然越小。所以,内层循环可以提前退出,这就是我们的第一个优化点。
根据上面的暴力算法,内层循环退出后,如果没有找到答案,外层循环就要继续,这就是Pointer1要向前走。Pointer1向前走一步如下所示:
我们这个例子中恰好找到答案了。但假如此时没有找到答案呢?比如这种情况:
向前移动Pointer1之后, ,这个时候,我们该怎么办呢?需要按照暴力搜索的逻辑,重启内层循环,从数组尾部开始,一个一个和此时的Pointer1(也就是2)进行匹配吗?
答案是不需要。
因为此时,我们已经可以断定,整个数组中是不可能有哪个数与2的和等于6的,为什么呢?因为 3.5 往右的每一个数,它们与1的和都是大于6的,自然与2的和更是大于6。
为什么3.5 往右的每一个数,它们与1的和都是大于6的?因为这是上一轮循环的结果,我们的Pointer2是从尾部一个一个往前推,如果与1的和大于6,就继续,直到小于6才停下来的。
而3.5往左的每一个数,都比3.5还小,它们与2的和只能比6更小。
既然这样,我们就压根不用进行任何内层循环查找了,直接将外层循环推进一步,就是将Pointer1向右推一步,这就是我们的第二个优化点。
细心的你,一定想问,如果是这种情况呢:
此时, ,那我们就需要进行内层循环了,不过,我们只用从现在的Pointer2的位置继续向数组头部方向进行查找就是,而不用从数组尾部重新开始,这就是我们的第三个优化点。
上面,其实就是双指针算法了!表面上看起来,两个指针,一会儿P2向数组头部走,一会儿P1向数组尾部走,看不出章法,第一次学习的同学,可能感到一头雾水。通过咱们的分析,原来就是对暴力查找算法的一个优化而已。 是不是顿时觉得清爽很多了?
3 代码实现
def pair_with_targetsum(arr, target_sum):
left, right = 0, len(arr) - 1
while(left < right):
current_sum = arr[left] + arr[right]
if current_sum == target_sum:
return [left, right]
if target_sum > current_sum:
left += 1 # 移动Pointer1
else:
right -= 1 # 移动Pointer2
return [-1, -1]