1. 问题与目标
问题描述
给定一个整数数组 nums,找出所有不重复的三元组 (a, b, c),使得:
a + b + c = 0
要求:
- 返回所有满足条件的三元组列表;
- 三元组内部顺序无关,但结果中不能出现重复三元组(如
[-1,0,1]只能出现一次); - 元素可以重复(如多个 0),但组合不能重复。
核心目标
- 时间复杂度达到主流最优级别
O(n^2); - 去重逻辑正确,不漏解、不重复;
- 代码结构清晰,便于以后扩展(比如改成 4Sum、kSum)。
2. 标准解法:排序 + 固定一位 + 双指针
整体套路:
-
排序
nums.sort()作用:
- 为双指针创造有序环境;
- 为去重提供便利(相同数字挤在一起)。
-
固定第一个数
nums[i]外层循环遍历索引
i,选定三元组的第一个数:first = nums[i]将问题转化为:在区间
(i, end]里找两数,使得:nums[j] + nums[k] = -first -
双指针找两数之和
在
[i+1, n-1]区间内设两个指针:left = i + 1 right = n - 1 target = -nums[i]然后:
- 如果
nums[left] + nums[right] == target→ 找到一个三元组; - 如果和小于
target→left++(增大和); - 如果和大于
target→right--(减小和)。
- 如果
复杂度
- 排序:
O(n log n) - 外层
i:最多O(n) - 内层双指针:每次
O(n)(left与right各走一遍)
总复杂度:O(n^2),这是 3Sum 的主流最优级别,基本不存在可以进一步降到 O(n log n) 或 O(n) 的通用算法。
3. 关键点一:去重策略
不做去重会导致大量重复三元组,例如:
[-1, 0, 1], [0, -1, 1], [1, 0, -1] ...
三个层面的去重:
-
对第一个数
nums[i]去重在外层循环中跳过相同的
i:if (i > 0 && nums[i] == nums[i - 1]) continue或 while 版本中:
if (i > 0 && nums[i] == nums[i - 1]) { i++ continue }目的是:保证“以某个值为 first 的情况只处理一次”。
-
对第二个数
nums[left]去重找到一个合法三元组后,往右跳过所有与当前
left相同的值:val leftVal = nums[left] while (left < right && nums[left] == leftVal) { left++ } -
对第三个数
nums[right]去重同理,往左跳过所有与当前
right相同的值:val rightVal = nums[right] while (left < right && nums[right] == rightVal) { right-- }
踩坑点(这次的 bug):
最初 Java 写法中 k 去重是:
int tempK = nums[k];
while (k >= j) {
if (tempK == nums[k]) {
k--;
}
}
问题:
-
当
tempK != nums[k]时,if不执行,外层while(k >= j)没有任何 break → 死循环; -
正确逻辑应该是「只在值相等时继续往里收缩」:
int tempK = nums[k]; while (j < k && nums[k] == tempK) { k--; }
这体现了:while 控制指针非常灵活,但条件没写好就很容易无限循环。
4. 关键点二:剪枝优化
你尝试加入了两条剪枝:
if (nums[i] > 0) {
break;
}
if (nums[i] + nums[i+1] + nums[i+2] > 0) {
break;
}
含义:
-
nums[i] > 0时直接 break因为数组已排序,当
nums[i] > 0时:nums[i] <= nums[j] <= nums[k] => nums[i] + nums[j] + nums[k] >= nums[i] + nums[i] + nums[i] > 0之后的 i 只会更大,不可能再出现和为 0 的三元组,因此可以提前结束。
-
nums[i] + nums[i+1] + nums[i+2] > 0时也可以 break这是更强的一种剪枝:看“当前区间的三个最小值之和”都已经 > 0,说明后面任何组合都会 > 0。
剪枝效果总结:
- 不改变复杂度数量级(仍为
O(n^2)),但能减少无效循环; - 写剪枝时要慎重考虑边界,如果数组里负数/零/正数混合很多,剪枝条件容易写得过于激进。
5. while 与 for 在这个算法中的差异
这次你明显更偏向用 while 写 3Sum,这里总结一下两者的区别和感受。
5.1 外层循环(固定 i)
for 写法:
for (i in 0 until n - 2) {
if (i > 0 && nums[i] == nums[i - 1]) continue
// ...
}
while 写法:
var i = 0
while (i < n - 2) {
if (i > 0 && nums[i] == nums[i - 1]) {
i++
continue
}
// ...
i++
}
对比:
- 性能上几乎没有差别;
- for 更像“遍历所有索引”的语义;
- while 让你可以更显式地“控制 i 是在循环开始、结束、还是中途跳多步自增”,比如一次跳过一段重复值。
对你这种“按指针移动过程来想问题”的思维方式,while 更贴近脑回路。
5.2 内层双指针(left / right)
典型写法几乎都是 while:
var left = i + 1
var right = n - 1
while (left < right) {
val sum = nums[i] + nums[left] + nums[right]
when {
sum == 0 -> { /* 收集结果 + 去重 */ }
sum < 0 -> left++
else -> right--
}
}
原因:
- 双指针的移动方向不固定,随
sum的结果来决定; - for 的自增是固定模式(每轮 +1 / -1),不适合表达“根据条件选择移动哪一个指针”;
- while 更自然地表达“循环条件 + 手动移动指针”的模式。
结论:
- 外层 i:for / while 属于风格问题;
- 内层双指针:while 更自然、更安全,更易与去重逻辑结合。
6. Kotlin 版本参考实现
最终可以保留的一份比较干净的 Kotlin 解法(以 while 为主,易于理解和调试):
class ThreeSumSolver {
/**
* 三数之和:返回所有不重复的三元组,使得 a + b + c == 0
*/
fun threeSum(nums: IntArray?): List<List<Int>> {
val res = mutableListOf<List<Int>>()
if (nums == null || nums.size < 3) return res
val arr = nums.sortedArray()
val n = arr.size
var i = 0
while (i < n - 2) {
val first = arr[i]
// 剪枝:当前最小值已经 > 0,后面不可能再出现和为 0
if (first > 0) break
// 对 first 去重
if (i > 0 && first == arr[i - 1]) {
i++
continue
}
var left = i + 1
var right = n - 1
while (left < right) {
val sum = first + arr[left] + arr[right]
when {
sum == 0 -> {
res.add(listOf(first, arr[left], arr[right]))
// 去重 left
val leftVal = arr[left]
while (left < right && arr[left] == leftVal) {
left++
}
// 去重 right
val rightVal = arr[right]
while (left < right && arr[right] == rightVal) {
right--
}
}
sum < 0 -> {
left++
}
else -> {
right--
}
}
}
i++
}
return res
}
}
7. 这次的收获
- 理解了 3Sum 的核心套路:排序 + 固定一位 + 双指针;
- 知道了
O(n^2)是合理且主流的最优复杂度; - 真正容易出错的在于:
- 去重必须同时考虑
i / left / right; - while 条件没写严谨 → 容易死循环,比如错误的 k 去重;
- 去重必须同时考虑
- 明确了为什么会更偏好 while 写法:
- 它更贴合“指针怎么移动”的思考方式;
- 但也需要更小心地维护循环条件与指针推进,保证每一轮都有进展、不越界。
以后再遇到类似 kSum / 4Sum 的题,可以直接照这个模板扩展:固定 k-2 个数,其余用双指针。