三数之和算法学习笔记
一、问题介绍与背景知识
1.1 3SUM问题定义
3SUM问题是计算机科学中的经典算法问题,要求判断给定实数集合是否存在三个元素之和为零。更一般化形式为rSUM问题,即寻找r个元素之和为特定值的组合 。在算法面试和实际应用中,通常需要找出所有满足条件的三元组,并且结果不能重复。
1.2 问题意义
3SUM问题在算法领域具有重要意义,主要体现在:
理论价值:作为计算复杂度理论中的经典问题,3SUM曾被猜想无法突破O(n²)的时间复杂度下界。直到2014年,Allan Grønlund和Seth Pettie才提出了时间复杂度为O(n²/(logn/log logn))的确定性算法,打破了此前关于Ω(n²)时间复杂度的猜想 。
实践应用:该问题在数据挖掘、密码学、网络分析等领域有广泛应用。例如,在社交网络分析中,可以寻找三个用户之间是否存在某种平衡关系;在密码学中,用于检测某些特定的数学模式。
面试热点:3SUM问题是算法面试中的高频题目,考察对双指针技巧、排序算法和优化策略的理解与应用能力。
二、暴力解法分析
2.1 原理分析
暴力解法是最直观的解决方法,其核心思想是遍历数组中所有可能的三元组组合,计算它们的和是否为0。具体实现是通过三重循环:
for (let i = 0; i < n; i++) {
for (let j = i+1; j < n; j++) {
for (let k = j+1; k < n; k++) {
if (nums[i] + nums[j] + nums[k] === 0) {
// 记录结果
}
}
}
}
2.2 时间复杂度
暴力解法的时间复杂度为O(n³),其中n是数组的长度。这是因为我们需要遍历所有可能的三元组组合,共有C(n,3) = n(n-1)(n-2)/6种组合 。
2.3 空间复杂度
暴力解法的空间复杂度为O(1),不考虑存储结果的空间。如果需要存储所有满足条件的三元组,空间复杂度可能达到O(n²)。
2.4 缺陷分析
虽然暴力解法简单易懂,但存在明显缺陷:
- 时间效率低下,当n较大时(如n=1000),计算量达到10亿级别
- 无法有效去重,容易产生重复的三元组
- 缺乏剪枝策略,无法提前终止无效的计算
这些缺陷使得暴力解法在实际应用和面试中难以被接受,因此需要寻找更高效的优化方法。
三、排序+双指针优化方法详解
3.1 优化思路
针对3SUM问题,最有效的优化方法是先排序后使用双指针技术。这种优化思路基于以下观察:
- 数组排序后,元素具有顺序性,便于后续操作
- 通过固定一个元素,将问题转化为两数之和问题
- 双指针技术可以在O(n)时间内找到两数之和,整体复杂度降至O(n²)
这种方法巧妙地将三重循环优化为双重循环,大大提高了算法效率。
3.2 算法步骤
排序+双指针优化方法的具体步骤如下:
步骤1:排序数组 对输入数组进行升序排序,便于后续的双指针操作和去重处理。
步骤2:固定第一个元素
使用外层循环固定第一个元素nums[i],从数组开头开始遍历。
步骤3:双指针查找剩余两个元素
在i的右侧使用两个指针left(初始化为i+1)和right(初始化为数组末尾),通过移动指针寻找满足nums[i] + nums[left] + nums[right] = 0的组合。
步骤4:去重处理
在固定i和移动left、right的过程中,跳过重复的元素,确保结果不重复。
3.3 双指针移动逻辑
双指针技术的核心在于根据当前三数之和的值来决定指针的移动方向:
- sum > 0:右指针左移,减小总和
- sum < 0:左指针右移,增大总和
- sum === 0:记录结果,并同时移动左右指针,寻找其他可能解
这种移动方式确保了每个元素只被访问一次,不会遗漏任何可能的解。
四、JavaScript代码实现分析
4.1 完整代码
function threeSum(nums) {
// 先排序
nums.sort((a, b) => a - b);
const res = [];
// 固定一个数字
for (let i = 0; i < nums.length - 2; i++) {
// 跳过重复的数字
if (i > 0 && nums[i] === nums[i - 1]) continue;
// 剪枝优化
if (nums[i] > 0) break;
// 双指针
let left = i + 1;
let right = nums.length - 1;
while (left < right) {
const sum = nums[i] + nums[left] + nums[right];
if (sum === 0) {
res.push([nums[i], nums[left], nums[right]]);
// 继续找其他解
left++;
right--;
// 跳过重复的数字
while (left < right && nums[left] === nums[left - 1]) left++;
while (left < right && nums[right] === nums[right + 1]) right--;
} else if (sum < 0) {
left++; // 需要更大的数
} else {
right++; // 需要更小的数
}
}
}
return res;
}
// 示例测试
const nums = [-1, 0, 1, 2, -1, -4];
console.log(threeSum(nums)); // 输出: [[-1, -1, 2], [-1, 0, 1]]
4.2 代码结构分析
这段JavaScript代码实现了排序+双指针优化方法,结构清晰,包含以下关键部分:
- 排序处理:使用
nums.sort((a, b) => a - b)对数组进行升序排序 - 结果存储:定义
res数组存储所有满足条件的三元组 - 外层循环:固定第一个元素
nums[i],遍历范围为0到nums.length - 3 - 内层双指针循环:在固定
i后,使用left和right指针寻找其他两个元素 - 去重与剪枝处理:在循环中加入去重和剪枝逻辑,提高算法效率
4.3 代码逐行解释
第1-2行:
function threeSum(nums) {
nums.sort((a, b) => a - b);
- 定义
threeSum函数,接收数组nums作为参数 - 使用JavaScript内置的排序方法对数组进行升序排序,时间复杂度为O(n log n)
第3行:
const res = [];
- 定义结果数组
res,用于存储所有满足条件的三元组
第5-7行:
for (let i = 0; i < nums.length - 2; i++) {
if (i > 0 && nums[i] === nums[i - 1]) continue;
- 外层循环固定第一个元素
nums[i],遍历范围为0到nums.length - 3 - 跳过重复的
nums[i]:如果当前元素与前一个元素相同,则直接跳过当前循环,避免重复解
第8行:
if (nums[i] > 0) break;
- 剪枝优化:如果当前元素
nums[i]大于0,且数组已排序,则后续元素都大于等于当前元素,三数之和必然大于0,无需继续遍历
第10-12行:
let left = i + 1;
let right = nums.length - 1;
- 初始化双指针:
left指针从i+1开始,right指针从数组末尾开始
第14-22行:
while (left < right) {
const sum = nums[i] + nums[left] + nums[right];
if (sum === 0) {
res.push([nums[i], nums[left], nums[right]]);
left++;
right--;
while (left < right && nums[left] === nums[left - 1]) left++;
while (left < right && nums[right] === nums[right + 1]) right--;
} else if (sum < 0) {
left++;
} else {
right--;
}
}
}
return res;
}
- 双指针循环:
left和right指针向中间移动,直到相遇 - 求和判断:计算当前三数之和
sum - 等于0的情况:
- 将三元组推入结果数组
- 同时移动左右指针,寻找其他可能解
- 跳过重复的
nums[left]和nums[right],避免重复解
- 小于0的情况:左指针右移,增大总和
- 大于0的情况:右指针左移,减小总和
五、去重与剪枝优化技巧
5.1 去重技巧
在3SUM问题中,去重是确保结果唯一性的关键。代码中实现了两种去重方式:
外层循环去重:
if (i > 0 && nums[i] === nums[i - 1]) continue;
- 当固定第一个元素
nums[i]时,如果当前元素与前一个元素相同,则直接跳过当前循环 - 这样可以避免相同的第一个元素导致重复的三元组
内层循环去重:
while (left < right && nums[left] === nums[left - 1]) left++;
while (left < right && nums[right] === nums[right + 1]) right--;
- 当找到一个满足条件的三元组后,移动左右指针时需要跳过重复的元素
- 左指针跳过与前一个元素相同的值
- 右指针跳过与后一个元素相同的值
示例说明:以输入[-1, 0, 1, 2, -1, -4]为例,排序后为[-4, -1, -1, 0, 1, 2]。当固定第一个元素为-1时,如果直接继续遍历,可能会得到重复的解[-1, -1, 2]。通过外层循环的去重逻辑,可以跳过第二个-1,避免重复解。
5.2 剪枝技巧
剪枝是提前终止无效计算的有效手段,可以显著提高算法效率:
外层循环剪枝:
if (nums[i] > 0) break;
- 如果当前元素
nums[i]大于0,且数组已排序,则后续元素都大于等于当前元素 - 三数之和必然大于0,无需继续遍历,直接终止外层循环
双指针移动剪枝:
// sum < 0 时,左指针右移
// sum > 0 时,右指针左移
- 根据当前三数之和的值,决定指针的移动方向
- 当
sum < 0时,需要更大的数,左指针右移 - 当
sum > 0时,需要更小的数,右指针左移 - 这种移动方式可以避免遍历所有可能的组合,大大减少计算量
示例说明:以输入[-4, -1, -1, 0, 1, 2]为例,当固定i=0(nums[i]=-4)时,双指针left=1(nums[left]=-1)和right=5(nums[right]=2)的初始和为-4 + (-1) + 2 = -3,小于0,因此左指针右移。当left=2(nums[left]=-1)时,和为-4 + (-1) + 2 = -3,仍然小于0,左指针继续右移。直到找到合适的组合为止。
六、算法时间复杂度分析
6.1 整体时间复杂度
排序+双指针优化方法的时间复杂度为O(n²),其中n是数组的长度。具体分析如下:
- 排序阶段:时间复杂度为O(n log n),使用快速排序算法
- 外层循环:时间复杂度为O(n),遍历n-2个元素
- 内层双指针循环:时间复杂度为O(n),每个元素最多被访问一次
- 总时间复杂度:O(n log n) + O(n) × O(n) = O(n² + n log n) = O(n²)
相比暴力解法的O(n³),这种优化方法大大提高了算法效率。
6.2 空间复杂度
排序+双指针优化方法的空间复杂度为O(1),不考虑存储结果的空间。如果需要存储所有满足条件的三元组,空间复杂度可能达到O(n²)。
七、算法变体与扩展
7.1 总和非零的情况
当问题要求三数之和为任意常数C时,可以通过以下方法解决:
- 将数组中每个元素减去C/3
- 对新数组使用排序+双指针法求解三数之和为0的组合
- 将找到的解加上C/3,得到原问题的解
这种方法可以将总和非零的情况转化为总和为零的情况,便于使用相同的算法解决。
7.2 三个不同数组的情况
当问题要求从三个不同的数组X、Y、Z中分别取一个元素,使得它们的和为零时,可以通过以下步骤解决:
- 对X、Y、Z三个数组进行排序
- 遍历X数组中的每个元素x
- 对于每个x,使用双指针法在Y和Z数组中寻找满足y + z = -x的元素对
这种方法的时间复杂度为O(n²),其中n是三个数组中最大的长度。
八、算法应用场景与总结
8.1 应用场景
3SUM算法在以下场景中有广泛应用:
- 数据挖掘:在大规模数据集中寻找具有特定关系的三元组
- 密码学:检测某些密码学问题中的数学模式
- 网络分析:在社交网络或通信网络中寻找平衡关系
- 算法面试:考察对双指针技巧和排序算法的理解与应用能力
8.2 总结
排序+双指针法是解决3SUM问题的高效方法,具有以下特点:
- 时间效率:时间复杂度为O(n²),相比暴力解法的O(n³)有显著提升
- 空间效率:空间复杂度为O(1),不额外占用内存
- 去重能力:通过外层循环和内层双指针的去重逻辑,确保结果唯一性
- 剪枝优化:通过提前终止无效的计算,进一步提高算法效率
学习要点:
- 掌握排序算法的基本原理和实现
- 理解双指针技术的工作原理和应用场景
- 掌握去重和剪枝优化的具体实现方法
- 学会将复杂问题分解为更简单的问题(如将3SUM转化为2SUM)