【学习记录】三数之和:当双指针遇见"去重剪枝"------从有序数组到多路搜索的思维跃迁
在上一讲中,我们用双指针解决了"两数之和 II(有序数组)",并探索了它与大模型解码策略的深层共鸣。今天,我们把难度升级到 三数之和(LeetCode 15) ------这道题不仅是双指针的经典应用,更是检验你是否真正理解"剪枝"与"去重"的试金石。我们将从暴力解法出发,逐步优化到排序 + 双指针,并再次跨越到大模型的 树搜索(Tree Search) 和 两阶段检索。读完这篇,你会对"如何用有序性剪枝"产生肌肉记忆。
📌 目录
- 题目描述
- 从暴力到优雅:思路演进
- [核心解法:排序 + 双指针](#核心解法:排序 + 双指针)
- 代码实现(Python)
- 图解示例
- 复杂度分析
- 关键细节:去重与剪枝
- 知识图谱扩展:三数之和与树搜索
- 三句话带走
- 留给你的思考题
一、题目描述
给你一个整数数组 nums,判断是否存在三元组 [nums[i], nums[j], nums[k]] 满足 i != j != k,且 nums[i] + nums[j] + nums[k] == 0。请你返回所有和为 0 且不重复的三元组。
注意:答案中不可以包含重复的三元组。
示例:
输入:nums = [-1, 0, 1, 2, -1, -4]
输出:[[-1, -1, 2], [-1, 0, 1]]
二、从暴力到优雅:思路演进
暴力法(O(n³))
三重循环枚举所有三元组,然后去重。这是最直观的做法,但面对 n = 3000 时直接超时,而且去重逻辑繁琐。
哈希表优化(O(n²))
固定第一个数,然后用哈希表找另外两个数。时间复杂度 O(n²),空间 O(n)。但去重依然麻烦。
排序 + 双指针(O(n²),空间 O(log n) 到 O(1))
- 先排序,将数组变为有序,这为双指针创造了前提。
- 固定一个数
nums[i],然后在i+1到n-1的区间内,用双指针找两数之和等于-nums[i]。 - 由于数组有序,双指针可以在 O(n) 内完成一次查找,总复杂度 O(n²)。
排序的额外好处:重复元素必然相邻,去重变得极其简单------只需跳过相邻的相同元素。
三、核心解法:排序 + 双指针
- 排序 :
nums.sort() - 外层循环 :遍历
i从 0 到 n-3- 如果
nums[i] > 0,直接break(因为排序后后面所有数都 ≥ 0,三数之和不可能为 0) - 如果
i > 0且nums[i] == nums[i-1],跳过(避免重复三元组)
- 如果
- 内层双指针 :
- 左指针
L = i + 1,右指针R = n - 1 - 计算
s = nums[i] + nums[L] + nums[R] - 若
s == 0:记录结果,然后移动L和R并跳过重复值 - 若
s < 0:说明太小,L++ - 若
s > 0:说明太大,R--
- 左指针
四、代码实现(Python)
python
def threeSum(nums):
nums.sort()
n = len(nums)
res = []
for i in range(n - 2):
# 剪枝:最小的数都大于0,后面不可能有解
if nums[i] > 0:
break
# 去重:跳过重复的第一个数
if i > 0 and nums[i] == nums[i-1]:
continue
L, R = i + 1, n - 1
target = -nums[i]
while L < R:
s = nums[L] + nums[R]
if s == target:
res.append([nums[i], nums[L], nums[R]])
# 跳过左指针的重复值
while L < R and nums[L] == nums[L+1]:
L += 1
# 跳过右指针的重复值
while L < R and nums[R] == nums[R-1]:
R -= 1
L += 1
R -= 1
elif s < target:
L += 1
else:
R -= 1
return res
五、图解示例
以 nums = [-1, 0, 1, 2, -1, -4] 为例:
- 排序后 :
[-4, -1, -1, 0, 1, 2] - 外层循环:
| i | numsi | 跳过重复? | 双指针区间 | 过程 |
|---|---|---|---|---|
| 0 | -4 | 否 | L=1(-1), R=5(2) | -1+2=1 ≠ 4,s<target → L++ |
| L=2(-1), R=5(2) | -1+2=1 ≠ 4 → L++ | |||
| L=3(0), R=5(2) | 0+2=2 ≠ 4 → L++ | |||
| L=4(1), R=5(2) | 1+2=3 ≠ 4 → L++,结束 | |||
| 1 | -1 | 否 | L=2(-1), R=5(2) | -1+2=1 == 1 → 记录 -1,-1,2;去重后 L=3, R=4 |
| L=3(0), R=4(1) | 0+1=1 == 1 → 记录 -1,0,1;L++=4, R--=3 结束 | |||
| 2 | -1 | 是(与前一个相同) | 跳过 | |
| 3 | 0 | 否 | L=4(1), R=5(2) | 1+2=3 > target(0) → R--=4,结束 |
| 4 | 1 | nums[i] > 0,break |
最终结果:[[-1, -1, 2], [-1, 0, 1]]。
六、复杂度分析
- 时间复杂度:O(n²),外层循环 O(n),内层双指针 O(n),总 O(n²)。排序 O(n log n) 在 n 较大时可忽略。
- 空间复杂度 :
- 排序依赖:O(log n)(Python 的 Timsort 额外空间 O(n) 但在分析中通常记为 O(n) 或 O(log n))
- 结果数组不计入额外空间,所以我们认为额外空间 O(1)(除了排序的栈空间)。
七、关键细节:去重与剪枝
7.1 外层去重
python
if i > 0 and nums[i] == nums[i-1]:
continue
如果不跳过,会出现重复的三元组(例如两个 -1 各作为第一个数,会导致重复结果)。
7.2 内层去重
python
while L < R and nums[L] == nums[L+1]: L += 1
while L < R and nums[R] == nums[R-1]: R -= 1
找到一组解后,移动指针时跳过重复值,避免同一解被多次记录。
7.3 剪枝
python
if nums[i] > 0:
break
因为数组有序,最小的数都大于 0,则三数之和必然大于 0,无需继续。
八、知识图谱扩展:三数之和与树搜索
在上一讲,我们把双指针与 Beam Search 连接起来。今天,我们把三数之和的"外层固定 + 内层双指针"结构与大模型的 树搜索(Tree Search) 进行类比。
| 三数之和 | 大模型解码中的树搜索 |
|---|---|
外层循环固定第一个数 nums[i] |
确定生成文本的第一个 token(主题/风格) |
| 内层双指针在剩余区间找两数和 | 在剩余词汇空间中搜索后续 token 的组合 |
| 去重剪枝 | Beam Search 的剪枝:只保留概率最高的 K 条路径,砍掉低概率分支 |
| 排序后双指针 | Top‑K 采样:先对候选词按概率排序,再取前 K 个 |
底层共鸣:
- 三数之和的"固定一个点,再在剩余区间用双指针"本质上是一种两阶段搜索。
- 大模型生成文本时,每一步也面临多分支选择。如果穷举所有组合,复杂度指数增长(O(V^T))。因此,必须依靠剪枝策略(如 Beam Search)------它就像三数之和里的"去重"和"break",在保证一定质量的前提下大幅缩小搜索空间。
- 当 Beam Width = 1 时,就是贪心解码(每步只选概率最高的词),这类似于三数之和中直接返回第一个解(不要求全部解)。
思想迁移 :无论是算法题还是大模型,排序/概率排序 + 剪枝 都是将指数级搜索降为多项式时间的通用手段。理解了三数之和的去重逻辑,你就理解了 Beam Search 为什么要在每一步砍掉低概率路径------因为那些路径几乎不可能成为最优,就像排序后 nums[i] > 0 时可以直接 break 一样。
九、三句话带走
- 直觉:三数之和 = 固定一个数 + 两数之和(双指针)。先给数组排个序,让重复元素站在一起,让双指针有了"单调性"的靠山。
- 机制 :外层循环跳过重复的第一个数,内层双指针找到和等于
-nums[i]后,再跳过左右指针的重复值,确保三元组唯一。 - 本质:这是"基于排序的确定性剪枝 + 去重",与大模型的 Beam Search 异曲同工------排序后按阈值裁剪,保留精英,抛弃平庸。
十、留给你的思考题
如果数组中有大量重复元素,且要求返回所有不重复的组合,我们的去重逻辑已经处理。但如果题目改为"返回所有组合,允许重复元素"(即每个元素可重复使用),你会如何修改代码?(提示:LeetCode 39 组合总和)
连接到大模型 :允许重复使用元素,相当于大模型生成时允许重复 token。这种"可重复组合"的搜索,在解码策略中对应什么?------它对应 多样本生成 (如多次采样)或 温度调节(提高温度使概率分布更平缓,增加重复概率)。你能否从"剪枝"和"去重"的角度,解释温度参数如何影响大模型的多样性?
🎯 拆解完毕
今天,我们从三数之和出发,不仅学会了排序 + 双指针 + 去重的经典套路,更跨越到了大模型的树搜索剪枝策略。当你能够把一道算法题和一种工程思想无缝连接时,你就真正掌握了"举一反三"的能力。带着这个思维,去刷 LeetCode 第 18 题(四数之和),你会发现它只是三数之和的外层再套一层循环。😊
LeetCode 39. 组合总和
题目描述
给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target,找出 candidates 中所有可以使数字和为目标数 target 的 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。
candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。
示例 1:
输入:candidates = [2,3,6,7], target = 7
输出:[[2,2,3],[7]]
解释:
2 和 3 可以形成一组组合,2+2+3=7。注意 2 可以使用多次。
7 也是一个组合。
示例 2:
输入:candidates = [2,3,5], target = 8
输出:[[2,2,2,2],[2,3,3],[3,5]]
提示:
1 <= candidates.length <= 301 <= candidates[i] <= 200candidate中的每个元素都是独一无二的。1 <= target <= 500
Python 代码(回溯 + 剪枝)
python
from typing import List
class Solution:
def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
# 结果集
res = []
# 排序:方便后续剪枝(当当前数字大于剩余目标时,后面的更大,直接跳出循环)
candidates.sort()
def backtrack(start: int, path: List[int], remain: int):
"""
回溯搜索所有组合
:param start: 当前可以选择的起始索引(允许重复使用当前元素,所以下一层从 i 开始)
:param path: 当前已选择的数字列表
:param remain: 剩余目标值
"""
if remain == 0:
# 找到一组有效组合,加入结果(注意拷贝,避免后续修改)
res.append(path[:])
return
for i in range(start, len(candidates)):
# 如果当前数字已经大于剩余目标,后面更大的数字更不可能,直接剪枝退出循环
if candidates[i] > remain:
break
# 选择当前数字
path.append(candidates[i])
# 递归搜索,因为可以重复使用,所以下一层仍从 i 开始(不是 i+1)
backtrack(i, path, remain - candidates[i])
# 撤销选择,回溯
path.pop()
backtrack(0, [], target)
return res
算法复杂度分析
- 时间复杂度 : O ( N T / M ) O(N^{T/M}) O(NT/M),其中 N N N 是
candidates长度, T T T 是目标值, M M M 是最小候选数。实际上,这是一个指数级复杂度,但剪枝能大幅优化。 - 空间复杂度 : O ( T / M ) O(T/M) O(T/M),递归栈的最大深度取决于目标值和最小数字的比值。
关键点说明
- 排序 + 剪枝 :排序后,当
candidates[i] > remain时,因为后续元素都更大,不可能有解,直接break跳出循环,减少不必要的递归。 - 允许重复使用 :递归时
backtrack(i, path, remain - candidates[i]),起始索引仍为i而不是i+1,保证同一个元素可以被多次选择。 - 路径拷贝 :
res.append(path[:])确保结果中保存的是当前组合的快照,不会被后续回溯修改。
LeetCode 18. 四数之和
题目描述
给你一个由 n 个整数组成的数组 nums ,和一个目标值 target 。请你找出并返回满足下述全部条件且 不重复 的四元组 nums\[a, numsb, numsc, numsd](若两个四元组元素一一对应,则认为两个四元组重复):
- 0 <= a, b, c, d < n
- a、b、c 和 d 互不相同
- numsa + numsb + numsc + numsd == target
你可以按 任意顺序 返回答案。
示例 1:
输入:nums = 1,0,-1,0,-2,2, target = 0
输出:\[-2,-1,1,2,-2,0,0,2,-1,0,0,1]
示例 2:
输入:nums = 2,2,2,2,2, target = 8
输出:\[2,2,2,2]
提示:
- 1 <= nums.length <= 200
- -10^9 <= numsi <= 10^9
- -10^9 <= target <= 10^9
Python 代码(排序 + 双指针)
from typing import List
class Solution:
def fourSum(self, nums: Listint, target: int) -> ListList\[int]:
n = len(nums)
if n < 4:
return \[\]
nums.sort()
res = []
# 固定第一个数
for i in range(n - 3):
# 去重:跳过重复的 nums[i]
if i > 0 and nums[i] == nums[i - 1]:
continue
# 固定第二个数
for j in range(i + 1, n - 2):
# 去重:跳过重复的 nums[j]
if j > i + 1 and nums[j] == nums[j - 1]:
continue
# 双指针查找后两个数
left, right = j + 1, n - 1
while left < right:
total = nums[i] + nums[j] + nums[left] + nums[right]
if total == target:
res.append([nums[i], nums[j], nums[left], nums[right]])
# 跳过重复的 left 和 right
while left < right and nums[left] == nums[left + 1]:
left += 1
while left < right and nums[right] == nums[right - 1]:
right -= 1
left += 1
right -= 1
elif total < target:
left += 1
else:
right -= 1
return res
剪枝优化(可选,提升效率)
from typing import List
class Solution:
def fourSum(self, nums: Listint, target: int) -> ListList\[int]:
n = len(nums)
if n < 4:
return \[\]
nums.sort()
res = []
for i in range(n - 3):
# 去重
if i > 0 and nums[i] == nums[i - 1]:
continue
# 剪枝:最小四数和 > target,后面的更大,直接退出
if nums[i] + nums[i + 1] + nums[i + 2] + nums[i + 3] > target:
break
# 剪枝:最大四数和 < target,当前 i 太小,跳过
if nums[i] + nums[n - 3] + nums[n - 2] + nums[n - 1] < target:
continue
for j in range(i + 1, n - 2):
# 去重
if j > i + 1 and nums[j] == nums[j - 1]:
continue
# 剪枝
if nums[i] + nums[j] + nums[j + 1] + nums[j + 2] > target:
break
if nums[i] + nums[j] + nums[n - 2] + nums[n - 1] < target:
continue
left, right = j + 1, n - 1
while left < right:
total = nums[i] + nums[j] + nums[left] + nums[right]
if total == target:
res.append([nums[i], nums[j], nums[left], nums[right]])
while left < right and nums[left] == nums[left + 1]:
left += 1
while left < right and nums[right] == nums[right - 1]:
right -= 1
left += 1
right -= 1
elif total < target:
left += 1
else:
right -= 1
return res
复杂度分析
- 时间复杂度: O ( n 3 ) O(n^3) O(n3),其中 n n n 是数组长度。两层循环加双指针,总复杂度为 O ( n 3 ) O(n^3) O(n3)。
- 空间复杂度: O ( 1 ) O(1) O(1),不考虑返回结果所占用的空间。排序的栈空间为 O ( log n ) O(\log n) O(logn)。
关键点说明
1.排序是前提
:排序后可以使用双指针,并且方便去重。
2.去重逻辑
:
- 固定 i 时,如果 numsi == numsi-1 则跳过,避免重复组合。
- 固定 j 时同理,j > i + 1 且 numsj == numsj-1 则跳过。
- 找到一组解后,移动 left 和 right 时需要跳过重复值。
3.剪枝优化
(可选): - 最小和剪枝:如果当前最小的四个数之和已经大于 target,后面的组合只会更大,直接 break。
- 最大和剪枝:如果当前最大的四个数之和还小于 target,说明当前 i 或 j 太小,可以 continue 跳过。
4.与三数之和的关系
:四数之和可以看作是三数之和的扩展,外层多固定一个数,内层用双指针。掌握了三数之和,四数之和只需要多一层循环。
示例执行过程(简化版)
输入:nums = 1,0,-1,0,-2,2, target = 0
1.排序后:
-2, -1, 0, 0, 1, 2
2.i = 0, numsi = -2
-
j = 1, numsj = -1,双指针找 0, 0, 1, 2 中的两数和 = 3(因为 -2 + -1 + 两数和 = 0 → 两数和 = 3)
-
找到 0 + 2 = 2(小于3),1 + 2 = 3(等于3),得到组合 -2, -1, 1, 2
-
找到 0 + 0 = 0(小于3),跳过
3.继续遍历,最终得到
\[-2, -1, 1, 2\], \[-2, 0, 0, 2\], \[-1, 0, 0, 1\]