--- tags: ["LeetCode", "双指针", "中等"] create time: 2026-05-13 16:00 --- # 06-三数之和 ## 题面 给你一个整数数组 `nums` ,判断是否存在三元组 `[nums[i], nums[j], nums[k]]` 满足 `i != j`、`i != k` 且 `j != k` ,同时还满足 `nums[i] + nums[j] + nums[k] == 0` 。请你返回所有和为 **0** 且不重复的三元组。 注意:答案中**不可以包含重复**的三元组。 **示例 1:** ``` 输入:nums = [-1,0,1,2,-1,-4] 输出:[[-1,-1,2],[-1,0,1]] 解释: nums[0] + nums[1] + nums[2] = (-1) + 0 + 1 = 0 。 nums[1] + nums[2] + nums[4] = 0 + 1 + (-1) = 0 。 nums[0] + nums[3] + nums[4] = (-1) + 2 + (-1) = 0 。 不同的三元组是 [-1,0,1] 和 [-1,-1,2] 。 注意,输出的顺序和三元组的顺序并不重要。 ``` **示例 2:** ``` 输入:nums = [0,1,1] 输出:[] 解释:唯一可能的三元组和不为 0 。 ``` **示例 3:** ``` 输入:nums = [0,0,0] 输出:[[0,0,0]] 解释:唯一可能的三元组和为 0 。 ``` **提示:** - `3 <= nums.length <= 3000` - `-10^5 <= nums[i] <= 10^5` --- ## 思路 > [!question] 💡 思考 > 如果题目是"两数之和等于 0",我们会怎么做?排序后用双指针在两端相向收缩即可。但现在是**三数之和**,多了一个未知数——我们该如何把三维问题降成二维? 关键突破口:如果我们**固定其中一个数**,剩下的问题就等价于"两数之和等于某个目标值",正好可以用双指针解决。 ### 整体框架 > [!info] 🎯 核心思想 > 1. **先排序** —— 去重的基础,也是双指针能工作的前提。 > 2. **枚举第一个数** `nums[i]`,然后对剩余部分用双指针找另外两个数,使三者之和为 0。 > 3. **跳过重复** —— 三层去重:外层跳过重复的 `i`,内层跳过重复的 `left` 和 `right`。 ```mermaid flowchart TD A["排序数组 nums"] --> B["for i 从 0 到 n-3"] B --> C{"nums[i] > 0?"} C -->|"是"| D["提前结束(最小值已正)"] C -->|"否"| E{"i > 0 且 nums[i] == nums[i-1]?"} E -->|"是"| F["continue(跳过重复 i)"] E -->|"否"| G["left = i+1, right = n-1"] G --> H{"left < right?"} H -->|"否"| B H -->|"是"| I["sum = nums[i]+nums[left]+nums[right]"] I --> J{"sum > 0?"} J -->|"是"| K["right--"] J -->|"否"| L{"sum < 0?"} L -->|"是"| M["left++"] L -->|"否"| N["记录三元组\n左指针右移去重\n右指针左移去重"] K --> H M --> H N --> H F --> B D --> O["返回结果"] ``` ### 去重策略详解 这是本题最核心的难点。假设数组已排序,以下三个位置都需要处理重复: > [!abstract] 🔬 为什么排序后重复元素会聚集? > > 排序让相同的数字排在一起,这样当我们遍历过一个值之后,下一个相同值一定紧跟其后。通过检查前一个位置,我们可以直接跳过整个重复段,而不需要额外空间。 **第一层:外层循环去重(固定点 i)** ``` if i > 0 and nums[i] == nums[i-1]: continue // 跳过重复的第一个数 ``` > [!question] ❓ 为什么 `i > 0` 才检查? > 因为 `i = 0` 时没有前驱元素,自然不需要去重。更重要的是——只有当 `i > 0` 时,`nums[i-1]` 已经作为固定点被完整枚举过一遍了,此时再用同样的值只会产生重复组合。 **第二层:左指针去重(第二个数 left)** ``` // 找到一组解后,left 右移并跳过所有重复值 left++ for left < right and nums[left] == nums[left-1]: left++ ``` **第三层:右指针去重(第三个数 right)** ``` // 找到一组解后,right 左移并跳过所有重复值 right-- for left < right and nums[right] == nums[right+1]: right-- ``` ### 剪枝优化 排序后的数组可以带来重要的剪枝机会: > [!tip] ✂️ 两条剪枝规则 > > **① 最小值剪枝**:如果 `nums[i] > 0`,那么后面的所有数也都大于 0,三个正数之和不可能为 0 → **直接 break**。 > > **② 最大值剪枝**:当前 `nums[i]` 与最大的两个数之和仍小于 0,说明 `nums[i]` 太小了,继续尝试也没有意义 → **continue**(注意是 continue,不是 break,因为后面更大的 `i` 可能可行)。 以 `nums = [-4, -1, -1, 0, 1, 2]` 为例,逐步展示去重过程: | i | nums[i] | left | right | sum | 动作 | |---|---------|------|-------|-----|------| | 0 | **-4** | 1 | 5 | -4 + (-1) + 2 = **-3** | `< 0`,left++ | | 0 | -4 | 2 | 5 | -4 + (-1) + 2 = **-3** | `< 0`,left++ | | 0 | -4 | 3 | 5 | -4 + 0 + 2 = **-2** | `< 0`,left++ | | 0 | -4 | 4 | 5 | -4 + 1 + 2 = **-1** | `< 0`,left++ | | 0 | -4 | 5 | 5 | — | `left >= right`,进入下一轮 i | | 1 | **-1** | 2 | 5 | -1 + (-1) + 2 = **0** ✅ | 记录 `[-1,-1,2]`,left 去重→3,right 去重→4 | | 1 | -1 | 3 | 4 | -1 + 0 + 1 = **0** ✅ | 记录 `[-1,0,1]`,left 去重→4,right 去重→3 | | 1 | -1 | 4 | 3 | — | `left >= right`,进入下一轮 i | | 2 | **-1** | — | — | `i > 0 && nums[2] == nums[1]` → **continue**(跳过重复!) | | 3 | **0** | 4 | 5 | 0 + 1 + 2 = **3** | `> 0`,right-- → 相遇,退出 | 最终结果:`[[-1,-1,2], [-1,0,1]]` **时间复杂度:O(n²)** —— 外层循环 O(n),内层双指针 O(n),总体 O(n²)。 **空间复杂度:O(log n)** —— 排序的空间开销(取决于语言实现,Go 使用快速排序的递归栈)。 --- ## 代码提示 ``` // 伪代码模板 sort(nums) result = [] for i from 0 to n-3: // 剪枝:最小值已经为正 if nums[i] > 0: break // 去重:跳过重复的 i if i > 0 and nums[i] == nums[i-1]: continue left = i + 1 right = n - 1 target = -nums[i] // 剩余两数之和应等于 -nums[i] for left < right: sum = nums[left] + nums[right] if sum == target: result.append([nums[i], nums[left], nums[right]]) // 去重 left left++ while left < right and nums[left] == nums[left-1]: left++ // 去重 right right-- while left < right and nums[right] == nums[right+1]: right-- else if sum < target: left++ // 和太小,左指针右移增大 else: right-- // 和太大,右指针左移减小 ``` > [!note] 🧠 目标值的转换 > `nums[i] + nums[left] + nums[right] == 0` 等价于 `nums[left] + nums[right] == -nums[i]`。把三数之和问题转化为"在子数组中找两数之和等于一个固定目标值",这就是典型的**问题降维**。 --- ## 技巧 > [!tip] 🔑 核心模式:排序 + 双指针(Sorted Two Pointers) > > 这是双指针中最经典的一类——**「一固定,双滑动」**:固定一个维度后,在剩余的有序区间上,左右指针根据大小关系各自向内收缩。这类模式的通用步骤: > 1. **排序**(前提条件,同时也是去重的基石) > 2. **枚举**第一个元素(可以是任意位置,常见的是从头到尾扫一遍) > 3. **双指针**在剩余区间查找 > 4. **去重**:每层循环/搜索中都处理相等相邻元素 > > 变体应用:四数之和(LeetCode 18)、最接近的三数之和(LeetCode 16)、三数最小的三元组之和(LeetCode 17)。 > [!info] 🐹 Go 中的排序 > Go 的标准库提供 `slices.Sort()` (Go 1.21+) 或 `sort.Ints()` (老版本)。排序后切片仍然是原切片的引用视图,不会创建副本。 > ```go > import "slices" > slices.Sort(nums) > ``` > [!warning] ⚠️ 常见错误 > > **① 去重放在错误的位置**:先去重再移动指针会导致漏掉当前解。正确顺序是先记录结果,再去重移动。 > > **② `i` 的范围错误**:`i` 最多走到 `n - 3`,因为还需要至少两个元素给 left 和 right。 > > **③ 忘记剪枝**:如果不做 `nums[i] > 0` 的提前结束优化,虽然不影响正确性,但在面试中是一个加分项。 > > **④ 内层去重用错比较对象**:left 去重时应该和 `nums[left-1]` 比(刚走过的),right 去重时应该和 `nums[right+1]` 比(刚跳过的)。 > [!summary] 📊 复杂度速查 > | 方面 | 复杂度 | 说明 | > |------|--------|------| > | 时间 | O(n²) | 排序 O(n log n) + 主逻辑 O(n²) | > | 空间 | O(log n) | 排序递归栈 | > | 去重层数 | 3 层 | i / left / right | > | 剪枝条件 | 2 条 | nums[i] > 0; nums[i] + nums[n-2] + nums[n-1] < 0 | --- ## 代码 ```go func threeSum(nums []int) [][]int { slices.Sort(nums) n := len(nums) var result [][]int 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 { result = append(result, []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-- } } else if sum < target { left++ // 和太小,需要更大的数 } else { right-- // 和太大,需要更小的数 } } } return result } ``` > [!success] ✅ 运行验证 > - **LeetCode 第 15 题**,通过率约 38%,中等难度中的高频面试题。 > - 核心难点在于**去重的正确处理**,建议先在草稿纸上模拟排序后的数组走向,理解每一层去重的时机和边界条件。 > - 这道题也可以看作是「两数之和」问题的升级版,掌握了双指针模板后,延伸到「k 数之和」也很自然——依次固定 k-2 个数,最后两层用双指针收尾。