动态规划问题

243 阅读28分钟

1 前言

本章所有的题解代码:题解代码

动态规划问题的一般形式就是求局部或者全局的最优解。动态规划其实是运筹学的一种最优化方法,例如求最长递增子序列,最小编辑距离等。

动态规划的核心问题就是穷举。想要正确穷举,就必须熟练掌握回溯的思维,列出正确的状态转移方程,并且正确判断算法问题是否具备[最优子结构]。此外动态规划问题也存在[重叠子问题],需要借助备忘录或者DP table优化穷举过程,避免不必要的计算。

动态规划三要素记为:

  • 状态转移方程
  • 具备最优子结构
  • 重叠子问题

在求解动态规划问题时,可以参考如下的思维框架来求解状态转移方程:

明确[状态] -> 明确[选择] -> 定义dp数组

下面通过两个例子来具体解释动态规划的基本原理。

1.1 斐波那契数列

T509 斐波那契数列

时间复杂度

递归问题的时间复杂度如何计算?

子问题数量 * 解决子问题需要的时间

例如求解斐波那契数列,总共有n个子问题,每个子问题只需要相加,没有任何的循环,所以每个子问题的时间为o(1),综上,本例的时间复杂度为o(N)

状态转移方程

什么是状态转移方程?

实质上就是用来描述问题结构的数学公式。

千万不要看不起暴力求解,动态规划问题最困难的就是写出暴力解,即状态转移方程。

1.2 凑零钱问题

1.2.1 递归方式

如何列出正确的状态转移方程|转移函数?

  • 确定状态:目标就是为了达到总金额amount
  • 确定选择,导致状态发生变化的行为。每次选择硬币都会导致状态发生变化
  • 确定dp方程。递归方式使用的是自顶向下的方式,dp函数为int dp(coins, remainders)

递归方式容易爆栈

1.2.3 迭代方式

迭代方式和递归方式不同,采用了自底向上的方式。

状态转移方程为dp[i] = Math.min(dp[i], dp[i - coin] + 1)

  • 初始化dp数组,索引表示金额,值表示硬币个数
  • 初始化dp,dp[coin] = 1,其他值为amount + 1
  • 从1开始,自底向上更新dp,如果i - coin < 0表示子问题无解
  • 最后判断是否出现了dp[amount] == amount + 1,得出正确结果

1.3 总结

递归和迭代两种方法的区别在于,前者是自顶向下的,后者是自底向上的。在拆分问题的时候,需要自顶向下,列出状态转移方程,然后自底向上更新dp数组。

2 动态规划基本技巧

2.1 数学归纳思想

动态规划的难点在于寻找状态转移方程,下面借助经典的T300 最长递增子序列问题引出动态规划问题的通用技巧:数学归纳思想

总结一下寻找状态转移方程的步骤:

1、明确dp数组的定义,这一点十分重要。

2、根据dp数组的定义,运用数学归纳法的思想,假设dp[0...i-1]已知,求出dp[i]

将该问题拓展到二维,即为俄罗斯信封问题:T354俄罗斯套娃信封问题

2.2 备忘录初值敲定技巧

如何设置动态规划问题的 base case、备忘录的初始值,本节通过剖析 T931下降路径最小和分析三个问题:

  • base case 的条件如何确定?
  • 备忘录的初始值如何确定
  • 边界情况的返回值如何确定

base case 的条件如何确定?

base case 条件和dp数组的定义有关。在T931题中,int[][] dp表示从matrix[0][j]落到matrix[i][j]所走的最短路径,因此我们将base case设置为矩阵第一行,也就是下落的起始位置的值。

总之,base case的确定和dp数组的定义息息相关。

备忘录的初值如何确定?

备忘录的初值一般都是存储的特殊值,要和合法数据区分开来。

边界情况的返回值如何确定

对于不合法的索引,返回值如何确定,要根据我们的状态转移方程的逻辑确定。

对于T931题,要取的值是三个中的最小的,所以我们将不合法的值设置为最大,即Integer.MAX_VALUE,保证存在有效数据的情况下,不会取到非法数据。

3 子序列问题

