nSum 问题通用算法

1,255 阅读4分钟

一、从 2Sum 说起

刷过力扣的用户想必对这道题不会陌生——两数之和。这是一道简单题,给定一个整数数组 nums 和一个整数目标值 target,要求在数组中找出 和为目标值 target 的那 两个 整数,并返回这两个整数对应的数组下标。题目还有一些额外的信息、要求,比如假设只存在一个有效答案,要求同一个元素不能用两次,返回顺序没有要求等,都不是这个问题的核心。

image.png

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]
};

image.png

这个算法有内外两层循环,复杂度是 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]
};

image.png

这个算法有两个一层的循环,复杂度为 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]
};

image.png

1.4 如果数组有序

我们稍微修改一点条件,如果原数组有序,我们其实可以用双指针的方法。假设数组是升序排列的,我们让一个指针从 0 开始往右移动,让另一个指针从最后开始往左移动。若两者对应的元素之和小于目标值,则左指针右移;若大于,则右指针左移;若等于,则返回结果。如果左指针大于等于右指针,则表示没有找到结果。

image.png

依照上面的算法描述,我们可以写出如下代码。

/**
 * @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]
};

image.png

该算法只有一层 while 循环,时间复杂度为 O(n),空间占用是常量级别的,空间复杂度为 O(1)

1.5 再修改一些条件

  1. 去处 1.4 中的“数组有序”的条件

  2. 数组中可能有重复的元素

  3. 答案可能不止一组,要求返回所有答案构成的数组,同一个元素只能使用一次

  4. 不返回数组下标,需要返回数组元素

举个例子,假设 nums[1, 2, 1, 2, 3, 0, 3]target3,要求返回 [[0, 3], [1, 2]]

这里还是可以用我们 1.4 中的双指针方法,针对这些条件做一些处理即可:

  • 针对条件 1 - 数组无序,我们可以自行排序,使用 JS 内置的 sort 排序方法,其时间复杂度可以认为是 O(n·logn)
  • 针对条件 2 - 存在重复元素,我们移动指针时,把相同的元素跳过;
  • 针对条件 3 - 返回所有答案构成的数组,我们找到答案时不返回,而是把它存到一个数组中。

下图为跳过重复元素的示意图,nums[1, 1, 2, 3, 4, 4, 4],假设其中的 target5,找到 [1, 4] 这个解后,把它放入结果数组,得到 [[1, 4]];接着左指针右移、右指针左移、跳过重复元素,左指针指向元素 2,右指针指向元素 3,找到 [2, 3] 这个解,把它放入结果数组,得到 [[1, 4], [2, 3]]

image.png

完整代码如下。

/**
 * @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
}

image.png

乍一看这里面一堆 while,但其实只是从两边往中间遍历了一遍。排序的复杂度为 O(n·logn)while 的复杂度为 O(n),整体时间复杂度为 O(n·logn)。空间复杂度是常量级的 O(1)

这个 twoSum 算法是个比较通用的算法了,后面的 threeSumnSum 算法是在它的基础上修改得到的,继续往下看之前,请确保你已经理解它了。

二、3Sum4Sum 呢?

2.1 3Sum

在明白了 1.5 中的算法之后,我们来看另一道题——三数之和。这是中等难度的题目,给定一个整数数组 nums,找出所有和为 0 且不重复的三元组。

image.png

我们稍微改一下题目,让这道题更通用一点,原题目中要求三元组的和为 0,我们把目标和改为 target 参数的值。

1.5 中,我们已经求了两数之和,那么把 nums 中的元素遍历一遍,借助两数之和方法 twoSum 找到符合要求的二元组,再与当前下标对应的元素一起,组成符合条件的三元组。

image.png

/**
 * @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 且不重复的四元组。

image.png

参照 2Sum => 3Sum,我们可以非常轻易地想到,可以把 nums 中的元素遍历一遍,借助三数之和方法 threeSum 找到符合要求的三元组,再与当前下标对应的元素一起,组成符合条件的三元组。(是的,连图都是改了一点点之后复制的)

image.png

代码我们也直接复制,做少量修改,具体代码如下。

/**
 * @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),这里就不细说了。我们可以注意到,fourSumthreeSum 中有一些重复代码,在后面的 nSum 中我们继续看。

三、nSum 问题

我们在上一节借助 2Sum 解决了 3Sum 问题,借助 3Sum 解决了 4Sum 问题,同理,我们可以一直套娃下去解决 5Sum8Sum100Sum 问题…刚才我们注意到,fourSumthreeSum 中有一些重复代码,具体来说,是以下代码:

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 casen === 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
)

image.png

代码看起来挺复杂,但逻辑很简单,递归调用自身求解 n - 1 数之和,base casen === 2 时。

四、总结

文章到这里就结束了,我们从 twoSum 问题的最朴素的方法——双层循环穷举法讲起,利用 map 对它进行优化,用空间换取时间。接着我们讨论了针对有序数组的双指针解法,并在此基础上总结了 threeSumfourSum 问题的解法,顺手解决了三道力扣上的算法题。最后,我们顺着这个思路总结了 nSum 问题的通用算法。将来力扣上再出现 5Sum100Sum 问题我们也都不怕了,直接使用此算法即可。