双指针法
双指针法应该只是一种思想,而非算法,一开始我没有理解透其中的妙处,特别是对于三数之和的问题,先前特别困惑为何双指针法能将时间复杂度降低为 O ( N 2 ) O(N^2) O(N2),在写题,debug的过程中结合力扣的官方题解,总算是有些理解。
三数之和问题(力扣第15题)
三数之和
给你一个整数数组 nums ,判断是否存在三元组 [nums[i], nums[j], nums[k]] 满足 i != j、i != k 且 j != k ,同时还满足 nums[i] + nums[j] + nums[k] == 0 。请
你返回所有和为 0 且不重复的三元组。
注意:答案中不可以包含重复的三元组。
输入:nums = [-1,0,1,2,-1,-4]
输出:[[-1,-1,2],[-1,0,1]]
解释:
nums[0] + nums[1] + nums[2] = (-1) + 0 + 1 = 0 。
nums[1] + nums[2] + nums[4] = 0 + 1 + (-1) = 0 。
nums[0] + nums[3] + nums[4] = (-1) + 2 + (-1) = 0 。
不同的三元组是 [-1,0,1] 和 [-1,-1,2] 。
注意,输出的顺序和三元组的顺序并不重要。
输入:nums = [0,1,1]
输出:[]
解释:唯一可能的三元组和不为 0 。
输入:nums = [0,0,0]
输出:[[0,0,0]]
解释:唯一可能的三元组和为 0 。
常规暴力思路
这里多提一嘴,在刚开始准备一些机考写题的过程中,我非常不重视题目的暴力解法,很多时候直接跳过暴力解法的思考过程而直接去想更高效的算法,例如回文字符的动态规划等等,但是最近也有在社区的一些分享中学习到说暴力解法的重要性,它是我们理解得出更高效算法的基础,这点被我忽视了很久导致对一些算法的思考不够深入。在对本题的思考过程中,我充分理解到了暴力解法对思考过程的重要性。
事实上,要直接对本题使用三层循环的方法解题也不算简单,因为问题中提到了不可以包含重复的三元组,对于这个问题最高效的解法很容易想到的是先排序,然后在遍历的过程中直接对重复的元素进行跳过。由于暴力解法代码比较简单,大致写一下伪代码。
sort(nums);
for(i;i<n;i++)//i即为第一个数字
{
if(nums[i]==nums[i-1])//判断是否是重复的数字,若是,则直接跳过,我们只需要对重复数字中第一个出现的进行判断即可
continue;
for(j=i+1;j<n;j++)//j为第二个数字
{
if(nums[j]==nums[j-1])//判断是否是重复的数字
continue;
for(k=j+1;k<n;k++)
{
if(nums[k]==nums[k-1])//判断是否是重复的数字
continue;
if(nums[i]+nums[k]+nums[k]==0)
若符合要求则加入结果
}
}
}
很明显暴力解法的时间复杂度为 O ( N 3 ) O(N^3) O(N3),是非常糟糕的,因此考虑对代码进行优化。
排序+双指针法
1、首先根据前面的分析,首先需对数组进行排序,利用
s
o
r
t
(
)
sort()
sort()函数对数组进行默认的升序排序后,可以得到一个新的升序数组,如[-2,1,0,1,1,2,3],则我们枚举出的结果
(
n
u
m
s
[
i
]
,
n
u
m
s
[
j
]
,
n
u
m
s
[
k
]
)
(nums[i],nums[j],nums[k])
(nums[i],nums[j],nums[k]),一定满足
n
u
m
s
[
i
]
≤
n
u
m
s
[
j
]
≤
n
u
m
s
[
k
]
nums[i]\le nums[j]\le nums[k]
nums[i]≤nums[j]≤nums[k]。
2、此外容易发现,当前两个数
n
u
m
s
[
i
]
,
n
u
m
s
[
j
]
nums[i],nums[j]
nums[i],nums[j]确定之后,nums[k]的值也是唯一确定的,并且其中有一个重要的关系,由于升序排序数组的缘故,假设遍历到了
j
j
j的下一个合法位置
j
′
j'
j′,
j
′
>
j
j'>j
j′>j,即
j
′
j'
j′在
j
j
j的右侧,有
n
u
m
s
[
j
′
]
>
n
u
m
s
[
j
]
nums[j']>nums[j]
nums[j′]>nums[j],因此
k
k
k的下一个遍历位置
k
′
k'
k′必须满足
n
u
m
s
[
k
′
]
<
n
u
m
s
[
k
]
nums[k']< nums[k]
nums[k′]<nums[k],才能够保证有
n
u
m
s
[
i
]
+
n
u
m
s
[
j
]
+
n
u
m
s
[
k
]
=
0
nums[i]+ nums[j]+ nums[k]=0
nums[i]+nums[j]+nums[k]=0的可能性出现,由于在升序数组中,这意味着
k
′
k'
k′必须在
k
k
k的左侧 .
根据上述两点性质分析,我们发现可以遍历的过程可以从小到大枚举
j
j
j,同时从大到小枚举
k
k
k,因此第二重与第三重循环本质上可以是一种并列的关系。
这便是双指针的想法,我们保持第二重对于j的循环不变,其意义为从数组左边开始往右移动的指针;将第三重循环变成从数组最右端开始往左移动的指针。理解了这个性质,我们来看下面的代码。
主要核心代码如下
for (int i = 0; i < n; i++) //第一重循环
{
if (i != 0 && nums[i] == nums[i - 1]) //若重复,跳过
{
continue;
}
else
{
int k = n - 1; //重点在这里!!!!!!
for (int j = i + 1; j < n; j++) //第二重循环
{
if (j != i+1 && nums[j] == nums[j - 1]) //若重复,跳过
{
continue;
}
while (j < k && nums[j] + nums[k]>=-nums[i])
{
if (k != n - 1 && nums[k] == nums[k + 1]) //若重复,跳过
{
k = k - 1; //k指针左移
continue;
}
if (nums[i] + nums[j] + nums[k] == 0) //找到了,定下j时,由分析的性质知每一次k循环最多出现一个符合的结果,则遍历出后可以跳出k循环
{
result.push_back({nums[i],nums[j],nums[k]});
break;
}
k = k - 1; //k指针左移
}
if(j==k) //若两指针相遇,则跳出第二重循环
break;
}
}
}
代码分析
个人认为,最核心的代码在于int k = n-1;
所在的位置,它出现在第二层循环前面,我最开始自己写双指针的时候,虽然分析到了上述的性质,但是在代码编写的时候,习惯性地将该定义语句放到了第二重循环内部:
for (int i = 0; i < n; i++) //第一重循环
{
if (i != 0 && nums[i] == nums[i - 1]) //若重复,跳过
{
continue;
}
else
{
for (int j = i + 1; j < n; j++) //第二重循环
{
int k = n - 1;
......
while(...)
这导致代码本身仍旧是三重循环
O
(
N
3
)
O(N^3)
O(N3),那么为什么将int k=n-1;
放在第二重循环前面就能简化为
O
(
N
2
)
O(N^2)
O(N2)了呢?
核心
主要是与后面的if(j==k) break;
语句配合,在第二重循环中,j左指针向右移动一个位置,k指针就会向左移动若干位置,若找到三数和为0的位置,则记录三数,并且k指针停留在原地,一直移动到两个指针相遇,就会触发if语句跳出第二层循环,此时双指针一共移动的位置数为N(j,k指针合计走的路程),操作步数为N,时间复杂度为
O
(
N
)
O(N)
O(N)。因此加上第一层循环,总的时间复杂度为
O
(
N
2
)
O(N^2)
O(N2).
最后贴上完整代码,未对输入作处理,直接初始化了一个vector。
#include<iostream>
#include<vector>
#include<algorithm>
using namespace std;
int main()
{
vector<int> nums = { -1,0,1,2,-1,-4};
int n = nums.size();
sort(nums.begin(), nums.end());
vector<vector<int>>result;
for (int i = 0; i < n; i++)
{
if (i != 0 && nums[i] == nums[i - 1]) //若重复,跳过
{
continue;
}
else
{
int k = n - 1;
for (int j = i + 1; j < n; j++)
{
if (j != i+1 && nums[j] == nums[j - 1])
{
continue;
}
while (j < k&&nums[j] + nums[k]>=(-nums[i]))
{
if (k != n - 1 && nums[k] == nums[k + 1])
{
k = k - 1;
continue;
}
if (nums[i] + nums[j] + nums[k] == 0) //找到了,定下j时,由特性知每一次k循环最多出现一个,则可以跳出k循环
{
result.push_back({nums[i],nums[j],nums[k]});
break;
}
k = k - 1;
}
if(k==j)
break;
}
}
}
}