本节从T72 计算编辑距离入手,探讨子序列问题的解题思路。

3.1 思路分析

需要明确:不管是s1变为s2还是s2变为s1,结果都是一样的。

小技巧

解决两个字符串的动态规划问题,一般都是使用两个指针i, j分别指向两个字符串的头部或者尾部,然后尝试写状态转移方程。

例如,将i, j分别指向两个字符串的头部,将dp[i]dp[j]定义为s1[0...i], s2[0...j]子串的编辑距离,i,j一步步向后移动的过程就是问题规模逐步增大的过程。

3.2 递归暴力解法

思路分析

递归采用自顶向下的解法,将一个大问题拆分成若干个小问题。

将双指针i, j分别指向s1, s2的尾部

两种情况:

  • 如果两个字符串相同:编辑距离不增加,两个指针同时往左移动
  • 如果两个字符不相同:
    • 删除s1.charAt(i)i指针往左移动一步,dp + 1,返回值为dp(s1, i-1, s2, j) + 1
    • 增加s2.charAt(j)i指针不动,j移动一位,dp + 1,调用递归函数dp(s1, i, s2, j) + 1
    • 替换为s2.chatAt(j)i,j指针同时往左移动,返回值为dp(s1, i - 1, s2, j - 1) + 1
  • 返回最小的dp

3.3 备忘录优化

使用递归进行dfs查询的时候,有许多值被重复计算了,需要使用数组memo[m][n]存储中间计算结果,如果有结果,直接返回数据,不必要进入dfs

优化后的代码如下:

public int minDistanceI(String word1, String word2) {
        int m = word1.length();
        int n = word2.length();
        
        // 初始化成员变量memo
        memo = new int[m][n];
        for (int[] row : memo) {
            Arrays.fill(row, -1); // 表示(i, j)结尾的最短编辑距离没有计算出来
        }
        return dpWithMemo(word1, m, word2, n);
        
        
    }

    private int dpWithMemo(String word1, int i, String word2, int j) {
        if (i == -1) return j + 1;
        if (j == -1) return i + 1;
        
        if (memo[i][j] != -1) return memo[i][j];
        if (word1.charAt(i) == word2.charAt(j)) {
            memo[i][j] = dpWithMemo(word1, i -1, word2, j - 1);
        } else {
            memo[i][j] = Math.min(
                    dpWithMemo(word1, i, word2, j - 1) + 1, // 插入,j移动
                    Math.min(
                            dpWithMemo(word1, i - 1, word2, j) + 1, // 删除,i移动
                            dpWithMemo(word1, i - 1, word2, j - 1) + 1 // 替换,i j 移动
                    )
            );
        }
        return memo[i][j];
    }

3.4 DP table解法

前文说到,两个单词需要两个指针i, j,因此我们需要使用二维dp[m + 1][n + 1]记录以i, j结尾的字符字串之间的最短编辑距离。

为什么要多出来一位呢?

为了base case 即DP table的初始化工作。即当某一指针走到尽头的时候,两个字串的最短编辑距离。

初始化时,需要为dp[0][:]dp[:][0]赋初值,初值为和空串的编辑距离。

DP table如何更新

DP table的更新自底向上更新。

4 背包类型问题

4.1 0-1背包问题

4.1.1 问题描述

给你一个可装载重量为 W 的背包和 N 个物品,每个物品有重量和价值两个属性。其中第 i 个物品的重量为 wt[i],价值为 val[i]。现在让你用这个背包装物品,每个物品只能用一次,在不超过被包容量的前提下,最多能装的价值是多少?

什么是0-1背包?

题目中的物品不可以分割,要么装进包里,要么不装,不能说装一半,这就是0-1背包的来历。

解决背包问题没什么特别的技巧,就是穷举。

4.1.2 解题套路

1)明确两个点:状态和选择

  • 状态:背包的容量 和 可选择的物品
  • 选择:要么装进背包,要么不装进去背包

2)明确 DP table 的定义

前文讲到问题有两个状态,因此 DP table 有两个维度,分别表示当前背包的最大容量,以及前 i 个商品可供选择的情况下能够装的最大价值。

