1. 问题描述
给你一个整数数组 nums ,判断是否存在三元组 [nums[i], nums[j], nums[k]] 满足 i != j、i != k 且 j != k ,同时还满足 nums[i] + nums[j] + nums[k] == 0 。请你返回所有和为 0 且不重复的三元组。
注意:答案中不可以包含重复的三元组。
示例 1:
输入: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] 。
注意,输出的顺序和三元组的顺序并不重要。
示例 2:
输入:nums = [0,1,1]
输出:[]
解释:唯一可能的三元组和不为 0 。
示例 3:
输入:nums = [0,0,0]
输出:[[0,0,0]]
解释:唯一可能的三元组和为 0 。
2. 解题思路
求三数之和的这道编程题如果直接用暴力破解,使用三重for循环,那么时间复杂度T(N)=O(N^3),效果很差,可以考虑先将数组排序(排序之后去重会比较方便),然后使用高低位指针向中间逼近的方式找三数相加和为0的值。
2.1 具体步骤
- 使用快排将数组排序。
- 取三个值,nums[i]、nums[j]、nums[k],初始状态为i=0,j=i+1,k=len-1(len为数组的长度)。
- 索引 i 逐渐向高位移动,对于每一次确定的nums[i]值,在 k > j 的情况下 ,若 nums[i] + nums[j] + nums[k] <= 0,则nums[j]从 i+1 的索引位置开始向高位移动, 若 nums[i] + nums[j] + nums[k] > 0,则nums[k]从索引 len-1 的位置向低位移动, 即 j 和 k 只会有一个移动,直至不满足 k > j 的条件则停止移动,结束此次遍历。
- 重复步骤三,直至数据遍历完成。
注意:在取i、j、k索引位置的值的时候,要注意去重的细节处理。
2.2 图形展示
3. 代码实现
public List<List<Integer>> threeSum(int[] nums) {
int len = nums.length;
List<List<Integer>> allList = new ArrayList<>();
if (len < 3) {
return allList;
}
Arrays.sort(nums);
for (int i = 0; i < (len - 2); i++) {
int k = len - 1;
// 排序之后,最小的索引的数据大于0,就不会出现 nums[i] + nums[j] + nums[k] == 0 的场景,直接跳出循环即可
if (nums[i] > 0) {
break;
}
// 排序之后,最大的索引的数据小于0,就不会出现 nums[i] + nums[j] + nums[k] == 0 的场景,直接跳出循环即可
if (nums[k] < 0) {
break;
}
if (i > 0 && nums[i] == nums[i-1]) { // 相同的元素,只能取一个
continue;
}
for (int j = i + 1; j < (len - 1); j++) {
if (j > i + 1 && nums[j] == nums[j-1]) { // 相同的元素,只能取一个
continue;
}
// while循环不能在if(k<=j)判断下面,否则会有问题,比如数组 int[] nums = {-6,1,3,5};
while (k > j && (nums[i] + nums[j] + nums[k] > 0)) {
k--;
}
if (k <= j) {
break;
}
if ((nums[i] + nums[j] + nums[k]) == 0) {
List<Integer> tempList = Arrays.asList(nums[i], nums[j], nums[k]);
allList.add(tempList);
}
}
}
return allList;
}
4. 代码分析
for循环的代码,是将相同的代码重复多次,所以需要处理好边界问题,对于三数之和的问题,除了处理好边界问题,还要考虑去重的处理。
4.1.1 索引 i 的边界问题处理
对应代码:
for (int i = 0; i < (len - 2); i++) {//..}
可以看到 i 从0开始, i < len - 2(len是数组的长度),即 i 可以取的最大值为len - 3,结合图说明
因为需要取三个元素,所以索引 i 取到len-3即可。
4.1.2 索引 j 的边界问题处理
对应代码:
for (int j = i + 1; j < (len - 1); j++) {//..}
可以看到 j 从 i + 1 开始,j < len - 1(len是数组的长度),即 j 可以取的最大值为len - 2,结合图说明(图同索引 i 对应的图)
4.1.3 索引 k 的边界问题处理
对应代码:
for (int i = 0; i < (len - 2); i++) {
// 省略无关代码
// ...
int k = len - 1;
// 省略无关代码
// ...
for (int j = i + 1; j < (len - 1); j++) {
// 省略无关代码
// ...
while (k > j && (nums[i] + nums[j] + nums[k] > 0)) {
k--;
}
if (k <= j) {
break;
}
// 省略无关代码
// ...
}
}
// 省略无关代码
// ...
可以看到对于每一次循环,k 都是从 len-1 的位置开始,若 nums[i] + nums[j] + nums[k] > 0, 则 k 向低位索引移动,直至不再满足 k > j。即k的边界是动态的。结合图说明(图同索引 i 对应的图)
4.2.1 索引 i 取值的去重处理
对应代码:
// 省略无关代码
// ...
for (int i = 0; i < (len - 2); i++) {
// 省略无关代码
// ...
//索引 i 取值的去重代码
if (i > 0 && nums[i] == nums[i-1]) { // 相同的元素,只能取一个,即相同的值,取索引最小的位置
continue;
}
}
// 省略无关代码
// ...
去重的代码中if的判断条件满足的情况下,当前索引 i 对应数组中的值不会被取到,i > 0 保证了nums[i-1] 不会发生索引越界的问题,并且可以取到 i = 0 位置的值(这也是一个边界的处理),nums[i] == nums[i-1] 进行 continue保证了相同元素不断跳过,直至 nums[i] != nums[i-1],这个时候 i 位置的值是可以取的,下面结合一个具体的数组进行说明。见下图:
上图只对i循环分析了6次,可以看到,排序之后相同值会紧邻,相同的值取值的时候取的都是索引最小位置。
4.2.2 索引 j 取值的去重处理
对应代码:
// 省略无关代码
//...
for (int j = i + 1; j < (len - 1); j++) {
// 相同的元素,只能取一个, 这里要说明的是,当j从重复元素取值时,若这个值和i取的相同,则j取值的索引仅大于i取值的
// 索引,若不同,则j取值取的是相同值的最小索引位置的值
if (j > i + 1 && nums[j] == nums[j-1]) {
continue;
}
// 省略无关代码
//...
}
// 省略无关代码
//...
j取值从 i+1 的位置开始取值,j > i + 1 的时候判断 nums[j] == nums[j-1] 是否成立,如果成立则跳过,保证了相同的值 j 索引只取一次。下面结合一个具体的数组进行说明。见下图:
可以看到,在上图的示例中,我们给了索引 i 为0的场景下,索引 j 可以取到的值,当 i 和 j 取的是相同的值-6时,i取的是重复的值-6中最小索引位置的元素,j 取值-6的索引仅大于 i 取值-6的索引,且 j 对于 -6 只取一次,当取值-5时,j 由于和 i 取的不是相同的值,所以j取值取到了-5最小索引位置的值,且只取了一次。保证了重复数据的去重问题。
4.2.3 索引 k 取值的去重处理
对应代码:
for (int i = 0; i < (len - 2); i++) {
// 省略无关代码
// ...
int k = len - 1;
// 省略无关代码
// ...
for (int j = i + 1; j < (len - 1); j++) {
// 省略无关代码
// ...
while (k > j && (nums[i] + nums[j] + nums[k] > 0)) {
k--;
}
if (k <= j) {
break;
}
if ((nums[i] + nums[j] + nums[k]) == 0) {
List<Integer> tempList = Arrays.asList(nums[i], nums[j], nums[k]);
allList.add(tempList);
}
}
}
// 省略无关代码
// ...
可以看到,在 k > j 的前提下,若 nums[i] + nums[j] + nums[k] > 0, 则k会一直向低位索引移动,假设k移动到某个位置 k2(紧挨着k2左侧的k1和k2的值相同),使得 nums[i1] + nums[j1] + nums[k2] = 0,这时会跳出while循环,将这组满足相加为0的集合放到allList,然后执行 j++ ,即 j 右移的操作,所以取不到nums[i1] + nums[j1] + nums[k1] = 0 这个和 nums[i1] + nums[j1] + nums[k2] = 0 重复的组合。下面结合一个具体的数组进行说明。见下图:
从上图的5个步骤可以看到,在k > j 前提下,k向左移动,nums[0] + nums[1] + nums[4] >0不成立,k停止移动,将nums[0] + nums[1] + nums[4]=0的组合放到集合中,然后j开始右移。所以尽管nums[3]=nums[4],但是我们取不到nums[0] + nums[1] + nums[3]=0这个组合,保证了k不会取重复的值。
4.3 代码的细节补充说明
代码如下:
for (int i = 0; i < (len - 2); i++) {
// 省略无关代码
// ...
int k = len - 1;
// 省略无关代码
// ...
for (int j = i + 1; j < (len - 1); j++) {
// 省略无关代码
// ...
// while循环不能在if(k<=j)判断下面,否则会有问题,比如数组 int[] nums = {-6,1,3,5};
while (k > j && (nums[i] + nums[j] + nums[k] > 0)) {
k--;
}
if (k <= j) {
break;
}
}
}
while循环的代码不能和if(k<=j) 这个判断互换位置,互换位置会有问题,下面结合一个具体的数组进行说明。见下图:
由上图可以看到,k<=j若放在while循环的下方,会出现 j 和 k 取了相同索引非法值的情况。总结一下就是:若数组中 存在 nums[a-1] + nums[a+1] = 2nums[a], 且有nums[i] + nums[a-1] + nums[a+1]=0 (i < a-1),k<=j若放在while循环的下方就会取到非法值。
5. 时间复杂度
T(N) = O(N^2),快排的时间复杂度为T(N) = O(NlogN);由于 j 和 k 只会有一个移动,所以for循环的时间复杂度是
T(N) = O(N^2),
所以整体时间复杂度是 T(N) = O(N^2)
6. 空间复杂度
空间复杂度为 O(logN), 在不考虑存储结果集的情况下。若不能改变原数组,则还需要复杂一份原数组的值,那么空间复杂度为O(N)。