面试准备——动态规划(4):合并石头的最低成本 & 击爆气球

题目1:合并石头的最低成本(leetcode1000题)

有 N 堆石头排成一排,第 i 堆中有 stones[i] 块石头。
每次移动(move)需要将连续的 K 堆石头合并为一堆,而这个移动的成本为这 K 堆石头的总数。
找出把所有石头合并成一堆的最低成本。如果不可能,返回 -1 。
示例1:

输入:stones = [3,2,4,1], K = 2
输出:20
解释:
从 [3, 2, 4, 1] 开始。
合并 [3, 2],成本为 5,剩下 [5, 4, 1]。
合并 [4, 1],成本为 5,剩下 [5, 5]。
合并 [5, 5],成本为 10,剩下 [10]。
总成本 20,这是可能的最小值。

示例2:

输入:stones = [3,2,4,1], K = 3
输出:-1
解释:任何合并操作后,都会剩下 2 堆,我们无法再进行合并。所以这项任务是不可能完成的。

示例3:

输入:stones = [3,5,1,2,6], K = 3
输出:25
解释:
从 [3, 5, 1, 2, 6] 开始。
合并 [5, 1, 2],成本为 8,剩下 [3, 8, 6]。
合并 [3, 8, 6],成本为 17,剩下 [17]。
总成本 25,这是可能的最小值。

提示:

  • 1 <= stones.length <= 30
  • 2 <= K <= 30
  • 1 <= stones[i] <= 100

思路:
对于k=2的情况:
每次合并都是相邻的两堆进行合并。用dp[i][j]表示从i到j这个区间合并为1个堆时的最小代价。那么有转移方程:

dp[i][j]=min(dp[i][j],dp[i][k]+dp[k+1][j]+sum[j]-sum[i-1])

即从i到j这个区间合并为1个堆时的最小代价等于从i到k这个区间合并为1个堆时的最小代价加从k+1到j这个区间合并为1个堆时的最小代价和两个堆的总数量。

对于k>2的情况:
通过观察上面的例子,我们可以知道K=2时,我们做的其实是将一部分合并为1堆,另一部分合并为1堆,最后形成一堆。典型的子问题划分问题,可以想象归并排序的过程。

现在K!=2了,那么我们就将一部分合并成K-1堆,另一部分合并成1堆,然后合并。

至于为什么不是K-2堆和2堆以及K-3堆和3堆是因为我们的子问题是合并成1堆,当前状态是由前一状态得到的。而我们最初只有dp[i][i][1]=0这个条件。

我们用dp[i][j][m]表示从i到j这个区间形成m堆所需的最小代价。
则转移方程为:

初始化 dp[i][i][1]=0
dp[i][j][K]=min(dp[i][j][k],dp[i][k][K-1]+dp[k+1][j][1])    
dp[i][j][1]=min(dp[i][j][K]+sum[j]-sum[k-1])

Initial status dp[i][i][1] = 0 and dp[i][i][m] = infinity
dp[i][j][m] = min(dp[i][mid][1] + dp[mid + 1][j][m - 1] + stonesNumber[i][j])

分析:

  • 用dp(i,j)代表把第i堆到第j堆合并成一堆的成本;
  • (n-1)% (K - 1)==0(j - i) % (K - 1)==0则代表可以合并为一堆;
  • sum[i]代表前i堆石头的总数;
  • 在i,j之间移动mid,每次移动K-1个距离,dp(i,mid)+dp(mid+1,j)代表以mid为界限合并为两队的成本,取最小值;
    ,res再加上两堆的石头数(sum[j+1]-sum[i])
    dp(i,j)返回res;
    若n堆不能合并成一堆((n-1)%(K-1)不等于0),返回-1;
    反之返回dp(0,n-1)

代码:

class Solution:
    def mergeStones(self, stones, K):
        n = len(stones)
        if n == 1: return 0
        if n < K: return -1
        if (n - K) % (K - 1): return -1

        inf = float('inf')
        memo = {}

        def dp(i, j, m):
            if (i, j, m) in memo: return memo[(i, j, m)]
            if i == j:
                res = 0 if m == 1 else inf
                memo[(i, j, m)] = res
                return res

            if m == 1:
                res = dp(i, j, K) + sum(stones[i:j + 1])
            else:
                res = inf
                for mid in range(i, j, K - 1):  # length must be K+n*(K-1)
                    res = min(res, dp(i, mid, 1) + dp(mid + 1, j, m - 1))
            memo[(i, j, m)] = res
            return res

        res = dp(0, n - 1, 1)
        return res if res < inf else -1

直接用dp数组还得先算m大的,计算方向比较麻烦,直接递归+“备忘录/Memo”优化来的思路清晰一点,如果不加Memo优化,可能会超时。

题目2:击爆气球(leetcode312题)

给定n个气球,索引从0到n-1。每个气球上涂上一些对它表示数组的数组。你被要求爆裂所有的气球。如果你爆气球我你会得到nums[左] *nums[我] *nums[右]硬币。在这里左和右是相邻的I指数后,爆发,左,右然后成为相邻。
提示:

  • You may imagine nums[-1] = nums[n] = 1. They are not real therefore you can not burst them.
  • 0 ≤ n ≤ 500, 0 ≤ nums[i] ≤ 100

示例:

Input: [3,1,5,8]
Output: 167 
Explanation: nums = [3,1,5,8] --> [3,5,8] -->   [3,8]   -->  [8]  --> []
             coins =  3*1*5      +  3*5*8    +  1*3*8      + 1*8*1   = 167

