# 三数之和 (3Sum) LeetCode 15. Medium ## 题目描述 给你一个整数数组 `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 ``` ## 思路推导 ### 暴力解法分析 **最直观的思路**:三层循环枚举所有可能的三元组。 ```python def threeSum(nums): result = set() n = len(nums) for i in range(n): for j in range(i+1, n): for k in range(j+1, n): if nums[i] + nums[j] + nums[k] == 0: # 排序后加入集合,避免重复 triplet = sorted([nums[i], nums[j], nums[k]]) result.add(tuple(triplet)) return [list(t) for t in result] ``` **时间复杂度**:O(n³) - 外层循环:O(n) - 中层循环:O(n) - 内层循环:O(n) - 总计:O(n³) **空间复杂度**:O(1),不考虑结果存储 **问题分析**: 1. 效率太低:n=2000 时,n³ = 8×10⁹ 次运算,会超时 2. 去重困难:需要额外的集合操作 3. 无法利用已知信息优化 ### 优化思考 - 第一步:降维 **观察**:固定第一个数后,问题变成"两数之和" ```python # 固定第一个数 nums[i] # 问题转化为:在 nums[i+1:] 中找两个数,使和为 -nums[i] ``` **为什么这样思考?** - 三数之和 = 固定一个数 + 两数之和 - 两数之和可以用双指针 O(n) 解决 - 总复杂度:O(n) × O(n) = O(n²) **优化后的思路**: ```python for i in range(n): target = -nums[i] # 在 nums[i+1:] 中用双指针找两数之和为 target twoSum(nums, i+1, target) ``` ### 优化思考 - 第二步:双指针的前提条件 **问题**:为什么需要排序? **关键理解**:双指针依赖数组的**单调性** ``` 假设数组有序:[-4, -1, -1, 0, 1, 2] ↑ ↑ ↑ i left right 如果 nums[left] + nums[right] < target: - 由于数组升序,增大 left → 和会变大 - 减小 right → 和会变小 - 所以应该 left++ 如果 nums[left] + nums[right] > target: - 减小 right → 和会变小 - 所以应该 right-- ``` **不排序的后果**: - 无法确定指针移动方向 - 可能遗漏正确答案 - 无法利用有序性进行剪枝 ### 优化思考 - 第三步:去重策略 **去重的三个关键点**: 1. **外层去重**:跳过重复的第一个数 ```python if i > 0 and nums[i] == nums[i-1]: continue ``` 2. **内层去重**:找到答案后跳过重复元素 ```python while left < right and nums[left] == nums[left+1]: left += 1 while left < right and nums[right] == nums[right-1]: right -= 1 ``` 3. **为什么排序有利于去重**? - 相同的数会相邻 - 只需比较相邻元素即可去重 - 时间复杂度从 O(n²) 降到 O(n) ## 解题思路 ### 核心思想 **排序 + 双指针**:先排序,固定第一个数,再用双指针找后两个数。 **为什么这样思考?** 1. **排序的作用**: - 去除重复结果(相同数相邻) - 使数组有序,才能使用双指针 - 提前终止(如果当前数>0,后面都>0) 2. **双指针的原理**: - 数组有序后,如果 sum < target,需要增大 → left++ - 如果 sum > target,需要减小 → right-- - 利用单调性,避免暴力枚举 3. **降维思想**: - 三数之和 → 固定一个数 → 两数之和 - O(n³) → O(n²) ### 详细算法流程 **步骤1:预处理 - 排序** ```python nums.sort() # O(n log n) ``` **作用**: - 去重:相同元素相邻 - 双指针基础:利用有序性 - 提前终止:最小数>0则退出 **步骤2:外层循环 - 固定第一个数** ```python for i in range(len(nums) - 2): # 去重:跳过重复元素 if i > 0 and nums[i] == nums[i-1]: continue # 提前终止:如果最小数>0,后面不可能=0 if nums[i] > 0: break # 双指针找后两个数 left, right = i + 1, len(nums) - 1 target = -nums[i] while left < right: current_sum = nums[left] + nums[right] if current_sum == target: result.append([nums[i], nums[left], nums[right]]) # 去重:跳过重复的left while left < right and nums[left] == nums[left+1]: left += 1 # 去重:跳过重复的right while left < right and nums[right] == nums[right-1]: right -= 1 # 同时移动,寻找下一组解 left += 1 right -= 1 elif current_sum < target: left += 1 # 需要更大的和 else: right -= 1 # 需要更小的和 ``` **关键点详解**: 1. **为什么循环到 `len(nums)-2`?** - 需要留2个数给双指针 - i 最大只能到 n-3 2. **为什么判断 `i > 0`?** - 第一个元素不用判断重复 - 避免越界访问 3. **为什么用 `break` 而不是 `continue`?** - 后面都>0,不可能和为0 - 直接退出外层循环 **步骤3:内层双指针 - 两数之和** ```python def twoSum(nums, start, target): left, right = start, len(nums) - 1 while left < right: current_sum = nums[left] + nums[right] if current_sum == target: result.append([-target, nums[left], nums[right]]) # 去重:跳过重复的left while left < right and nums[left] == nums[left+1]: left += 1 # 去重:跳过重复的right while left < right and nums[right] == nums[right-1]: right -= 1 # 同时移动,寻找下一组解 left += 1 right -= 1 elif current_sum < target: left += 1 # 需要更大的和 else: right -= 1 # 需要更小的和 ``` **关键点**: - 为什么找到答案后还要跳过重复?避免重复结果 - 为什么找到答案后要同时移动?继续寻找其他组合 ### 关键细节说明 **细节1:为什么是 `if i > 0`?** ```python # 错误写法 if nums[i] == nums[i-1]: # i=0时会越界! continue # 正确写法 if i > 0 and nums[i] == nums[i-1]: # 第一个元素不用判断 continue ``` **细节2:为什么找到答案后要同时移动?** ``` 假设:[-2, 0, 1, 1, 2] i L R 找到:-2 + 0 + 2 = 0 ✓ 如果只移动一个指针: - L++: [-2, 0, 1, 1, 2] → -2 + 1 + 2 = 1 > 0 → R-- 但这样可能错过其他组合 正确做法:同时移动 - L++ and R--: 继续寻找其他可能的组合 ``` **细节3:为什么break而不是continue?** ```python if nums[i] > 0: break # 正确:后面的数都>0,不可能和为0 # continue # 错误:会继续无意义的循环 ``` **推理**: - 数组已排序 - 如果 nums[i] > 0,则 nums[i+1] >= nums[i] > 0 - 任意三个正数相加不可能为0 - 直接退出,节省时间 **细节4:为什么使用 `while` 而不是 `if` 去重?** ```python # 错误写法:只跳过一个重复元素 if nums[left] == nums[left+1]: left += 1 # 正确写法:跳过所有重复元素 while left < right and nums[left] == nums[left+1]: left += 1 ``` **示例**: ``` 数组:[-2, -1, -1, -1, 0, 1, 2] i L R 找到:-2 + (-1) + 3 = 0 ✓ 如果用 if: 只跳过一个-1,还会重复 如果用 while: 跳过所有-1,避免重复 ``` ### 边界条件分析 **边界1:数组长度不足** ``` 输入:[0, 1] 输出:[] 原因:长度<3,无法组成三元组 处理:循环条件 range(len(nums)-2) 自动处理 ``` **边界2:全部为0** ``` 输入:[0, 0, 0, 0] 输出:[[0, 0, 0]] 去重逻辑:只保留一个组合 过程: - i=0: 找到 [0,0,0],跳过后续重复 - i=1: nums[1]==nums[0],跳过 - i=2: nums[2]==nums[1],跳过 ``` **边界3:有重复元素** ``` 输入:[-1, -1, 0, 1] 输出:[[-1, 0, 1]] 去重逻辑:跳过第二个-1 过程: - i=0: 找到 [-1,0,1] - i=1: nums[1]==nums[0],跳过 ``` **边界4:最小的正整数情况** ``` 输入:[-2, -1, 0, 1, 2] 输出:[[-2, 0, 2], [-2, -1, 3], [-1, 0, 1]] 提前终止:i=0时nums[i]=-2<0,继续 i=3时nums[i]=1>0,退出 ``` ### 复杂度分析(详细版) **时间复杂度**: ``` - 排序:O(n log n) - 外层循环:O(n) - 内层双指针:O(n) - 总计:O(n log n) + O(n²) = O(n²) 为什么主项是O(n²)? - n² >> n log n (当n较大时) - 渐近复杂度取最高阶 ``` **空间复杂度**: ``` - 排序:O(log n) (快速排序栈空间) - 结果存储:O(k) (k为结果数量) - 指针变量:O(1) - 总计:O(log n) (不考虑结果存储) ``` --- ## 图解过程 ``` 数组: [-4, -1, -1, 0, 1, 2] ↑ ↑ ↑ i left right 第一轮: i = 0, nums[i] = -4 left = 1, right = 5 sum = -4 + (-1) + 2 = -3 < 0 left++ left = 2, right = 5 sum = -4 + (-1) + 2 = -3 < 0 left++ left = 3, right = 5 sum = -4 + 0 + 2 = -2 < 0 left++ left = 4, right = 5 sum = -4 + 1 + 2 = -1 < 0 left++ left >= right, 退出 第二轮: i = 1, nums[i] = -1 left = 2, right = 5 sum = -1 + (-1) + 2 = 0 ✓ 结果: [-1, -1, 2] left = 3, right = 4 sum = -1 + 0 + 1 = 0 ✓ 结果: [-1, 0, 1] 第三轮: i = 2, nums[i] = -1 (重复,跳过) 第四轮: i = 3, nums[i] = 0 > 0, 退出 最终结果: [[-1,-1,2], [-1,0,1]] ``` --- ## 代码实现 ```go func threeSum(nums []int) [][]int { result := [][]int{} n := len(nums) // 步骤1:排序 sort.Ints(nums) // 步骤2:外层循环,固定第一个数 for i := 0; i < n-2; i++ { // 去重:跳过重复的第一个数 if i > 0 && nums[i] == nums[i-1] { continue } // 提前终止:如果最小数>0,后面不可能=0 if nums[i] > 0 { break } // 双指针找后两个数 left, right := i+1, n-1 target := -nums[i] for left < right { sum := nums[left] + nums[right] if sum == target { result = append(result, []int{nums[i], nums[left], nums[right]}) // 去重:跳过重复的left for left < right && nums[left] == nums[left+1] { left++ } // 去重:跳过重复的right for left < right && nums[right] == nums[right-1] { right-- } // 同时移动,寻找下一组解 left++ right-- } else if sum < target { left++ // 需要更大的和 } else { right-- // 需要更小的和 } } } return result } ``` --- ## 执行过程演示 **输入**:[-1, 0, 1, 2, -1, -4] **排序后**:[-4, -1, -1, 0, 1, 2] ``` i=0, nums[i]=-4, target=4 left=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=5, right=5: 退出 i=1, nums[i]=-1, target=1 left=2, right=5: -1+2=1 == 1 ✓ 添加 [-1, -1, 2] left=3, right=4 left=3, right=4: 0+1=1 == 1 ✓ 添加 [-1, 0, 1] left=4, right=3: 退出 i=2, nums[i]=-1 (重复,跳过) i=3, nums[i]=0 > 0, 退出 结果:[[-1, -1, 2], [-1, 0, 1]] ``` --- ## 常见错误 ### 错误1:忘记排序 ❌ **错误代码**: ```go func threeSum(nums []int) [][]int { // 直接遍历,没有排序 for i := 0; i < len(nums)-2; i++ { // ... } } ``` ✅ **正确代码**: ```go func threeSum(nums []int) [][]int { sort.Ints(nums) // 必须先排序 for i := 0; i < len(nums)-2; i++ { // ... } } ``` **原因**:不排序无法使用双指针,无法去重 --- ### 错误2:去重逻辑不完整 ❌ **错误代码**: ```go // 只去重了第一个数 if i > 0 && nums[i] == nums[i-1] { continue } // left和right没有去重 ``` ✅ **正确代码**: ```go // 三个地方都要去重 if i > 0 && nums[i] == nums[i-1] { continue } for left < right && nums[left] == nums[left+1] { left++ } for left < right && nums[right] == nums[right-1] { right-- } ``` **原因**:避免重复结果 --- ### 错误3:指针移动条件错误 ❌ **错误代码**: ```go if sum < target { right-- // 错误!应该增大和 } ``` ✅ **正确代码**: ```go if sum < target { left++ // 正确!增大left可以增大和 } ``` **原因**:数组有序,left越大,和越大 --- ### 错误4:提前终止条件错误 ❌ **错误代码**: ```go if nums[i] >= 0 { // 错误!等于0也要继续 break } ``` ✅ **正确代码**: ```go if nums[i] > 0 { // 正确!大于0才退出 break } ``` **原因**:[0, 0, 0] 是有效答案 --- ## 进阶问题 ### Q1: 如果是四数之和? **方法**:两层循环 + 双指针,时间 O(n³) ```go func fourSum(nums []int, target int) [][]int { result := [][]int{} sort.Ints(nums) n := len(nums) for i := 0; i < n-3; i++ { if i > 0 && nums[i] == nums[i-1] { continue } for j := i + 1; j < n-2; j++ { if j > i+1 && nums[j] == nums[j-1] { continue } left, right := j+1, n-1 for left < right { sum := nums[i] + nums[j] + nums[left] + nums[right] if sum == target { result = append(result, []int{nums[i], nums[j], nums[left], nums[right]}) for left < right && nums[left] == nums[left+1] { left++ } for left < right && nums[right] == nums[right-1] { right-- } left++ right-- } else if sum < target { left++ } else { right-- } } } } return result } ``` **Q2: 如果数组很大,如何优化?** **优化**: 1. 提前终止:`nums[i] * 3 > target`(正数情况) 2. 二分查找:确定第二个数后,二分查找后两个 3. 哈希表:空间换时间 --- ## P7 加分项 ### 深度理解 - **排序的作用**:去重 + 双指针基础 - **双指针原理**:利用有序性,单向移动 - **去重策略**:多处去重,确保结果唯一 ### 实战扩展 - **大数据场景**:外部排序 + 分段处理 - **分布式场景**:MapReduce 框架 - **业务场景**:推荐系统、用户画像匹配 ### 变形题目 1. [16. 最接近的三数之和](https://leetcode.cn/problems/3sum-closest/) 2. [18. 四数之和](https://leetcode.cn/problems/4sum/) 3. [259. 较小的三数之和](https://leetcode.cn/problems/3sum-smaller/) --- ## 总结 **核心要点**: 1. **排序**:为双指针和去重创造条件 2. **固定一个数**:将问题转化为两数之和 3. **双指针**:根据 sum 与 target 的关系移动指针 4. **多重去重**:i、left、right 都要跳过重复元素 **易错点**: - 忘记排序 - 去重逻辑不完整 - left 和 right 的移动条件 - 优化提前终止的条件 **最优解法**:排序 + 双指针,时间 O(n²),空间 O(1)