一、题目与考点拆解
题目要求
给定一个整数数组 nums,找出所有满足以下条件的三元组:
- 三个下标互不相同(
i≠j、i≠k、j≠k) - 三数之和等于 0
- 答案中不能包含重复的三元组 (元素相同、顺序不同算重复,如
[-1,0,1]和[0,-1,1]视为同一个)
两个核心考点
这道题真正考察的从来不是枚举能力,而是:
- 降维思维:如何把三数之和拆解成「固定一个数 + 两数之和」
- 去重能力 :如何利用有序性高效去重,避免重复结果 暴力三重循环 O (n³) 既超时又难去重,最优解的标准方案是:排序 + 双指针。
二、思路推导:从暴力法到最优解
1. 暴力法:最朴素的思路
三层循环枚举所有 i<j<k 的组合,判断和是否为 0。
- 时间复杂度:O (n³),数组长度 1000 就会超时
- 痛点:找到的三元组存在大量重复,额外去重逻辑繁琐且易出错
2. 第一次优化:排序 + 哈希表
固定第一个数 nums[i],问题降维为:在 i 后面的区间里,找两个数之和等于 -nums[i],也就是经典的两数之和问题。
- 用哈希表做两数之和,整体时间复杂度降到 O (n²)
- 痛点:去重依然麻烦,需要分别处理第一个数重复、数对重复,边界条件多
3. 最终最优解:排序 + 双指针
排序是整个算法的基石,一举两得:
- 有序性支持双指针:数组升序后,两数之和可以用左右双指针相向查找,线性时间内完成,不需要额外哈希空间
- 有序性方便去重:相同元素会挨在一起,只需要简单的相邻判断就能跳过重复,优雅解决去重难题
整体时间复杂度:O(n²) (排序 O (nlogn) + 外层遍历 O (n) + 内层双指针 O (n),主导项为 n²) 空间复杂度:O(logn)(排序的栈开销,不计结果存储)
三、前置铺垫:有序数组的两数之和(双指针版)
三数之和的内核是有序数组两数之和,先把这个基础搞懂,三数之和就是套壳。
问题
给定升序数组,找出所有和为 target 的不重复数对。
双指针核心逻辑
- 左指针
left指向区间起点,右指针right指向区间终点 - 计算两数之和
sum = nums[left] + nums[right] - 三种情况:
sum == target:找到解,记录后跳过重复元素,双指针同时收缩sum < target:和偏小,左指针右移,增大和sum > target:和偏大,右指针左移,减小和
- 当
left >= right时停止
核心前提:数组必须有序,才有「左移和变大、右移和变小」的单调性,双指针才能成立。
四、完整算法流程(Step by Step)
- 边界特判:数组长度小于 3,直接返回空列表
- 数组升序排序:双指针和去重的基础
- 遍历固定第一个数 nums i :
- 提前终止:若
nums[i] > 0,后续全为正数,三数之和不可能为 0,直接终止循环 - 首数去重:若
i>0且nums[i] == nums[i-1],跳过当前轮,避免重复三元组
- 提前终止:若
- 双指针查找剩余两数 :
- 初始化:
left = i+1,right = nums.length - 1 - 当
left < right时循环:- 计算当前三数之和
sum sum == 0:记录结果,左右指针分别跳过重复元素,再收缩指针sum < 0:左指针右移,增大和sum > 0:右指针左移,减小和
- 计算当前三数之和
- 初始化:
- 返回结果集合
五、最终定稿:可直接背诵的 AC 代码
java
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
class Solution {
public List<List<Integer>> threeSum(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
// 特判:长度不足3,无合法三元组
if (nums.length < 3) return result;
// 第一步:升序排序,双指针和去重的前提
Arrays.sort(nums);
// 第二步:遍历固定第一个数
for (int i = 0; i < nums.length; i++) {
// 首数大于0,后续全为正数,和不可能为0,提前终止
if (nums[i] > 0) break;
// 首数去重:和前一个相同则跳过,避免重复三元组
if (i > 0 && nums[i] == nums[i - 1]) continue;
// 左右双指针,在i之后的区间查找
int left = i + 1;
int right = nums.length - 1;
// 第三步:双指针相向查找
while (left < right) {
// 每轮必须重新计算和!指针移动后和会变化
int sum = nums[i] + nums[left] + nums[right];
if (sum == 0) {
// 找到解:将三个数打包成List,加入结果集
result.add(Arrays.asList(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 result;
}
}
六、逐行代码深度拆解(含所有疑惑点解答)
1. 结果集合与 Arrays.asList
List<List<Integer>> result = new ArrayList<>();
result.add(Arrays.asList(nums[i], nums[left], nums[right]));
result类型是List<List<Integer>>:大列表里装小列表,每个小列表对应一个三元组Arrays.asList():Java 工具方法,作用是快速把一组元素打包成一个 List 对象,省去了「新建 ArrayList → 逐个 add」的繁琐写法,是算法题的标准语法糖- 注意:该方法返回的 List 固定大小,不能增删元素,但本题只用于存储结果,完全够用
2. 为什么必须先排序?
两个不可替代的作用:
- 赋予数组单调性,双指针的「和小左移、和大右移」逻辑才能成立
- 让重复元素相邻,通过简单的相邻比较就能完成去重,无需额外空间
3. nums i > 0 为什么用 break?
if (nums[i] > 0) break;
数组是升序的,第一个数已经大于 0,后面的数都≥它,三个正数相加永远大于 0,后续所有 i 都不可能有解,因此直接终止整个外层循环,属于性能优化。
- 换成
return result功能上完全等价,最终结果一致;但break语义更精准(终止循环),属于算法题规范写法
4. 首数去重为什么用 continue?为什么和前一个比?
if (i > 0 && nums[i] == nums[i - 1]) continue;
- continue 的作用:只跳过当前这一轮 i,后续不重复的 i 还要继续处理,因此不能用 break
- 和前一个比的原因 :如果和后一个比,会漏掉合法解。例如
[-1,-1,2],i=0 时若和后一个比,会直接跳过第一个 - 1,导致合法三元组被遗漏 - 核心原则:去重跳后不跳前------ 第一个出现的元素要完整处理,后续重复出现的再跳过
5. sum 为什么必须写在 while 循环内部?
这是最高频的手写致命错误:如果把 sum 写在循环外,只会根据初始指针计算一次,后续指针移动后 sum 不会更新,所有判断全错。
记住铁则:只要指针动了,和就要重新算。sum 必须放在内层循环的第一行,每轮都更新。
6. 去重之后为什么还要额外收缩指针?
这是最容易困惑的点,核心要分清两个步骤的作用:
- 去重循环 :把所有和当前值相同的重复元素扫过去,指针最终停在当前值的最后一个重复位置 例如
[-1,-1,-1,0],left 初始为 0,去重循环结束后 left=2,值还是 - 1(最后一个 - 1) - 额外收缩一步:从「当前值的末尾」跳到「下一个新值的开头」 上面的例子中,left++ 后变成 3,值变为 0,才真正跳到了下一个不同的元素
如果只去重不额外收缩,指针会停在旧值上,下一轮循环依然会生成重复三元组,甚至死循环。
七、复盘
坑 1:sum 写在 while 循环外(致命错误)
- 现象:指针移动后 sum 不更新,永远基于初始值判断,找不到正确解,甚至死循环
- 修正:sum 必须放在内层 while 循环的第一行,每轮重新计算
坑 2:首数去重和后一个元素比
- 现象:漏掉包含重复元素的合法三元组(如
[-1,-1,2]) - 修正:和前一个元素比,
nums[i] == nums[i-1]
坑 3:去重循环不加边界判断
- 现象:遇到全相同元素时,指针一直移动导致数组越界
- 修正:所有去重循环都必须加上
left < right的边界条件
坑 4:找到解后忘记收缩指针
- 现象:指针原地不动,无限循环重复添加同一个三元组
- 修正:去重完成后必须执行
left++和right--
坑 5:循环条件写成 left <= right
- 现象:left 和 right 指向同一个元素,出现同一个元素使用两次的情况,违反下标不同的要求
- 修正:循环条件必须是
left < right,相遇即停止
坑 6:break 和 continue 混用
- 现象:该终止循环的时候跳了单轮,该跳单轮的时候终止了循环
- 修正:后续全无解用 break,仅当前元素无效用 continue
八、完整样例一步步模拟
输入:nums = [-1,0,1,2,-1,-4] 排序后:[-4, -1, -1, 0, 1, 2]
第 1 轮:i=0,nums i=-4,目标两数和 = 4
- left=1,right=5 → sum=-4+(-1)+2=-3 < 0 → left++
- left=2 → sum=-4+(-1)+2=-3 < 0 → left++
- left=3 → sum=-4+0+2=-2 < 0 → left++
- left=4 → sum=-4+1+2=-1 < 0 → left++
- left=5,不满足 left<right → 本轮结束,无结果
第 2 轮:i=1,nums i=-1,目标两数和 = 1
- left=2,right=5 → sum=-1+(-1)+2=0 → 记录
[-1,-1,2]- 左去重:nums 2=-1,nums 3=0,无重复
- 右去重:nums 5=2,nums 4=1,无重复
- 收缩:left=3,right=4
- sum=-1+0+1=0 → 记录
[-1,0,1]- 左右均无重复
- 收缩:left=4,right=3 → 循环结束
第 3 轮:i=2,nums i=-1
- i>0 且 nums 2==nums 1 → 重复,continue 跳过
第 4 轮:i=3,nums i=0
- left=4,right=5 → sum=0+1+2=3 > 0 → right--
- left=4,不满足 left<right → 本轮结束
第 5 轮:i=4,nums i=1 > 0 → break 终止循环
最终结果:[[-1,-1,2], [-1,0,1]],与标准答案完全一致。
九、面试标准话术与延伸
口述思路(直接背)
这道题我采用排序加双指针的思路。首先对数组做升序排序,排序一方面可以让数组具备单调性,支持双指针的线性查找;另一方面可以让重复元素相邻,方便后续去重。
然后遍历数组,把每个元素作为三元组的第一个数,将三数之和问题降维成有序数组的两数之和问题。在第一个数之后的区间里,用左右双指针相向移动查找:和小于目标就左指针右移,和大于目标就右指针左移,相等就记录结果并跳过重复元素。
同时对第一个数也做去重,避免重复的三元组。整体时间复杂度是 O (n²),空间复杂度主要是排序的开销 O (logn)。
高频追问
-
时间复杂度为什么是 O (n²)? 排序是 O (nlogn),外层遍历 O (n),内层双指针整体也是 O (n),主导项为 n²。
-
四数之和怎么做? 完全一致的思路:再多套一层循环固定前两个数,剩余区间用双指针找两数之和,去重逻辑完全相同,时间复杂度 O (n³)。 通用规律:k 数之和 = k-2 层循环 + 双指针,时间复杂度 O (nᵏ⁻¹)。
-
能不能不用排序? 可以用哈希表,但去重逻辑非常繁琐,且需要额外空间,面试和工程中都优先用排序双指针方案。
十、复习速记口诀
先排序,再固定,降维两数双指针; 和为零,记结果,左右去重再收缩; 和小左移,和大右移,首数重复直接跳; 首数大于零,直接就停掉。
总结
三数之和是模板性极强的高频题,核心套路非常固定:排序降维 + 双指针查找 + 三处去重。 复习优先级:
- 先记整体框架:排序→固定首数→双指针
- 再记三个去重点:首数去重、左指针去重、右指针去重
- 最后避坑:sum 放循环内、去重要加边界、收缩指针别忘记