思路:
首先想到的是暴力遍历解空间,但是这道题的暴力搜索将相当的复杂,因为每打爆一个气球,断开的地方又重新挨上,所有剩下的气球又要重新遍历,这使得分治法不能 work,时间复杂度过高,为O(n!)。
对于像这种求极值问题,我们一般都要考虑用动态规划来做。
首先维护一个二维动态数组 dp,其中 dp[i][j] 表示打爆区间[i,j]中的所有气球能得到的最多金币。
题目中说明了边界情况,当气球周围没有气球的时候,旁边的数字按1算,这样我们可以在原数组两边各填充一个1,方便于计算。
这道题的最难点就是找状态转移方程。所谓的状态转移,就是假设已知了其他状态,来推导现在的状态
假如区间只有一个数,比如 dp[i][i],那么计算起来就很简单,直接乘以周围两个数字即可更新。
如果区间里有两个数字,那么就要算两次了,先打破第一个再打破了第二个,或者先打破第二个再打破第一个,比较两种情况,其中较大值就是该区间的dp值。
对于有很多数的区间 [i,j],要遍历这个区间内的每个气球,设指针为k,k在区间 [i, j] 中,假如第k个气球最后被打爆,那么此时区间 [i, j] 被分成了三部分,[i, k-1],[k],和 [k+1, j],只要我们之前更新过了 [i, k-1] 和 [k+1, j] 这两个子区间的 dp 值,可以直接用 dp[i][k-1] 和 dp[k+1][j],那么最后被打爆的第k个气球的得分该怎么算呢,你可能会下意识的说,就乘以周围两个气球被 nums[k-1] * nums[k] * nums[k+1],但其实这样是错误的,为啥呢?dp[i][k-1] 的意义是什么呢,是打爆区间 [i, k-1] 内所有的气球后的最大得分,此时第 k-1 个气球已经不能用了,同理,第 k+1 个气球也不能用了,相当于区间 [i, j] 中除了第k个气球,其他的已经爆了,那么周围的气球只能是第 i-1 个,和第 j+1 个了,所以得分应为 nums[i-1] * nums[k] * nums[j+1]。因此,状态转移方程为:

dp[i][j] = max(dp[i][j], nums[i - 1] * nums[k] * nums[j + 1] + dp[i][k - 1] + dp[k + 1][j])                 ( i ≤ k ≤ j )

有了状态转移方程了,接下来要考虑本题的第二大难点——区间的遍历顺序。
一般来说,我们遍历所有子区间的顺序都是i从0到n,然后j从i到n,然后得到的 [i, j] 就是子区间。但是这道题用这种遍历顺序就不对,在前面的分析中已经说了,我们需要先更新完所有的小区间,然后才能去更新大区间,而用这种一般的遍历子区间的顺序,会在更新完所有小区间之前就更新了大区间,从而不一定能算出正确的dp值,比如拿题目中的那个例子 [3, 1, 5, 8] 来说,一般的遍历顺序是:

[3] -> [3, 1] -> [3, 1, 5] -> [3, 1, 5, 8] -> [1] -> [1, 5] -> [1, 5, 8] -> [5] -> [5, 8] -> [8]

显然不是我们需要的遍历顺序,正确的顺序应该是先遍历完所有长度为1的区间,再是长度为2的区间,再依次累加长度,直到最后才遍历整个区间:

[3] -> [1] -> [5] -> [8] -> [3, 1] -> [1, 5] -> [5, 8] -> [3, 1, 5] -> [1, 5, 8] -> [3, 1, 5, 8]

我们其实只是更新了 dp 数组的右上三角区域,我们最终要返回的值存在 dp[1][n] 中,其中n是两端添加1之前数组 nums 的个数。循环和递归写法参见代码如下:

class Solution:
    def maxCoins(self, nums) -> int:
        # 循环写法
        n = len(nums)
        if n == 1: return nums[0]
        nums.append(1)
        nums.insert(0,1)

        dp = [[0 for _ in range(n+2)] for _ in range(n+2)]

        for l in range(1,n+1):
            for i in range(1,n-l+2):
                j = i+l-1
                for k in range(i,j+1):
                    dp[i][j] = max(dp[i][j],nums[i-1]*nums[k]*nums[j+1]+dp[i][k-1]+dp[k+1][j])

        print(dp)
        return dp[1][n]

        # # 递归写法
        # n = len(nums)
        # if n == 1: return nums[0]
        # nums.append(1)
        # nums.insert(0,1)
        # dp = [[0 for _ in range(n + 2)] for _ in range(n + 2)]
        #
        # def burst(nums,dp,i,j):
        #     if i>j: return 0
        #     if dp[i][j] > 0: return dp[i][j]
        #     res = 0
        #     for k in range(i,j+1):
        #         res = max(res, nums[i - 1] * nums[k] * nums[j + 1] + burst(nums, dp, i, k - 1) + burst(nums, dp, k + 1, j))
        #
        #     dp[i][j] = res
        #     return res
        #
        # return burst(nums,dp,1,n)

参考网址:
Leetcode1000 合并石头的最低成本 区间DP
LeetCode1000.Minimum Cost to Merge Stones(合并石头的最低成本)
1000. Minimum Cost to Merge Stones
[LeetCode] Burst Balloons 打气球游戏

发布了143 篇原创文章 · 获赞 161 · 访问量 29万+

猜你喜欢

转载自blog.csdn.net/vivian_ll/article/details/100077000