news 2026/9/11 7:58:14

用Golang攻克三数之和:排序+双指针的优雅解法与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用Golang攻克三数之和:排序+双指针的优雅解法与避坑指南

1. 整体设计思路:这道题为什么值得用 Golang 认真刷一遍

LeetCode 第 15 题“三数之和”属于那种“一看就会,一写就废”的经典题目。你的第一反应多半是三重循环暴力枚举,等提交后发现超时,才意识到这道题真正的考点是如何把 O(n³) 的暴力解优化到 O(n²)。我在面试候选人的时候也经常拿这道题当试金石,它能快速暴露一个人对排序、双指针、去重这三个基础概念的理解深度。

先说结论:这道题最优雅的解法是排序 + 双指针。先对数组排序,固定一个数,再用左右指针在剩余区间内寻找两数之和等于目标值。整个流程只需要两重循环,时间复杂度 O(n²),空间复杂度 O(1)(不计输出数组)。用 Golang 实现这个算法非常顺手,因为标准库sort包提供了开箱即用的排序方法,而且 Golang 的切片操作在构建结果集时非常自然。

为什么哈希表方案在“两数之和”里好用,到这里就变得很别扭?因为“三数之和”要求返回不重复的三元组。用哈希表虽然能把查找降到 O(1),但去重逻辑会变得非常麻烦——你需要额外的 Set 结构去重,而且结果集本身还要再次去重。排序 + 双指针天然解决这个问题:排序后相同元素相邻排列,跳过重复值只需要一行continue。这是算法思维上的一个关键转变:与其事后去重,不如在设计流程时就避免重复

还有一点值得说:这道题非常考验编码细节。左右指针移动的时机、去重判断的位置、边界条件的处理,任何一个环节写错,要么死循环,要么漏解,要么越界。对 Golang 玩家来说,还有一个隐藏考点——切片(slice)的底层机制。如果对 slice 的共享内存特性不熟悉,很容易在 append 时踩坑。我在下文会专门展开。

适合读这篇文章的人:准备校招或社招的 Golang 开发者、刷 LeetCode 想找“最优解 + 避坑指南”的人、以及对算法复杂度优化感兴趣的程序员。看完你不仅能 AC 这一题,还能把“排序 + 双指针”这个套路迁移到四数之和、最接近的三数之和等一系列题目上。

2. 核心难点拆解:去重逻辑和指针移动的底层逻辑

2.1 为什么排序是这道题的胜负手

排序的意义不只是让数组有序,而是把“无序集合中的组合查找”变成“有序序列上的线性扫描”。一个很直观的例子:在无序数组[3, 1, -2, 0, 2, -1]里,你想找两数之和等于某个 target,暴力做法是双重循环;但排完序变成[-2, -1, 0, 1, 2, 3]之后,你可以用左右指针从两端向中间逼近,每次移动一个指针,最多走 n 步就能找到所有组合。

这里涉及一个关键原理:单调性。排序后,左指针向右移动会让两数之和增大(或不小于当前值),右指针向左移动会让两数之和减小。当两数之和小于目标值时,说明当前左指针位置的数太小,只有左指针右移才有可能逼近目标值;反之亦然。这个“单调性决策”就是双指针算法高效的本质。

Golang 里排序只需要一行:

sort.Ints(nums)

sort.Ints底层用的是 pdqsort 算法,平均时间复杂度 O(n log n),在几乎所有实际场景下都表现优秀。对数组长度小于 12 的切片,它会切换为插入排序;对近乎有序的数组,它会走特殊优化路径。总之这个排序实现非常稳定,你不需要手写快排。

2.2 去重逻辑:三个位置的 continue 分别解决什么问题

去重是三数之和最容易出错的地方。很多人第一次写出来的代码长这样:

