前言
双指针有很多用法,这里仅针对数组类型的题目需要枚举时进行的一种优化操作,类似于回溯过程中的"剪枝",减少需要枚举的元素个数。
算法思路
可以使用双指针优化全枚举的题目会有以下的特点:
- 需要枚举数组元素间的组合(常为两个元素)
- 有某个限制条件用来决定哪个指针该移动
双指针常见的移动方式:
- 两端向中间移动
- 中间向两端移动
- 一前一后同向移动
双指针其实和滑动窗口类似,只是滑动窗口要考虑到窗口区间所有元素,双指针有的时候仅考虑端点元素即可
例题 1
给你一个长度为 n 的整数数组 nums 和 一个目标值 target。请你从 nums 中选出三个整数,使它们的和与 target 最接近。
返回这三个数的和。
假定每组输入只存在恰好一个解。 示例 1:
输入:nums = [-1,2,1,-4], target = 1
输出:2
解释:与 target 最接近的和是 2 (-1 + 2 + 1 = 2) 。
示例 2:
输入:nums = [0,0,0], target = 1
输出:0
思路
- 暴力解法:枚举所有可能的三元组<a, b, c>找出其中最接近的target的组合,时间复杂度O(n^3);
- 双指针优化:在枚举过程中其实每一轮最外层循环固定a之后,在其余元素中找出一个组合(b, c)
使得(b, c)最接近于
target - a的值 ,这就转化为找两数之和的问题,满足算法思路中的三个条件
- 需要枚举数组间元素间的组合(常为两个元素) :枚举(b, c)
- 有某个限制条件用来决定哪个指针该移动:指针朝向接近于
target - a的方向移动 - 双指针相互奔赴,相遇停止:指针相遇表示枚举完成
需要先对数组排序(为什么下文会说),这样固定a之后,剩余的部分就可以使用双指针法去排除一些不需要比较的组合
代码
class Solution {
public int threeSumClosest(int[] nums, int target) {
Arrays.sort(nums); // 排序
int res = nums[0] + nums[1] + nums[2];
int minDis = Math.abs(res - target);
// 固定a
for(int i = 0; i <= nums.length - 3; i++) {
int a = nums[i];
// 双指针遍历剩余(b, c)
int pb = i + 1; // 左指针
int pc = nums.length - 1; // 右指针
while(pb != pc) {
int b = nums[pb];
int c = nums[pc];
int sum = a + b + c;
int dis = Math.abs(sum - target);
if(dis < minDis) { // 最小间隔和res
minDis = dis;
res= sum;
}
// 移动指针
if(sum > target) {
--pc;
} else if(sum < target){
++pb;
} else { // 与target相等无需再继续
return target;
}
}
}
return res;
}
}
时间复杂度:O(n^2 + nlogn)
nlogn是排序的时间复杂度
双指针的本质就是在每一次比较之后,通过限定条件直接舍弃一些没有必要去比较的组合 比如
nums = [-1,2,1,-4,3,5,7]
排序之后
nums = [-4,-1,1,2,3,5,7]
target = 4
某一刻确定a为 -1, 此时b = 1,c = 7 ,需要在区间[1,2,3,5,7] 找出的(b, c)之和更接近于 4-(-1) = 5的这样一个组合;
此时 b + c = 1 + 7 = 8 > 5, 那么显然c应该往左移动,因为b往右移动b + c只会更大(数组有序),b + c + a的值与target的差值只会更大,因此不可能作为答案,之后算法会将pc左移来缩小可能的b,c和的范围,此次移动等于舍弃了(2, 7)(3, 7)(5, 7)这三种组合,为什么可以直接舍弃,也是因为在有序数组中,这些舍弃的组合和原组合(1, 7)相比最大值一样,最小值肯定比之前的组合最小值大,结果只会更大,不会是最后的答案;
上述的一次移动就同时删除了三种组合,每次移动会删除的组合数与此时双指针的区间间距有关,因此会以O(n)的速度删除组合,最终遍历(b,c)组合时的时间复杂度为O(n^2/n) = O(n),外层遍历a的时间复杂度为O(n),因此总的时间复杂度O(n^2)
本题指针移动方向的判定标准是 a+b+c 与target的关系,或者说是 b + c 与 target - a的关系,每次指针移动后都会排除掉一些没有必要尝试的组合,这里也可以看出为什么需要排序 --- 区间有序可以保证按照既定规则移动指针,每次移动后删除的组合一定不会是正确答案.
例题 2
示例 1
给你 n 个非负整数 a1,a2,...,an,每个数代表坐标中的一个点 (i, ai) 。在坐标内画 n 条垂直线,垂直线 i 的两个端点分别为 (i, ai) 和 (i, 0) 。找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。说明:你不能倾斜容器。
输入:[1,8,6,2,5,4,8,3,7] 输出:49 解释:图中垂直线代表输入数组 [1,8,6,2,5,4,8,3,7]。在此情况下,容器能够容纳水(表示为蓝色部分)的最大值为 49。
思路
找两条线即找两条边界作为容器的边界,枚举边界的组合选出一组最大的组合即可
容器的容纳水量取决于容器两边界中较短的边和区间长度,暴力解法直接枚举所有可能的容器边界组合比较可得出答案,时间复杂度O(n^2^),显然这样的枚举也是可以使用双指针优化的
初始假设容器两边界是左右两个端点,此时容纳水量为 1 * 8 = 8,直觉上为了探寻更大的容水量应该是较短的那一边往较高的那一边移动,才有可能产生更大容积(提高短板一直是实力提升的关键0-0)。
事实上,如果此时将左指针右移,舍弃掉的无需比较的组合是(1, 3),(1, 8),(1, 4),(1, 5),(1, 2),(1, 6),(1, 8),即a1和后面所有元素的组合,为什么可以直接舍弃呢?
因为a1右边的元素比a1大,对(a1,a8)即(1, 3)来说,一定有(a2, a8)即(8, 3)比其容量更大,同理对(a1,a7)即(1, 8)来说,,一定有(a2, a7)即(8, 8)比其容量更大。对于每一个舍弃掉的组合,将其中第一个元素换成a2都会是一个更优解
*题目中所有数据均为整数,所以一个元素比另外一个元素大,那么至少大1; 而将a
1替换为a2付出的长度代价仅为1,所以只要右侧元素更大,将左边界由当前元素替换为右侧相邻元素一定会使得容器容积(高 * 宽)更大
原边界由(ai,aj)变成(ai+1,aj),且左边界高度变高,本轮舍弃这些组合(用(ai, ak)表示)的原因是: 存在一个左边界大于这些组合的,且长度比其仅短1的组合(ai+1, ak),使得这些组合不会是最优解
也许你会问,指针移动的判断条件是左右边界相比较,如果左指针移动后左边的边界更小了,那又是为什么舍弃者些组合的? 假设输入如下:
输入:[5,4,3,2,8,12,9]
某时刻 左边界为a1=5,右边界为a7=9,此时左边界右移,舍弃组合(a1,a6),(a1,a5),(a1,a4),(a1,a3),(a1,a2),舍弃掉的这些组合都具有的特征是:
- 比(a
1,a7)区间长度短 - 短板长度不会多于a
1
显然满足这种条件的组合,其容积不会多于(a1,a7)
所以,原边界由(ai,aj)变成(ai+1,aj),且左边界高度度变小,本轮舍弃组合(用(ai, ak)表示)的原因是: (ai, ak)所获得的容积一定会小于(ai,aj),不会是最优解
综上可以看到无论左边界移动后,新的左边界的值比原来的小还是大,本次移动所舍弃的组合都是没有问题的,右边界也是一样。
代码
public int maxArea(int[] height) {
int left = 0; // left ptr
int right = height.length - 1; // right ptr
int maxArea = Integer.MIN_VALUE;
while(left < right) {
int nowArea = (height[left] < height[right] ? height[left] : height[right]) * (right - left);
maxArea = nowArea > maxArea ? nowArea : maxArea; // 更新最大容积
if(height[left] < height[right]) {
++left;
} else {
--right;
}
}
return maxArea;
}
时间复杂度O(n)
小结
平时做题大可不必如此细致的分析舍弃的元素特征,总的来看,既然要选择最大的容积,每次移动都朝向可能产生更大容积的方向移动即可,每移动一次看作是一次尝试,如果获取到了更大值更新答案即可