dp[i][j] = val表示对于一系列商品只对前i个商品选择,在背包容量为j的情况下能够装价值为val的商品。

因此最终答案就是dp[N][W],需要对第一行和第一列初始化为0,表示没有容量或者没有东西可以装,什么也装不了。

3)根据选择,思考状态转移的逻辑

首先分析选择:对于第i件商品,可以选择放进背包不放进背包

  • 不放进背包dp[i][w] = dp[i - 1][w],背包里头的东西没有动
  • 放进背包dp[i][w] = val[i - 1] + dp[i - 1][w - wt[i - 1]]
    • val[i - 1]表示第i件商品的价值
    • w - wt[i - 1]表示背包里偷剩余的空间

4)处理边界情况,得出代码

如果w - wt[i - 1] < 0表示背包里头并没有多余的空间来放第i件商品,需要处理。

public int knapsack(int w, int[] wt, int[] val) {
    int n = wt.length; // 商品的数量
    
    // 初始化 DP 数组
    int[][] dp = new int[n + 1][w + 1];
    
    for (int i = 1; i <= n; i++) {
        for(int j = 1; j <= w; j++) {
            if (w - wt[i - 1] < 0) {
                // 没有空间放入第i件商品
                dp[i][j] = dp[i - 1][j];
            } else {
                // 择优选择放入或者不放入
                dp[i][j] = Math.max(
                	dp[i - 1][w - wt[i - 1]] + val[i - 1], // 放入
                    dp[i - 1][w] // 不放
                );
            }
        }
    }
    return dp[n][w];
}

4.2 子集背包问题

4.2.1 问题描述

见题T416 分割等和子集

4.2.2 思路分析

这一题是和上一题相同的,dp[][]数组的定义只有1-i个物品可选的时候,能否将背包装满。

  • 状态:背包的容量 和 可选择的数字
  • 选择:放进背包 和 不放进背包

dp数组的定义:背包容量为i的时候,能够从1-j的物品中选择若干物品,使得背包装满

状态转移逻辑

  • 不装dp[i][j] = dp[i][j-1]
  • dp[i][j] = dp[i - nums[j - 1]][j - 1]

4.2.3 空间优化

我们可以看到,装不装这个问题都之和dp[][j - 1]有关,所以我们将二维dp数组压缩为一维,每次迭代时更新dp

4.3 完全背包问题

本节从T518 零钱兑换II入手探讨背包问题的变种。

4.3.1 我的思路

典型的背包问题:

状态:金额 和 硬币选择

选择:用当前面额还是不用

DP tableint[amount + 1][n] dp

初始化:第一行全部为1,表示金额为0,只有一种方法可以实现

状态转移:使用1 -> i面额总共有dp[i][j - 1] + dp[i - nums[j - 1]][j]中方法,用了就可以用多次

返回值dp[amount][n]

4.3.2 优化方法

创建一维dp数组dp[amount + 1],对硬币种类遍历,

假设第i个硬币的面值为3,从3开始对dp数组更新,dp[x] = dp[x] + dp[x - coin]dp[x]表示未用过x时候的种类,而dp[x - coin]表示用到了当前硬币的种类。

代码如下:

public int changeI(int amount, int[] coins) {
    int[] dp = new int[amount];
    dp[0] = 1;

    for (int coin : coins) {
        for (int i = coin; i <= amount; i++) {
            dp[i] = dp[i - coin] + dp[i];
        }
    }
    return dp[amount];
}

5 游戏中的动态规划

5.1 最小路径和

本节剖析一下经典的动态规划题目,T64 最小路径和

5.2 地下城游戏

T174地下城游戏

5.2.1 思路分析

这一题看上去和最小路径相似,但是也略有不同。

想要获得该问题的最优子结构,就必须从暴力法开始。

动态规划问题都可以通过回溯问题来解决,尽管回溯法会出现超时的情况,但是不影响我们将回溯法作为问题的突破口。

只要能够正确得出dp函数的定义,以及递归和跳出递归的条件,就能解决了。

5.2.2 回溯法

定义dp函数为从(i, j)到终点即(m - 1, n - 1)所需要的最小的血量,即dp(grid, i, j)

