题目
494. 目标和
难度: 中等
题目分析:这道题第一眼,竟然不知道如何下手……说明我对于BFS和DFS的掌握还不扎实。最后是看了答案才有点明白,这个问题其实就是背包问题的变形……于是,直接的解法是使用递归的方法来实现DFS,或者我们自己维持一个栈来编写非递归的解法;自然,这里也可以使用BFS,因为题目需要我们找出所有解,这两种方法只是前进方向不一样。
另外值得一提的是,这个方法也能使用动态规划。 这道题应该用动态规划或是01规划
本篇会包括4个解法。
1. 解法一:基于BFS,运行超时(暴力解法)
from collections import deque
class Solution:
def findTargetSumWays(self, nums: List[int], S: int) -> int:
# 要求返回所有,空间探索,试试广度优先探索
# 之后深度优先探索,可以自己定义栈,也可以用递归
# 广度优先搜索
count = 0
num = len(nums)
temp_sum = 0
my_que = deque() #
# 初值入队
my_que.append(temp_sum)
while my_que:
for i in range(num - 1):
size = len(my_que)
for _ in range(size): # 队伍里的可能性
temp_sum = my_que.popleft()
my_que.append(temp_sum + nums[i])
my_que.append(temp_sum - nums[i]) # 两种情况入队
while my_que: # 加最后一个数,能知道结果
temp_sum = my_que.popleft()
if temp_sum + nums[-1] == S:
count += 1
if temp_sum - nums[-1] == S:
count +=1
return count
1.1 运行结果:
1.2 分析:
规定时间内,勉强完成一半的测试用例。截图的例子,单独运行,就需要440ms, 我应该尽可能减少遍历的次数。
2.解法二:基于递归的DFS,也是超时(暴力搜索)
class Solution:
def findTargetSumWays(self, nums: List[int], S: int) -> int:
# 使用递归
self.count = 0
self.length = len(nums)
self.calculate(nums, 0, 0, S)
return self.count
def calculate(self, nums, i, sum_i, S):
if i == self.length: # 说明都加完了
if sum_i == S:
self.count += 1
return
self.calculate(nums, i+1, sum_i + nums[i], S)
self.calculate(nums, i+1, sum_i - nums[i], S)
2.1 运行结果:
2.2 分析:
通过的例子数,比上面基于BFS的还少,说明这个方法更慢…… 这个答案是照着官方的标准答案来的,还通不过,结合之前也遇到过,一模一样的代码,运行时间远超之前的代码,所以很有可能是Leetcode的网站进行了升级,或是要求我们充钱,才提供更快的判题吧。 我表示抱歉,上面的话是对Leetcode的污蔑。这道题用BFS或是递归解不出来,是因为这道题目本就不是考察这两种方法,而是考虑动态规划,或是进一步说,01规划。我参考了Leetcode里面速度最快的代码,该代码使用01规划,于是,只需要76ms, 便解出来了!
3. 解法二的另一种递归解法:
class Solution:
def findTargetSumWays(self, nums: List[int], S: int) -> int:
def dfs(nums, n, sum_i):
if n < 0:
if sum_i == 0:
return 1
else:
return 0
ans = 0
ans += dfs(nums, n - 1, sum_i - nums[n])
ans += dfs(nums, n - 1, sum_i + nums[n])
return ans
return dfs(nums, len(nums)-1, S)
3.1 运行结果:
3.2 分析:
倒在跟上面差不多的位置……可以肯定的说,这道题不适合用暴力搜索去做。这个补充的解法,跟解法二的差别只在于我们是把每个数一次加上呢,还是从目标S依次减去而已,主体一样。运行速度太慢。
3.3 思考:如何自己维护一个栈,来写非递归的DFS呢?
4. 解法二的改进:使用存储,减少递归次数, 加速
class Solution:
def findTargetSumWays(self, nums: List[int], S: int) -> int:
visited = {}
def dfs(nums, n, sum_i, visited):
if n < 0:
if sum_i == 0:
return 1
else:
return 0
ans = 0
if (n, sum_i) in visited:
return visited[(n, sum_i)]
else:
ans += dfs(nums, n - 1, sum_i - nums[n], visited)
ans += dfs(nums, n - 1, sum_i + nums[n], visited)
visited[(n, sum_i)] = ans
return ans
return dfs(nums, len(nums)-1, S, visited)
4.1 运行结果:
4.2 分析:
!!!终于!!!
这个解法改进了上面递归,主体都是一样的,区别只在于增加了一个字典保存算过的值, 每次要送入递归前,检查是否已经算过,就可以极大的减少递归次数,从而加快速度!上面其他函数,可都是运行超时啊!
这儿收获的经验是,递归解法中,总可以通过存储中间结果,而加快程序运行。
5.又一解法:简洁版本的带存储的递归算法
class Solution:
def findTargetSumWays(self, nums: List[int], S: int) -> int:
# 使用字典,节省空间版 简洁
visited = {}
def dfs(nums, i, cur, visited):
if i < len(nums) and ((i, cur) not in visited):
visited[(i, cur)] = dfs(nums, i+1, cur + nums[i], visited) +\
dfs(nums, i+1, cur - nums[i], visited)
return visited.get((i, cur), int(cur==S))
return dfs(nums, 0, 0, visited)
5.1 运行结果:
5.2 分析:
看到简洁的代码,总是令人赏心悦目。
这种解法巧妙的地方在于,使用字典 get() 方法,统一的处理索引在或不在字典里的情况。此处递归的终止条件是
i
=
l
e
n
(
n
u
m
s
)
−
1
i=len(nums)-1
i=len(nums)−1, 也就是数组的最后一个元素也加完了,这时候可以判读cur是否等于S。这些数字对,肯定不会在字典里,所以,会返回get()方法后面的参数 int(cur == S), 如果等于S, 返回1,说明这路走得通; 不等于,说明这条路走不通,为0。
6. 解法三: 动态规划
使用动态规划的关键是,写出状态转移方程,
class Solution:
def findTargetSumWays(self, nums: List[int], S: int) -> int:
dp = [[0]*2001 for i in range(len(nums))] # 根据题意,j最大为2000
# 初始条件
dp[0][nums[0] + 1000] = 1
dp[0][-nums[0] + 1000] += 1 # nums[0]可能为0
for i in range(1, len(nums)):
for j in range(-1000, 1000):
dp[i][j+1000] = dp[i-1][j - nums[i] + 1000]
if j + nums[i] <= 1000: # 确保在索引内
dp[i][j + 1000] += dp[i-1][j + nums[i] + 1000]
return dp[len(nums) - 1][S+1000] if S <= 1000 else 0
6.1 运行结果
6.2 分析:
虽然运行结果很慢,但这是我自己学习掌握了动态规划后,独立写出来的答案!感动!!!这里特别感谢帅地写的动态规划教程,简洁明了,手把手教我学会了动态规划,这里是链接-为什么你学不过动态规划?告别动态规划,谈谈我的经验
7. 解法四: 01规划
初步分析:
这道题等价于,我们从数组各元素中,挑出一部分用加号,剩下的用减号,然后,二者的和等于S。
S
(
P
)
−
S
(
N
)
=
S
S
(
P
)
−
S
(
N
)
+
(
S
(
P
)
+
S
(
N
)
)
=
S
+
(
S
(
P
)
+
S
(
N
)
)
2
S
(
P
)
=
S
+
S
(
A
)
S(P) - S(N) = S \\ S(P) - S(N) + (S(P) + S(N)) = S + (S(P) + S(N)) \\ 2 S(P) = S + S(A)
S(P)−S(N)=SS(P)−S(N)+(S(P)+S(N))=S+(S(P)+S(N))2S(P)=S+S(A)
其中,
S
(
P
)
S(P)
S(P)表示前面要标“+”号元素的和,
S
(
N
)
S(N)
S(N)是要标“-”号元素的和,
S
(
A
)
S(A)
S(A)是所有元素的和。分析可以发现,标“+” 的元素的两倍,等于所有元素的和 跟 目标和 S 相加,于是,问题可以转化为,从数组nums中,挑出部分元素,使其和等于
1
/
2
(
S
(
A
)
+
S
)
1/2(S(A) + S)
1/2(S(A)+S)
问题这样转化有两大好处:
- 当 S ( A ) + S S(A) + S S(A)+S 是奇数的时候,我们可以肯定,这道题无解(奇数不可能是 S ( P ) S(P) S(P)的两倍),也就是O(1)时间内给出答案
- S ( P ) ≥ 0 S(P) \geq 0 S(P)≥0, 于是,在构造状态数组的时候,就不需要考虑索引为负的情形(参考动态规划),同时,遍历范围也从 ( − S , S ) (-S, S) (−S,S) 变到 ( 0 , S ) (0, S) (0,S)。
因此,程序变快是必然的。
class Solution:
def findTargetSumWays(self, nums: List[int], S: int) -> int:
sum_all = sum(nums)
if (sum_all + S) % 2 == 1 or sum_all < S:
# 第一个条件是奇数的话,不存在;而是和达不到S,也不存在
return 0
# 错误判定,当一个元素为0的时候,有加减两种情况啊!
#if sum_all == S:
# return 1
S = (sum_all + S) // 2
# 剩下的问题,转化为从数组中依次挑数字,组成和为 S
dp = [0] * (S+1)
dp[0] = 1 # 组成和S=0的,只有一种取法,什么都不挑
for num in nums:
for j in range(S, num-1, -1):
# 此处一定要从 S 往小更新,反过来会有重复计算的问题
dp[j] += dp[j - num]
return dp[S]
7.1 运行结果:
7.2 分析:
!!!原来对于这道题,最快的解法是 01 规划!!!
掌握了BFS或是DFS, 只是提供了一种暴力搜索的方法,而对于这种题来说,有更适合它的解法。
这也提醒我,不能掌握了BFS或是DFS后,眼里看什么都是遍历……这两种方法适合在最开始,没有什么思路的时候用。
泪奔!!!终于经过这几天的努力,把这个解法掌握了!
7.2.1 几个注意点:
- 关于for循环里面,究竟是从目标和S递减到每个元素num呢?还是反过来从num到S。经过我自己的逐步拆分研究,只有前者是对的,后者存在大量的重复运算。
对于数组dp[i](它的含义是,当元素和为i时,拥有的组合方法数目)。第一轮循环后,只有数组dp[num1] = 1 (即参数得到更新)。 这非常合理:因为当我们只取一个数num1的时候,我们只有一个方法获得和S=num1;取第二个数num2时,我们可以更新dp[num1 + num2] = 1, dp[num2]=1, 分析同理。 这种参数更新方式,是自下往上层层更新。
这时我们来看反过来的情况,j从num增长到S。
当取第一个数 num1, 第一轮下来,获得更新的参数是 dp[num1] = 1 (正确), 还会更新dp[2 * num1]=1, dp[3*num1]=1… 直到 n*num1大于S为止(这是for循环里面dp更新公式的自然推论)。 不用再往下算,已经能发现矛盾的地方了!就是,我们只选取了一个数 num1, 怎么可能凑出和等于 2*num1 呢?
这里的错误很隐晦,不直观。大家可以参考解法三的动态规划,里面我们使用了一个二维数组,第一个下标,是储存我们使用的元素及其个数,这一个维度,确保我们不会把不相关的量关联起来。所以,上面的 dp[2*num1] 用二维数组表示,应该是 dp[0][2 * num](含义是,使用索引为 0 的数,能凑出和为 2*num1 的方法数),答案很显然,应该为 0; 这一项跟 dp[num1] (可等效为 dp[0][num1])并无关系,是互相独立的。这也就导致了错误的产生。
划重点!!!! 一遍来说,动态规划,主要是用二维数组来表示状态转移方程,根据问题特点,有些可以转化为一维数组。解法四里面的,就是可以转化的例子,这里特别注意参数更新顺序!实在想不通的话,就翻译成二维数组,虽然多用了空间,但可以保证不出错啊!