1.双指针算法总述
-
1.1 双指针算法是什么
双指针算法,是指通过各司其职又互相配合的两枚指针,解决线性表(线性表包括链表和顺序表两类)问题的一类算法
其中,各司其职指两个指针背负不同的任务进行移动,互相配合指两个指针的移动也需要协同配合。就像产品和开发,产品负责设计而开发负责实现,两者的任务不同,但同时为了实现产品的落地,他们需要相互配合
-
1.2 双指针可应用的问题
双指针应用在线性表问题,即链表问题和顺序表问题
-
1.3 双指针的优势(知道双指针算法好在哪里,才算有一定的理解)
双指针间的协同配合自带剪枝buff,能自动淘汰明显的不可行解,从而降低问题求解的时间复杂度到O(n)甚至O(log n)级别(因为双指针算法只对线性表进行了单次循环的顺序扫描,理解这一点很重要)
-
1.4 双指针算法的分类(重点)
基于不同双指针算法的适用情况,我将其分为3类:
- 快慢双指针。用于解决链表问题,两指针一快一慢从链表头节点head扫描至链表末节点(从数据结构上也很容易理解,因为链表结构只能顺藤摸瓜,只能从头扫到尾,木有其他扫描方式)
- 首尾双指针,特点是【夹逼】。用于解决顺序表问题,两个指针一个指头、一个指尾,向中间夹逼,两指针相遇时结束,此时刚好扫描整个顺序表一次
- 蠕动双指针。也就是滑动窗口,特点是【一伸一缩】。用于解决顺序表问题,两个指针都从顺序表头部出发,一个指针做蠕虫头,另一指针做蠕虫尾,一伸一缩蠕动前进,直到蠕虫抵达顺序表末尾。我们都知道蚯蚓,蚯蚓怎么蠕动的呢?当蚯蚓前进时,身体后部的刚毛钉入土里,使后部不能移动,这时身体就向前伸长;接着身体前部的刚毛钉入土里,使前部不能移动,这时向前缩短。就这样,蚯蚓一伸一缩完成向前移动。那么我们想象一条蚯蚓爬过一个顺序表(如一个数组),这就是蠕动双指针的工作模式
2. 三种双指针算法的实例剖析
以下例子部分来源于https://leetcode-cn.com/circle/article/GMopsy/
-
2.1解决链表问题的快慢双指针
2.1.1 判定链表是否含环
判断链表含环是一个很基本很经典的问题,一快一慢双指针从head出发,如果有环,那么快指针必定先进入环然后一直转圈圈,然后慢指针也进来转圈圈,它们一定会相遇;如果不含环,那么就是一条直线,快慢指针永远不会相遇。python实现如下:
class DetectCycle(object):
def detect(self, head):
# 快慢指针
slow = fast = head
# 令快指针每次走2个node,慢指针每次走一个node
while fast is not None and fast.next is not None:
fast = fast.next.next
slow = slow.next
if fast == slow:
return True
# 跳出while说明指针能够走到最后,没有环
return False
2.1.2 如果链表有环,找出环的起点位置。如上图中的3就是环的起点
还是假设快指针速度是慢指针的2倍,假设它们相遇在环的某个节点A,慢针走了s步,快针就走了2s步,因此快针比慢针多走的s步刚好在环里绕了n圈(n是整数)。这时让其中任一个指针重新指向头节点,另一个指针从相遇点A开始继续在环里转圈,这时回到头节点的指针走s步后可以到达A,继续转圈的指针走s步也能重新回到A,如果让它们以相同速度前进(速度具体是多少无所谓,同速就行),它们就能在环的起点B相遇,然后携手走过B-A回到A。不清楚的可以画图。python实现:
class DetectCycle(object):
def detect(self, head):
# 快慢指针
slow = fast = head
# 令快指针每次走2个node,慢指针每次走一个node
while fast is not None and fast.next is not None:
fast = fast.next.next
slow = slow.next
if fast == slow:
break
# 慢针回头
slow = head
while slow != fast:
fast = fast.next
slow = slow.next
return slow
2.1.3 给一个无环链表,寻找链表的n等分点
求n等分点就设置n个指针,速度分别是1/n, 2/n, ..., (n-1)/n, 1,一起从head出发,当最快的指针走到尾部的时候,其他指针所处的位置就是n等分点
2.1.4 给一个无环链表,寻找倒数第n个元素
设置两个同速指针,一个先移动n个节点,然后另一个开始移动;当先出发的指针到达末尾时,后出发的指针所在的位置即为所求
从上面的例子可以看到,无环链表的快慢指针算法都是快指针扫遍链表后结束,时间复杂度O(n)
-
2.2 解决顺序表问题的首尾指针
用两个经典的例子,二分查找和两数和问题
2.2.1 二分查找
对于已经排好序的数组,可以用二分查找,利用bottom和top双指针,解决一些搜索的问题
如:
给定一个排序数组和一个目标值,在数组中找到目标值,并返回其最小索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置(leetcode第35题改编),下面的实现代码特别是注释要认真看!
class Solution(object):
def searchInsert(self, nums, target):
"""
:type nums: List[int]
:type target: int
:rtype: int
"""
# 二分查找的区间统一设置为左闭右开的形式
# bottom的作用是从左到右找到小于target的元素的上界
bottom = 0
# top的作用是从右到左找到大于等于target的元素的下界
top = len(nums)
while bottom < top:
mid = bottom + (top - bottom)//2
if target > nums[mid]:
bottom = mid + 1
# 注意,这时bottom = mid + 1,但mid+1指向的元素可能大于等于target值。
# 这种情况下,原bottom才是小于target的元素的上界,现bottom已经变成大于等于target的元素的下界。
# 这个质变是好的,bottom的功能已经变成了top的功能,从此以后再也不会出现target > nums[mid]的情况,top会一直向bottom靠近直到重合
else:
# 因为区间设为左闭右开,所以这里很简洁,不用mid减1
top = mid
# 当bottom等于top时,bottom或者top的位置大于等于target值的元素下界
# nums.insert(bottom, target)
return bottom
成绩:
执行用时 :40 ms, 在所有 Python 提交中击败了91.73%的用户
内存消耗 :12.4 MB, 在所有 Python 提交中击败了5.47%的用户
原题默认给定数组无重复元素,我的解法同样适用于有重复元素的有序数组。但是不知道为什么内存消耗这么大。。。明明就额外存了top bottom 和 mid。。。
关于二分查找,知乎这篇文章也讲得挺好,知乎-二分查找
可以看到,对利用top bottom双指针对有序的顺序表进行搜索,并不需要暴力地逐一搜索其中的每一个元素,而是折半搜索,丢弃明显不可能的另一半区间,这就是自动剪枝的功能。对于长度为N的有序数组,二分查找只需要进行O(log2 N)+1次
2.2.2 两数和问题
给定一个有序整数数组 nums 和一个目标值 target,请你在该数组中找出和为目标值的那 两个 整数,并返回他们的数组下标
可以假设每种输入只会对应一个答案。但是,不能重复利用这个数组中同样的元素
示例:
给定 nums = [2, 7, 11, 15], target = 9
因为 nums[0] + nums[1] = 2 + 7 = 9
所以返回 [0, 1]
只要数组有序,双指针一般都能发挥作用。这道题的解法有点类似二分查找,通过调节 left 和 right 可以调整 sum 的大小:
class Solution(object):
def twoSum(self, nums, target):
"""
:type nums: List[int]
:type target: int
:rtype: List[int]
"""
nums = sorted(nums)
left = 0
right = len(nums) - 1
while left < right:
if nums[left] + nums[right] > target:
# 和大了,right左移
right -= 1
elif nums[left] + nums[right] < target:
# 和小了,left右移
left += 1
else:
return [left, right]
return None
2.3 蠕动双指针
关于蠕动双指针,here已经说得比较详细了,有需要可以进去看
蠕动双指针非常适合解决子串之类的问题,本质上,每一次伸是为了求可行解,每一次缩是为了优化这个可行解,6啊
抽象出来的蠕动双指针代码模板:
left = 0
right = 0
while right < len(s):
# 右指针先伸
hash.add(s[right]) or do other things
right += 1
# 左指针配合缩
while condition is valid:
hash.remove(s[left]) or do other things
left += 1
leetcode上的例题:
给定一个字符串,请你找出其中不含有重复字符的 最长子串 的长度
示例:
输入: "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3
我的解法,利用蠕动双指针,i指子串头,j指子串尾,固定i,j右移,直到子串中出现重复元素暂停,这是将i缩进至当前子串中重复元素的下一位;如此反复,直到j指向初始字符串的最后一个字符:
class Solution(object):
def lengthOfLongestSubstring(self, s):
"""
:type s: str
:rtype: int
"""
i = j = 0
max_len = 0
# 当多于1个元素时
if len(s) > 1:
# 核心代码在while代码块
while j < len(s) - 1:
current_sub = s[i:j+1]
j += 1
if s[j] in current_sub:
# i指针移到子串中重复元素的下一位
i = i + current_sub.index(s[j]) + 1
max_len = max(max_len, len(current_sub))
else:
max_len = max(max_len, len(current_sub) + 1)
return max_len
elif len(s) == 1:
return 1
else:
return 0
成绩:
执行用时 :44 ms, 在所有 Python 提交中击败了92.34%的用户
内存消耗 :12 MB, 在所有 Python 提交中击败了46.11%的用户
再看看leetcode中的另外一题-三数之和,这题坑很多,指针的移动需要仔细考虑
给定一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?找出所有满足条件且不重复的三元组。
注意:答案中不可以包含重复的三元组
例如, 给定数组 nums = [-1, 0, 1, 2, -1, -4],
满足要求的三元组集合为:
[
[-1, 0, 1],
[-1, -1, 2]
]
我的解法是
1.先将三元组分类,三元组中最小值相同的归为同一类三元组
2.将原数组排序,使用指针i从左到右遍历数组,指向的元素作为三元组中的最小值。这样,指针i的每一次移动都对应一类三元组,时间复杂度O(N)
3.p q两个指针分别从i+1和数组末尾相向移动,相遇时停止移动,时间复杂度O(N)
因此,我的解法的时间复杂度是O(n2)
class Solution(object):
def threeSum(self, nums):
"""
:type nums: List[int]
:rtype: List[List[int]]
"""
# 先排序,方便接下来使用首尾双指针
nums_sorted = sorted(nums)
result = []
# 规定i指针指向三元组中最小的元素
for i in range(len(nums_sorted)-2):
# 如果i指针指向的元素和上次指向的元素相等,则跳过
if i > 0 and nums_sorted[i] == nums_sorted[i-1]:
continue
second_num = float('inf')
p = i + 1
q = len(nums_sorted) - 1
while p < q:
total_sum = nums_sorted[i] + nums_sorted[p] + nums_sorted[q]
if total_sum > 0:
q -= 1
elif total_sum < 0:
p += 1
else:
if nums_sorted[p] != second_num:
three_element = [nums_sorted[i], nums_sorted[p], nums_sorted[q]]
# 这里遍历result判断当前三元组是否在答案中太耗费时间了,会超出时间限制,应该操作指针遇到相同元素时跳过
# if three_element not in result:
# result.append(three_element)
result.append(three_element)
second_num = nums_sorted[p]
p += 1
q -= 1
return result
成绩:
执行用时 :628 ms, 在所有 python 提交中击败了76.44%的用户
内存消耗 :15.1 MB, 在所有 python 提交中击败了32.76%的用户