线性表中的双指针法是指通过两个指针(游标)来指示线性表中的元素的方法。双指针的使用本身并没有什么神奇之处,但是通过合适地操纵指针的移动,在某些特定问题中却能化腐朽为神奇,大大降低算法的时间复杂度。
根据对双指针不同的移动规则,整理了常用的双指针方法及其所用来解决的问题:
双指针法 | 操作说明 | 经典问题 |
---|---|---|
首尾指针 | 使用两个指针指示线性表的首尾元素 | 二分查找、NSum |
口袋指针 | 一个指针指示口袋口,另一个指针用于遍历 | 数组划分 |
窗口指针 | 两个指针指示窗口范围 | 窗口蠕动法求解连续子序列问题 |
快慢指针 | 使用两个移动速度不同的指针 | 环的检测和交点确定 |
早晚指针 | 使用出发时间不同的两个指针 | 消除距离差额 |
双表指针 | 两个指针分别指示两个线性表当前位置 | 数组归并 |
首尾指针
- 适用问题:需要同时操作首尾元素,或者需要指示变化中的线性表范围,使用首尾指针经常需要先对数组进行排序;
- 使用思路:
- 指针含义:l指示线性表首部元素,r指示线性表尾部元素
- 移动规则:依条件向内移动,直至相遇
反转线性表
- 问题:[344] 反转字符串,请编写一个函数,其功能是将输入的字符串反转过来;
- 思路:使用首尾指针交换元素,然后首尾指针向内移动,直至首指针不再小于尾指针;
- 代码:
1 | class Solution(object): |
- 分析:空间O(n),时间O(n)
盛最多水的容器
- 问题:[11]盛最多水的容器,给定 n 个非负整数 a1,a2,…,an,每个数代表坐标中的一个点 (i, ai) 。画 n 条垂直线,使得垂直线 i 的两个端点分别为 (i, ai) 和 (i, 0)。找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。
- 思路:分治+首尾指针,问题首先划分为两个子问题,使用两侧较小边和不使用两侧较小边,然后取二者中的最大值;使用较小边时,容器两侧容纳最多的水,不使用较小边的子问题可以是原始问题的同类子问题,可以用递归求解,也可以用首尾指针来求,如果使用了较小边l,收集当前最大后l++,反之较小边为r则r—,直至l == r。
- 代码:
1 | class Solution(object): |
- 分析:空间O(1),时间O(n)
二分查找
1 | # 闭区间写法:用[l,r]代表当前查找范围 |
2 Sum
无序数组的 2 Sum
- 问题:[1] 两数之和,给定一个整数数组和一个目标值,找出数组中和为目标值的两个数。你可以假设每个输入只对应一种答案,且同样的元素不能被重复利用
- 思路:使用哈希表存储元素残差,如果元素在哈希表则说明和为target,返回
- 代码:
1 | class Solution(object): |
- 分析:空间O(n),时间O(n)
有序数组的2Sum
- 问题:[167] 两数之和 II,给定一个已按照升序排列 的有序数组,找到两个数使得它们相加之和等于目标数。函数应该返回这两个下标值 index1 和 index2,其中 index1 必须小于 index2
- 思路:首尾指针法,如果量元素之和小于target则移动右移左指针,如果大于则左移右指针,如果相等,则返回两下标
- 代码:
1 | class Solution(object): |
- 分析:空间O(1),时间O(n)
3 Sum
- 问题:[15]三数之和,给定一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?找出所有满足条件且不重复的三元组。
- 思路:先排序+2Sum,先对数组进行排序,从前向后每次选取一个不同的元素x作为基准,然后在后面的数组中找到所有不重复的2Sum等于target-x的组合,再合并收集;保证解完备且不重复的核心有两点(选择下一个不同元素中的第一个):①基准选择:每次选择下一个不同元素中的第一个,不同基准不会有重复解,第一个元素作为基准不会漏解②2Sum时:每次移动指针到第一个不重复的位置,也保证了2Sum的完备且不重复
- 代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25class Solution(object):
def threeSum(self, nums):
"""
:type nums: List[int]
:rtype: List[List[int]]
"""
nums.sort()
n = len(nums)
target = 0
res = []
for i in xrange(n-2):
if i == 0 or nums[i] != nums[i-1]:
base = nums[i]
l,r = i + 1, n - 1
while l < r:
if nums[l] + nums[r] == target - base:
res.append([base,nums[l],nums[r]])
l,r = l+1,r-1
while l < r and nums[l] == nums[l-1]: l += 1
while l < r and nums[r] == nums[r+1]: r -= 1
elif nums[l] + nums[r] < target - base:
l += 1
else:
r -= 1
return res - 分析:空间$O(1)$,时间$O(n^2)$
N sum
- 问题:[18] 四数之和,给定一个包含 n 个整数的数组 nums 和一个目标值 target,判断 nums 中是否存在四个元素 a,b,c 和 d ,使得 a + b + c + d 的值与 target 相等?找出所有满足条件且不重复的四元组
- 思路:排序+递归+2Sum,NSum可以转化为N-1Sum,逐步转化为2Sum,类似的只要每次选取下一个不同元素中的第一个元素,就能保证最终解的完备且不重复
- 代码:
1 | class Solution(object): |
- 分析:空间$O(1)$,时间$O(n^{N-1})$
最接近的三数之和
- 问题:[16. 最接近的三数之和]给定一个包括 n 个整数的数组 nums 和 一个目标值 target。找出 nums 中的三个整数,使得它们的和与 target 最接近。返回这三个数的和。假定每组输入只存在唯一答案。
- 思路:排序后转化为2Sum,收集绝对差值最小的组合
- 代码:
1 | class Solution(object): |
口袋指针
- 适用问题:将数组中满足条件的元素移至一端,要求原地修改且满足条件的元素相互位置保持稳定;
- 使用思路:假想用一只“袋子”盛放满足条件的元素,初始时袋子为空,用l=-1指示袋子口的位置,使用另一个指针从0开始遍历整个数组,如果当前元素满足条件,则袋口+1,将当前元素通过交换放入袋中,当遍历结束时,所有满足条件的元素就都被放入到袋子中了。
移动 0
- 问题:[283] 移动零,给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序
- 思路:口袋指针法,初始口袋口-1,遍历整个数组,如果元素不等于0,袋口扩充,并将该元素和袋口元素交换
- 代码:
1 | class Solution(object): |
- 分析:空间O(1),时间O(n)
红白蓝划分
- 问题:[75] 分类颜色,给定一个包含红色、白色和蓝色,一共 n 个元素的数组,原地对它们进行排序,使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列,我们使用整数 0、 1 和 2 分别表示红色、白色和蓝色
- 思路:两次口袋指针法,先把0放头部,再把1放0后面数组的头部;或者使用首尾两个口袋,一次遍历将0放头口袋,将2放尾口袋
- 代码:
1 | class Solution(object): |
- 分析:空间O(1),时间O(n)
快排划分
- 问题:选取首元素作为枢轴值,将数组中小于等于该值的元素交换至头部,大于该值的交换置数组尾部,返回枢轴值最终的位置
- 思路:口袋指针法
- 代码:
1 | def partition(A,low,high): |
- 分析:空间O(1),时间O(n)
寻找第k大
- 问题:[215] 数组中的第K个最大元素,在未排序的数组中找到第 k 个最大的元素
- 思路:有很多方法可以求解此问题,但最高效的方法是口袋指针法,类似于快排划分,将首元素(先做随机交换)作为枢轴值,将所有大于等于它的元素交换至数组头部,返回数轴值最终位置pivot,如果枢轴值位置刚好是k-1,则说明该位置的值即为第k大的值,如果i
k-1,在左半边nums[:i]寻找第k大 - 代码:如果枢轴值不进行随机选择,效率还不如先排序!!
1 | import random |
- 分析:最好情形$T(n)=T(n/2)+O(n)$,空间复杂度O(lgn),时间复杂度为O(n)
窗口指针
包含剪枝条件的连续子序列问题:
- 连续子序列问题:求解满足条件f的连续子序列;
- 包含剪枝条件:如果在找到了一个首次满足特定条件g的连续子序列之后,发现可以排除掉(l<=x
r)部分的解,就可以左移连续子序列的左侧边界l直至条件g不再满足,即(l’<=x,r<=y)不能排除;
常见的这类问题有:
- 满足条件的最长连续子序列问题;
- 满足条件的最短连续子序列问题;
- 内在的满足剪枝条件的其他类型连续子序列问题;
窗口蠕动法:将连续子序列看做是一个“窗口”,用左右指针l,r来标识窗口,起初窗口为空(l=0,r=-1),备选解解存在于(x>=l,y>r),每次通过右移右指针来扩展窗口(l,r),如果当前窗口满足上述减枝条件g,则通过右移左指针实现剪枝(l’,r),移动过程中收集满足条件f的解,剩余备选解在(x>=l’,y>r)中,循环以上过程得到所有满足条件的连续子序列。因为窗口移动的过程通常是由右指针来扩展,由左指针来收缩,很像虫子向前蠕动的过程,因此叫窗口蠕动更形象。
窗口蠕动包含三个步骤:扩展-剪枝-收集(核心是设计出合适的减枝条件)
- 初始化空窗口l=0,r=-1
- 循环以下过程直至右指针越界
- 扩展:右移右指针
- 剪枝:如果达到下一个可剪枝的窗口,则右移左指针,直至不能再剪枝
- 在窗口满足条件时收集备选解
- 返回最终解
满足条件的最长连续子序列
无重复最长子串
- 问题:[3] 无重复字符的最长子串,给定一个字符串,找出不含有重复字符的最长子串的长度
思路:当窗口首次包含重复时,(r>x>l,r>y>l)(不是最长)以及(x=l,y>r)(包含重复)部分可以剪枝,右移左指针直至包含重复
- ① 初始化窗口l=0,r=-1
- ② 循环以下过程直至右指针越界r==n
- 右移右指针
- 如果当前窗口包含重复,右移左指针,直至当前窗口不包含重复
- 更新不包含重复字符的子串的最大长度
- ③ 返回最大长度
代码:
1 | from collections import Counter |
- 分析:空间O(n),时间O(n)
满足条件的最短连续子序列
和大于s的最短子数组
- 问题:[209] 长度最小的子数组,给定一个含有 n 个正整数的数组和一个正整数 s ,找出该数组中满足其和 ≥ s 的长度最小的子数组。如果不存在符合条件的子数组,返回 0
- 思路:当窗口和首次大于等于s时,(r>x>l,r>y>l)(小于s)以及(x=l,y>r)(不是最短)部分可以剪枝,右移左指针直至窗口和小于s
- ① 初始化窗口l=0,r=-1
- ② 循环以下过程直至右指针越界r==n
- 右移右指针
- 如果当前窗口和>=s,更新最短数组的的长度,右移左指针,直至当前窗口<s
- ③ 返回最大长度
- 代码:
1 | def minSubArrayLen(self, s, nums): |
- 分析:空间O(1),时间O(n)
最小覆盖子串
连续序列覆盖子串问题是一类非常经典的问题,值得认真体会,关键点有两个:
- 剪枝条件:当已经覆盖时,可以剪枝,l右移至不再覆盖,即第一次need[s[l]]从0变成1的时候
- 用count和need标识总需求和每个字符的需求数:count=0时表示已经覆盖,need为负数时表示已超出需求
- 问题:[76] 最小覆盖子串,给定一个字符串 S 和一个字符串 T,请在 S 中找出包含 T 所有字母的最小子串
1 | 输入: S = "ADOBECODEBANC", T = "ABC" |
- 思路:当窗口首次覆盖子串时,继续扩展r不是最优,(l,r)内不能覆盖,可进行剪枝,右移l直至不再覆盖,不再覆盖不好判断,但不再覆盖的前一位置可以判断。用count统计当前窗口还缺多少个字符,用need记录当前每种字符需求数,如果是负数代表多余个个数。(覆盖问题使用need存放每个元素的需求,使用count存放总需求对于剪枝判断是个很好的技巧,下面我们还会反复遇到)
- 代码:
1 | def minWindow(self, s, t): |
-分析:空间O(n),时间O(n)
最小区间
- 问题:[632] 最小区间, 你有 k 个升序排列的整数数组。找到一个最小区间,使得 k 个列表中的每个列表至少有一个数包含在其中。我们定义如果 b-a < d-c 或者在 b-a == d-c 时 a < c,则区间 [a,b] 比 [c,d] 小
1 | 输入:[[4,10,15,24,26], [0,9,12,20], [5,18,22,30]] |
- 思路:同最小覆盖子串,核心在于如何判断刚好已覆盖和刚好未覆盖,将所有值按照(值,所属分组号)排序,在排序后的数组上进行窗口滑动,当刚好都覆盖时进行剪枝,同时收集最优结果,右值l直至刚好不再覆盖。
- 代码:
1 | from collections import Counter |
- 分析:涉及到排序,空间O(n),时间O(nlgn)
其他包含剪枝的连续子序列问题
包含字符串的排列
- 问题:[567] 字符串的排列,给定两个字符串 s1 和 s2,写一个函数来判断 s2 是否包含 s1 的排列
- 思路:窗口蠕动法,只要s2的某个子串的字符统计量等于s1的字符统计量,即返回True。该问题包含减枝条件,当加入的字符统计量超出s1的需求时可以剪枝,右移左指针直至不超,此时判断s1的需求数是否刚好满足,满足则返回True(不多、又刚好满足需求),否则继续扩展窗口。仍然使用need代表每个元素的需求数,用count代表总体需求数。
- 代码:可以看到,代码与上面两题基本上相同
1 | def checkInclusion(self, s1, s2): |
- 分析:空间O(n),时间O(n)
乘积小于K的子数组
- 问题:[713] 乘积小于K的子数组,给定一个正整数数组 nums,找出该数组内乘积小于 k 的连续的子数组的个数
- 思路:直接应用窗口移动法时,不满足剪枝条件,因为(l<x<r,x<=y<r)这部分不能被剪掉,但如果这一部分已被统计过了,就仍然可以正常剪枝。当窗口满足条件时,我们统计以r结束的满足条件的连续数组个数,当r放入后如果不满足条件,我们就可以进行正常的剪枝了,因为后面的肯定不满足,前面的已经被统计过,此时只需要将l移至下个满足条件的位置即可。
- 代码:
1 | def numSubarrayProductLessThanK(self, nums, k): |
- 分析:空间O(1),时间O(n)
区间子数组的个数
- 题目:
1 | 给定一个元素都是正整数的数组A ,正整数 L 以及 R (L <= R)。 |
- 思路:
1 | class Solution(object): |
快慢指针
快慢指针:使用移动速度不同的两个指针slow,fast(通常fast移动速度是slow的两倍)来遍历线性表。
环形检测:如果线性表中存在环,那么快慢指针从同一起点出发,如果能够再次相遇,则说明线性表中存在环。
寻找中位数:快慢指针同时从线性表首元素出发,当快指针无法前进时,慢指针会指向下中位。
链表中用的最多,比如[141] 环形链表,[142] 环形链表 II,[234]回文链表,详情参考链表,这里仅讨论在顺序表中的使用
寻找重复数
- 问题:[287]寻找重复数,给定一个包含 n + 1 个整数的数组 nums,其数字都在 1 到 n 之间(包括 1 和 n),可知至少存在一个重复的整数。假设只有一个重复的整数,找出这个重复的数。
1 | 不能更改原数组(假设数组是只读的)。 |
- 思路:1~n的n+1个整数组成的数组,可以看作是一条静态链表,下标0代表头指针,列表中的值代表next指针,类似于链表中寻找环的交点算法,可以找到数组中的重复元素
- 代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20def findDuplicate(self, nums):
"""
:type nums: List[int]
:rtype: int
"""
n = len(nums)
slow = fast = 0
# 首先找到下一个交点
while True:
slow = nums[slow]
fast = nums[nums[fast]]
if slow == fast:
break
# 排除另一个慢指针,与上一个慢指针同时出发,交点即重复元素
slow2 = 0
while True:
slow = nums[slow]
slow2 = nums[slow2]
if slow == slow2:
return slow - 分析:空间O(1),时间O(n^2)
早晚指针
早晚指针:在指针移动某位置时,另一个指针从头出发,主要用处在于产生/消除差额。
使用快慢指针判断环的交点问题中也使用到了早晚指针,另一个经典的应用是寻找链表中倒数第k个节点,原理是当第一个指针移动了k次时,再派出一个指针从头开始同步移动,最后当早指针不能前进时,晚指针指向的就是倒数第k个节点。
双表指针
- 指针含义:两个指针分别用于指示两个线性表中的当前位置
- 指针移动:具体问题具体分析
- 问题特点:当需要同时操作两个线性表时,典型的有数组归并,链表逆序
数组交集
- 问题:[349] 两个数组的交集,给定两个数组,写一个函数来计算它们的交集
- 思路:该问题使用集合很容易在O(n)时间复杂度下求解,如果不使用集合,可先对数组排序,用两个指针指示两个数组的出现不同元素的第一个元素,如果相等则收集,直至其中一个遍历完。虽然是一种笨的方法,但用到的方法在很多问题中都会遇到。
- 代码:
1 | class Solution(object): |
归并排序
详见链表或者排序
链表逆序
详见链表