T15 三数之和
题目链接:leetcode-cn.com/problems/3s…
基本思路
为了便于入手,我们可以先不考虑题目中"答案中不可以包含重复的三元组"的要求,先只考虑如何在一个乱序的数组中找出3个和为0的数。
首先,既然我们已经知道传入的数组nums
有可能是乱序的,那么为了方便处理,我们不妨先对TA进行升序排序。这里我们使用Array.prototype.sort
即可。
JavaScript中原生的数组排序方法的原理为先将数组元素转换为「字符串」,再按照字符串的「字典序」进行排序。因为本题中出现了负数,所以不能直接调用原生方法,必须传入判别函数,即
nums.sort((a, b) => a - b)
.
在处理有序数组的问题中,我们常用的方法便是「双指针」,而本题中需要我们寻找3个数,因此我们不妨设定3个指针进行解答。
基本思路如下:
- 设指针
i
、j
、k
分别代表三个需要寻找的数,设数组nums
最后一个元素下标为n
;- 使用指针
i
对nums
进行遍历以确定第一个数. 同时让指针j
、k
分别从i + 1
和n
开始向中间移动,以扫描整个数组,避免漏解.- 记
sum = nums[i] + nums[j] + nums[k]
.
- 若
sum = 0
,则成功找到一组答案. 并且让i
和j
继续向中间移动,以尝试寻找其他答案;- 若
sum < 0
,则说明三数之和偏小,保持j
不动,向右移动i
;- 若
sum > 0
,则说明三数之和偏大,保持i
不动,向左移动j
;
代码如下:
function threeSum(nums) {
const ans = [];
const n = nums.length - 1;
nums.sort((a, b) => a - b);
for(let i = 0; i <= n - 2; i++) {
let j = i + 1;
let k = n;
let x = nums[i];
while(j < k) {
let [y, z] = [nums[j], nums[k]];
let sum = x + y + z;
if (sum === 0) {
ans.push([x, y, z]);
j++;
k--;
} else if (sum < 0) {
j++;
} else if (sum > 0) {
k--;
}
}
}
return ans;
}
处理重复
下面我们来实现答案中不可以包含重复的三元组的要求,这里提供两种方法。
朴素方法
由于我们事先已经对nums
数组进行升序排序,因而不难推知我们获取到每组三元组一定是按照nums[i] < nums[j] < nums[k]
的固定顺序排列的。
所以一种很容易想到的朴素方法便是,我们可以直接按顺序记录每一组为正确答案的三元组。当下一次出现sums = 0
的情况时,检查新求得的有序三元组是否与我们已记录的有序三元组重复即可。
代码如下:
function threeSum(nums) {
const ans = [];
const n = nums.length - 1;
const sets = new Set();
nums.sort((a, b) => a - b);
for(let i = 0; i <= n - 2; i++) {
let j = i + 1;
let k = n;
let x = nums[i];
while(j < k) {
let [y, z] = [nums[j], nums[k]];
let sum = x + y + z;
if (sum === 0) {
!sets.has('' + x + y + z) && (ans.push([x, y, z]), sets.add('' + x + y + z));
j++;
k--;
} else if (sum < 0) {
j++;
} else if (sum > 0) {
k--;
}
}
}
return ans;
}
这种方法虽然很简单,但其缺点也很明显。
首先,我们不得不创建一个额外的Set
对象(具体实现中也有可能是其他数据结构)供我们记录已经出现过的三元组,造成了额外的空间开销。
其次我们必须在每一次sums = 0
的时候才能对三元组进行记录和校验,难以在重复解出现的第一时间进行发现和纠正,造成了时间上的浪费。
有没有更加理想的方法?
调节指针
想要找到一个更加理想的方法,我们需要对这个问题的本质有更加深刻的理解:为什么会出现「重复解」?
为了更方便地理解这个问题,我们这里举一个具体的例子:nums = [-1, -1, 0, 1]
(假定数组已完成排序)。
当i = 0
、j = 2
、k = 3
时,出现了sum = 0
,我们成功求出了第一个三元组[-1, 0, 1]
。
尔后i = 1
,另外两个指针的初值分别为j = 2
、k = 3
。很不巧的是,这时又出现了sum = 0
,而因为nums[0] = nums[1] = 1
,此时的三元组恰恰又是[-1, 0, 1]
!重复解出现了!
现在我们势必已经清楚「重复解」出现的原因了,即排序后的数组nums
中存在连续的重复数字,导致指针前后两次获取到的数字可能是一样的。
我们可以直接对数组进行「去重」吗?答案是否定的。例如我们令nums = [-4, -1, -1, 0, 1, 2]
,那么由题意ans = [[-1,-1,2],[-1,0,1]]
。因为在同一个三元组中可以出现重复的数字,所以我们不能够企图通过去重来解决问题。
OK,现在请你再看一遍我们刚才分析的出现重复解的原因,相信你的心里已经有一个正确的解决方案了!
是的,只需要我们进行检测每一次指针移动后,它们新指向的值与前一次指向的值是否重复就可以了。如果发现重复,再次「调节」该指针的位置即可。
此外根据方程的数学原理,很容易证明:我们只需要通过调节指针i
、j
,保证nums[i] ≠ nums[i - 1]
、nums[j] ≠ nums[j - 1]
,就能保证不出现重复解,而不必再去关心nums[k]
和nums[k + 1]
是否重复。
代码如下:
function threeSum(nums) {
const ans = [];
const n = nums.length - 1;
nums.sort((a, b) => a - b);
for(let i = 0; i <= n - 2; i++) {
//注意必须保证i避免越界
if(i >= 1 && nums[i] === nums[i - 1]) continue;
let j = i + 1;
let k = n;
let x = nums[i];
while(j < k) {
//注意必须保证j避免越界
while (nums[j] === nums[j - 1] && j < n && j > i + 1) j++;
//如果j和k错位,表面在当前的nums[i]下不可能再有解,故直接放弃当前循环
if (j >= k) break;
let [y, z] = [nums[j], nums[k]];
let sum = x + y + z;
if (sum === 0) {
ans.push([x, y, z]);
//别忘了继续让j和k向中间靠拢,不然就死循环了
j++, k--;
} else if (sum < 0) {
j++;
} else if (sum > 0) {
k--;
}
}
}
return ans;
}
编码过程中的要点已通过代码注释进行说明,故本文不再赘述!
通过「指针调节」的方法,我们避免了额外的空间开销和不必要的时间浪费,实现了程序性能的优化。
T16 最接近的三数之和
题目链接:leetcode-cn.com/problems/3s…
这道题与上一道T15的解法非常类似,我们只需将其的代码稍作改造即可。
改造后的代码如下:
function threeSumClosest(nums, target) {
const n = nums.length - 1;
let delta = Infinity;
let ans = null;
nums.sort((a, b) => a - b);
for(let i = 0; i <= n - 2; i++) {
if(i >= 1 && nums[i] === nums[i - 1]) continue;
let j = i + 1;
let k = n;
let x = nums[i];
while(j < k) {
while (nums[j] === nums[j - 1] && j < n && j > i + 1) j++;
if (j >= k) break;
let [y, z] = [nums[j], nums[k]];
let sum = x + y + z;
if (sum === target) {
return sum;
} else if (sum < target) {
j++;
} else if (sum > target) {
k--;
}
if (Math.abs(sum - target) < delta) {
delta = Math.abs(sum - target);
ans = sum;
}
}
}
return ans;
}
T17 四数之和
题目链接:leetcode-cn.com/problems/4s…
这道题是「三数之和」的推广,解题的关键在于我们需要再增加一个变量来控制最外层的区间,之后外层区间内我们就可以移植「三数之和」的代码了。
代码如下:
function fourSum(nums, target) {
const ans = [];
const n = nums.length - 1;
nums.sort((a, b) => a - b);
for(let i = 0; i <= n - 3; i++) {
if(i >= 1 && nums[i] === nums[i - 1]) continue;
for(let j = i + 1; j <= n - 2; j++) {
//由于外层区间的范围是在不断变化的,
//所以j开始微调的条件不是j >= 2,而是j >= i + 2.
if(j >= i + 2 && nums[j] === nums[j - 1]) continue;
let k = j + 1;
let l = n;
while(k < l) {
while (nums[k] === nums[k - 1] && k < n && k > j + 1) k++;
if (k >= l) break;
let [a, b, c, d] = [ nums[i], nums[j], nums[k], nums[l] ];
let sum = a + b + c + d;
if (sum === target) {
ans.push([a, b, c, d]);
k++, l--;
} else if (sum < target) {
k++;
} else if (sum > target) {
l--;
}
}
}
}
return ans;
}
写在文末
我是来自在校学生编程兴趣小组江南游戏开发社的PAK向日葵,我们目前正在致力于开发自研的非营利性网页端同人游戏《植物大战僵尸:旅行》,以锻炼我们的前端应用开发能力。
我们诚挚邀请您体验我们的这款优秀作品,如果您喜欢TA的话,欢迎向您的同事和朋友推荐。如果您有技术方面的问题希望与我们探讨,欢迎直接与我联系。您的支持是我们最大的动力!