总言
主要内容:编程题举例,理解双指针思想。
文章目录
- 总言
- 1、双指针
- 2、零移动(easy)
- 2.1、思路分析
- 2.2、题解
- 3、复写零(easy)
- 3.1、题解
- 4、快乐数(medium)
- 4.1、思路分析
- 4.2、题解
- 5、盛水最多的容器(medium)
- 5.1、暴力求解(穷举所有情况)
- 5.2、单调性+双指针
- 6、有效三角形的个数(medium)
- 6.1、暴力求解
- 6.2、单调性+双指针
- 7、查找总价格为目标值的两个商品(easy)
- 7.1、暴力求解
- 7.2、二分查找
- 7.3、单调性+双指针
- 8、三数之和(medium)
- 8.1、单调性+双指针
- 9、四数之和(medium)
- 9.1、单调性+双指针
- Fin、共勉。
1、双指针
总言:常见的双指针有两种形式,一种是对撞指针,一种是左右指针。 (PS:在以数组形式为基础的结构中,这里的指针可以简化为数组元素下标。实际上,这里的双指针只是一个思想,在不同情景下可以用不同形式表示,并不一定要局限于指针变量本身。)
对撞指针概述:
对撞指针: 一般用于顺序结构中,也称左右指针。对撞指针从两端向中间移动。一个指针从最左端开始,另一个从最右端开始,然后逐渐往中间逼近。
对撞指针的终止条件: 一般是两个指针相遇或者错开 (也可能在循环内部找到结果,直接跳出循环),也就是:
left == right (两个指针指向同⼀个位置)
left > right (两个指针错开)
快慢指针概述:
快慢指针: ⼜称为⻳兔赛跑算法,其基本思想就是使⽤两个移动速度不同的指针在数组或链表等序列结构上移动。
常用实现方式: 在一次循环中,每次让慢的指针向后移动一位,而快的指针往后移动两位,实现一快一慢。(此外还有其它实现方式,看情况而定。)
场景举例: 这种方法对于处理环形链表或数组非常有用。此外,若要研究的问题出现循环往复的情况时,均可考虑使用快慢指针的思想。
2、零移动(easy)
2.1、思路分析
题源:链接
1)、思路说明
此类题属于数组划分,即根据⼀种划分方式(题目条件),将数组的内容分成左右两部分,不同区间具有不同属性。这种类型的题,⼀般就是使⽤「双指针」来解决。两指针划分区间,指针移动过程中,保持各区间属性不变。
如上图:两个指针dest、cur。
cur指针
:从左到右遍历数组,在扫描的过程中,根据遇到的情况分类处理,实现数组的划分。此题中:
[0,cur-1]:该区间内元素属于已经处理完成的部分(满足左侧非零元素右侧零元素);
[cur,n):该区间内元素等待被处理。知道cur遍历到数组末尾,所有元素处理完毕。
dest指针
:数组按条件划分的分界点。在此题中,以零元素将区间分为两段,因此dest指针用来记录非零数序列的最后⼀个位置。
[0,dest]:该区间内元素均为非零元素。
[dest+1,cur-1]:该区间内元素均为零元素。
2)、如何判断移动?
初始时: cur = 0
,用于遍历数组; dest = -1
指向非零元素序列的最后⼀个位置。(满足三区间属性:非零元素区、零元素区、待处理区)
cur从左到右遍历: 对元素排查处理,会遇到两种情况。但无论遇到哪种情况,cur++
(处理非零元素、零元素的任务是由desc指针来做的)。
cur遍历遇到0,没desc的事,cur++;
cur遍历遇到非0,要将该元素划分入desc指针左侧(此部操作由desc指针和cur指针置换完成),cur++;
desc处理分界线操作: 始终要记住这里 [0, dest] 的元素全部都是非零元素, [dest + 1, cur - 1] 的元素全是零。
当cur遇到一个非零元素,意味着desc左侧范围要新增一个位置,由于这里是进行元素交换,因此需要先让desc+1。
2.2、题解
class Solution {
public:
void moveZeroes(vector<int>& nums) {
int dest,cur;
dest = -1; cur = 0;
while(cur < nums.size())
{
if(nums[cur])//cur遍历过程中,遇到符合情况的元素,才会改动dest分界线
{
swap(nums[dest++ + 1], nums[cur]);//先交换数据,再让dest++。可分开成两条语句来写。
}
cur++;//而cur本身作为遍历的指针,无论哪种情况(0/非0)都要向后挪动排查寻找,故可统一来写。
}
}
};
再次精简版:
class Solution {
public:
void moveZeroes(vector<int>& nums) {
for (int cur = 0, dest = -1; cur < nums.size(); cur++)
if (nums[cur]) // 处理⾮零元素
swap(nums[++dest], nums[cur]);
}
};
3、复写零(easy)
题源:链接。
3.1、题解
实际原地复写是在异地复写的基础上演变而来的。若是异地复写,则直接遍历数组,用一个新的vector按照要求进行尾插即可。
在原地复写中,若如果从前向后进⾏原地复写操作的话,由于 0 的出现会复写两次,导致没有复写的数被覆盖掉。因此可以选择从后往前的复写策略。但是从后向前复写时,需要找到最后⼀个复写的数。
流程如下:
Ⅰ、双指针从前往后遍历, 找到最后⼀个复写的数(只找数,不做修改);
如果 arr[cur] 不为0,当前元素不需要复制, dest 只增加1。
如果 arr[cur] 为0,当前元素需要复写, dest 增加2,
Ⅱ、判断边界情况。在遍历过程中,如果 dest 超过了数组的最大索引 n-1,则说明没有足够的空间来放置所有的0。在这种情况下,将数组的最后一个元素设置为0,并将 dest 和 cur 指针相应地向后移动。
Ⅲ、 从后向前进⾏复写操作。
class Solution {
public:
void duplicateZeros(vector<int>& arr) {
// 1. 找到最后一次复写数
int cur = 0, dest = -1, n = arr.size();
while (cur < n) {
if (arr[cur])//cur指向非零数
dest++;
else//cur指向零,dest复写(这里只找位置)
dest += 2;
if (dest >= n - 1)//判断 dest 是否已经到结束位置
break;
cur++;
}
// 2. 处理⼀下边界情况
if (dest == n) {
arr[n - 1] = 0;
cur--;
dest -= 2;
}
// 3. 从后向前完成复写操作
while (cur >= 0) {
if (arr[cur]) //cur不为零时
arr[dest--] = arr[cur--];
else { //cur为零时,需要复写
arr[dest--] = 0;
arr[dest--] = 0;
cur--;
}
}
}
};
4、快乐数(medium)
4.1、思路分析
题源:链接。
1)、思路说明
根据题目可知,对于该正整数,无论最后结果是否为快乐数,⼀定会进入死循环。只不过快乐数的环为1 → 1 → 1 → 1…(元素始终为1);不快乐数的环中含有各种数字。
既然该变化的过程最终会形成一个圈,对于此类问题可以用快慢指针来解决。快慢指针的特性: 在⼀个圆圈中,快指针总是会追上慢指针的,也就是说他们总会相遇在⼀个位置上。 因此,若相遇位置的值是 1 ,那么这个数⼀定是快乐数;如果相遇位置不是 1 的话,那么就是不快乐数。
2)、扩展:证明无论如何都会相遇(鸽巢原理)
题目说明:
1
<
=
n
<
=
2
31
−
1
1 <= n <= 2^{31} - 1
1<=n<=231−1 ,即 2,147,483,648
共10位数,要求对
n
n
n 的每位数上的数字进行平方和,我们选该位数下最大的数来验证。
假设
n
=
9999999999
n = 9999999999
n=9999999999,则
9
2
+
9
2
+
9
2
+
…
…
=
9
2
×
10
=
810
9^2 +9^2+9^2+……=9^2×10 =810
92+92+92+……=92×10=810,即
n
n
n 范围内,新数所得计算结果
[
1
,
810
]
[1, 810]
[1,810] 之间。
以最坏的情况来假设(变化了810次所获得结果均无重复),根据鸽巢原理,当变化超过810次后(即
≥
811
≥ 811
≥811),所获得的结果仍旧在
[
1
,
810
]
[1, 810]
[1,810] 范围内,则必然会有一个数重复,如此构成循环,因此可以⽤快慢指针来解决。
4.2、题解
如下:
#include <math.h>
class Solution {
public:
int fun(int n) { //用于计算数的平方:注意这里数学函数使用,也可以直接计算。
int ret = 0;
while (n) {
ret += pow(n % 10, 2);
n /= 10;
}
return ret;
}
bool isHappy(int n) {
int slow = n;
int fast = fun(n);
while (slow != fast) {
slow = fun(slow);
fast = fun(fun(fast));
}
return slow == 1 && fast == 1;
}
};
5、盛水最多的容器(medium)
题源:链接。
5.1、暴力求解(穷举所有情况)
说明: 最先能想到的是应该是穷举所有情况(暴力解法),列出能构成的所有容器情况,找出其中容积最⼤的值,该法下时间复杂的是 O ( n 2 ) O(n^2) O(n2) ,在OJ题中存在超时可能。
class Solution {
public:
int maxArea(vector<int>& height) {
int n = height.size();
int ret = 0;
// 两层 for 枚举出所有可能出现的情况
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
// 计算容积,找出最⼤的那⼀个
ret = max(ret, min(height[i], height[j]) * (j - i));
}
}
return ret;
}
};
在此基础上优化,看看有没有其它解法。
5.2、单调性+双指针
1)、思路分析
说明: 设两指针 i
, j
,指向的水槽板高度分别为 h[i]
, h[j]
,此状态下水槽面积为 S(i,j)
。由于可容纳水的高度由两板中的 短板
决定,因此可得面积公式 :
S
(
i
,
j
)
=
m
i
n
(
h
[
i
]
,
h
[
j
]
)
×
(
j
−
i
)
,即
S
=
高度
×
宽度。
S(i,j)=min(h[i],h[j])×(j−i),即S=高度×宽度。
S(i,j)=min(h[i],h[j])×(j−i),即S=高度×宽度。
对长度: 在每个状态下,无论长板或短板向中间收窄一格
,都会导致水槽 底边宽度 −1
变短。
对高度:
若向内移动长板 ,会出现两种情况,①新板比当前短板高,此时水槽高度不变;②新板比当前短板矮,此时水槽高度减小。最终计算的水槽高度 min(h[i],h[j])
不变或变小,根据计算公式,水槽的面积一定变小 。
若向内移动短板 ,新板有可能比当前短板高,此时水槽高度 min(h[i],h[j])
可能变大,因此下个水槽的面积可能增大 。
2)、题解
PS:要注意体积的计算和这里下标的关系。该写法时间复杂的是
O
(
n
)
O(n)
O(n) 。
class Solution {
public:
int maxArea(vector<int>& height) {
int max = 0; // 用于记录每次获取到的容器最大体积
int left = 0; //左侧板下标
int right = height.size()-1; //右侧板下标
while(left < right) //结束条件:两板相遇(两指针相遇)
{
int volume = minvol(height[left],height[right])*(right-left); //计算当前指向的容积大小
max = max > volume ? max: volume;//判断是否容积最大
//其它写法:max = max(max,volume);
if(left < right && height[left] < height[right]) //向内移动短板
left++;
else
right--;
}
return max;
}
};
6、有效三角形的个数(medium)
题源:链接。
判断三条边能组成三角形的条件为: 任意两边之和大于第三边,任意两边之差小于第三边。
6.1、暴力求解
说明: 使用三层循环枚举所有情况。时间复杂度为 O ( n 3 ) O(n^3) O(n3),可能会超时。
class Solution {
public:
int triangleNumber(vector<int>& nums) {
// 1. 排序
sort(nums.begin(), nums.end());
int n = nums.size(), ret = 0;
// 2. 从⼩到⼤枚举所有的三元组
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
for (int k = j + 1; k < n; k++) {
// 当最⼩的两个边之和⼤于第三边的时候,统计答案
if (nums[i] + nums[j] > nums[k])
ret++;
}
}
}
return ret;
}
};
6.2、单调性+双指针
1)、思路说明
①排序,优化判断三角形成立的条件: 根据题目,对于任意a、b、c,要满足三角形意味着任意两边之和大于第三边,则代表要判断三次(a+b>c、a+c>b、b+c>a),所以可以先给数组排个序再来判断。
②双指针扫描,确定三边: 固定⼀个最长边,然后在比这条边小的有序数组中找出⼀个⼆元组,使这个⼆元组之和大于这个最长边。
此时可能出现的情况:
a、nums[left] + nums[right] < nums[i]
。①若此时right
向左移动,则nums[left] + nums[right]
的值只会更小。②只有left
向右移动,nums[left] + nums[right]
的值才有可能变大。③综上,该情况下,left
位置的元素可以舍去, left++
进⼊下轮循环。
b、nums[left] + nums[right] > nums[i]
。 ①若此时left
向右移动,则nums[left] + nums[right] > nums[i]
仍旧成立,意味着 [left, right - 1]
区间上的所有元素均可以与 nums[right]
构成比nums[i]
大的⼆元组,可直接计算存在的总数:b - a
。②此时 right
位置的元素的所有情况相当于全部考虑完毕, right--
,进⼊下⼀轮判断。
2)、题解
class Solution {
public:
int triangleNumber(vector<int>& nums) {
sort(nums.begin(), nums.end()); // 排序
int sum = 0; // 用于记录总数
for (int i = nums.size() - 1; i >= 2; --i) {
// 在nums[i]左区间内用双指针遍历寻找符值
int left = 0;
int right = i - 1;
while (left < right) {
if (nums[left] + nums[right] > nums[i]) // 满足条件
{
sum += right - left; // 统计出当前区间段内满足条件的总次数
right--; // 进⼊下轮循环
} else {
left++; // 不满足条件,让left++增大,进⼊下轮循环
}
}
}
return sum;
}
};
7、查找总价格为目标值的两个商品(easy)
题源:链接。
此题解法有多种,这里仅作部分举例。
7.1、暴力求解
说明: 双层循环,定义num1、num2,判断 num1+num2 = target
即可。该解法时间复杂度为
O
(
n
2
)
O(n^2)
O(n2),但相比之下没有利用到给定数组有序这一条件。
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
int n = nums.size();
for (int i = 0; i < n; i++) { // 第⼀层循环从前往后列举第⼀个数
for (int j = i + 1; j < n; j++)
{ // 第⼆层循环从 i 位置之后列举第⼆个数
if (nums[i] + nums[j] == target) // 两个数的和等于⽬标值,说明我们已经找到结果了
return {nums[i], nums[j]};
}
}
return {-1, -1};
}
};
7.2、二分查找
说明: num1 + num2 = target
→ num1 = target - num2
。如此,只需要固定其中一位数,再在有序数组中二分查找另一位数即可。这种做法下,优化了查找速度,时间复杂度为
O
(
n
∗
l
o
g
2
n
)
O(n*log_2n)
O(n∗log2n)。
class Solution {
public:
vector<int> twoSum(vector<int>& price, int target) {
// 固定其中一个价格
for (int i = 0; i < price.size(); ++i) {
// 在给定值中,用二分查找一个价格
int num = target - price[i];
int left = 0;
int right = price.size() - 1;
while (left < right) {
int mid = (left + right) / 2;
if (price[mid] >= num)
right = mid;
else
left = mid + 1;
}
if(price[left] == num)
return {price[i],price[left]};
}
return {};
}
};
7.3、单调性+双指针
class Solution {
public:
vector<int> twoSum(vector<int>& price, int target) {
// 定义双指针:这里是数组下标
int num1 = 0;
int num2 = price.size() - 1;
while (num1 < num2) {
int sum = price[num1] + price[num2];
if (sum < target) // 说明需要将sum值增大,升序数组,只能让左指针右移
num1++;
else if (sum > target) // 说明需要将sum值减小,升序数组,只能让右指针左移
num2--;
else
return {price[num1] , price[num2]};
}
return {};//找不到的情况
}
};
扩展延伸: 上述三种方法一步步走来,我们解决的是如何找→如何快速找到 这一过程,实际上题目只要求找满足条件的一组值,扩展一下,若是要找满足条件的所有组合,又该如何做?
由于数组有序,只需要继续向内部寻找即可。实际下述的例题就需要如此处理。
8、三数之和(medium)
题源:链接。
8.1、单调性+双指针
1)、思路分析
说明: 此题仍旧可以使用暴力解法,先排个序,再枚举出所有情况,由于题目要求三元组不重复,因此可以考虑使用set等容器去重处理,其时间复杂度为
O
(
n
3
)
O(n^3)
O(n3)。
这里我们使用双指针思想进行优化。实际上本题为上一题的加强版,num1 + num2 + num3 = 0
→ num1 + num2 = - num3
,如此基本思想和上题类似:
Ⅰ. 先排序;
Ⅱ. 然后固定⼀个数 num3;
Ⅲ. 在该数后的区间内,使用「双指针算法」快速找到两个数之和等于 - num3 即可。
但需要注意两点:
1)、如何找全所有组合? 在找到一组后,继续缩小区间范围寻找。
2)、如何去重? 与上一题不同的是,这里需要考虑组合数重复的情况,即[-1,0,1]
和[0,1,-1]
是同一组合。
2)、题解
class Solution {
public:
vector<vector<int>> threeSum(vector<int>& nums) {
// 1、排序
sort(nums.begin(), nums.end());
vector<vector<int>> ret;
// 2、找三元组
for (int i = 0; i < nums.size(); ) {
if(nums[i] > 0) break;//一个小优化:当固定数为整数时,其右侧区间内找不到任意两数和为负数
// 准备工作
int left = i + 1; // 左指针
int right = nums.size() - 1;// 右指针
int target = -nums[i]; // 两数相加等于相反数
// 找数
while (left < right) {
int sum = nums[left] + nums[right];
if (sum < target) {left++;}
else if (sum > target) {right--; }
else {
// 当前固定数下找到一组,存放当前组合,继续找。
ret.push_back({nums[i], nums[left], nums[right]});
left++;
right--;
// (注意跳过重复数:先让left、right向内移动,再来去重,这种写法相对简便)
while (left < right && nums[left] == nums[left - 1]) { left++; };
while (left < right && nums[right] == nums[right + 1]) { right--; };
}
}
// 当前固定值的情况找全,去重,寻找下一轮固定数。
++i;
while (i < nums.size() && nums[i] == nums[i - 1]) ++i;
}
return ret;
}
};
9、四数之和(medium)
题源:链接。
9.1、单调性+双指针
此题核心思想与上一题相同,只是在原先基础上多加了一层。nums[a] + nums[b] + nums[c] + nums[d] = target
→ nums[b] + nums[c] + nums[d] = target - nums[d]
,转换一下,四数就变成了原来的三数。
Ⅰ、 依次固定⼀个数 a ;
Ⅱ、 在这个数 a 的后面区间上,利用「三数之和」找到三个数,使这三个数的和等于 target - a 即可。
这里仍旧需要去重+找全。
class Solution {
public:
vector<vector<int>> fourSum(vector<int>& nums, int target) {
vector<vector<int>> ret;
//1、排序
sort(nums.begin(),nums.end());
//2、找组合数
int n = nums.size();
for(int a = 0; a < n ; )
{
for(int b = a + 1; b < n; )
{
int c = b+1;
int d = n-1;
long long aim = (long long)target -nums[a] -nums[b];
while(c < d)//双指针找数
{
int sum = nums[c] +nums[d];
if(sum > aim) { d--;}
else if(sum < aim) { c++; }
else
{
//找到符合条件的一组
ret.push_back({nums[a],nums[b],nums[c],nums[d]});
c++; d--;
//去重
while(c < d && nums[c] == nums[c-1]) { c++; }
while(c < d && nums[d] == nums[d+1]) { d--; }
}
}
//当前b指向的值找完,去重后继续下一轮
b++;
while(b < n && nums[b] == nums[b-1]) b++;
}
//当前a指向的值找完,去重后继续下一轮
a++;
while(a < n && nums[a] == nums[a-1]) a++;
}
return ret;
}
};