三数之和(3Sum)复盘笔记

47 阅读5分钟

1. 问题与目标

问题描述

给定一个整数数组 nums,找出所有不重复的三元组 (a, b, c),使得:

a + b + c = 0

要求:

  • 返回所有满足条件的三元组列表;
  • 三元组内部顺序无关,但结果中不能出现重复三元组(如 [-1,0,1] 只能出现一次);
  • 元素可以重复(如多个 0),但组合不能重复。

核心目标

  • 时间复杂度达到主流最优级别 O(n^2)
  • 去重逻辑正确,不漏解、不重复;
  • 代码结构清晰,便于以后扩展(比如改成 4Sum、kSum)。

2. 标准解法:排序 + 固定一位 + 双指针

整体套路:

  1. 排序

    nums.sort()
    

    作用:

    • 为双指针创造有序环境;
    • 为去重提供便利(相同数字挤在一起)。
  2. 固定第一个数 nums[i]

    外层循环遍历索引 i,选定三元组的第一个数:

    first = nums[i]
    

    将问题转化为:在区间 (i, end] 里找两数,使得:

    nums[j] + nums[k] = -first
    
  3. 双指针找两数之和

    [i+1, n-1] 区间内设两个指针:

    left = i + 1
    right = n - 1
    target = -nums[i]
    

    然后:

    • 如果 nums[left] + nums[right] == target → 找到一个三元组;
    • 如果和小于 targetleft++(增大和);
    • 如果和大于 targetright--(减小和)。

复杂度

  • 排序:O(n log n)
  • 外层 i:最多 O(n)
  • 内层双指针:每次 O(n)leftright 各走一遍)

总复杂度:O(n^2),这是 3Sum 的主流最优级别,基本不存在可以进一步降到 O(n log n)O(n) 的通用算法。


3. 关键点一:去重策略

不做去重会导致大量重复三元组,例如:

[-1, 0, 1], [0, -1, 1], [1, 0, -1] ...

三个层面的去重:

  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 的情况只处理一次”。

  2. 对第二个数 nums[left] 去重

    找到一个合法三元组后,往右跳过所有与当前 left 相同的值:

    val leftVal = nums[left]
    while (left < right && nums[left] == leftVal) {
        left++
    }
    
  3. 对第三个数 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;
}

含义:

  1. 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 的三元组,因此可以提前结束。

  2. 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. 这次的收获

  1. 理解了 3Sum 的核心套路:排序 + 固定一位 + 双指针;
  2. 知道了 O(n^2) 是合理且主流的最优复杂度
  3. 真正容易出错的在于:
    • 去重必须同时考虑 i / left / right
    • while 条件没写严谨 → 容易死循环,比如错误的 k 去重;
  4. 明确了为什么会更偏好 while 写法:
    • 它更贴合“指针怎么移动”的思考方式;
    • 但也需要更小心地维护循环条件与指针推进,保证每一轮都有进展、不越界。

以后再遇到类似 kSum / 4Sum 的题,可以直接照这个模板扩展:固定 k-2 个数,其余用双指针。