一、从 2Sum 说起
刷过力扣的用户想必对这道题不会陌生——两数之和。这是一道简单题,给定一个整数数组 nums 和一个整数目标值 target,要求在数组中找出 和为目标值 target 的那 两个 整数,并返回这两个整数对应的数组下标。题目还有一些额外的信息、要求,比如假设只存在一个有效答案,要求同一个元素不能用两次,返回顺序没有要求等,都不是这个问题的核心。
1.1 穷举
最朴素的办法是穷举,穷举所有的组合,找到符合目标的组合。那么我们可以用两层循环来穷举,算法的 JavaScript 语言描述如下。
/**
* @param {number[]} nums
* @param {number} target
* @return {number[]}
*/
var twoSum = function(nums, target) {
const length = nums.length
for (let i = 0; i < length; i++) {
// 内层从 i + 1 开始循环即可,因为 0 ~ i - 1 在外层循环时已经与 j 比较过了
for (let j = i + 1; j < length; j++) {
if (nums[i] + nums[j] === target) return [i, j]
}
}
return [-1, -1]
};
这个算法有内外两层循环,复杂度是 O(n^2)。
1.2 用 Map 优化
想优化也很简单,我们可以用一个 Map 来存储元素及其下标,这样就可以把内层查找的复杂度降到 O(1),代码如下。
/**
* @param {number[]} nums
* @param {number} target
* @return {number[]}
*/
var twoSum = function(nums, target) {
const length = nums.length
const map = new Map()
// 用一次循环记录数组元素的下标
for (let i = 0; i < length; i++) {
map.set(nums[i], i)
}
for (let i = 0; i < length; i++) {
// 下标不能为当前元素
if (map.has(target - nums[i]) && i !== map.get(target - nums[i])) {
return [i, map.get(target - nums[i])]
}
}
return [-1, -1]
};
这个算法有两个一层的循环,复杂度为 O(n),第二个循环中查找 map 的复杂度是 O(1),算法的时间复杂度为 O(n),但空间复杂度从 O(1) 变成了 O(n)。这就是最简单的,牺牲空间换取时间的思路。
1.3 再优化一下
上面的代码中,我们单独用了一个 for 循环来生成 map,一共用了两个 for 循环,我们可不可以把两个循环合起来呢?当然是可以的。我们去掉原本的第一个循环,把相应逻辑放到原本第二个循环内,如果找到了符合要求的 key 则返回,否则把当前的 i 存入 map,代码如下。
/**
* @param {number[]} nums
* @param {number} target
* @return {number[]}
*/
var twoSum = function(nums, target) {
const length = nums.length
const map = new Map()
for (let i = 0; i < length; i++) {
// 下标不能为当前元素
if (map.has(target - nums[i]) && i !== map.get(target - nums[i])) {
// 把 i 换到后面,因为 map 中的元素是之前循环时加入的,一定在前面
// (其实题目没要求顺序,不调换也可以)
return [map.get(target - nums[i]), i]
}
// 记录数组元素的下标
map.set(nums[i], i)
}
return [-1, -1]
};
1.4 如果数组有序
我们稍微修改一点条件,如果原数组有序,我们其实可以用双指针的方法。假设数组是升序排列的,我们让一个指针从 0 开始往右移动,让另一个指针从最后开始往左移动。若两者对应的元素之和小于目标值,则左指针右移;若大于,则右指针左移;若等于,则返回结果。如果左指针大于等于右指针,则表示没有找到结果。
依照上面的算法描述,我们可以写出如下代码。
/**
* @param {number[]} nums
* @param {number} target
* @return {number[]}
*/
var twoSum = function(nums, target) {
let leftIndex = 0, rightIndex = nums.length - 1
while (leftIndex < rightIndex) {
const sum = nums[leftIndex] + nums[rightIndex]
if (sum === target) {
// 找到目标直接返回
return [leftIndex, rightIndex]
} else if (sum < target) {
// 上面有 return,这里可以不需要 else,但为了逻辑清晰,使用了 else
// 还不够,加
leftIndex++
} else if (sum > target) {
// 这是最后一种情况,可以直接用 else,但为了逻辑清晰,把条件也写出来了,后面的代码也会这样做
// 超了,减
rightIndex--
}
}
return [-1, -1]
};
该算法只有一层 while 循环,时间复杂度为 O(n),空间占用是常量级别的,空间复杂度为 O(1)。
1.5 再修改一些条件
-
去处 1.4 中的“数组有序”的条件
-
数组中可能有重复的元素
-
答案可能不止一组,要求返回所有答案构成的数组,同一个元素只能使用一次
-
不返回数组下标,需要返回数组元素
举个例子,假设 nums 为 [1, 2, 1, 2, 3, 0, 3],target 为 3,要求返回 [[0, 3], [1, 2]]。
这里还是可以用我们 1.4 中的双指针方法,针对这些条件做一些处理即可:
- 针对条件
1- 数组无序,我们可以自行排序,使用JS内置的sort排序方法,其时间复杂度可以认为是O(n·logn); - 针对条件
2- 存在重复元素,我们移动指针时,把相同的元素跳过; - 针对条件
3- 返回所有答案构成的数组,我们找到答案时不返回,而是把它存到一个数组中。
下图为跳过重复元素的示意图,nums 为 [1, 1, 2, 3, 4, 4, 4],假设其中的 target 为 5,找到 [1, 4] 这个解后,把它放入结果数组,得到 [[1, 4]];接着左指针右移、右指针左移、跳过重复元素,左指针指向元素 2,右指针指向元素 3,找到 [2, 3] 这个解,把它放入结果数组,得到 [[1, 4], [2, 3]]。
完整代码如下。
/**
* @param {number[]} nums
* @param {number} target
* @return {number[][]}
*/
var twoSum = function(nums, target) {
const res = []
// 先变成升序数组
nums = nums.sort((a, b) => a - b)
let leftIndex = 0, rightIndex = nums.length - 1
while (leftIndex < rightIndex) {
const leftValue = nums[leftIndex], rightValue = nums[rightIndex], sum = leftValue + rightValue
if (sum === target) {
res.push([leftValue, rightValue])
// 移动指针时,把相同的元素跳过
while (leftIndex < rightIndex && nums[leftIndex] === leftValue) leftIndex++
while (leftIndex < rightIndex && nums[rightIndex] === rightValue) rightIndex--
} else if (sum < target) {
while (leftIndex < rightIndex && nums[leftIndex] === leftValue) leftIndex++
} else if (sum > target) {
while (leftIndex < rightIndex && nums[rightIndex] === rightValue) rightIndex--
}
}
return res
}
乍一看这里面一堆 while,但其实只是从两边往中间遍历了一遍。排序的复杂度为 O(n·logn),while 的复杂度为 O(n),整体时间复杂度为 O(n·logn)。空间复杂度是常量级的 O(1)。
这个 twoSum 算法是个比较通用的算法了,后面的 threeSum、nSum 算法是在它的基础上修改得到的,继续往下看之前,请确保你已经理解它了。
二、3Sum 、4Sum 呢?
2.1 3Sum
在明白了 1.5 中的算法之后,我们来看另一道题——三数之和。这是中等难度的题目,给定一个整数数组 nums,找出所有和为 0 且不重复的三元组。
我们稍微改一下题目,让这道题更通用一点,原题目中要求三元组的和为 0,我们把目标和改为 target 参数的值。
在 1.5 中,我们已经求了两数之和,那么把 nums 中的元素遍历一遍,借助两数之和方法 twoSum 找到符合要求的二元组,再与当前下标对应的元素一起,组成符合条件的三元组。
/**
* @param {number[]} nums
* @param {number} target
* @return {number[][]}
*/
var threeSum = function(nums, target) {
// 在上面的 twoSum 方法的基础上有一点点修改:
// 1. 新增了一个 startIndex 的参数,表示从哪个下标开始查找(原本默认从 0 开始查找)
// 2. 把原本的数组排序放到 threeSum 中
const twoSum = (nums, target, startIndex) => {
const res = []
// 把左侧指针的起始位置作为参数传进来
let leftIndex = startIndex, rightIndex = nums.length - 1
while (leftIndex < rightIndex) {
const leftValue = nums[leftIndex], rightValue = nums[rightIndex], sum = leftValue + rightValue
if (sum === target) {
res.push([leftValue, rightValue])
// 移动指针时,把相同的元素跳过
while (leftIndex < rightIndex && nums[leftIndex] === leftValue) leftIndex++
while (leftIndex < rightIndex && nums[rightIndex] === rightValue) rightIndex--
} else if (sum < target) {
while (leftIndex < rightIndex && nums[leftIndex] === leftValue) leftIndex++
} else if (sum > target) {
while (leftIndex < rightIndex && nums[rightIndex] === rightValue) rightIndex--
}
}
return res
}
const res = []
// 先变成升序数组,注意:之前是在 twoSum 中排序的
nums = nums.sort((a, b) => a - b)
for (let i = 0; i < nums.length; i++) {
// 从第 i + 1 个元素开始寻找两数之和
twoSum(nums, target - nums[i], i + 1).forEach(item => {
res.push([nums[i], ...item])
})
// 跳过相同元素
while (i < nums.length && nums[i] === nums[i + 1]) i++
}
return res
};
我们再来分析一下算法的时间复杂度,在 threeSum 中,排序的复杂度是 O(n·logn),for 循环的复杂度是 O(n),内部嵌套了复杂度为O(n) 的 twoSum 方法(注意,twoSum 中没有之前的排序了,只有一层循环,故复杂度为 O(n)),综上可知,算法的时间复杂度为 O(n^2)。
2.2 4Sum
老规矩,我们上题目——四数之和。没有任何花样,依然是整数数组 nums,目标和 target,找到所有和为 target 且不重复的四元组。
参照 2Sum => 3Sum,我们可以非常轻易地想到,可以把 nums 中的元素遍历一遍,借助三数之和方法 threeSum 找到符合要求的三元组,再与当前下标对应的元素一起,组成符合条件的三元组。(是的,连图都是改了一点点之后复制的)
代码我们也直接复制,做少量修改,具体代码如下。
/**
* @param {number[]} nums
* @param {number} target
* @return {number[][]}
*/
var fourSum = function(nums, target) {
const threeSum = (nums, target, startIndex) => {
const twoSum = (nums, target, startIndex) => {
const res = []
// 把左侧指针的起始位置作为参数传进来
let leftIndex = startIndex, rightIndex = nums.length - 1
while (leftIndex < rightIndex) {
const leftValue = nums[leftIndex], rightValue = nums[rightIndex], sum = leftValue + rightValue
if (sum === target) {
res.push([leftValue, rightValue])
// 移动指针时,把相同的元素跳过
while (leftIndex < rightIndex && nums[leftIndex] === leftValue) leftIndex++
while (leftIndex < rightIndex && nums[rightIndex] === rightValue) rightIndex--
} else if (sum < target) {
while (leftIndex < rightIndex && nums[leftIndex] === leftValue) leftIndex++
} else if (sum > target) {
while (leftIndex < rightIndex && nums[rightIndex] === rightValue) rightIndex--
}
}
return res
}
const res = []
for (let i = startIndex; i < nums.length; i++) {
// 从第 i + 1 个元素开始寻找两数之和
twoSum(nums, target - nums[i], i + 1).forEach(item => {
res.push([nums[i], ...item])
})
// 跳过相同元素
while (i < nums.length && nums[i] === nums[i + 1]) i++
}
return res
}
const res = []
// 排序放在最外层
nums = nums.sort((a, b) => a - b)
for (let i = 0; i < nums.length; i++) {
// 从第 i + 1 个元素开始寻找三数之和
threeSum(nums, target - nums[i], i + 1).forEach(item => {
res.push([nums[i], ...item])
})
// 跳过相同元素
while (i < nums.length && nums[i] === nums[i + 1]) i++
}
return res
};
问题顺利解决了,复杂度也很好分析,时间复杂度为 O(n^3),这里就不细说了。我们可以注意到,fourSum 和 threeSum 中有一些重复代码,在后面的 nSum 中我们继续看。
三、nSum 问题
我们在上一节借助 2Sum 解决了 3Sum 问题,借助 3Sum 解决了 4Sum 问题,同理,我们可以一直套娃下去解决 5Sum、8Sum、100Sum 问题…刚才我们注意到,fourSum 和 threeSum 中有一些重复代码,具体来说,是以下代码:
const res = []
for (let i = startIndex; i < nums.length; i++) {
// 从第 i + 1 个元素开始寻找 n - 1 数之和
nSum(nums, target - nums[i], i + 1).forEach(item => {
res.push([nums[i], ...item])
})
// 跳过相同元素
while (i < nums.length && nums[i] === nums[i + 1]) i++
}
return res
可以想象,如果我们求解 100Sum 问题时,照现在的写法,类似上面的代码块会写 98 次,其中的变化的量就只有 nSum 方法的这个 n,我们可以把它作为一个参数,递归调用自己即可。base case 为 n === 2,此时问题为 twoSum 问题。
完整代码如下。
/**
* @param {number[]} nums
* @param {number} target
* @param {number} startIndex
* @param {number} n
* @return {number[][]}
*/
// 注意:调用这个方法前,一定要先将 nums 按升序排列
const nSum = (nums, target, startIndex, n) => {
if (n === 2) {
const res = []
// 把左侧指针的起始位置作为参数传进来
let leftIndex = startIndex, rightIndex = nums.length - 1
while (leftIndex < rightIndex) {
const leftValue = nums[leftIndex], rightValue = nums[rightIndex], sum = leftValue + rightValue
if (sum === target) {
res.push([leftValue, rightValue])
// 移动指针时,把相同的元素跳过
while (leftIndex < rightIndex && nums[leftIndex] === leftValue) leftIndex++
while (leftIndex < rightIndex && nums[rightIndex] === rightValue) rightIndex--
} else if (sum < target) {
while (leftIndex < rightIndex && nums[leftIndex] === leftValue) leftIndex++
} else if (sum > target) {
while (leftIndex < rightIndex && nums[rightIndex] === rightValue) rightIndex--
}
}
return res
} else {
const res = []
for (let i = startIndex; i < nums.length; i++) {
// 从第 i + 1 个元素开始寻找两数之和
nSum(nums, target - nums[i], i + 1, n - 1).forEach(item => {
res.push([nums[i], ...item])
})
// 跳过相同元素
while (i < nums.length && nums[i] === nums[i + 1]) i++
}
return res
}
}
// 让我们来试试
const arr = [8, 4, 2, 1, 0, 10, 15, 21, 2, 3, 3, 7]
const sortedArr = arr.sort((a, b) => a - b)
console.log(
nSum(sortedArr, 6, 0, 2), // 两数之和,目标为 6
nSum(sortedArr, 11, 0, 3), // 三数之和,目标为 11
nSum(sortedArr, 30, 0, 4), // 四数之和,目标为 30
nSum(sortedArr, 76, 0, 12), // 12 数之和,目标为76
)
代码看起来挺复杂,但逻辑很简单,递归调用自身求解 n - 1 数之和,base case 为 n === 2 时。
四、总结
文章到这里就结束了,我们从 twoSum 问题的最朴素的方法——双层循环穷举法讲起,利用 map 对它进行优化,用空间换取时间。接着我们讨论了针对有序数组的双指针解法,并在此基础上总结了 threeSum、fourSum 问题的解法,顺手解决了三道力扣上的算法题。最后,我们顺着这个思路总结了 nSum 问题的通用算法。将来力扣上再出现 5Sum、100Sum 问题我们也都不怕了,直接使用此算法即可。