Leetcode 刷题 (14) 队列和堆栈应用实例:目标和(吐血整理 01背包问题、动态规划易错点,深度分析)

题目

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)

问题这样转化有两大好处:

  1. S ( A ) + S S(A) + S S(A)+S 是奇数的时候,我们可以肯定,这道题无解(奇数不可能是 S ( P ) S(P) S(P)的两倍),也就是O(1)时间内给出答案
  2. 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 几个注意点:
  1. 关于for循环里面,究竟是从目标和S递减到每个元素num呢?还是反过来从numS。经过我自己的逐步拆分研究,只有前者是对的,后者存在大量的重复运算
    对于数组dp[i](它的含义是,当元素和为i时,拥有的组合方法数目)。第一轮循环后,只有数组dp[num1] = 1 (即参数得到更新)。 这非常合理:因为当我们只取一个数num1的时候,我们只有一个方法获得和S=num1;取第二个数num2时,我们可以更新dp[num1 + num2] = 1, dp[num2]=1, 分析同理。 这种参数更新方式,是自下往上层层更新。
    这时我们来看反过来的情况,jnum增长到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])并无关系,是互相独立的。这也就导致了错误的产生。
    划重点!!!! 一遍来说,动态规划,主要是用二维数组来表示状态转移方程,根据问题特点,有些可以转化为一维数组。解法四里面的,就是可以转化的例子,这里特别注意参数更新顺序!实在想不通的话,就翻译成二维数组,虽然多用了空间,但可以保证不出错啊!
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值