这道题我刷了好几遍,每次在面试前都会把它翻出来重新写一遍。不是说它难,而是它在 LeetCode Hot 100 里的地位很特别——你几乎不可能在真正的面试里碰到一模一样的三数之和,但面试官完全可能换一个壳,考你排序加双指针这个组合套路。用 Go 语言实现三数之和,既能验证你对切片的操作是否熟练,也能检验你对去重逻辑的理解是否透彻。这篇文章就把这道题的完整解法、推导过程、Go 实现细节和我在提交过程中踩过的坑一次性讲清楚。
先说结论:排序加双指针是这道题的标准解法,时间复杂度 O(N^2),空间复杂度 O(log N) 到 O(N)(取决于排序实现)。只要你把去重逻辑写对,一次性通过所有测试用例没有任何问题。
1. 这道题为什么值得出现在 Hot 100:先看暴力解法的死穴
1.1 题目到底在问什么:隐藏的"去重"要求
题目描述很简单:给定一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a、b、c,使得 a + b + c = 0。请你找出所有满足条件且不重复的三元组。注意:答案中不可以包含重复的三元组。
这里有一个容易被新手忽略的重点:不重复这三个字。比如数组是 [-1, 0, 1, 1],你找到 [-1, 0, 1] 之后,后面再用第二个 1 组合出的 [-1, 0, 1] 就算重复,不能输出。LeetCode 的判题系统会先对结果做排序和去重再比较,但你的代码如果输出两个同样的三元组,直接判错。
所以这道题表面上是一道求和题,实际上是一道"枚举加去重"的组合问题。这个隐藏要求决定了我们后续所有设计的方向。
还有一个细节:返回的是三元组的列表,每个三元组内部的数字可以按任意顺序返回,但最终提交时系统会统一排序比较。所以你在代码里返回 [0, -1, 1] 和 [-1, 0, 1] 都行,只要数值集合相同就算通过。
1.2 暴力三重循环为什么不行:去重比枚举更麻烦
最直接的思路就是三重循环枚举所有 i、j、k,检查 nums[i] + nums[j] + nums[k] 是否等于 0。代码写起来很简单:
func threeSumBrute(nums []int) [][]int { n := len(nums) res := [][]int{} for i := 0; i < n; i++ { for j := i + 1; j < n; j++ { for k := j + 1; k < n; k++ { if nums[i] + nums[j] + nums[k] == 0 { res = append(res, []int{nums[i], nums[j], nums[k]}) } } } } return res }这段代码能跑通样例,但存在两个致命问题。
时间复杂度是 O(N^3)。当数组长度 N = 3000 时,内层循环要执行约 45 亿次,LeetCode 上直接超时。Hot 100 里的题通常 N 的上限是 3 * 10^4 甚至更大,暴力解法在实际数据规模下没有任何存活空间。
第二个问题更隐蔽:三重循环天然会产生大量重复三元组。比如数组是 [-1, 0, 1, 2, -1, -4],你去重最简单的方式是把每个三元组排序后作为 key 存进 map。但这里有个陷阱:Go 的切片不能直接作为 map 的 key,你只能转成字符串或者用组合后的整数键。把三元组排序再拼成字符串,这个操作本身就有 O(3 log 3) 的开销,而且 map 的存储会拖垮内存。
所以说,暴力解法不是"不够优雅"的问题,而是根本不可行。我们需要一个既能降低复杂度、又能天然避免重复的方案。这就引出了排序加双指针。
2. 排序 + 双指针:把 O(N^3) 拉回 O(N^2) 的完整推演
2.1 排序带来什么:让数组有了"方向"
先看一个朴素的直觉:如果数组是乱序的,你可以从任何位置开始找三个数,方向完全不可控。但如果你先把数组排序,比如从小到大排好,那么当你固定第一个数 a 时,剩下两个数 b 和 c 的搜索空间变成了一个有序区间。在有序数组里,"找两个数和等于 target"这个问题可以用双指针从两端往中间夹逼,而夹逼的过程保证每一种组合只会被检查一次。
这就是排序带来的第一个好处:双指针可以工作。第二个好处是排序后相同的元素会相邻排列,这让去重变得极其简单——跳过相邻的重复元素即可。
为什么双指针在有序数组里能高效找到两数之和?因为当你把 left 指针放在区间最左端(最小值),right 指针放在区间最右端(最大值),它们的和是当前区间内"最小加最大"的结果。如果这个结果比 target 大,说明右边的最大值太大,需要把 right 向左移动一位;如果比 target 小,说明左边的最小值太小,需要把 left 向右移动一位。每次比较后至少排除一个元素,所以整体只需要线性时间。
用一个生活化的类比:你在一个从小到大的队伍里找两个人,让他们身高之和等于某个固定值。最矮的和最高的站在一起,如果两人身高加起来太高,就让最高的往前走一位;如果太低,就让最矮的往后走一位。这样每走一步就排除一个人,不会漏掉任何可能。
2.2 双指针如何工作:固定一个,夹逼另外两个
三数之和的完整策略是:
- 首先对 nums 排序。
- 用 for 循环固定第一个数 nums[i]。
- 在 i+1 到 n-1 的区间内,用 left 和 right 两个指针找两个数,使 nums[left] + nums[right] == -nums[i]。
- 找到一组就记录,然后移动指针继续找。
为什么要固定第一个数而不是固定中间那个或者让三个指针一起动?因为固定一个是最自然的降维思路:三数之和变成了"单指针遍历一个数 + 双指针找两数之和",整体复杂度是 O(N * N)。固定哪个数其实都可以,但固定第一个数时,搜索区间始终在它的右侧,能避免把同一个三元组重复枚举。
这里需要特别注意一个细节:当 i 固定后,left 必须从 i+1 开始,而不是从 0 开始。如果你让 left 从 0 开始,那么当 i=1 时,你可能会在 left 指向 nums[0] 的情况下组合出 (nums[1], nums[0], nums[k]),这和 i=0 时组合出的 (nums[0], nums[1], nums[k]) 本质上是同一个三元组。为了彻底避免这种重复,所有搜索都在当前位置的右侧进行是最干净的约定。
有人会问:为什么不用哈希表法遍历 i 和 j,然后在 map 里找第三个数?这个思路对应的是"两数之和"的扩展版,时间复杂度也是 O(N^2),但问题在于去重非常麻烦。你需要在结果集合里手动去掉重复的三元组,而且 map 对负数和大数的处理会引入额外的哈希计算成本。排序加双指针之所以成为这道题的标准解,正是因为它在 O(N^2) 的时间里同时解决了去重问题。
2.3 一个简单例子手推全过程
用一个具体的数组走一遍:nums = [-1, 0, 1, 2, -1, -4]。
第一步,排序后得到 [-4, -1, -1, 0, 1, 2]。
i = 0,nums[i] = -4,目标 target = 4。left = 1,right = 5。
- nums[1] + nums[5] = -1 + 2 = 1 < 4,left++。
- nums[2] + nums[5] = -1 + 2 = 1 < 4,left++。
- nums[3] + nums[5] = 0 + 2 = 2 < 4,left++。
- nums[4] + nums[5] = 1 + 2 = 3 < 4,left++。
- left == right,结束。这一轮没有找到。
i = 1,nums[i] = -1,目标 target = 1。left = 2,right = 5。
- nums[2] + nums[5] = -1 + 2 = 1 == target,记录三元组 [-1, -1, 2]。
- 去重:左边 nums[2] == nums[3](都是 -1),left 跳到 3;右边没有相同。然后 left++,right--,left = 4,right = 4,结束。
i = 2,nums[i] = -1。注意此时 nums[2] == nums[1] == -1,直接跳过,否则会找到重复的 [-1, -1, 2]。
i = 3,nums[i] = 0,目标 target = 0。left = 4,right = 5。
- nums[4] + nums[5] = 1 + 2 = 3 > 0,right--。
- left == right,结束。
i = 4,nums[i] = 1,目标 target = -1。left = 5,right = 5,不满足 left < right,结束。同时 nums[i] > 0,可以提前 break。
最终结果只有 [[-1, -1, 2]]。注意数组里其实还有 [-1, 0, 1] 这个组合,但去重后只算一种。上面的过程里 i=1 之后其实 nums[i] = -1 和前面的重复,被跳过了,所以 [-1, 0, 1] 在 i=1 时已经考虑过(nums[1] = -1, nums[3] = 0, nums[4] = 1),只是我在上面的推演中因为 left 指针先去重跳到了 4,漏写了中间过程。你实际跑代码时会发现,i=1 这一轮确实能同时检查到 [-1, -1, 2] 和 [-1, 0, 1]。
3. Go 语言实现:完整代码与每个关键行的取舍
3.1 可运行的 Go 解法
直接给出可以提交到 LeetCode 的完整代码:
import "sort" func threeSum(nums []int) [][]int { n := len(nums) if n < 3 { return [][]int{} } sort.Ints(nums) res := make([][]int, 0) for i := 0; i < n-2; i++ { // 剪枝:如果第一个数已经大于 0,后面不可能凑出和为 0 if nums[i] > 0 { break } // 跳过重复的第一个数 if i > 0 && nums[i] == nums[i-1] { continue } left, right := i+1, n-1 target := -nums[i] for left < right { sum := nums[left] + nums[right] if sum == target { res = append(res, []int{nums[i], 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 res }这段代码在 LeetCode 官方题解里就是标准示例,Go 语言版本可以直接通过。
3.2 为什么第一步是 sort.Ints
Go 的 sort 包提供了内置的整数切片排序函数 sort.Ints,底层是 pdqsort,一种结合了插入排序、堆排序和快速排序的混合算法,平均时间复杂度 O(N log N),实际运行速度很快。
代码里第一件事就是判空:if n < 3 直接返回空二维切片。这一步不是可选的,因为三数之和要求至少三个元素,如果切片长度小于 3,后面的循环条件 i < n-2 本身就不会进入,但显式判空能提高代码可读性,也能避免一些粗心大意导致的数组越界问题。
排序为什么必须放在前面?因为整个双指针算法依赖有序性。如果不排序,右指针从最右端往左移动时不能确定当前 right 指向的是不是"当前区间内最大的数",那么移动指针的规则就完全失效,整个 O(N) 的两数搜索无法成立。
3.3 去重的三处细节,少一处就 WA
这是这道题最容易出错的地方,全网的提交记录里大量 WA 都出在去重逻辑上。完整答案需要三处去重:
第一处:外层循环跳过重复的 i。当 i > 0 且 nums[i] == nums[i-1] 时,直接 continue。理由很直观:既然上一次已经用 nums[i-1] 作为第一个数搜索过所有组合,现在再用相同的数搜一遍,得到的三元组一定前面都有了。
这里有个常见的迷惑点:为什么是 nums[i] == nums[i-1] 而不是 nums[i] == nums[i+1]?如果我写成后者,那么在遇到连续三个相同数字时,可能会跳过有用的组合。比如 nums = [-1, -1, -1, 2, -1],用 nums[i] == nums[i+1] 判断,第一个 -1 会被跳过,但第一个 -1 加上后面两个数可能构成唯一有效解。更关键的是,比较 nums[i] 和它左边已经处理过的元素,意味着"这个值我已经处理过了,不再处理",逻辑上是干净的。比较右边则意味着"这个值我还没处理就不要了",这是错误的。
第二处:内层循环找到一组答案后,跳过重复的 nums[left]。为什么要在找到答案后去重,而不是在移动 left 之前?因为如果 left 和 left+1 的值相同,你在当前 left 位置找到的答案如果记录,那么下一个 left 位置找到的很可能是同一个三元组,必须跳过。去重的标准写法是 for left < right && nums[left] == nums[left+1] { left++ }。
第三处:同样地,找到答案后跳过重复的 nums[right]。对称处理,for left < right && nums[right] == nums[right-1] { right-- }。
这三处缺一不可。只去重 i 而不去重 left/right,当数组里重复元素较多时会产生大量重复三元组;只去重 left/right 而不去重 i,外层循环会重复枚举一模一样的组合。
3.4 边界条件的处理顺序
代码里两个剪枝条件的顺序值得注意:
if nums[i] > 0 { break } if i > 0 && nums[i] == nums[i-1] { continue }为什么先判断大于 0 再判断重复?因为数组已经从小到大排序,一旦 nums[i] > 0,无论后面怎么选,三个数的和都不可能等于 0(正数加正数加正数永远是正数),直接 break 终止整个循环,这是最有效的剪枝。而重复判断只是跳过当前 i,循环还会继续,所以先做能提前终止的判断更合理。如果你把顺序反过来,在 nums[i] > 0 时仍然去做重复判断,虽然结果一样,但逻辑上不干净,而且多了一次无意义的比较。
另外一个细节是循环条件的写法:for i := 0; i < n-2; i++,这里 i 最大到 n-3,保证后面至少还有 left 和 right 两个位置可以取。有人喜欢写成 i < n,内层再判断 left < right,这样也能跑,但循环会多执行两次空操作,没必要。
4. 最容易翻车的排查链路:我在提交时踩过的坑
4.1 坑一:找到答案后忘了移动指针,直接超时
我第一次写这道题的时候,内层循环 is:
if sum == target { res = append(res, []int{nums[i], nums[left], nums[right]}) }然后就没有然后了。left 和 right 都不动,while 循环永远走不出去,直接超时。这个问题看起来蠢,但实际写代码时很容易犯,因为找到答案后的大脑惯性是"处理完了,该跳出内层循环了",但题目要求找出所有组合,不能跳出。
正确的做法是找到一组后,先做去重,然后 left++、right-- 同时收缩。为什么两个指针都要动?因为当前 left 和 right 的组合已经用过了,如果只移动一边,另一边保持不变,那么新的 sum 只可能远离 target(除非数组里有重复值导致的等价组合,但我们已经用去重逻辑把这种组合跳过了),不可能再找到新答案。所以最合理的下一步就是两边各进一步。
4.2 坑二:去重写在了错误的位置
另一个让我印象深刻的错误是,我把去重写在了比较 sum 之前:
for left < right { for left < right && nums[left] == nums[left+1] { left++ } for left < right && nums[right] == nums[right-1] { right-- } // 然后才开始比较 sum }这样写的问题是:如果 left 和 right 指向的值刚好凑成 target,但你先把 left 移到了最后一个重复值的位置,那么你记录的答案就不是"第一个出现的组合",虽然数值是一样的,但记录时机偏晚,容易漏掉组合。更重要的是,这种去重方式把原本应该在"找到答案后"执行的逻辑提前到了"比较之前",导致在 sum != target 时也做了无意义的去重移动,指针位置会乱。
正确的位置是在判断 sum == target 之后、记录完答案之后立即去重。这样你记录的每一个三元组都是"一组等值组合"中最早的那个,然后通过跳过后面的重复值避免重复记录。
4.3 坑三:用 map 去重,结果内存爆了
我还试过一种投机取巧的写法:不去严格遵循去重逻辑,而是把每个答案三元组排序后转成字符串,存进 map[string]bool,最后再把 map 里的 key 拆回整数切片返回:
key := fmt.Sprintf("%d,%d,%d", a, b, c)这个写法在数组规模小的时候能通过,但一旦 N 到 3000,合法的三元组数量可能达到几十万甚至上百万,fmt.Sprintf 的格式化开销加上 map 的存储开销会直接把内存打爆,甚至比暴力法还慢。LeetCode 的判题环境对内存有严格限制,这种"用工具去重"的思路在工程上可行,但在算法题里是下策。
依赖排序去重的双指针写法之所以高效,就是因为它不需要任何额外的全局去重结构,只在局部做指针移动就能保证结果唯一。这是理解这道题精髓的关键。
4.4 对比评测:正确写法与错误写法
拿一个极端测试用例来对比:nums = [0, 0, 0, 0, 0, 0],正确结果只有一个三元组 [0, 0, 0]。
正确写法输出:
[[0 0 0]]如果少写了 left/right 去重,你可能会得到 6 个甚至更多重复的 [0 0 0],判题直接失败。如果少写了 i 去重,外层循环对每个位置的 0 都会跑一遍,结果里全是重复的三元组。
另一个边界用例:nums = [-2, 0, 0, 2, 2]。正确结果是 [[-2, 0, 2]]。
这里的关键是:在找到 [-2, 0, 2] 之后,left 指向第一个 0,right 指向最后一个 2,如果只跳 left 不跳 right,或者只跳 right 不跳 left,都有可能额外生成重复组合。必须两边都跳到不重复的值才算处理干净。
5. 排序 + 双指针的思路还能怎么用:延伸题型
5.1 四数之和:再加一层循环
趁热打铁说说四数之和。它的思路和三数之和完全一致,只是需要两层外层循环固定两个数,然后内层双指针找另外两个数:
func fourSum(nums []int, target int) [][]int { sort.Ints(nums) n := len(nums) res := [][]int{} 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 sumNeed := target - nums[i] - nums[j] for left < right { sum := nums[left] + nums[right] if sum == sumNeed { res = append(res, []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 < sumNeed { left++ } else { right-- } } } } return res }复杂度从 O(N^2) 变成了 O(N^3),原理一模一样。Go 语言里要注意 target 可能为负数,所以不能像三数之和那样加一个 nums[i] > target 的剪枝,只能靠重复判断和边界判断来控制。
5.2 最接近的三数之和
这类题里另一个高频考点是"最接近目标值的三数之和"。思路也是排序加双指针:固定一个数,用双指针逼近剩余两个数的和,每次计算当前和与 target 的差值,保留最小差值。关键是在 sum 大于 target 时 right--,小于时 left++,等于时直接返回 target,因为不可能更接近了。
这道题不需要去重,代码反而更简单。但核心套路是一致的:遇到"数组里找多个元素满足某种约束"的问题,排序加双指针是首先要考虑的方案之一。
5.3 双指针的适用边界
不是所有数组求和问题都适合双指针。比如题目要求返回下标而不是数值,排序就会破坏下标信息,这时候要优先考虑哈希表。又比如数组本身就是无序且不允许排序的,双指针也失效。判断要不要排序的依据是:你关心元素的相对顺序吗?如果不关心,排序加双指针往往是降复杂度的利器。
双指针本身的适用条件更苛刻:数列必须有序,或者至少具备单调性。因为只有大小关系明确时,"小了就向右、大了就向左"才能保证不漏解。一旦缺失这个性质,双指针的正确性就要打问号。
从三数之和往后看,LeetCode 的 Two Pointers 系列基本都是这个框架的变体。掌握了这道题的去重逻辑和指针移动规则,后面做三数之和的各个变体、四数之和、盛最多水的容器、接雨水,思路都会顺畅很多。
我个人的体会是,三数之和这道题考察的不是"你会不会二分"或者"你知不知道某个冷门 API",而是你能不能把一个 O(N^3) 的暴力问题,通过排序将搜索空间压缩成可以夹逼的有序结构,同时处理好去重这种工程细节。Go 语言实现的简洁性让这套算法显得非常清爽,但简洁的背后需要你对切片、sort 包和循环控制都非常熟悉。建议大家在本地 IDE 里把断点打上,用一个重复元素很多的数组逐步运行,亲眼观察 left 和 right 的移动过程,这比背十遍题解都管用。