三数之和问题:双指针法与去重逻辑详解
最近在leetcode刷题时,在三数之和上面产生了一些理解上的误区,包括阅读carl的视频后对于部分内容的理解仍然存在偏差。
究其根本,是在于对每次返回数组首位元素遍历可能性产生了误解:
在该题目中,每次遍历应将首位数字的所有结果全部存入结果数组,以方便去重,而不是每次存入一个结果!
一、问题定义与核心难点
给定一个整数数组 nums
,找出所有满足以下条件的不重复三元组 [nums[i], nums[j], nums[k]]
:
i、j、k
互不相同;- 三元组元素之和为
0
; - 结果中不存在重复的三元组(如
[1, -1, 0]
和[-1, 1, 0]
视为同一组合,需去重)。
核心难点:去重逻辑
如何避免重复的三元组是算法设计的关键。例如,数组 [-1, -1, 2, 0, 1]
中,[-1, -1, 2]
和 [-1, 0, 1]
是合法解,但需确保每个解仅出现一次。
二、算法思路:排序+双指针法
1. 排序数组(预处理)
- 目的:使相同元素相邻,便于后续去重;为双指针移动提供有序环境(左指针右移增大和,右指针左移减小和)。
- 操作 :使用
Arrays.sort(nums)
将数组升序排列。
2. 固定第一个元素,双指针查找剩余两数
- 遍历第一个元素
i
:从数组头部开始,固定nums[i]
,在剩余元素[i+1, n-1]
中找两数nums[j]
和nums[k]
,使得nums[i] + nums[j] + nums[k] = 0
。 - 双指针初始化 :左指针
j = i + 1
(最小剩余元素),右指针k = n - 1
(最大剩余元素)。 - 指针移动逻辑 :
- 若
nums[j] + nums[k] < -nums[i]
:左指针右移(和太小,需增大)。 - 若
nums[j] + nums[k] > -nums[i]
:右指针左移(和太大,需减小)。 - 若相等:记录解,并移动双指针跳过重复元素(去重)。
- 若
三、三次去重操作:避免重复的关键
1. 第一个元素去重(外层循环)
- 条件 :当
i > 0
且nums[i] == nums[i-1]
时,跳过当前i
。 - 逻辑:数组有序,若当前元素与前一个相同,则以当前元素开头的三元组必然与前一个元素开头的三元组重复(因为后续元素相同)。
- 示例 :数组
[-1, -1, 0, 1]
,当i=1
时,nums[i] == nums[i-1]
,直接跳过,避免重复处理以-1
开头的组合。
2. 第二个元素去重(内层左指针)
- 条件 :找到解
(i, j, k)
后,若nums[j] == nums[j+1]
,右移j
直到遇到不同元素。 - 逻辑 :同一层循环中,固定
i
后,若j
指向的元素重复,生成的三元组会重复(如[-1, -1, 2]
若不跳过重复的-1
,会多次记录)。
3. 第三个元素去重(内层右指针)
- 条件 :找到解
(i, j, k)
后,若nums[k] == nums[k-1]
,左移k
直到遇到不同元素。 - 逻辑 :与第二个元素去重类似,确保同一
i
下,k
指向的元素唯一。
四、分步骤代码实现
1. 排序数组
java
Arrays.sort(nums); // 升序排序,使相同元素相邻,便于去重
2. 遍历第一个元素并去重
java
for (int i = 0; i < nums.length - 2; i++) {
// 第一个元素去重:跳过与前一个相同的元素,避免重复组合
if (i > 0 && nums[i] == nums[i-1]) {
continue;
}
int target = -nums[i]; // 剩余两数之和需为 -nums[i]
int j = i + 1, k = nums.length - 1; // 双指针初始化
// 双指针查找剩余两数
while (j < k) {
int sum = nums[j] + nums[k];
if (sum == target) {
// 记录合法解
res.add(Arrays.asList(nums[i], nums[j], nums[k]));
// 第二个元素去重:跳过所有重复的 nums[j]
while (j < k && nums[j] == nums[j+1]) {
j++;
}
// 第三个元素去重:跳过所有重复的 nums[k]
while (j < k && nums[k] == nums[k-1]) {
k--;
}
// 移动指针继续查找
j++;
k--;
} else if (sum < target) {
j++; // 和太小,左指针右移
} else {
k--; // 和太大,右指针左移
}
}
}
3. 完整代码
java
import java.util.*;
public class ThreeSum {
public List<List<Integer>> threeSum(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
if (nums == null || nums.length < 3) {
return res; // 特判:元素不足3个,直接返回
}
Arrays.sort(nums); // 排序,为去重和双指针做准备
int n = nums.length;
for (int i = 0; i < n - 2; i++) {
// 第一个元素去重:跳过与前一个相同的元素
if (i > 0 && nums[i] == nums[i-1]) {
continue;
}
int target = -nums[i]; // 剩余两数需和为 -nums[i]
int j = i + 1, k = n - 1; // 双指针初始化
while (j < k) {
int sum = nums[j] + nums[k];
if (sum == target) {
// 添加合法解
res.add(Arrays.asList(nums[i], nums[j], nums[k]));
// 跳过重复的第二个元素
while (j < k && nums[j] == nums[j + 1]) {
j++;
}
// 跳过重复的第三个元素
while (j < k && nums[k] == nums[k - 1]) {
k--;
}
// 移动指针,继续寻找下一组解
j++;
k--;
} else if (sum < target) {
j++; // 和太小,左指针右移
} else {
k--; // 和太大,右指针左移
}
}
}
return res;
}
public static void main(String[] args) {
ThreeSum solution = new ThreeSum();
int[] nums = {-1, 0, 1, 2, -1, -4};
System.out.println(solution.threeSum(nums)); // 输出 [[-1, -1, 2], [-1, 0, 1]]
}
}
五、关键细节与示例验证
1. 去重逻辑的数学证明
- 第一个元素去重 :假设
nums[i] = nums[i-1]
,由于数组有序,i
位置的元素与i-1
位置元素相同,且后续元素相同,因此以i
开头的三元组必然与以i-1
开头的三元组重复,跳过i
不影响结果完整性。 - 第二、三个元素去重 :在固定
i
的情况下,若nums[j]
重复,双指针移动时会再次遇到相同值,导致重复解,因此必须跳过。
2. 示例:数组 [-1, -1, 0, 1, 2]
- 排序后 :
[-1, -1, 0, 1, 2]
- 遍历
i=0
(第一个-1
) :- 双指针找到
j=1
(第二个-1
)、k=4
(2
),和为0
,记录[-1, -1, 2]
。 - 第二个元素去重:
nums[j] == nums[j+1]
不成立(j=1
后是0
),直接移动j=2
,k=3
,和为0+1=1
,等于target=1
,记录[-1, 0, 1]
。
- 双指针找到
- 遍历
i=1
(第二个-1
) :- 由于
nums[i] == nums[i-1]
,跳过,避免重复处理以-1
开头的组合。
- 由于
六、总结:去重的核心原则
- 排序是基础:有序数组让相同元素相邻,为去重提供条件。
- 分层去重 :
- 第一层(第一个元素):确保每个不同的起始值仅处理一次,避免重复的"头部"组合。
- 第二、三层(剩余元素):在固定头部的情况下,确保同一层内的中间和尾部元素唯一,避免重复的"身体"和"尾部"组合。
- 逻辑完整性 :去重操作不会漏掉合法解,因为每个不同的起始值(如第一个
-1
)会处理所有可能的后续组合(如第二个-1
和0
、1
等),而重复的起始值(如第二个-1
)会被跳过,避免冗余计算。
通过这三次去重,算法在 O ( n 2 ) O(n^2) O(n2) 的时间复杂度内高效解决问题,确保结果唯一且完整。理解去重的本质------对相同元素的"位置重复性"进行过滤,而非"值重复性"------是掌握该算法的关键。