数组求和问题(N-Sum)是算法面试中经久不衰的经典题型。从最基础的 Two Sum 到进阶的 kSum,其核心本质往往殊途同归。本文将以资深工程师的视角,系统梳理 2Sum、3Sum、3Sum Closest4Sum 的解题演进之路,探讨如何通过双指针剪枝策略将时间复杂度压榨至极限。

通用解题模式

在深入具体代码之前,我们要建立一个全局视角。这类问题通常可以归纳为以下三种模式:

  1. 确定性问题:如 Two Sum,寻找精确的目标解。
  2. 最优化问题:如 3Sum Closest,寻找与目标最接近的解。
  3. 组合问题:如 4Sum,寻找所有满足条件的不重复元组。

核心优化方向永远是围绕时间复杂度进行的:

  • 空间换时间:利用哈希表将 $O(n)$ 查找优化至 $O(1)$。
  • 排序 + 双指针:这是解决 N-Sum 问题的“银弹”。通过排序将无序数组转化为有序,从而利用双指针的单调性,将 $O(n^k)$ 的复杂度降低至 $O(n^{k-1})$。
  • 剪枝(Pruning):在搜索过程中提前剔除不可能的分支,这在 N > 2 时尤为重要。

1. Two Sum:万法之源

LeetCode 1 | 链接

这是所有人的算法起点,但它引出了两个最基本的流派。

核心思路

  • 暴力法:双层循环,最直观但效率最低,时间复杂度 $O(n^2)$。
  • 哈希表(最优解):利用 Map 记录遍历过的元素 {value: index},在遍历 nums[i] 时,直接查找 target - nums[i] 是否存在。

Go 最佳实践

工程代码中,我们直接采用哈希表法,这在这一层面上是时间最优的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func twoSum(nums []int, target int) []int {
    // 预分配 map 空间可微容易减少 rehash 开销,虽非必须但为好习惯
    seen := make(map[int]int, len(nums))
    for i, num := range nums {
        if j, exists := seen[target-num]; exists {
            return []int{j, i}
        }
        seen[num] = i
    }
    return nil
}

2. Three Sum:排序与双指针的艺术

LeetCode 15 | 链接

从两数之和到三数之和,题目难度陡增。主要难点在于去重。如果沿用哈希表法,去重逻辑会非常复杂且难以维护。此时,排序 + 双指针成为了标准解法。

核心思路

  1. 排序:首先对数组进行排序。这是使用双指针和去重的前提。
  2. 固定一位:遍历数组,固定枚举 nums[i]
  3. 双指针汇聚:在 i 后面的区间 [i+1, n-1] 中,设置左右指针 leftright,寻找 nums[left] + nums[right] == -nums[i]
  4. 极致剪枝
    • 提前退出:如果 nums[i] > 0,由于数组已排序,后续不可能再有数字之和等于 0。
    • 跳过重复:无论是固定的 i,还是移动的 leftright,遇到相同数值必须跳过,以避免产生重复结果。

Go 实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
func threeSum(nums []int) [][]int {
    sort.Ints(nums)
    n := len(nums)
    // 预估容量,避免频繁扩容
    result := make([][]int, 0, n/3)

    for i := 0; i < n-2; i++ {
        // 剪枝 1: 最小的数都大于 0,没戏了
        if nums[i] > 0 {
            break
        }
        // 剪枝 2: i 去重
        if i > 0 && nums[i] == nums[i-1] {
            continue
        }

        left, right := i+1, n-1
        for left < right {
            sum := nums[i] + nums[left] + nums[right]

            if sum == 0 {
                result = append(result, []int{nums[i], nums[left], nums[right]})
                // 剪枝 3: 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 < 0 {
                left++
            } else {
                right--
            }
        }
    }
    return result
}

3. Three Sum Closest:最优化搜索

LeetCode 16 | 链接

解题思路

这道题是 3Sum 的变体。区别在于我们需要维护一个全局变量来记录当前的最小差距(gap)。框架依然是 排序 + 双指针,只是循环内部的逻辑不再是寻找相等,而是不断更新“最接近”的值。

Go 实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
func threeSumClosest(nums []int, target int) int {
    sort.Ints(nums)
    n := len(nums)
    // 初始化一个足够大的差距或者是第一次的结果
    closest := nums[0] + nums[1] + nums[2]

    for i := 0; i < n-2; i++ {
        if i > 0 && nums[i] == nums[i-1] {
            continue
        }

        left, right := i+1, n-1
        for left < right {
            sum := nums[i] + nums[left] + nums[right]

            // 发现更接近的值,更新快照
            if abs(sum-target) < abs(closest-target) {
                closest = sum
            }

            if sum == target {
                return sum
            } else if sum < target {
                left++
            } else {
                right--
            }
        }
    }
    return closest
}

func abs(x int) int {
    if x < 0 { return -x }
    return x
}

4. Four Sum:递归与泛化

LeetCode 18 | 链接

解题思路

四数之和虽然看起来更可怕,但本质上只是在 3Sum 外面再套了一层循环。它的时间复杂度是 $O(n^3)$。

降维思想

  • 2Sum: $O(n)$ (Hash) 或 $O(n \log n)$ (Sort)
  • 3Sum: 固定 1 个数 -> 2Sum ($O(n^2)$)
  • 4Sum: 固定 1 个数 -> 3Sum -> 2Sum ($O(n^3)$)
  • kSum: 递归降维至 2Sum。

Go 实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func fourSum(nums []int, target int) [][]int {
    sort.Ints(nums)
    n := len(nums)
    result := make([][]int, 0)

    for i := 0; i < n-3; i++ {
        // 剪枝 1: 最小值剪枝
        if i > 0 && nums[i] == nums[i-1] { continue }
        // 剪枝 2: 数值范围剪枝(进阶优化,可选)
        // if nums[i] + nums[i+1] + nums[i+2] + nums[i+3] > target { break }
        // if nums[i] + nums[n-3] + nums[n-2] + nums[n-1] < target { 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
            for left < right {
                sum := nums[i] + nums[j] + nums[left] + nums[right]
                if sum == target {
                    result = append(result, []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 < target {
                    left++
                } else {
                    right--
                }
            }
        }
    }
    return result
}

总结与思考

问题最佳解法时间复杂度核心考点
2Sum哈希表$O(n)$空间换时间
3Sum排序 + 双指针$O(n^2)$去重、双指针单调性
4Sum排序 + 双层循环 + 双指针$O(n^3)$降维思想、剪枝

工程启示: 在处理海量数据的匹配问题时,排序往往是化繁为简的第一步。虽然它带来了 $O(n \log n)$ 的开销,但带来的有序性使得后续的查找和去重效率呈指数级优化。此外,剪枝思想在任何搜索类算法中都是提升性能的关键——永远不要计算那些注定无效的结果。