递归解的代码如下:

int calculateMinimumHp(int[][] grid) {
    retrun dp(grid, 0, 0);
}
int dp(int[][] grid, int i, int j) {
    int m = grid.length;
    int n = grid[0].length;
    
    // 跳出递归的条件:到达终点
    if (i == m - 1 && j == n - 1) {
        return grid[i][j] >= 0 ? 1 : 1 - grid[i][j];
    }
}

想要求得dp(0, 0)的返回值,就必须通过dp(0 + 1, 1)dp(0, 0 + 1)中推出dp(0, 0),正确进行状态转移。

那么如何推导出状态转移方程呢?

我们知道了dp(1, 0) = 4dp(0, 1) = 3,根据题意,要从(0, 1) -> (0, 0),也就是从右边得出,假设grid(0, 0) = -1,可以得出dp(0, 0) = dp(0, 1) - grid(0, 0)。将上面的过程推广到一一般情况,可以得出状态转移方程为:

val=min(dp(i+1,j),dp(i,j+1))grid(0,0)dp(i,j)={valif val>0,1if val0.val = \min(dp(i + 1, j), dp(i, j + 1)) - grid(0, 0) \\ dp(i,j) = \begin{cases} val & \text{if } val > 0,\\ 1 & \text{if } val \le 0.\\ \end{cases}

即:

int val = min (dp(i + 1, j), dp(i, j + 1)) - grid(i, j);
dp(i, j) = val <= 0 ? 1 : val;

5.2.3 备忘录优化

public int calculateMinimumHPI(int[][] dungeon) {
    int m = dungeon.length;
    int n = dungeon[0].length;
    // 备忘录初始化,未记录的值为-1,合法的血量应该都为[1, infinity]
    memo = new int[m][n];
    for (int[] row : memo) {
        Arrays.fill(row, -1);
    }
    return dp(dungeon, 0, 0);
}

private int[][] memo; // 备忘录,记录计算过的值

private int dp(int[][] dungeon, int i, int j) {
    int m = dungeon.length;
    int n = dungeon[0].length;
    
    // base case
    if (i == m - 1 && j == n - 1) {
        return dungeon[i][j] >= 0 ? 1 : 1 - dungeon[i][j];
    }
    // 边界条件
    if (i == m || j == n) {
        return Integer.MAX_VALUE;
    }
    
    // 查询备忘录
    if (memo[i][j] != -1) {
        return memo[i][j];
    }
    
    // 状态转移逻辑
    int val = Math.min(
            dp(dungeon, i + 1, j),
            dp(dungeon, i, j + 1)
    ) - dungeon[i][j];
    return val > 0 ? val : 1;
}

5.2.4 动态规划

从上节回溯的解法思路出发,回溯是自顶向上的,即从 左上->右下,那么动态规划数组应到是从 右下-> 左上

  • DP table:定义为int[m][n] dp,表示从当前位置到grid(m-1, n-1)需要的最少的血量
  • 状态转移dp[i][j] = Math.min(dp[i + 1][j], dp[i][j + 1]) - grid[i][j]
  • 初始状态dp[m - 1][n - 1] = grid[i][j] >= 0 ? 1 : 1 - grid[i][j]

注意本题的DP table是从右下到左上更新的,这一点和前面最小路径和相反。这是因为我们站在(i,j)的位置,应当考虑下一步的路,即往下走还是往右走,而不是站在哪里,考虑从上面来还是从左边来。

具体地说,如果从左上到右下更新DP table,就会造成路径寻求局部最小的耗费生命值的路径或者追求更多更大的血包。

5.3 自由之路

T514 自由之路

6 贪心类型问题

6.1 老司机加油站

见LeetCode第134题T134加油站

6.1.1 暴力求解

从每个加油站开始作为起点,暴力求解。

算法的时间复杂度为o(N2)o(N^2)

6.1.2 图像解法

定义remainders += gas[i] - cost[i]表示第i个加油站获得的汽油减去到达第i个加油站耗费的汽油,即当前汽油值。这样就可以得出N个数据点,连成函数图,可以看到油箱剩余汽油的情况。

image-20241008201827594

可以从图中看到,如果想要车子从一个站点行驶到最后一个站点,折线图不能有小于0的部分。所以,我们应该把最低点的下一个车站作为汽车的起始点,这样可以保证油箱剩余的油始终在x轴之上,即油箱里头始终有油。

当然,如果reminders < 0,表示根本没有足够的油来环游一周,直接返回-1

时间复杂度为o(N)o(N)

6.1.3 贪心解法

结论:如果从i站出发到不了j站,那么从i + 1 ~ j - 1之间的站出发都到不了j站,下一站应当从j + 1出发。

根据这个结论,我们可以优化一下暴力解的代码:

public int canCompleteCircuit(int[] gas, int[] cost) {
    int n = gas.length;
    int sum = 0;
    for (int i = 0; i < n; i++) {
        sum += gas[i] - cost[i];
    }
    if (sum < 0) {
        // 总油量小于总的消耗,无解
        return -1;
    }
    // 记录油箱中的油量
    int tank = 0;
    // 记录起点
    int start = 0;
    for (int i = 0; i < n; i++) {
        tank += gas[i] - cost[i];
        if (tank < 0) {
            // 无法从 start 到达 i + 1
            // 所以站点 i + 1 应该是起点
            tank = 0;
            start = i + 1;
        }
    }
    return start == n ? 0 : start;
}

时间复杂度为o(N)o(N)

动态规划经典例题

T509 斐波那契数列

题目描述

斐波那契数 (通常用 F(n) 表示)形成的序列称为 斐波那契数列 。该数列由 01 开始,后面的每一项数字都是前面两项数字的和。也就是:

F(0) = 0F(1) = 1
F(n) = F(n - 1) + F(n - 2),其中 n > 1

给定 n ,请计算 F(n)

我的思路

  • 题目描述已经给出了状态转移方程dp_n = dp_{n-1} + dp_{n-2}
  • 需要两个来存储计算中间结果,即int dp_n, dp_{n-1}
  • 根据状态转移方程自底向上求解问题

T322 零钱兑换

题目描述

给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。

计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1

你可以认为每种硬币的数量是无限的。

示例 1:

输入:coins = [1, 2, 5], amount = 11
输出:3 
解释:11 = 5 + 5 + 1

我的思路

  • 最少的硬币代表着每次我们使用最大的面额兑换
  • 循环条件为:while (amount != 0)
  • 每次兑换成功之后count++
  • reminder < coins[0]说明换不了了,就直接返回-1
  • 跳出循环返回count

思路不正确,coins数组并不是1,5,10这样成倍数的,不能排序之后从大到小进行递归。

递归方式

见[1.2.1 递归方式](# 1.2.1 递归方式)

暴力迭代方式

  • 新建dpint[amount+1] dp

  • 初始化dpdp[coin] = 1

  • 更新dp数组

    for (i = amount; i >= 0; i--) {
        if (dp[i] != 0) {
            for (int coin : coins) {
                if (i+ count <= amount) {
                    dp[i + coin] = dp[i + coin] == 0 ? 1 + dp[i] : Math.min(1 + dp[i] : dp[i + coin])
                }
            }
        }
    }
    

数组迭代

见[1.2.3 迭代方式](#1.2.3 迭代方式)

T300 最长递增子序列问题

题目描述

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。

子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。

示例 1:

输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。

我的思路

  • 定义dp数组int[n] dp并初始化dp[0] = 1,dp[i]表示以这个元素结尾最长的子序列的长度
  • dp如何更新?对于第i个元素,从0开始到i - 1遍历寻找离他左边最近的元素的dp[j]值,并记录最大的dp[j]
  • dp[i] = Math.max(dp[j] + 1, 1),要么是前一个严格小的 + 1,要么就是1
  • 在迭代过程中,记录最长的dp[i]

时间复杂度为o(N^2)

二分查找优化

这道题更优的解题思路见:二分查找优化,具体推导过程不再赘述。

T354 俄罗斯套娃信封问题

题目描述

给你一个二维整数数组 envelopes ,其中 envelopes[i] = [wi, hi] ,表示第 i 个信封的宽度和高度。

当另一个信封的宽度和高度都比这个信封大的时候,这个信封就可以放进另一个信封里,如同俄罗斯套娃一样。

请计算 最多能有多少个 信封能组成一组“俄罗斯套娃”信封(即可以把一个信封放到另一个信封里面)。

注意:不允许旋转信封。

示例 1:

输入:envelopes = [[5,4],[6,4],[6,7],[2,3]]
输出:3
解释:最多信封的个数为 3, 组合为: [2,3] => [5,4] => [6,7]

我的思路[超时]

  • 首先对数组元素排序,从小到大递增
  • 定义dp数组,表示当前信封最多能装多少个,初始化int[n] dp数组为dp[0] = 1
  • 更新dp数组
    • 对于第i个信封,从0 -> i - 1遍历严格小于他的信封
    • 如果存在则dp[i] = dp[j] + 1
    • 如果不存在则dp[i] = 1
    • 同时记录最大的maxDp
  • 返回maxDp

思路

  • 首先对数组元素排序,长按照从小达到排序,宽按照从大到小排序,这是为了在长度相同的情况下,防止互相嵌套
  • 抽取宽度数组width[]
  • 对宽度数组求最长严格单调递增子序列
  • 使用二分法求,否则超时

T931 下降路径最小和

题目描述

给你一个 n x n方形 整数数组 matrix ,请你找出并返回通过 matrix下降路径最小和

下降路径 可以从第一行中的任何元素开始,并从每一行中选择一个元素。在下一行选择的元素和当前行所选元素最多相隔一列(即位于正下方或者沿对角线向左或者向右的第一个元素)。具体来说,位置 (row, col) 的下一个元素应当是 (row + 1, col - 1)(row + 1, col) 或者 (row + 1, col + 1)

我的思路

  • 新建一个int[n][n] dp数组,第一行初始化为dp[i][j] = matrix[i][j]
  • 从第2行开始遍历,dp[i][j] = matrix[i][j] + Math.min(dp[i-1][j-1], dp[i-1][j], dp[i-1][j+1])
  • 返回最后一行最小的dp

T72 计算编辑距离

题目描述

给你两个单词 word1word2请返回将 word1 转换成 word2 所使用的最少操作数

你可以对一个单词进行如下三种操作:

  • 插入一个字符
  • 删除一个字符
  • 替换一个字符

示例 1:

输入:word1 = "horse", word2 = "ros"
输出:3
解释:
horse -> rorse (将 'h' 替换为 'r')
rorse -> rose (删除 'r')
rose -> ros (删除 'e')

我的思路

实质上是寻找两个单词最长的公共子序列。

没思路

T416 分割等和子集

题目描述

给你一个 只包含正整数非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

示例 1:

输入:nums = [1,5,11,5]
输出:true
解释:数组可以分割成 [1, 5, 5][11]

我的思路

  • 平均分为两组,首先先计算出数组和,如果是奇数,直接返回。计算出来每个子集的和subSum
  • 寻找子序列为和为subSum的索引 ,背包问题
  • 两个状态:子集和 以及 选择的数,所以dp[sunSum + 1][n + 1],表示选择当前数字下,能够装的最大的物品
  • 我们只需要看看最后一行是否全部为subSum即可

T518 零钱兑换II

题目描述

给你一个整数数组 coins 表示不同面额的硬币,另给一个整数 amount 表示总金额。

请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0

假设每一种面额的硬币有无限个。

题目数据保证结果符合 32 位带符号整数。

示例 1:

输入:amount = 5, coins = [1, 2, 5]
输出:4
解释:有四种方式可以凑成总金额:
5=5
5=2+2+1
5=2+1+1+1
5=1+1+1+1+1

我的思路

典型的背包问题:

状态:金额 和 硬币选择

选择:用当前面额还是不用

DP tableint[amount + 1][n] dp

初始化:第一行全部为1,表示金额为0,只有一种方法可以实现

状态转移:使用1 -> i面额总共有dp[i][j - 1] + dp[i - nums[j - 1]][j]中方法,用了就可以用多次

返回值dp[amount][n]

T64 最小路径和

题目描述

给定一个包含非负整数的 m×nm \times n 网格 grid ,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。

**说明:**每次只能向下或者向右移动一步。

示例

输入:grid = [[1,3,1],[1,5,1],[4,2,1]]
输出:7
解释:因为路径 13111 的总和最小。

我的思路

  • 状态:坐标,x轴和y轴
  • 选择:往还是往
  • DP tableint[m][n] dp ,起点位置设置为dp[0][0] = grid[0][0],第一行和第一列都要初始化
  • 状态转移dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j]
  • 边界条件i - 1 < 0 || j - 1 < 0
  • 返回值dp[m - 1][n - 1]

T174 地下城游戏

题目描述

恶魔们抓住了公主并将她关在了地下城 dungeon右下角 。地下城是由 m x n 个房间组成的二维网格。我们英勇的骑士最初被安置在 左上角 的房间里,他必须穿过地下城并通过对抗恶魔来拯救公主。

骑士的初始健康点数为一个正整数。如果他的健康点数在某一时刻降至 0 或以下,他会立即死亡。

有些房间由恶魔守卫,因此骑士在进入这些房间时会失去健康点数(若房间里的值为负整数,则表示骑士将损失健康点数);其他房间要么是空的(房间里的值为 0),要么包含增加骑士健康点数的魔法球(若房间里的值为正整数,则表示骑士将增加健康点数)。

为了尽快解救公主,骑士决定每次只 向右向下 移动一步。

返回确保骑士能够拯救到公主所需的最低初始健康点数。

**注意:**任何房间都可能对骑士的健康点数造成威胁,也可能增加骑士的健康点数,包括骑士进入的左上角房间以及公主被监禁的右下角房间。

示例

image-20241008095931059

输入:dungeon = [[-2,-3,3],[-5,-10,1],[10,30,-5]]
输出:7
解释:如果骑士遵循最佳路径:右 -> 右 -> 下 -> 下 ,则骑士的初始健康点数至少为 7

我的思路

  • 状态:位置坐标,即x轴和y轴
  • 选择:向左还是向下
  • DP table:两张表,一个表示当前体力cur[][],另一个表示最小体力min[][]
  • 更新
    • (i, j)有两条路,从上面来和从左边来
      • 从上面来:min(i,j) = Math.min(cur(i-1, j) + dungeon(i, j), min(i - 1, j))
      • 从左边来:min(i, j) = Math.min(cur(i, j - 1) + dungeon(i, j), min(i, j - 1))
      • 我们取两个之间最大的那个
    • cur[][]的更新:
      • 如果上面选择了从上面来:cur(i,j) = cur(i - 1, j) + dungeon(i, j)
      • 从左边过来:cur(i,j) = cur(i, j - 1) + dungeon(i, j)

思路全错

T514 自由之路

题目描述

电子游戏“辐射4”中,任务 “通向自由” 要求玩家到达名为 “Freedom Trail Ring” 的金属表盘,并使用表盘拼写特定关键词才能开门。

给定一个字符串 ring ,表示刻在外环上的编码;给定另一个字符串 key ,表示需要拼写的关键词。您需要算出能够拼写关键词中所有字符的最少步数。

最初,ring 的第一个字符与 12:00 方向对齐。您需要顺时针或逆时针旋转 ring 以使 key 的一个字符在 12:00 方向对齐,然后按下中心按钮,以此逐个拼写完 key 中的所有字符。

旋转 ring 拼出 key 字符 key[i] 的阶段中:

  1. 您可以将 ring 顺时针或逆时针旋转 一个位置 ,计为1步。旋转的最终目的是将字符串 ring 的一个字符与 12:00 方向对齐,并且这个字符必须等于字符 key[i]
  2. 如果字符 key[i] 已经对齐到12:00方向,您需要按下中心按钮进行拼写,这也将算作 1 步。按完之后,您可以开始拼写 key 的下一个字符(下一阶段), 直至完成所有拼写。

image-20241008160724501

输入: ring = "godding", key = "gd"
输出: 4
解释:
 对于 key 的第一个字符 'g',已经在正确的位置, 我们只需要1步来拼写这个字符。 
 对于 key 的第二个字符 'd',我们需要逆时针旋转 ring "godding" 2步使它变成 "ddinggo"。
 当然, 我们还需要1步进行拼写。
 因此最终的输出是 4

我的思路

  • 状态:当前要匹配的字符key[i]和指针指向的字符ring[j]
  • 选择:往左移动还是往右移动

DP 函数如何定义?根据状态和选择来。

dp(ring, i, key, j)表示从j -> key.length() - 1的字符串,ringi处时需要的最小的步数。

状态转移?你该往左扭还是往右扭?

假设k个匹配的字符,则共有k * 2个选择

int dp(String ring, int i, String key, int j) {
    // 完成输入
    if (j == key.length()) return 0;
    
    // 做出选择
    int res = Integer.MAX_VALUE:
    for (int k : kIndexList) {
        res = min(
        	// 把 i 顺时针扭到 k的代价
            // 把 i 逆时针扭到 k的代价
        );
    }
}

加入备忘录,最终实现代码为:

HashMap<Character, List<Integer>> indexList = new HashMap<>(); // 记录某个char的索引位置
// 备忘录
int[][] memo;

/**
 * 自由之路
 * @param ring
 * @param key
 * @return
 */
public int findRotateSteps(String ring, String key) {
    int m = ring.length();
    int n = key.length();

    // 备忘录初始化为0
    memo = new int[m][n];
    for (int[] arr : memo) {
        Arrays.fill(arr, 0);
    }
    // 记录圆环上字符到索引的映射
    for (int i = 0; i < ring.length(); i++) {
        char c = ring.charAt(i);
        if (!indexList.containsKey(c)) {
            indexList.put(c, new ArrayList<>());
        }
        indexList.get(c).add(i); // 添加索引
    }

    // 从第1个字符开始输入,圆盘位置在12点方向
    return dp(ring, 0, key, 0);
}

private int dp(String ring, int i, String key, int j) {

    if (j == key.length()) return 0; // 递归结束条件

    if (memo[i][j] != 0) return memo[i][j]; // 查找备忘录

    int n = ring.length();
    int res = Integer.MAX_VALUE;
    for (int k : indexList.get(key.charAt(j))) { // 当前字符在ring字符串的位置
        // 拨动圆盘的次数
        int counts = Math.abs(k - i); // i 到 k 之间的距离
        // 选择顺时针拨动还是逆时针
        counts = Math.min(counts, n - counts); //谁短听谁的
        // 继续输入下一个字符
        int remainders = dp(ring, k, key, j + 1);
        res = Math.min(res,  1 + counts + remainders); // 选择整体步数较小的
    }
    memo[i][j] = res;

    return res;
}

T134 加油站

题目描述

在一条环路上有 n 个加油站,其中第 i 个加油站有汽油 gas[i] 升。

你有一辆油箱容量无限的的汽车,从第 i 个加油站开往第 i+1 个加油站需要消耗汽油 cost[i] 升。你从其中的一个加油站出发,开始时油箱为空。

给定两个整数数组 gascost ,如果你可以按顺序绕环路行驶一周,则返回出发时加油站的编号,否则返回 -1 。如果存在解,则 保证 它是 唯一 的。

示例 1:

输入: gas = [1,2,3,4,5], cost = [3,4,5,1,2]
输出: 3
解释:
从 3 号加油站(索引为 3 处)出发,可获得 4 升汽油。此时油箱有 = 0 + 4 = 4 升汽油
开往 4 号加油站,此时油箱有 4 - 1 + 5 = 8 升汽油
开往 0 号加油站,此时油箱有 8 - 2 + 1 = 7 升汽油
开往 1 号加油站,此时油箱有 7 - 3 + 2 = 6 升汽油
开往 2 号加油站,此时油箱有 6 - 4 + 3 = 5 升汽油
开往 3 号加油站,你需要消耗 5 升汽油,正好足够你返回到 3 号加油站。
因此,3 可为起始索引。

我的思路

  • 循环遍历加油站,作为起点
  • 从当前起点记录gas ,如果不能到达下一站,则记录下一站的位置
  • 从下一站开始遍历

时间复杂度为o(N2)o(N^2)