for i := 0; i < n-2; i++ { for left := i + 1; left < n-1; left++ { for right := n - 1; right > left; right-- { // 暴力三重循环 } } }

这种写法最大的问题不是时间复杂度高,而是结果里有大量重复。比如数组[-1, 0, 1, -1, 0, 1],暴力枚举会找出两套完全相同的[-1, 0, 1]。所以去重是这道题的核心考点之一。

在排序 + 双指针的框架下,需要在三个位置做去重处理:

第一个位置:外层循环的 i(固定数)。i > 0nums[i] == nums[i-1]时,直接跳过当前轮次。这里比较的是nums[i-1]而不是nums[i+1],目的是确保保留每组重复值中的第一个。如果改成比较nums[i] == nums[i+1]并跳过,会漏掉像[-1, -1, 2]这样的合法三元组——因为 nums[i] 和 nums[i+1] 相等,但你跳过的是 i 这个位置,而不是 i+1。

第二个位置和第三个位置:内层循环的 left 和 right。当找到一个合法三元组后,需要把 left 向右移动跳过所有重复值,right 向左移动跳过所有重复值。注意这里有一个细节:跳过后还需要再移动一次,让指针停在非重复值上。很多人在这里丢失一步,导致死循环。

我把去重的完整代码块贴出来,注释写得详细一些:

if nums[left]+nums[right] == target { ans = append(ans, []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-- }

2.3 指针移动时机的陷阱:什么时候移动,什么时候不移动

双指针的移动逻辑看似简单,实际非常容易写错。核心规则是:三个数之和与 0 比较,根据大小关系决定移动哪一边

  • nums[i] + nums[left] + nums[right] < 0时,说明整体太小了,需要把 left 向右移动(增大和)。
  • nums[i] + nums[left] + nums[right] > 0时,说明整体太大了,需要把 right 向左移动(减小和)。
  • 当等于 0 时,记录结果,然后left 和 right 同时向中间移动

这个“同时移动”的细节值得展开。为什么找到答案后不能只移动一边?因为 left 右移会让和变大,right 左移会让和变小,如果只移动一边,剩下的组合要么太大要么太小,不可能正好等于 0(除非存在重复值,但重复值已经被去重逻辑处理掉了)。所以找到一组解后,两边都要动,进入全新的区间继续搜索。

还有一个常见的边界细节:外层循环的终止条件是i < n-2,因为至少要留两个位置给 left 和 right。left 初始化为i+1,right 初始化为n-1。内层循环条件是left < right,不能写成left <= right,否则会出现 left 和 right 指向同一个元素的非法情况——题目要求三个下标互不相同。

2.4 Golang 切片机制与 append 的隐藏坑

这是 Golang 特有的问题,其他语言玩家可能感受不到。我在第一次用 Golang 写这道题时,直接在循环里写:

tmp := nums[i] ans = append(ans, []int{tmp, nums[left], nums[right]})

这个写法是安全的,因为[]int{...}每次都是新建一个切片,底层数组是独立分配的。但如果图省事写成了:

triplet := make([]int, 3) for ... { triplet[0] = nums[i] triplet[1] = nums[left] triplet[2] = nums[right] ans = append(ans, triplet) }

那就完蛋了。因为triplet是同一个切片,每次循环修改的是同一块底层数组,最后ans里存的多个“三元组”实际上都指向同一个底层数组,打印出来会发现全是最后一组数据。这就是 Golang 切片的共享底层数组特性导致的经典 bug。

提示:如果复用同一个切片对象,必须每次copy一份新的,或者直接用[]int{}构造新切片。最简单的方式就是写[]int{nums[i], nums[left], nums[right]},让运行时每次分配新内存。

另一个切片相关的问题是append的扩容机制。ans初始可以不指定容量,运行时按需扩容,这没问题。但如果你提前知道结果的大致数量(比如最坏情况下有 C(n,3) 个组合),可以预分配容量减少扩容次数。不过在刷题场景里没必要过度优化,append的摊还复杂度本来就是 O(1)。

3. 完整实现与关键环节走读:一步步写出可 AC 的代码

3.1 从暴力解到双指针的演进过程

先看最朴素的暴力解,理清问题规模:

func threeSumBrute(nums []int) [][]int { n := len(nums) ans := [][]int{} for i := 0; i < n-2; i++ { for j := i + 1; j < n-1; j++ { for k := j + 1; k < n; k++ { if nums[i]+nums[j]+nums[k] == 0 { ans = append(ans, []int{nums[i], nums[j], nums[k]}) } } } } return ans }

时间复杂度 O(n³)。当 n = 3000 时,内层循环要执行约 45 亿次,LeetCode 直接给你一个 TLE(Time Limit Exceeded)。即使不考虑性能,这个解法还需要额外去重,代码量反而更臃肿。

优化的思路是:固定一个数 i,问题就变成了在 i 右侧的区间内找两数之和等于 -nums[i]。两数之和可以用哈希表达到 O(n),整体 O(n²);但去重麻烦。也可以用双指针达到 O(n),前提是区间有序。所以我们先排序,再用双指针——这就是整个算法设计的完整推理链。

3.2 添加剪枝条件:两道硬边界大幅提升平均性能

排序之后,数组有一个非常重要的性质:越往右的元素越大。基于这个性质我们可以加两个剪枝条件,在数据量大的时候能省下大量无意义的循环。

剪枝一:最小和都大于 0,直接结束循环。

对于外层循环当前的nums[i],如果nums[i] + nums[i+1] + nums[i+2] > 0,意味着当前位置 i 往后的任意三元组(因为 i+1、i+2 是 i 之后最小的两个数)的和都大于 0,不可能等于 0。此时直接break整个外层循环。

剪枝二:最大和都小于 0,跳过当前 i。

如果nums[i] + nums[n-2] + nums[n-1] < 0,说明当前位置 i 就算配上数组里最大的两个数,和仍然小于 0,那当前 i 这个固定值是不可能凑出 0 的。此时不需要 break(因为 i 继续往后走,nums[i] 会变大,有可能满足条件),只需要continue跳过当前轮次,让 i++ 继续。

这个剪枝看起来不起眼,但对包含大量负数或大量正数的测试用例,效果非常明显。我从实测数据来看,加了剪枝的版本在 LeetCode 的随机测试集上平均能快 30% 到 50%。

3.3 完整 AC 代码与逐步讲解

直接给出我觉得最清晰、最适合作为模板的版本:

import "sort" func threeSum(nums []int) [][]int { n := len(nums) if n < 3 { return [][]int{} } sort.Ints(nums) ans := make([][]int, 0) for i := 0; i < n-2; i++ { // 外层去重:跳过重复的固定数 if i > 0 && nums[i] == nums[i-1] { continue } // 剪枝一:最小和大于 0,不可能再找到解 if nums[i]+nums[i+1]+nums[i+2] > 0 { break } // 剪枝二:最大和小于 0,当前 i 无解,继续往后找 if nums[i]+nums[n-2]+nums[n-1] < 0 { continue } left, right := i+1, n-1 target := -nums[i] for left < right { sum := nums[left] + nums[right] if sum == target { ans = append(ans, []int{nums[i], nums[left], nums[right]}) // 内层去重:跳过重复的 left 和 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 ans }

逐段说明:

  • if n < 3是防御性判断,数组长度不足 3 时直接返回空结果。
  • sort.Ints(nums)排序,为双指针创造有序环境。
  • 外层循环固定nums[i]target设为-nums[i],内层问题转化为两数之和。
  • sum == target时记录结果,然后执行去重和指针移动。
  • sum < target时 left 右移,因为需要更大的数来凑足 target。
  • sum > target时 right 左移,因为需要更小的数来降低和。

这个版本的代码结构非常规律,非常适合作为“排序 + 双指针”类题目的记忆模板。把target的赋值方式稍微改一改,就能套用到三数之和的变体题上。

3.4 时间与空间复杂度验证

排序:O(n log n)。

外层循环:i 从 0 到 n-3,共 n-2 次。每次内层双指针最多移动 n 次。整体 O(n²)。

空间复杂度:如果不计算输出数组,只用了常数额外空间(排序是原地排序,ans是输出),是 O(1)。如果计算输出数组,最坏情况下结果数量是 O(n²)(比如全零数组会输出 C(n,3) 个三元组?其实不对,去重后只有一个),更准确的说是 O(k),k 为结果数量。面试时回答 O(1) 通常就够了,但主动提一句“不计输出空间是 O(1)”会显得你考虑问题更全面。

这里有个细节:Go 的sort.Ints是原地排序,不申请额外数组,这比其他语言里某些返回新数组的排序 API 更节省空间。

4. 常见问题排查与实战避坑:从 TLE 到 AC 的完整回顾

4.1 为什么我移除了重复值却还是超时

一个很常见的错误是只做了外层去重,内层没有去重。这不会导致结果出错——因为重复三元组只是被多次 append 到 ans 里,题目不会判错,但 LeetCode 的判题器要求结果集不重复,所以被判定为 Wrong Answer。真正导致 TLE 的往往是没有加剪枝条件,尤其是面对一个全是正数的数组时,nums[i]+nums[i+1]+nums[i+2] > 0会在第一次外层循环就 break,效率天差地别。

我看过一些新手提交的代码,他们在sum == target后只执行left++或只执行right--,这会导致无法跳出循环或者漏解。为什么?因为找到一组解后,区间内的任意其他组合都不可能再满足条件(基于排序后的单调性),必须左右同时收缩。如果只移动一边,sum要么变大要么变小,永远不再等于 target,除非遇到重复值,而重复值又需要另一个去重逻辑来处理——等于你在用一个复杂的逻辑去掩盖一个本来简单的规则。

4.2 一个典型的死循环场景复盘

我曾经看到过一个学员写的这样一段代码:

if sum == target { ans = append(ans, []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-- }

表面看起来没问题:重复值被跳过了。但如果当前 left 和 right 指向的元素都没有重复值,两个 for 循环直接不执行,left 和 right 原地不动,外层 while 进入死循环。这就是我前面强调的“跳过后还要再走一步”的原因。

调试这类问题有个非常实用的技巧:在关键分支打印 left、right、sum 的值,观察指针是否在某个区间内来回摆动。

4.3 LeetCode 判题器的隐藏约束:结果集顺序不影响 AC

很多新手会在提交前纠结:我的三元组顺序和官方答案不一致,会不会判错?LeetCode 对于这种“组合类”题目,内部会对每个三元组排序,并对整个结果集排序后再做比较,所以顺序不影响 AC。这意味着你不需要为了“输出顺序”去调整代码逻辑,专注于找出全部不重复组合即可。

Golang 的解决方案在 LeetCode 上还有一个隐性的优势:标准库的sort.Ints性能极好,而且语言本身没有 Java 那种大量样板代码的负担,写出来的解法通常比较简洁,面试时手写也更容易让面试官看清你的思路。

4.4 实战排查速查表

我把常见问题整理成一个速查表,方便你对照检查:

症状可能原因解决方案
结果集出现重复三元组外层或内层去重缺失补齐外层nums[i]==nums[i-1]的 continue,以及找到答案后的 left/right 去重
超时 TLE没有加剪枝条件,或用了哈希表方案加上最小和/最大和的剪枝,确认是双指针实现
死循环找到答案后没有同时移动左右指针在去重循环后再补上left++; right--
输出全是最后一组数据复用了同一个切片对象,共享底层数组每次用[]int{}字面量构造新切片
数组越界 panic内层循环条件写成了left <= right改为left < right,确保两个指针不会指向同一位置
漏掉边界情况没有判断n < 3函数开头加防御性判断

4.5 我想分享的一个调优心得

这道题我前后写过不下十个版本,最终固定下来的模板就是上面那版。但还有一个微优化:target := -nums[i]提到内层循环外部。虽然编译器大概率会做公共子表达式消除,但这么写至少能让代码的意图更清晰——内层循环关注的是“两数之和等于 target”,而不是“三数之和等于 0”。这种命名方式的转变,让我在面试现场写代码时思路更顺畅,也方便面试官理解我的算法逻辑。

另外,如果你在主函数里直接调用threeSum并通过了测试,不要急着收工。建议你多测几个边界用例:空数组、单元素数组、全零数组、全正数数组、重复元素极多的数组。特别是全零数组,去重逻辑不正确的代码会输出 C(n,3) 个[0,0,0]导致结果集巨大,一眼就能看出问题。我在本地测试时最喜欢用nums := []int{0, 0, 0, 0, 0},期望输出[[0 0 0]],如果你的代码输出多条,基本可以断定去重有 bug。

5. 从三数之和到整个双指针家族:这套路能迁移到哪些题

5.1 最接近的三数之和:把等号改成绝对值比较

LeetCode 第 16 题“最接近的三数之和”和本题几乎同构。区别在于:不是找等于 0 的组合,而是找与 target 最接近的组合。维护一个全局变量closest,每次计算当前三数之和与 target 的差值绝对值,更新最小值。指针移动的规则稍微变一下:等于 target 时直接返回(因为不可能更接近了),大于 target 时右指针左移,小于时左指针右移。整体框架可以完全复用。

5.2 四数之和:外层多套一层循环

LeetCode 第 18 题“四数之和”就是在三数之和的外面再套一层循环。固定两个数 i 和 j,内层用双指针找两数之和。去重逻辑同样要处理三个位置(两处外层去重、一处内层去重)。注意四数之和需要考虑整数溢出——如果用int相加,Go 的 int 在 64 位平台下是 64 位,一般不会溢出;但如果题目给的数值很大,需要提前判断nums[i] > target - nums[j] - nums[left] - nums[right]这类写法避免直接相加。

5.3 两数之和 II(有序数组版)

LeetCode 第 167 题是一个更基础的双指针题。给定一个已排序数组和一个 target,找两数之和等于 target 的下标。它的双指针逻辑是三数之和内层循环的简化版。先刷 167,再刷 15,会感觉非常顺畅——15 的内层就是 167 的解法外加去重。

5.4 排序 + 双指针的真正适用边界

并不是所有“N 数之和”都适合双指针。如果题目要求返回的是下标而不是值,排序会打乱下标映射,这时通常用哈希表。如果数组长度很小(比如 n < 100),暴力解可能更快,因为常数因子更小——当然刷题时我们追求最优复杂度,不考虑这种特殊情况。学习这类题的关键是掌握决策树:什么时候用双指针(排完序后查两数关系)、什么时候用哈希表(需要保留下标、不需要去重)、什么时候用滑动窗口(子数组连续区间问题,和双指针是两码事)。

这三种模式我建议在脑子里建立清晰的区分。很多人把所有“前后两个指针遍历数组”的问题都叫双指针,其实滑动窗口和相向双指针解决的问题类别截然不同:滑动窗口维护的是连续子区间,相向双指针利用的是排序后的单调性。

回到三数之和这道题本身,它最有价值的启发是:先排序再处理,往往能把复杂问题简化一个维度。这个思想可以用在很多场景里,比如合并区间、会议室预定、判断是否存在重复元素等。学会这个套路,比单刷一道题的意义大得多。

我个人的习惯是每刷完一道经典题,会在本地再写一遍不带任何注释的版本,模拟面试环境。三数之和我大概写过十几遍,闭着眼都能把框架默写出来。这种肌肉记忆在真正的面试中非常有用——你不会在基础题上卡壳,可以把精力留给更复杂的系统设计问题。最后再分享一个小技巧:刷这道题之前,先把 167 题两数之和 II 做一遍。基础打牢了,三数之和对你来说就只是“两数之和加一层循环加去重”。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 7:58:02

2 分钟完整转换 Visio 文件:drawio-desktop 上手

2 分钟完整转换 Visio 文件&#xff1a;drawio-desktop 上手 【免费下载链接】drawio-desktop Official electron build of draw.io 项目地址: https://gitcode.com/GitHub_Trending/dr/drawio-desktop 同事发来的 .vsdx 在你 Mac 上双击&#xff0c;只有一句「无法打开…

作者头像 李华
网站建设 2026/9/11 7:55:14

WorkBuddy连接器实战:从定时同步到自动化流程编排

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 7:55:13

阿里开源Agent项目实战:从部署到生产落地的完整指南

前阵子各个技术群和朋友圈都在转发同一类消息——阿里又双叒开源Agent项目了。说实话&#xff0c;这两年“开源Agent”这个词都快被玩烂了&#xff0c;但点进去仔细看完项目的技术文档和Demo之后&#xff0c;我还是没忍住连夜把代码拉下来跑了一遍。这篇文章我不打算复述官方文…

作者头像 李华
网站建设 2026/9/11 7:54:23

C语言程序段分析:递归与指针自增的经典陷阱解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 7:53:49

语音模块与MCU串口通信协议设计六要素

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 7:51:55

国产分布式数据库选型避坑指南:从PolarDB-X实战看技术决策本质

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华