动态规划:区间DP问题【零神基础精讲】

0x3f:https://www.bilibili.com/video/BV1Gs4y1E7EU/

chenf99:由易到难,一步步说明思路和细节:https://leetcode.cn/problems/minimum-cost-to-merge-stones/solution/yi-dong-you-yi-dao-nan-yi-bu-bu-shuo-ming-si-lu-he/

文章目录

  • 区间DP
    • 区间DP定义、三部曲、模板
    • [516. 最长回文子序列](https://leetcode.cn/problems/longest-palindromic-subsequence/)【题型1】
      • 记忆化搜索=>动态规划
    • [1039. 多边形三角剖分的最低得分](https://leetcode.cn/problems/minimum-score-triangulation-of-polygon/)【题型2】
      • 记忆化搜索=>动态规划
      • 模板二写法
    • [375. 猜数字大小 II](https://leetcode.cn/problems/guess-number-higher-or-lower-ii/)
      • 记忆化搜索=>动态规划
    • [1312. 让字符串成为回文串的最少插入次数](https://leetcode.cn/problems/minimum-insertion-steps-to-make-a-string-palindrome/)(旧瓶装新酒)
      • 记忆化搜索=>动态规划
    • [1547. 切棍子的最小成本](https://leetcode.cn/problems/minimum-cost-to-cut-a-stick/)
    • [1000. 合并石头的最低成本](https://leetcode.cn/problems/minimum-cost-to-merge-stones/)(有点懵)
  • 其他
    • [877. 石子游戏](https://leetcode.cn/problems/stone-game/)(区间DP,博弈)

区间DP

区间DP定义、三部曲、模板

区间dp,顾名思义,在区间上dp,大多数题目的状态都是由区间(类似于dp[l][r]这种形式)构成的,就是我们可以把大区间转化成小区间来处理,然后对小区间处理后再回溯的求出大区间的值,因为大区间的最优必须要保证小区间也是最优

区间DP是线性DP的扩展,分阶段地划分问题,与阶段中元素出现的顺序和由前一阶段的哪些元素合并而来有很大的关系。

区间DP的特点:

(1)合并:即将两个或多个部分进行整合,当然也可以反过来。

(2)特征:能将问题分解为能两两合并的形式。

(3)求解:将整个问题取最优值,枚举合并点,将问题分解为左右两个部分,最后合并的两个部分的最优值得到原问题的最优值。

  • 简而言之:通过合并小区间的最优解进而得出整个大区间上的最优解

区间DP三部曲:

  1. 定义状态dp[i, j]为区间[i, j]的最优解

  2. **定义状态转移方程:**常见的写法如下

dp(i,j) = max/min{dp(i,k) + dp(k+1,j)} + w(i,j)   (i <= k < j)

其中dp(i,j)表示在区间[i,j]上的最优值,w(i,j)表示在转移时需要额外付出的代价,选取[i, j]之间的一个分界点k,分别计算[i, k][k+1, j]的最优解

  1. 初始化:dp[i][i] = 常数。区间长度为1时的最优解应当是已知的。

区间DP模板部分:

假设要求的区间最优解为dp[1, n],区间dp问题有两种编码方法:

第一种:常规DP写法

for (int i = n; i >= 1; --i) {
	for (int j = i + 1; j <= n; ++j) {
		for (int k = i; k < j; ++k) {
			dp[i,j] = max/min(dp[i,j], dp[i,k] + dp[k+1, j] + cost)
		}
	}
}

这种写法就是常规的dp写法,枚举i为子区间左边界,枚举j为子区间有边界,枚举k为分界点。要注意由于要求的是dp[1,n],所以i必须从大往小遍历,j必须从小往大遍历。这样在状态转移方程中利用的就是已求解的dp状态。

第二种:将区间分割成一个个小区间,求解每个小区间上的最优解。

dp = new int[n+1][n+1]

for (int len = 2; len <= n; len++) {         // 区间长度
    for (int i = 1; i + len - 1 <= n; i++) { // 枚举起点
        int j = i + len - 1;                 // 区间终点
		// 判断i j关系进行初始化
        for (int k = i; k < j; k++) {        // 枚举分割点,构造状态转移方程
            dp[i][j] = max/min(dp[i][j], dp[i][k] + dp[k + 1][j] + w[i][j]);
        }
    }
}
return dp[1][n] // 返回[1,n]整个区间的最优解

这种写法最常见,枚举len为区间长度,枚举i为区间左端点,由此可以计算出区间右端点j,枚举k为分界点。区间长度从2n,跟上一种写法相同。这种写法的正确性可能不如上一种那么直观,它从小到大枚举出所有区间,在求解大区间时,状态转移方程中利用的状态都是小区间的状态,必定在它之前被求解,所以也是正确的。

  • dp数组的维度和边界条件以及转移方程都是可变的,但是很多简单题都是这样可以做出来的,难题也都是情况更复杂了些,其最基本的思想还是不变的。

516. 最长回文子序列【题型1】

难度中等982

给你一个字符串 s ,找出其中最长的回文子序列,并返回该序列的长度。

子序列定义为:不改变剩余字符顺序的情况下,删除某些字符或者不删除任何字符形成的一个序列。

示例 1:

输入:s = "bbbab"
输出:4
解释:一个可能的最长回文子序列为 "bbbb" 。

示例 2:

输入:s = "cbbd"
输出:2
解释:一个可能的最长回文子序列为 "bb" 。

提示:

  • 1 <= s.length <= 1000
  • s 仅由小写英文字母组成

【思路1:转换】求s和反转后s的LCS

class Solution {
    public int longestPalindromeSubseq(String s) {
        String t = new StringBuilder(s).reverse().toString();
        int n = s.length();
        int[][] dp = new int[n+1][n+1];
        for(int i = 0; i < n; i++){
            for(int j = 0; j < n; j++){
                if(s.charAt(i) == t.charAt(j)){
                    dp[i+1][j+1] = dp[i][j] + 1;
                } else{
                    dp[i+1][j+1] = Math.max(dp[i+1][j], dp[i][j+1]);
                }
            }
        }
        return dp[n][n];
    }
}

记忆化搜索=>动态规划

【思路2:选或者不选】从两侧向内缩小问题规模

  • 要么不选第一个字母,要么不选最后一个字母
dp[i][j]的含义是s[i..=j]的最长回文子串的长度, 最终答案就是dp[0][s.len() - 1], 0 <= i <= j < s.len()
基本状态: 当i==j时, 即只有一个字符, 设置回文长度为1
下面是普通状态转移方法(i < j):
情况1: s[i] == s[j]: 最左边和最右边的字符相同, 我们可以直接将中间部分的最长回文子串长度(dp[i+1][j-1])2作为当前部分的最长回文子串长度dp[i][j]
	=> dp[i][j] = dp[i+1][j-1] + 2;(s[i] == s[j])
情况2: s[i] != s[j]: 最左边和最右边的字符不同, 没别的好办法, 只能取dp[i][j-1]与dp[i+1][j]的较大值
    => dp[i][j] = dp[i][j-1].max(dp[i+1][j]);(s[i] != s[j])

记忆化搜索

class Solution {
    char[] s;
    int[][] memo;
    public int longestPalindromeSubseq(String S) {
        s = S.toCharArray();
        int n = s.length;
        memo = new int[n][n];
        for(int i = 0; i < n; i++){
            Arrays.fill(memo[i], -1);
        }
        return dfs(0, n-1);
    }

    public int dfs(int i, int j){
        if(i > j) return 0; //空串
        if(i == j) return 1; // 只有一个字母
        if(memo[i][j] != -1) return memo[i][j];
        if(s[i] == s[j]) return memo[i][j] = dfs(i+1, j-1) + 2; // 都选
        return memo[i][j] = Math.max(dfs(i+1, j), dfs(i, j-1)); // 枚举哪个不选
    }
}

转成递推

因为计算f(i)需要f[i+1],因此i需要倒序枚举,f(j)需要计算f(j-1),所以j需要正序枚举

class Solution {
    public int longestPalindromeSubseq(String s) {
        int n = s.length();
        char[] c = s.toCharArray();
        int[][] f = new int[n+1][n+1];
        // 倒序枚举i
        for(int i = n-1; i >= 0; i--){
            f[i][i] = 1; // 初始化条件:只有一个字母
            // 正序枚举j
            for(int j = i+1; j < n; j++){
                if(c[i] == c[j]){
                    f[i][j] = f[i+1][j-1] + 2;
                }else 
                	f[i][j] = Math.max(f[i+1][j], f[i][j-1]);
            }
        }
        return f[0][n-1];

    }
}

1039. 多边形三角剖分的最低得分【题型2】

难度中等131

你有一个凸的 n 边形,其每个顶点都有一个整数值。给定一个整数数组 values ,其中 values[i] 是第 i 个顶点的值(即 顺时针顺序 )。

假设将多边形 剖分n - 2 个三角形。对于每个三角形,该三角形的值是顶点标记的乘积,三角剖分的分数是进行三角剖分后所有 n - 2 个三角形的值之和。

返回 多边形进行三角剖分后可以得到的最低分

示例 1:

动态规划:区间DP问题【零神基础精讲】_第1张图片

输入:values = [1,2,3]
输出:6
解释:多边形已经三角化,唯一三角形的分数为 6。

示例 2:

动态规划:区间DP问题【零神基础精讲】_第2张图片

输入:values = [3,7,4,5]
输出:144
解释:有两种三角剖分,可能得分分别为:3*7*5 + 4*5*7 = 245,或 3*4*5 + 3*4*7 = 144。最低分数为 144。

示例 3:

动态规划:区间DP问题【零神基础精讲】_第3张图片

输入:values = [1,3,1,4,1,5]
输出:13
解释:最低分数三角剖分的得分情况为 1*1*3 + 1*1*4 + 1*1*5 + 1*1*1 = 13。

提示:

  • n == values.length
  • 3 <= n <= 50
  • 1 <= values[i] <= 100

记忆化搜索=>动态规划

题解:

定义:dp[i][j]:表示从第i个到第j个角所形成的多边形的最小面积

状态转移方程:

dp[i][j] = min(dp[i][j], dp[i][k] + dp[k][j] + A[i] * A[k] * A[j])

记忆化搜索

class Solution {
    // 定义:dp[i][j]:表示从第i个到第j个角所形成的多边形的最小面积
    // dp[i][j] = min(dp[i][j], dp[i][k] + dp[k][j] + A[i] * A[k] * A[j])
    // 递归边界 dp[i][i+1] = 0   ; 递归入口:dfs(0, n-1)
    private int[] v;
    private int[][] memo;
    public int minScoreTriangulation(int[] values) {
        v = values;
        int n = v.length;
        memo = new int[n][n];
        for(int i = 0; i < n; i++){
            Arrays.fill(memo[i], -1); 
        }
        return dfs(0, n-1);
    }

    public int dfs(int i, int j) {
        if(i + 1 == j) return 0; // 只有两个点,无法形成三角形
        if(memo[i][j] != -1) return memo[i][j];
        int res = Integer.MAX_VALUE;
        for(int k = i+1; k < j; k++) {//枚举顶点k
            res = Math.min(res, dfs(i,k) + dfs(k,j) + v[i] * v[j] * v[k]);
        }
        return memo[i][j] = res;
    }
}

转成递推

  • i, 因为f[i]f[k]转移过来,所以i要倒序枚举
  • j>k, 因为f[i][j]f[i][k]转移过来,所以j要正序枚举
  • 答案f[0][n-1]
class Solution {
    // 定义:dp[i][j]:表示从第i个到第j个角所形成的多边形的最小面积
    // dp[i][j] = min(dp[i][j], dp[i][k] + dp[k][j] + A[i] * A[k] * A[j])
    // 递归边界 dp[i][i+1] = 0   ; 递归入口:dfs(0, n-1)
    public int minScoreTriangulation(int[] values) {
        int n = values.length;
        int[][] f = new int[n][n];
        for(int i = n-3; i >= 0; i--){//三角形至少三个顶点
            for(int j = i+2; j < n; j++){
                int res = Integer.MAX_VALUE;
                for(int k = i+1; k < j; k++){
                    res = Math.min(res, f[i][k] + f[k][j] + values[i] * values[j] * values[k]);
                }
                f[i][j] = res;
            }
        }
        return f[0][n-1];
    }

}

模板二写法

class Solution {
    // 定义:dp[i][j]:表示从第i个到第j个角所形成的多边形的最小面积
    // dp[i][j] = min(dp[i][j], dp[i][k] + dp[k][j] + A[i] * A[k] * A[j])
    // 递归边界 dp[i][i+1] = 0   ; 递归入口:dfs(0, n-1)
    public int minScoreTriangulation(int[] values) {
        int n = values.length;
        int[][] f = new int[n][n];
        for(int i = 0; i < n; i++) Arrays.fill(f[i], Integer.MAX_VALUE);
        for(int len = 3; len <= n; len++){ // 枚举区间长度
            for(int i = 0; i + len - 1 < n; ++i){ // 枚举区间起点
                int j = i + len - 1; // 根据长度和区间起点获得区间终点
                for(int k = i+1; k < j; k++){
                    if(k == i+1) f[i][k] = 0; // 三角形至少三个角
                    if(k == j-1) f[k][j] = 0;
                    f[i][j] = Math.min(f[i][j], f[i][k] + f[k][j] + values[i] * values[j] * values[k]);
                }
            }
        }
        return f[0][n-1];
    }

}

375. 猜数字大小 II

难度中等533

我们正在玩一个猜数游戏,游戏规则如下:

  1. 我从 1n 之间选择一个数字。
  2. 你来猜我选了哪个数字。
  3. 如果你猜到正确的数字,就会 赢得游戏
  4. 如果你猜错了,那么我会告诉你,我选的数字比你的 更大或者更小 ,并且你需要继续猜数。
  5. 每当你猜了数字 x 并且猜错了的时候,你需要支付金额为 x 的现金。如果你花光了钱,就会 输掉游戏

给你一个特定的数字 n ,返回能够 确保你获胜 的最小现金数,不管我选择那个数字

示例 1:

动态规划:区间DP问题【零神基础精讲】_第4张图片

输入:n = 10
输出:16
解释:制胜策略如下:
- 数字范围是 [1,10] 。你先猜测数字为 7 。
    - 如果这是我选中的数字,你的总费用为 $0 。否则,你需要支付 $7 。
    - 如果我的数字更大,则下一步需要猜测的数字范围是 [8,10] 。你可以猜测数字为 9 。
        - 如果这是我选中的数字,你的总费用为 $7 。否则,你需要支付 $9 。
        - 如果我的数字更大,那么这个数字一定是 10 。你猜测数字为 10 并赢得游戏,总费用为 $7 + $9 = $16 。
        - 如果我的数字更小,那么这个数字一定是 8 。你猜测数字为 8 并赢得游戏,总费用为 $7 + $9 = $16 。
    - 如果我的数字更小,则下一步需要猜测的数字范围是 [1,6] 。你可以猜测数字为 3 。
        - 如果这是我选中的数字,你的总费用为 $7 。否则,你需要支付 $3 。
        - 如果我的数字更大,则下一步需要猜测的数字范围是 [4,6] 。你可以猜测数字为 5 。
            - 如果这是我选中的数字,你的总费用为 $7 + $3 = $10 。否则,你需要支付 $5 。
            - 如果我的数字更大,那么这个数字一定是 6 。你猜测数字为 6 并赢得游戏,总费用为 $7 + $3 + $5 = $15 。
            - 如果我的数字更小,那么这个数字一定是 4 。你猜测数字为 4 并赢得游戏,总费用为 $7 + $3 + $5 = $15 。
        - 如果我的数字更小,则下一步需要猜测的数字范围是 [1,2] 。你可以猜测数字为 1 。
            - 如果这是我选中的数字,你的总费用为 $7 + $3 = $10 。否则,你需要支付 $1 。
            - 如果我的数字更大,那么这个数字一定是 2 。你猜测数字为 2 并赢得游戏,总费用为 $7 + $3 + $1 = $11 。
在最糟糕的情况下,你需要支付 $16 。因此,你只需要 $16 就可以确保自己赢得游戏。

示例 2:

输入:n = 1
输出:0
解释:只有一个可能的数字,所以你可以直接猜 1 并赢得游戏,无需支付任何费用。

示例 3:

输入:n = 2
输出:1
解释:有两个可能的数字 1 和 2 。
- 你可以先猜 1 。
    - 如果这是我选中的数字,你的总费用为 $0 。否则,你需要支付 $1 。
    - 如果我的数字更大,那么这个数字一定是 2 。你猜测数字为 2 并赢得游戏,总费用为 $1 。
最糟糕的情况下,你需要支付 $1 。

提示:

  • 1 <= n <= 200

记忆化搜索=>动态规划

class Solution {
    // 递归:设计递归函数为 int dfs(int l, int r) 传入参数 l 和 r 代表在范围 [l,r] 内进行猜数,
    // 返回值为在 [l,r] 内猜中数字至少需要多少钱。
    // 可以决策选哪个数但不能决策值落在哪边 
    // 就是要找你猜的数中的付出现金的最小值,但是你往左方向还是右方向是不能确定的,所以要选最大值
    int n;
    int[][] cache;
    public int getMoneyAmount(int n) {
        this.n = n;
        cache = new int[n+1][n+1];
        return dfs(1, n);
    }

    public int dfs(int l, int r){
        if(l >= r) return 0;
        if(cache[l][r] != 0) return cache[l][r];
        int res = Integer.MAX_VALUE;
        for(int x = l; x <= r; x++){
            // 当选择的数位 x 时,至少需要 cur 才能猜中数字
            int cur = Math.max(dfs(l,x-1), dfs(x+1, r)) + x;
            // 在所有我们可以决策的数值之间取最优
            res = Math.min(res, cur);
        }
        return cache[l][r] = res;
    }
}

转递推:

class Solution {
    // 在求解[l,r]的最小成本时,需要依赖[l,i-1]和[i+1,r]这样更小的区间
    public int getMoneyAmount(int n) {
        // 在[l,r]范围内进行猜数的最小成本
        int[][] f = new int[n+10][n+10];
        for(int len = 2; len <= n; len++){
            for(int l = 1; l+len-1 <= n; l++){
                int r = l+len-1;
                f[l][r] = Integer.MAX_VALUE;
                for(int x = l; x <= r; x++){
                    int cur = Math.max(f[l][x-1], f[x+1][r]) + x;
                    f[l][r] = Math.min(f[l][r], cur);
                }
            }
        }
        return f[1][n];
    }
}

1312. 让字符串成为回文串的最少插入次数(旧瓶装新酒)

难度困难178

给你一个字符串 s ,每一次操作你都可以在字符串的任意位置插入任意字符。

请你返回让 s 成为回文串的 最少操作次数

「回文串」是正读和反读都相同的字符串。

示例 1:

输入:s = "zzazz"
输出:0
解释:字符串 "zzazz" 已经是回文串了,所以不需要做任何插入操作。

示例 2:

输入:s = "mbadm"
输出:2
解释:字符串可变为 "mbdadbm" 或者 "mdbabdm" 。

示例 3:

输入:s = "leetcode"
输出:5
解释:插入 5 个字符后字符串变为 "leetcodocteel" 。

提示:

  • 1 <= s.length <= 500
  • s 中所有字符都是小写字母。

记忆化搜索=>动态规划

记忆化搜索

class Solution {
    char[] c;
    int[][] cache;
    public int minInsertions(String s) {
        c = s.toCharArray();
        int n = c.length;
        cache = new int[n][n];
        for(int i = 0; i < n; i++) Arrays.fill(cache[i], -1);
        return dfs(0, n-1);
    }

    public int dfs(int i, int j){
        if(i >= j) return 0; // 长度为1的串就是回文串
        if(cache[i][j] != -1) return cache[i][j];
        if(c[i] == c[j]) // 相同,不需要插入操作
            return cache[i][j] = dfs(i+1, j-1); 
        // 插入 
        // dfs(i+1, j) : 在 j 处插入一个c[i],则i+1, j不变
        // dfs(i, j-1) :在 i 处插入一个c[j],则i不变,j+1
        return cache[i][j] = Math.min(dfs(i+1, j), dfs(i, j-1)) + 1;
    }
}

转递推

class Solution {
    public int minInsertions(String s) {
        char[] c = s.toCharArray();
        int n = c.length;
        int[][] dp = new int[n][n];
        for(int i = n-1; i >= 0; i--){
            dp[i][i] = 0;
            for(int j = i+1; j < n; j++){
                if(c[i] == c[j]) dp[i][j] = dp[i+1][j-1];
                else
                    dp[i][j] = Math.min(dp[i+1][j], dp[i][j-1]) + 1;
            }
        }
        return dp[0][n-1];
    }
}

1547. 切棍子的最小成本

难度困难77

有一根长度为 n 个单位的木棍,棍上从 0n 标记了若干位置。例如,长度为 6 的棍子可以标记如下:

动态规划:区间DP问题【零神基础精讲】_第5张图片

给你一个整数数组 cuts ,其中 cuts[i] 表示你需要将棍子切开的位置。

你可以按顺序完成切割,也可以根据需要更改切割的顺序。

每次切割的成本都是当前要切割的棍子的长度,切棍子的总成本是历次切割成本的总和。对棍子进行切割将会把一根木棍分成两根较小的木棍(这两根木棍的长度和就是切割前木棍的长度)。请参阅第一个示例以获得更直观的解释。

返回切棍子的 最小总成本

示例 1:

动态规划:区间DP问题【零神基础精讲】_第6张图片

输入:n = 7, cuts = [1,3,4,5]
输出:16
解释:按 [1, 3, 4, 5] 的顺序切割的情况如下所示:

第一次切割长度为 7 的棍子,成本为 7 。第二次切割长度为 6 的棍子(即第一次切割得到的第二根棍子),第三次切割为长度 4 的棍子,最后切割长度为 3 的棍子。总成本为 7 + 6 + 4 + 3 = 20 。
而将切割顺序重新排列为 [3, 5, 1, 4] 后,总成本 = 16(如示例图中 7 + 4 + 3 + 2 = 16)。

示例 2:

输入:n = 9, cuts = [5,6,1,4,2]
输出:22
解释:如果按给定的顺序切割,则总成本为 25 。总成本 <= 25 的切割顺序很多,例如,[4, 6, 5, 2, 1] 的总成本 = 22,是所有可能方案中成本最小的。

提示:

  • 2 <= n <= 10^6
  • 1 <= cuts.length <= min(n - 1, 100)
  • 1 <= cuts[i] <= n - 1
  • cuts 数组中的所有整数都 互不相同

题解:https://leetcode.cn/problems/minimum-cost-to-cut-a-stick/solution/c-dong-tai-gui-hua-si-lu-zhuan-hua-by-ming-tian-ge/

这道题是经典的区间dp。类似石子合并问题、戳气球。

切分木棍也可以想象成每次合并相邻的木棍,使得总成本最小。

  • 状态dp[i][j]

    • 集合:所有把[i,j]的木棍合成一根的方案

    • 属性:求这些方案的最小成本。

  • 状态计算:

最后的合并位置是k

dp[i][j] = min(dp[i, k] + dp[k, j] + cost)

class Solution {
    public int minCost(int n, int[] cuts) {
        List<Integer> list = new ArrayList<>();
        list.add(0);
        list.add(n);
        for(int num : cuts) list.add(num);
        Collections.sort(list);
        int m = list.size();
        int[][] dp = new int[m][m];
        for(int len = 2; len < m; len++){ // 枚举区间
            for(int i = 0; i + len < m; i++){
                int j = i + len;
                dp[i][j] = Integer.MAX_VALUE;
                // 枚举每一个分割点
                for(int k = i + 1; k < j; k++){
                    dp[i][j] = Math.min(dp[i][j], dp[i][k] + dp[k][j] + list.get(j) - list.get(i));
                }
            }
        }
        return dp[0][m-1];
    }
}

1000. 合并石头的最低成本(有点懵)

难度困难214

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

题解0x3f:https://leetcode.cn/problems/minimum-cost-to-merge-stones/solution/tu-jie-qu-jian-dpzhuang-tai-she-ji-yu-yo-ppv0/

class Solution {
    int[][][] memo;
    int[] s; // 前缀和
    int k;
    public int mergeStones(int[] stones, int k) {
        int n = stones.length;
        if((n-1) % (k-1) > 0) 
            return -1; // 无法合并成一堆
        s = new int[n+1];
        for(int i = 0; i < n; i++){
            s[i+1] = s[i] + stones[i]; 
        }  
        this.k = k;
        memo = new int[n][n][k+1];
        for(int i = 0; i < n; i++){
            for(int j = 0; j < n; j++){
                Arrays.fill(memo[i][j], -1);
            }
        }      
        return dfs(0, n-1, 1);
    }

    // 定义dfs(i, j , p) 为把stones[i]到stones[j]合并成p堆的最低成本
    // dfs(i, j, 1) = dfs(i, j , k) + sum(i, j)
    public int dfs(int i, int j, int p){
        if(memo[i][j][p] != -1) return memo[i][j][p];
        if(p == 1) { // 合并成一堆
            return memo[i][j][p] = i == j ? 0 : dfs(i, j , k) + s[j+1] - s[i]; 
        }
        int res = Integer.MAX_VALUE;
        for (int m = i; m < j; m += k - 1) // 枚举哪些石头堆合并成第一堆
            res = Math.min(res, dfs(i, m, 1) + dfs(m + 1, j, p - 1));
        return memo[i][j][k] = res;
    }
}

记忆化搜索优化:

class Solution {
    int[][] memo;
    int[] s; // 前缀和
    int k;
    public int mergeStones(int[] stones, int k) {
        int n = stones.length;
        if((n-1) % (k-1) > 0) 
            return -1; // 无法合并成一堆
        s = new int[n+1];
        for(int i = 0; i < n; i++){
            s[i+1] = s[i] + stones[i]; 
        }  
        this.k = k;
        memo = new int[n][n];
        for(int i = 0; i < n; i++){
            Arrays.fill(memo[i], -1);
        }      
        return dfs(0, n-1);
    }

    // 优化:通过判断j-i是否为k-1的倍数,就能知道p=1还是>=2,p是多余的
    // dfs(i, j) 表示从i到j的石头堆合并到小于k堆的最小代价
    public int dfs(int i, int j){
        if(i == j) return 0; // 只有一堆石头,无需合并
        if(memo[i][j] != -1) return memo[i][j];
        int res = Integer.MAX_VALUE;
        for (int m = i; m < j; m += k - 1) // 枚举哪些石头堆合并成第一堆
            res = Math.min(res, dfs(i, m) + dfs(m + 1, j));
        if ((j-i) % (k-1) == 0) { // 可以合并成一堆
            res += s[j+1] - s[i];
        }
        return memo[i][j] = res;
    }
}

转递推:

class Solution {
    public int mergeStones(int[] stones, int k) {
        int n = stones.length;
        if ((n - 1) % (k - 1) > 0) // 无法合并成一堆
            return -1;

        int[] s = new int[n + 1];
        for (int i = 0; i < n; i++)
            s[i + 1] = s[i] + stones[i]; // 前缀和
        int[][] f = new int[n][n];
        for(int i = n-1; i >= 0; i--) {
            for(int j = i+1; j < n; j++){
                f[i][j] = Integer.MAX_VALUE;
                for(int m = i; m < j; m += k-1){
                    f[i][j] = Math.min(f[i][j], f[i][m] + f[m+1][j]);
                }
                if((j-i) % (k-1) == 0) {
                    f[i][j] += s[j+1] - s[i];
                }
            }
        }
        return f[0][n-1];
    }
}

其他

877. 石子游戏(区间DP,博弈)

难度中等486

Alice 和 Bob 用几堆石子在做游戏。一共有偶数堆石子,排成一行;每堆都有 整数颗石子,数目为 piles[i]

游戏以谁手中的石子最多来决出胜负。石子的 总数奇数 ,所以没有平局。

Alice 和 Bob 轮流进行,Alice 先开始 。 每回合,玩家从行的 开始结束 处取走整堆石头。 这种情况一直持续到没有更多的石子堆为止,此时手中 石子最多 的玩家 获胜

假设 Alice 和 Bob 都发挥出最佳水平,当 Alice 赢得比赛时返回 true ,当 Bob 赢得比赛时返回 false

示例 1:

输入:piles = [5,3,4,5]
输出:true
解释:
Alice 先开始,只能拿前 5 颗或后 5 颗石子 。
假设他取了前 5 颗,这一行就变成了 [3,4,5] 。
如果 Bob 拿走前 3 颗,那么剩下的是 [4,5],Alice 拿走后 5 颗赢得 10 分。
如果 Bob 拿走后 5 颗,那么剩下的是 [3,4],Alice 拿走后 4 颗赢得 9 分。
这表明,取前 5 颗石子对 Alice 来说是一个胜利的举动,所以返回 true 。

示例 2:

输入:piles = [3,7,2,3]
输出:true

提示:

  • 2 <= piles.length <= 500
  • piles.length偶数
  • 1 <= piles[i] <= 500
  • sum(piles[i])奇数

题解:https://leetcode.cn/problems/stone-game/solution/shi-zi-you-xi-dong-tai-gui-hua-qu-jian-d-5ra8/

class Solution {
    // dp[i][j] 【永远】是【先手】玩家赢后手的石头数
    public boolean stoneGame(int[] piles) {
        int n = piles.length;
        if(n == 0) return false;
        int[][] dp = new int[n][n];
        for(int i = 0; i < n; i++){
            dp[i][i] = piles[i];
        }
        for(int i = n-1; i >= 0; i--){
            for(int j = i+1; j < n; j++){
                // 先手玩家拿了开头的piles[i]
                // dp[i+1][j]表示的是后手玩家在这个区间内,比先手玩家多的最大石子个数
                // - dp[i+1][j] : 在这个区间内,先手玩家比后手玩家多的最大石子个数
                // 先手玩家拿了结尾的piles[j]
                // dp[i][j-1]表示的是后手玩家在这个区间内,比先手玩家多的最大石子个数
                // -dp[i][j-1] : 在这个区间内,先手玩家比后手玩家多的最大石子个数;
                // 在这两种情况中, 选择先手玩家和后手玩家选择石子堆后,石子个数差更大的一种情况
                dp[i][j] = Math.max(piles[i] - dp[i+1][j], piles[j] - dp[i][j-1]);
            }
        }
        return dp[0][n-1] >= 0;
    }
}
class Solution {
    // dp[i][j] 【永远】是【先手】玩家赢后手的石头数
    public boolean stoneGame(int[] piles) {
        int n = piles.length;
        if(n == 0) return false;
        int[][] dp = new int[n][n];
        for(int i = 0; i < n; i++){
            dp[i][i] = piles[i];
        }
        for(int i = n-1; i >= 0; i--){
            for(int j = i+1; j < n; j++){
                // 先手玩家拿了开头的piles[i]
                // dp[i+1][j]表示的是后手玩家在这个区间内,比先手玩家多的最大石子个数
                // - dp[i+1][j] : 在这个区间内,先手玩家比后手玩家多的最大石子个数
                // 先手玩家拿了结尾的piles[j]
                // dp[i][j-1]表示的是后手玩家在这个区间内,比先手玩家多的最大石子个数
                // -dp[i][j-1] : 在这个区间内,先手玩家比后手玩家多的最大石子个数;
                // 在这两种情况中, 选择先手玩家和后手玩家选择石子堆后,石子个数差更大的一种情况
                dp[i][j] = Math.max(piles[i] - dp[i+1][j], piles[j] - dp[i][j-1]);
            }
        }
        return dp[0][n-1] >= 0;
    }
}

你可能感兴趣的:(#,零神基础题型总结,动态规划)