本文概览:本文以LeetCode经典题目"三数之和"为例,系统讲解排序+双指针的解题思路,深入剖析三重去重机制和剪枝优化的实现细节,并详细解释为何固定最小元素是最优策略
一、题目
二、题目分析
给定一个整数数组 nums,找出所有满足条件的三元组 [nums[i], nums[j], nums[k]],使得 nums[i] + nums[j] + nums[k] == 0,且 i != j、i != k、j != k
目标:返回所有和为 0 且不重复的三元组
思路概览
Java实现代码如下
public List<List<Integer>> threeSum(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
if (nums == null || nums.length < 3) {
return res;
}
Arrays.sort(nums);
for (int i = 0; i < nums.length - 2; i++) {
if (nums[i] > 0)
break;
if (i > 0 && nums[i] == nums[i - 1])
continue;
int left = i + 1;
int right = nums.length - 1;
while (left < right) {
int sum = nums[i] + nums[left] + nums[right];
if (sum == 0) {
res.add(List.of(nums[i], nums[left], nums[right]));
while (left < right && nums[left] == nums[left + 1]) left++;
while (left < right && nums[right] == nums[right - 1]) right--;
left++;
right--;
} else if (sum < 0)
left++;
else
right--;
}
}
return res;
}
思路简要说明
-
排序是前提:将数组排序后,我们才能利用有序性使用双指针,同时也为去重提供了便利
-
固定一个,双指针找另外两个:外层循环固定
nums[i],内层用left和right双指针在i右侧的区间内寻找和为-nums[i]的两个数 -
双指针的移动逻辑:
- 若
sum < 0,说明三数之和偏小,left++让和增大 - 若
sum > 0,说明三数之和偏大,right--让和减小 - 若
sum == 0,找到了一组解,同时需要跳过重复元素避免重复解
- 若
三、思路详解
暴力解法入手
最自然的想法是使用三重循环枚举所有可能的三元组 (i, j, k),判断三数之和是否为 0
显然可以得出以下结论
- 时间复杂度:O(n³),效率极低
- 核心瓶颈:做了大量无效计算,很多组合在计算之前就可以被逻辑排除
- 关键思考:能否利用有序性,将三重循环优化为更低的时间复杂度?
排序 + 双指针解法
思路分析
由于我们需要找到三个数使它们的和为 0,如果数组是无序的,我们只能暴力枚举。但如果先将数组排序,就可以利用单调性来优化搜索过程
排序之后,我们采用固定一个数 + 双指针的策略:
- 外层循环固定
nums[i]:遍历数组,每次固定一个数作为三元组的第一个元素(即最小的元素) - 内层双指针寻找另外两个数:在
i右侧的区间[left, right]中,用左右指针向中间收缩,寻找和为-nums[i]的两个数
为什么固定的是三元组的第一个(最小)元素,而不是中间或右边的元素?
因为数组已经排序,三元组中的三个元素天然满足 nums[i] ≤ nums[left] ≤ nums[right]。如果我们将 nums[i] 固定为最小的那个元素,那么另外两个元素一定在 i 的右侧,搜索范围就是一个连续的区间 [i+1, n-1] ,双指针可以在这个区间上从两端向中间收缩
反过来想,如果我们固定的是中间元素 nums[left],那么比它小的元素在左边、比它大的元素在右边,搜索空间被拆成了两段,双指针就无法在一个连续区间上工作了;如果固定的是最大的元素 nums[right],同理,另外两个元素都在它左边,虽然可以工作,但这样外层循环从右往左遍历,剪枝逻辑(nums[i] > 0 直接 break)就无法使用了,因为最小的元素在最后才被固定,无法提前终止
所以,固定最小元素是最自然的选择:搜索空间连续、双指针可行、剪枝高效
为什么双指针有效?
因为数组是有序的,所以:
- 当
nums[i] + nums[left] + nums[right] < 0时,说明和偏小。由于left右侧的数更大,所以left++可以让和增大 - 当
nums[i] + nums[left] + nums[right] > 0时,说明和偏大。由于right左侧的数更小,所以right--可以让和减小
这样,内层循环就不再需要 O(n²) 的双重遍历,而是 O(n) 的双指针扫描
举例说明
以 nums = [-4, -1, -1, 0, 1, 2] 为例(已排序)
-
i = 0,nums[i] = -4,需要找两个数之和为 4left = 1, right = 5:-1 + 2 = 1 < 4,left++left = 2, right = 5:-1 + 2 = 1 < 4,left++left = 3, right = 5:0 + 2 = 2 < 4,left++left = 4, right = 5:1 + 2 = 3 < 4,left++left >= right,结束
-
i = 1,nums[i] = -1,需要找两个数之和为 1left = 2, right = 5:-1 + 2 = 1,找到一组[-1, -1, 2]- 跳过重复后继续...
left = 3, right = 4:0 + 1 = 1,找到一组[-1, 0, 1]
-
i = 2,nums[i] = -1,与i = 1时的值相同,跳过(去重) -
i = 3,nums[i] = 0,需要找两个数之和为 0left = 4, right = 5:1 + 2 = 3 > 0,right--left >= right,结束
-
i = 4,nums[i] = 1 > 0,直接 break(剪枝)
最终结果为 [[-1, -1, 2], [-1, 0, 1]]
时间复杂度:排序 O(n log n) + 双指针遍历 O(n²) = O(n²)
四、代码细节
这道题的难点不仅在于思路,更在于去重(筛重) 的处理。如果不做去重,会产生大量重复的三元组。代码中共有三处去重和一处剪枝,每一处都至关重要
1. 外层循环:跳过重复的 nums[i]
if (i > 0 && nums[i] == nums[i - 1])
continue;
为什么需要? 当 nums[i] 和 nums[i - 1] 相同时,以 nums[i] 为固定值搜索到的所有三元组,必然已经在前一轮 nums[i - 1] 时全部搜索过了。如果不跳过,就会产生重复解
为什么判断 i > 0? 当 i == 0 时,nums[i - 1] 会越界,所以必须加 i > 0 的保护
示例:nums = [-1, -1, 0, 1],当 i = 1 时 nums[1] = -1 == nums[0],直接跳过,否则会再次找到 [-1, 0, 1]
2. 找到解后:跳过重复的 nums[left]
while (left < right && nums[left] == nums[left + 1]) left++;
为什么需要? 当找到一组解后,如果 nums[left] 与下一个 nums[left + 1] 相同,那么继续 left++ 后找到的仍然是相同的组合,会产生重复解。所以需要提前跳过所有与当前 nums[left] 相同的元素
示例:nums = [-2, 0, 0, 2, 2],当 i = 0, left = 1, right = 4 时找到 [-2, 0, 2]。此时 nums[1] == nums[2],如果不跳过,left++ 后 left = 2,又会找到 [-2, 0, 2],重复!
3. 找到解后:跳过重复的 nums[right]
while (left < right && nums[right] == nums[right - 1]) right--;
为什么需要? 与跳过重复 nums[left] 同理,如果 nums[right] 与前一个 nums[right - 1] 相同,继续 right-- 后找到的仍然是相同的组合
示例:同样 nums = [-2, 0, 0, 2, 2],nums[3] == nums[4],需要跳过
4. 找到解后:最终移动指针
left++;
right--;
为什么跳过重复之后还要再移动一次? 上面的 while 循环只是把指针移到了最后一个重复元素的位置,还没有真正移动到下一个不同的元素。所以需要额外 left++ 和 right-- 来跳到新的候选值
注意顺序:必须先跳过重复,再移动指针。如果先移动指针再跳过重复,逻辑会出错,因为跳过重复的 while 循环是基于当前位置与下一个位置的比较
5. 剪枝优化:nums[i] > 0 直接 break
if (nums[i] > 0)
break;
为什么可以? 数组已经排序,如果 nums[i] > 0,那么 nums[left] 和 nums[right] 必然也大于 0(因为它们在 nums[i] 右边),三数之和不可能为 0。所以后续的所有 i 都无需再检查,直接 break 即可
这是 break 而不是 continue:因为数组有序,当前 nums[i] > 0 时,后面所有的 nums[i] 都更大,不可能再找到解,所以直接终止整个外层循环