15. 三数之和
题目描述
给你一个整数数组 nums,判断是否存在三元组 [nums[i], nums[j], nums[k]] 满足 i != j、i != k 且 j != k,同时还满足 nums[i] + nums[j] + nums[k] == 0。
返回所有和为 0 且不重复的三元组。注意:答案中不可以包含重复的三元组。
示例:
- 输入:
nums = [-1,0,1,2,-1,-4]→ 输出:[[-1,-1,2],[-1,0,1]] - 输入:
nums = [0,1,1]→ 输出:[] - 输入:
nums = [0,0,0]→ 输出:[[0,0,0]]
解题思路
第一步:理解问题本质
这道题的核心挑战有两点:
- 找出所有满足三数之和为 0 的组合。
- 去除重复的三元组。
如果用暴力枚举,去重是最麻烦的部分。而排序是解决去重问题的利器——排序后,相同的数字紧挨在一起,跳过重复元素只需要一个简单的判断。
第二步:暴力解法
三层嵌套循环,枚举所有三元组,用集合去重。
class Solution:
def threeSum(self, nums: list[int]) -> list[list[int]]:
n = len(nums)
result = set()
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:
triple = tuple(sorted([nums[i], nums[j], nums[k]]))
result.add(triple)
return [list(t) for t in result]
时间复杂度:O(n³),对于 n = 3000 约有 2.7 × 10¹⁰ 次操作,远超时间限制。
为何不够:三层循环枚举了大量无效组合,且用集合去重有额外开销。需要利用"有序"的性质减少枚举量。
第三步:优化解法(排序 + 哈希表)
固定第一个数 nums[i],问题转化为在剩余数组中找两数之和等于 -nums[i](即两数之和问题)。用哈希集合可以将内层两数之和从 O(n²) 降到 O(n)。
整体复杂度降到 O(n²),但去重逻辑仍然需要用集合,代码不够优雅。
第四步:最优解法(排序 + 双指针)
排序 + 双指针是这道题的标准最优解。排序后,我们可以用双指针在 O(n) 时间内完成两数之和的搜索,同时天然支持去重跳过。
完整思路:
- 排序:将数组从小到大排序,使相同元素相邻,便于去重。
- 固定第一个数:用
first从左到右枚举第一个数nums[first]。- 若
nums[first] > 0,由于数组有序,后面的数只会更大,三数之和不可能为 0,直接break。 - 若
nums[first] == nums[first-1](且first > 0),跳过,避免重复三元组。
- 若
- 双指针找剩余两数:在
first+1到n-1范围内,用second从左、third从右向中间夹逼,目标是nums[second] + nums[third] == -nums[first](即target)。- 若
nums[second] + nums[third] > target:和太大,third左移(减小右侧的数)。 - 若
nums[second] + nums[third] < target:和太小,second右移(增大左侧的数)。 - 若相等:记录结果,继续移动
second右移(同时third会在 while 循环中自动跟进)。 second去重:若nums[second] == nums[second-1](且second > first+1),跳过。
- 若
为什么双指针有效:数组有序后,second 和 third 的增减方向是确定的——second 只向右移,third 只向左移,两者不会回头,所以整个内层循环是 O(n) 的。
class Solution:
def threeSum(self, nums: list[int]) -> list[list[int]]:
n = len(nums)
if n < 3:
return []
nums.sort()
ans = []
for first in range(n):
# 跳过重复的第一个数
if first > 0 and nums[first] == nums[first - 1]:
continue
# 最小的数已大于 0,不可能凑出三数和为 0
if nums[first] > 0:
break
third = n - 1
target = -nums[first]
for second in range(first + 1, n):
# 跳过重复的第二个数
if second > first + 1 and nums[second] == nums[second - 1]:
continue
# third 左移直到不大于 target
while second < third and nums[second] + nums[third] > target:
third -= 1
# 两指针相遇,后续 second 增大只会让和更小,无解
if second == third:
break
if nums[second] + nums[third] == target:
ans.append([nums[first], nums[second], nums[third]])
return ans
完整代码实现
class Solution:
def threeSum(self, nums: list[int]) -> list[list[int]]:
n = len(nums)
if n < 3:
return []
nums.sort()
ans = []
for first in range(n):
if first > 0 and nums[first] == nums[first - 1]:
continue
if nums[first] > 0:
break
third = n - 1
target = -nums[first]
for second in range(first + 1, n):
if second > first + 1 and nums[second] == nums[second - 1]:
continue
while second < third and nums[second] + nums[third] > target:
third -= 1
if second == third:
break
if nums[second] + nums[third] == target:
ans.append([nums[first], nums[second], nums[third]])
return ans
示例推演
以 nums = [-1, 0, 1, 2, -1, -4] 为例,期望输出 [[-1,-1,2], [-1,0,1]]。
排序后:[-4, -1, -1, 0, 1, 2],索引 0~5。
first = 0,nums[first] = -4,target = 4,third = 5:
| second | nums[second] | nums[third] | 和 | 操作 |
|---|---|---|---|---|
| 1 | -1 | 2(idx=5) | 1 | 1 < 4,second 右移 |
| 2 | -1 | 2(idx=5) | 1 | 1 < 4,second 右移 |
| 3 | 0 | 2(idx=5) | 2 | 2 < 4,second 右移 |
| 4 | 1 | 2(idx=5) | 3 | 3 < 4,second 右移 |
| 5 | second == third,break |
本轮无结果。
first = 1,nums[first] = -1,target = 1,third = 5:
| second | nums[second] | third 位置 | nums[third] | 和 | 操作 |
|---|---|---|---|---|---|
| 2 | -1 | 5 | 2 | 1 | == target,记录 [-1,-1,2],second 右移 |
| 3 | 0 | 5(while: -1+2=1 不>1,不移) | 2 | 2 | 2 > 1,while: third 左移到 4 |
| 4 | 1 | 1 | == target,记录 [-1,0,1],second 右移 | ||
| 4 | 1 | 4(while: 0+1=1 不>1) | 1 | 2 | second == third(均为4),break |
本轮找到 [-1,-1,2] 和 [-1,0,1]。
first = 2,nums[first] = -1,与 nums[first-1] = -1 重复,跳过。
first = 3,nums[first] = 0 > 0为假,nums[first] = 0,继续;first = 3, nums[3]=0,target = 0,third = 5:
second=4, nums[4]=1, nums[5]=2, 和=3 > 0, third 左移到 4;second==third,break。无结果。
first = 4,nums[first] = 1 > 0,break。
最终结果:[[-1,-1,2], [-1,0,1]],正确。
复杂度分析
| 解法 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 暴力(三重循环) | O(n³) | O(n) | 集合去重,超时 |
| 排序 + 哈希表 | O(n²) | O(n) | 内层哈希查找,去重需额外集合 |
| 排序 + 双指针 | O(n²) | O(1) | 内层双指针 O(n),去重靠跳过相邻重复 |
排序本身 O(n log n),被 O(n²) 主导,整体仍为 O(n²)。空间复杂度不计输出结果占用的空间。
易错点总结
- 第二个数的去重时机:
second的去重条件是second > first + 1,而不是second > 0。这是因为second的起始位置是first + 1,第一次取值不需要跳过。 third的初始化位置:third = n - 1的初始化在first的循环内,每次second推进时,third不重置,因为有序数组中second增大意味着target - nums[second]减小,需要的third也只会更小或不变,不需要右移回去。这是双指针高效的关键。second == third时立刻 break:此时两指针相遇,后续second继续增大只会让两数之和变小,不可能等于target,所以整个内层循环可以终止。nums[first] > 0时 break 而非 continue:数组有序,first后面的数只会更大,不可能再有三数之和为 0 的情况,应break终止外层循环,而非continue跳过当前。
扩展思考
- 两数之和(LeetCode 1):本题内层双指针部分本质上就是有序数组的两数之和,理解这个子问题有助于掌握三数之和。
- 四数之和(LeetCode 18):再加一层固定第一个数的循环,内层依然是三数之和(双指针),复杂度为 O(n³)。
- 双指针本质:双指针之所以有效,是因为有序数组中,
second和third的移动方向是单调的——second只向右、third只向左,两者合计移动步数不超过 n,保证了 O(n) 的内层复杂度。这是"有序"性质带来的核心优化。