

文章目录
-
- 摘要
- 描述
- 题解答案
- 题解代码分析
-
- [1. 动态规划的基本思路](#1. 动态规划的基本思路)
- [2. 初始状态](#2. 初始状态)
- [3. 状态转移方程](#3. 状态转移方程)
- [4. 为什么这样能计算出排列个数?](#4. 为什么这样能计算出排列个数?)
- [5. 与组合问题的区别](#5. 与组合问题的区别)
- [6. 优化:避免不必要的计算](#6. 优化:避免不必要的计算)
- 示例测试及结果
-
- [示例 1:nums = [1,2,3], target = 4](#示例 1:nums = [1,2,3], target = 4)
- [示例 2:nums = [9], target = 3](#示例 2:nums = [9], target = 3)
- [示例 3:nums = [1,2], target = 3](#示例 3:nums = [1,2], target = 3)
- [示例 4:nums = [1], target = 1](#示例 4:nums = [1], target = 1)
- 时间复杂度
- 空间复杂度
- 进阶问题:如果数组中含有负数
- 实际应用场景
- 总结
摘要
这道题其实挺有意思的,它让我们找出所有可能的排列方式,使得这些数字的和等于目标值。虽然题目名字叫"组合总和",但实际上这是一道排列问题,因为顺序不同的序列被视为不同的组合。比如 (1, 2) 和 (2, 1) 被视为两种不同的方式。
这道题本质上是一个动态规划问题,我们可以用动态规划来高效地解决它。今天我们就用 Swift 来搞定这道题,顺便聊聊动态规划在实际开发中的应用场景。

描述
题目要求是这样的:给你一个由不同整数组成的数组 nums,和一个目标整数 target。请你从 nums 中找出并返回总和为 target 的元素排列的个数。
题目数据保证答案符合 32 位整数范围。
示例 1:
输入:nums = [1,2,3], target = 4
输出:7
解释:
所有可能的组合为:
(1, 1, 1, 1)
(1, 1, 2)
(1, 2, 1)
(1, 3)
(2, 1, 1)
(2, 2)
(3, 1)
请注意,顺序不同的序列被视作不同的组合。比如 (1, 1, 2) 和 (1, 2, 1) 被视为两种不同的方式。
示例 2:
输入:nums = [9], target = 3
输出:0
提示:
1 <= nums.length <= 2001 <= nums[i] <= 1000nums中的所有元素互不相同1 <= target <= 1000
这道题的核心思路是什么呢?其实这是一道动态规划问题。我们可以用 dp[i] 表示和为 i 的排列个数。对于每个 i,我们遍历 nums 中的每个数 num,如果 i >= num,那么 dp[i] 就可以从 dp[i - num] 转移过来。这样我们就能计算出所有可能的排列个数。

题解答案
下面是完整的 Swift 解决方案:
swift
class Solution {
func combinationSum4(_ nums: [Int], _ target: Int) -> Int {
// dp[i] 表示和为 i 的排列个数
var dp = Array(repeating: 0, count: target + 1)
// 初始状态:和为 0 的排列个数为 1(空排列)
dp[0] = 1
// 遍历每个可能的和
for i in 1...target {
// 遍历 nums 中的每个数
for num in nums {
// 如果当前和 i 大于等于 num,可以从 dp[i - num] 转移过来
if i >= num {
dp[i] += dp[i - num]
}
}
}
return dp[target]
}
}
题解代码分析
让我们一步步分析这个解决方案:
1. 动态规划的基本思路
动态规划的核心思想是:将大问题分解成小问题,通过解决小问题来解决大问题。在这道题中,我们要计算和为 target 的排列个数,可以将其分解为:对于每个可能的和 i,计算和为 i 的排列个数。
swift
var dp = Array(repeating: 0, count: target + 1)
我们创建一个数组 dp,其中 dp[i] 表示和为 i 的排列个数。数组大小为 target + 1,因为我们需要计算从 0 到 target 的所有可能和。
2. 初始状态
swift
dp[0] = 1
初始状态是:和为 0 的排列个数为 1。这对应空排列的情况,即不选任何数字。这个初始状态很重要,因为它是所有其他状态的基础。
3. 状态转移方程
swift
for i in 1...target {
for num in nums {
if i >= num {
dp[i] += dp[i - num]
}
}
}
这是核心的状态转移过程。对于每个可能的和 i(从 1 到 target),我们遍历 nums 中的每个数 num:
- 如果
i >= num,说明我们可以用num来组成和为i的排列 - 如果我们选择了
num,那么剩下的和就是i - num - 所以
dp[i]应该加上dp[i - num],表示所有和为i - num的排列都可以通过加上num来得到和为i的排列
注意,这里我们遍历 nums 的顺序很重要。因为题目要求的是排列(顺序不同视为不同),所以我们需要考虑所有可能的顺序。通过先遍历 i,再遍历 nums,我们确保了所有可能的排列都被计算到了。
4. 为什么这样能计算出排列个数?
让我们用一个例子来说明。假设 nums = [1, 2, 3],target = 4:
dp[0] = 1(空排列)dp[1]:可以用 1 组成,dp[1] = dp[0] = 1(只有一种方式:(1))dp[2]:- 可以用 1 组成:
dp[2] += dp[1] = 1((1, 1)) - 可以用 2 组成:
dp[2] += dp[0] = 1((2)) - 所以
dp[2] = 2
- 可以用 1 组成:
dp[3]:- 可以用 1 组成:
dp[3] += dp[2] = 2((1, 1, 1), (1, 2)) - 可以用 2 组成:
dp[3] += dp[1] = 1((2, 1)) - 可以用 3 组成:
dp[3] += dp[0] = 1((3)) - 所以
dp[3] = 4
- 可以用 1 组成:
dp[4]:- 可以用 1 组成:
dp[4] += dp[3] = 4((1, 1, 1, 1), (1, 1, 2), (1, 2, 1), (1, 3)) - 可以用 2 组成:
dp[4] += dp[2] = 2((2, 1, 1), (2, 2)) - 可以用 3 组成:
dp[4] += dp[1] = 1((3, 1)) - 所以
dp[4] = 7
- 可以用 1 组成:
可以看到,通过这种方式,我们计算出了所有可能的排列个数。
5. 与组合问题的区别
这道题虽然名字叫"组合总和",但实际上是一道排列问题。如果是组合问题,我们只需要考虑数字的选择,不需要考虑顺序。但这里是排列问题,顺序不同的序列被视为不同的组合。
在代码中,我们通过先遍历 i,再遍历 nums 来确保所有可能的顺序都被考虑到了。如果我们先遍历 nums,再遍历 i,那就变成了组合问题,顺序不同的序列会被视为相同的组合。
6. 优化:避免不必要的计算
虽然题目没有要求,但我们可以做一些优化。比如,如果 nums 中有很多大于 target 的数,我们可以先过滤掉它们:
swift
class Solution {
func combinationSum4(_ nums: [Int], _ target: Int) -> Int {
// 过滤掉大于 target 的数
let validNums = nums.filter { $0 <= target }
var dp = Array(repeating: 0, count: target + 1)
dp[0] = 1
for i in 1...target {
for num in validNums {
if i >= num {
dp[i] += dp[i - num]
}
}
}
return dp[target]
}
}
不过对于这道题,由于 nums[i] <= 1000 且 target <= 1000,这个优化可能不会带来明显的性能提升。
示例测试及结果
让我们用几个例子来测试一下这个解决方案:
示例 1:nums = [1,2,3], target = 4
swift
let solution = Solution()
let result1 = solution.combinationSum4([1,2,3], 4)
print("示例 1 结果: \(result1)") // 输出: 7
执行过程分析:
- 初始化:
dp = [1, 0, 0, 0, 0] i = 1:num = 1:dp[1] += dp[0] = 1num = 2:1 >= 2不成立,跳过num = 3:1 >= 3不成立,跳过dp[1] = 1
i = 2:num = 1:dp[2] += dp[1] = 1num = 2:dp[2] += dp[0] = 1num = 3:2 >= 3不成立,跳过dp[2] = 2
i = 3:num = 1:dp[3] += dp[2] = 2num = 2:dp[3] += dp[1] = 1num = 3:dp[3] += dp[0] = 1dp[3] = 4
i = 4:num = 1:dp[4] += dp[3] = 4num = 2:dp[4] += dp[2] = 2num = 3:dp[4] += dp[1] = 1dp[4] = 7
所有可能的排列:
- (1, 1, 1, 1)
- (1, 1, 2)
- (1, 2, 1)
- (1, 3)
- (2, 1, 1)
- (2, 2)
- (3, 1)
总共 7 种方式。
示例 2:nums = [9], target = 3
swift
let result2 = solution.combinationSum4([9], 3)
print("示例 2 结果: \(result2)") // 输出: 0
执行过程分析:
- 初始化:
dp = [1, 0, 0, 0] i = 1:1 >= 9不成立,dp[1] = 0i = 2:2 >= 9不成立,dp[2] = 0i = 3:3 >= 9不成立,dp[3] = 0
因为 nums 中只有 9,而 9 > 3,所以无法组成和为 3 的排列,结果为 0。
示例 3:nums = [1,2], target = 3
swift
let result3 = solution.combinationSum4([1,2], 3)
print("示例 3 结果: \(result3)") // 输出: 3
执行过程分析:
- 初始化:
dp = [1, 0, 0, 0] i = 1:num = 1:dp[1] += dp[0] = 1dp[1] = 1
i = 2:num = 1:dp[2] += dp[1] = 1num = 2:dp[2] += dp[0] = 1dp[2] = 2
i = 3:num = 1:dp[3] += dp[2] = 2num = 2:dp[3] += dp[1] = 1dp[3] = 3
所有可能的排列:
- (1, 1, 1)
- (1, 2)
- (2, 1)
总共 3 种方式。
示例 4:nums = [1], target = 1
swift
let result4 = solution.combinationSum4([1], 1)
print("示例 4 结果: \(result4)") // 输出: 1
执行过程分析:
- 初始化:
dp = [1, 0] i = 1:num = 1:dp[1] += dp[0] = 1dp[1] = 1
所有可能的排列:
- (1)
总共 1 种方式。
时间复杂度
这个算法的时间复杂度是 O(target × n) ,其中 target 是目标值,n 是 nums 数组的长度。
为什么是 O(target × n)?
- 外层循环遍历
i从 1 到target,共target次 - 内层循环遍历
nums中的每个数,共n次 - 每次循环的操作都是 O(1) 的
所以总时间复杂度是 O(target × n)。
对于这道题,target <= 1000,n <= 200,所以最坏情况下需要 200,000 次操作,这在现代计算机上是非常快的。
空间复杂度
这个算法的空间复杂度是 O(target)。
为什么是 O(target)?
我们使用了一个大小为 target + 1 的数组 dp 来存储中间结果。除了这个数组,我们没有使用额外的空间,所以空间复杂度是 O(target)。
对于这道题,target <= 1000,所以空间复杂度是可以接受的。
进阶问题:如果数组中含有负数
题目提到了一个进阶问题:如果给定的数组中含有负数会发生什么?问题会产生何种变化?如果允许负数出现,需要向题目中添加哪些限制条件?
问题分析
如果 nums 中含有负数,会出现以下问题:
-
无限循环 :如果
nums中有正数和负数,且它们的和可以抵消,那么可能存在无限多种排列方式。比如nums = [1, -1],target = 0,我们可以用任意多个 (1, -1) 对来组成和为 0 的排列。 -
状态转移变得复杂 :原来的状态转移方程
dp[i] += dp[i - num]在num为负数时仍然成立,但需要确保i - num在有效范围内。 -
需要限制条件:为了避免无限循环,我们需要添加一些限制条件,比如:
- 限制排列的长度(最多使用 k 个数字)
- 限制每个数字的使用次数
- 要求排列中正数和负数的数量相等
解决方案
如果允许负数出现,我们可以这样修改代码:
swift
class Solution {
func combinationSum4(_ nums: [Int], _ target: Int, maxLength: Int = 1000) -> Int {
// 如果 nums 中有负数,需要限制排列的最大长度
var dp = Array(repeating: 0, count: target + 1)
dp[0] = 1
// 使用二维 DP:dp[i][j] 表示用 j 个数字组成和为 i 的排列个数
var dp2D = Array(repeating: Array(repeating: 0, count: maxLength + 1), count: target + 1)
dp2D[0][0] = 1
for i in 0...target {
for j in 0..<maxLength {
for num in nums {
let nextSum = i + num
if nextSum >= 0 && nextSum <= target {
dp2D[nextSum][j + 1] += dp2D[i][j]
}
}
}
}
// 返回所有长度的排列个数之和
var result = 0
for j in 0...maxLength {
result += dp2D[target][j]
}
return result
}
}
不过这个解决方案比较复杂,而且对于原题(没有负数)来说是不必要的。
实际应用场景
动态规划在实际开发中的应用非常广泛。让我们看看几个常见的应用场景:
场景一:零钱兑换问题
这是一个经典的动态规划问题,和这道题非常相似:
swift
func coinChange(_ coins: [Int], _ amount: Int) -> Int {
var dp = Array(repeating: Int.max, count: amount + 1)
dp[0] = 0
for i in 1...amount {
for coin in coins {
if i >= coin && dp[i - coin] != Int.max {
dp[i] = min(dp[i], dp[i - coin] + 1)
}
}
}
return dp[amount] == Int.max ? -1 : dp[amount]
}
这个问题是求最少需要多少个硬币,而我们的问题是求有多少种排列方式。
场景二:爬楼梯问题
爬楼梯问题也可以用类似的思路解决:
swift
func climbStairs(_ n: Int) -> Int {
if n <= 2 {
return n
}
var dp = Array(repeating: 0, count: n + 1)
dp[1] = 1
dp[2] = 2
for i in 3...n {
dp[i] = dp[i - 1] + dp[i - 2]
}
return dp[n]
}
这个问题可以看作是 nums = [1, 2],target = n 的特殊情况。
场景三:路径计数问题
在网格中,从左上角到右下角有多少种路径:
swift
func uniquePaths(_ m: Int, _ n: Int) -> Int {
var dp = Array(repeating: Array(repeating: 0, count: n), count: m)
for i in 0..<m {
dp[i][0] = 1
}
for j in 0..<n {
dp[0][j] = 1
}
for i in 1..<m {
for j in 1..<n {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
}
}
return dp[m - 1][n - 1]
}
场景四:背包问题
完全背包问题也可以用类似的思路解决:
swift
func knapsack(_ weights: [Int], _ values: [Int], _ capacity: Int) -> Int {
var dp = Array(repeating: 0, count: capacity + 1)
for i in 1...capacity {
for j in 0..<weights.count {
if i >= weights[j] {
dp[i] = max(dp[i], dp[i - weights[j]] + values[j])
}
}
}
return dp[capacity]
}
总结
这道题虽然名字叫"组合总和",但实际上是一道排列问题。通过动态规划,我们可以高效地计算出所有可能的排列个数。
关键点总结:
- 理解题意:顺序不同的序列被视为不同的组合,所以这是排列问题
- 动态规划思路 :用
dp[i]表示和为i的排列个数 - 状态转移 :
dp[i] = sum(dp[i - num])fornuminnumswherei >= num - 遍历顺序 :先遍历
i,再遍历nums,确保所有可能的顺序都被考虑到 - 时间复杂度:O(target × n)
- 空间复杂度:O(target)
动态规划虽然看起来复杂,但一旦理解了核心思想,就能解决很多类似的问题。在实际开发中,动态规划常用于优化问题、计数问题等场景。