一、 算法核心思想
“三数之和”最大的难点不在于找出组合,而在于如何高效地去除重复解(即答案中不能包含重复的三元组)。该算法通过以下三个步骤精妙地解决了这个问题:
预处理(排序):首先对整个数组进行升序排序。排序的作用有两个:
允许我们使用双指针从两端向中间靠拢(单调性)。
将相同的数字聚集在一起,为后续的去重操作打下基础。
固定基准数(外层循环):遍历数组,以当前元素
nums[i]作为三元组的第一个数。双指针扫描(内层循环):在
nums[i]右侧的剩余区间[i + 1, n - 1]内,设置左指针left和右指针right。如果三个数之和等于 0,记录结果。
如果和小于 0,说明数据太小,左指针右移(
left++)。如果和大于 0,说明数据太大,右指针左移(
right--)。
二、 代码逐行拆解与去重细节
以下是该解法的源码及深度注释,重点解析了算法中的三次去重逻辑。
class Solution { public List<List<Integer>> threeSum(int[] nums) { // 1. 关键步骤:先对数组进行升序排序 Arrays.sort(nums); List<List<Integer>> ans = new ArrayList<>(); int n = nums.length; // 2. 外层循环:固定三元组的第一个元素 nums[i] for (int i = 0; i < n - 2; i++) { // 【第一处去重】:如果当前元素和上一个元素相同,直接跳过 // 思考:为什么是和 nums[i-1] 比,而不是和 nums[i+1] 比? // 答:因为 nums[i-1] 已经作为第一个数遍历过了,所有包含它的组合都已找完。 // 如果和 nums[i+1] 比,会把类似 [-1, -1, 2] 这种合法的包含重复元素的三元组错误地漏掉。 if (i >= 1 && nums[i] == nums[i - 1]) { continue; } // 3. 初始化双指针 int left = i + 1; // 两个指针只在固定的基准数右侧移动 int right = n - 1; int target = -nums[i]; // 我们需要满足 nums[left] + nums[right] == target // 4. 内层循环:双指针向中间靠拢 while (left < right) { int sum = nums[left] + nums[right]; if (sum == target) { // 找到满足条件的三元组,存入结果集 ans.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 < target) { // 和太小了,左指针右移以增大和 left++; } else { // 和太大了,右指针左移以减小和 right--; } } } return ans; } }三、 面试官常问:去重逻辑深度剖析
这道题在 CSDN 或面试中最有价值的部分就是去重细节。
1. 为什么外层去重要用nums[i] == nums[i-1]?
如果我们写成nums[i] == nums[i+1],在面对输入[-1, -1, 2]时:
当
i = 0(nums[0] = -1)时,算法发现nums[0] == nums[1],直接continue跳过。这样就会导致
[-1, -1, 2]这个唯一正确的解被丢弃。正确逻辑:我们应该允许同一个三元组内部包含重复数字(如
nums[0]和nums[1]),但不能允许两个不同三元组的首元素在不同轮次里扮演相同的角色。因此,通过与历史过去的nums[i-1]对比,能确保“用过即丢弃”。
2. 内层双指针找到解后,为什么要一边执行while去重,最后还要执行left++和right--?
当sum == target时,如果不去重,left和right仅仅各移动一步,如果移动到的新位置数字和刚才一模一样,就会产生一模一样的重复三元组。
内部的
while循环是负责把指针推到重复数字的最后一个位置。随后的
left++和right--才是真正让指针跨越到全新数字的关键一步。
四、 复杂度分析
1. 时间复杂度
数组排序:
Arrays.sort(nums)消耗O(n log n)时间。双层循环扫描:
外层循环执行了
n次。内层双指针
left和right在每一轮外层循环中,合起来最多从两端走到中间,即最多移动n次。嵌套循环的时间复杂度为
O(n * n) = O(n^2)。
总时间复杂度:
O(n log n) + O(n^2) = O(n^2)。相比于暴力三层循环的O(n^3),这是一个质的飞跃。
2. 空间复杂度
辅助空间:在算法执行过程中,除了存储答案的
ans之外,我们只使用了left、right、sum、target等几个常数级别的变量,它们占用O(1)的空间。排序空间:Java 中
Arrays.sort()对对象或基元类型的排序(如快速排序、TimSort)会消耗O(log n)到O(n)的隐式栈空间。总空间复杂度:忽略输出结果集,算法的执行空间复杂度为
O(log n)(主要由排序算法的栈空间决定)。