十八 动态规划-路径问题
18.1 地下城游戏
恶魔们抓住了公主并将她关在了地下城 dungeon 的 右下角 。地下城是由 m x n 个房间组成的二维网格。我们英勇的骑士最初被安置在 左上角 的房间里,他必须穿过地下城并通过对抗恶魔来拯救公主。
骑士的初始健康点数为一个正整数。如果他的健康点数在某一时刻降至 0 或以下,他会立即死亡。
有些房间由恶魔守卫,因此骑士在进入这些房间时会失去健康点数(若房间里的值为_负整数_,则表示骑士将损失健康点数);其他房间要么是空的(房间里的值为 0),要么包含增加骑士健康点数的魔法球(若房间里的值为_正整数_,则表示骑士将增加健康点数)。
为了尽快解救公主,骑士决定每次只 向右 或 向下 移动一步。
返回确保骑士能够拯救到公主所需的最低初始健康点数。
注意:任何房间都可能对骑士的健康点数造成威胁,也可能增加骑士的健康点数,包括骑士进入的左上角房间以及公主被监禁的右下角房间。
示例 1:
!
输入:dungeon = [[-2,-3,3],[-5,-10,1],[10,30,-5]]
输出:7
解释:如果骑士遵循最佳路径:右 -> 右 -> 下 -> 下 ,则骑士的初始健康点数至少为 7 。
示例 2:
输入:dungeon = \[0]
输出:1
动态规划推导状态转移的过程中,要注意: 当前位置的状态不应该受到后续状态的. 这道题如果采取正向 dp 的话, 那么初始的血量只能保证到当前位置的存活,但是无法保证后续位置的情况, 即不具备无后效性. 当时采取逆向 dp 的话, 可以确定从终点开始, 能够保证到达终点的最小血量为 1(不管之前如何加血扣血这一点都不会被影响, 包括接下来逆序 dp 的所有位置, 都具备这一点, 即使是加血的位置, 到达这个位置也至少需要一点健康度)
java
class Solution {
//dp[i][j] 表示在 dungeon[i - 1][j - 1] 所需的最低初始健康度
public int calculateMinimumHP(int[][] dungeon) {
int ret = 1;
int m = dungeon.length, n = dungeon[0].length;
int[][] dp = new int[m + 1][n + 1];
for(int i = 0; i < m + 1; i++){
for(int j = 0; j < n + 1; j++){
dp[i][j] = Integer.MAX_VALUE;
}
}
dp[m][n - 1] = 1;
for(int i = m - 1; i >= 0; i--){
for(int j = n - 1; j >= 0; j--){
dp[i][j] = Math.min(dp[i + 1][j], dp[i][j + 1]) - dungeon[i][j];
dp[i][j] = Math.max(dp[i][j], 1);
}
}
return dp[0][0];
}
}
十九、动态规划-简单多状态
所谓多状态,就是将题目中所有可能出现的状态列举出来,并且是"可持续"的状态而不是"瞬间的"状态
19.1 打家劫舍Ⅱ
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。
给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,今晚能够偷窃到的最高金额。
示例 1:
输入:nums = [2,3,2]
输出:3
解释:你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2), 因为他们是相邻的。
示例 2:
输入:nums = [1,2,3,1]
输出:4
解释:你可以先偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。
偷窃到的最高金额 = 1 \+ 3 = 4 。
这里的两种状态:
-
偷当前屋子
-
不偷当前屋子
如果偷当前屋子的话,那么上一个屋子就不能偷;如果不偷当前屋子,那么有两种状态来源,要么是上一个也不偷,要么是这上一个刚偷完这个不能偷。
另外,为了解决收尾房屋不能同时偷的问题,只需要分别进行 [0, n - 1] 和 [1, n] 的 DP 即可
java
class Solution {
public int rob(int[] nums) {
int n = nums.length;
if(n == 1){
return nums[0];
}
int[] dpa = new int[n];
int[] dpb = new int[n];
int ret = 0;
dpa[0] = nums[0];
dpb[0] = 0;
//关键点就在于,首尾的两个房间只能选一个
for(int i = 1; i < n - 1; i++){
dpa[i] = dpb[i - 1] + nums[i];
dpb[i] = Math.max(dpa[i - 1], dpb[i - 1]);
}
ret = Math.max(dpa[n - 2], dpb[n - 2]);
dpa[1] = nums[1];
dpb[1] = 0;
for(int i = 2; i < n; i++){
dpa[i] = dpb[i - 1] + nums[i];
dpb[i] = Math.max(dpa[i - 1], dpb[i - 1]);
}
ret = Math.max(ret, Math.max(dpa[n - 1], dpb[n - 1]));
return ret;
}
}
19.2 删除并获得点数
给你一个整数数组 nums ,你可以对它进行一些操作。
每次操作中,选择任意一个 nums[i] ,删除它并获得 nums[i] 的点数。之后,你必须删除 所有 等于 nums[i] - 1 和 nums[i] + 1 的元素。
开始你拥有 0 个点数。返回你能通过这些操作获得的最大点数。
示例 1:
输入:nums = [3,4,2]
输出:6
解释:
你可以执行下列步骤:
-
删除 4 获得 4 个点数,因此 3 也被删除。nums =
[2]。 -
之后,删除 2 获得 2 个点数。nums =
[]。
总共获得 6 个点数。
示例 2:
输入:nums = [2,2,3,3,3,4]
输出:9
解释:
你可以执行下列步骤:
-
删除 3 获得 3 个点数。所有的 2 和 4 也被删除。nums =
[3,3]。 -
之后,再次删除 3 获得 3 个点数。nums =
[3]。 -
再次删除 3 获得 3 个点数。nums =
[]。
总共获得 9 个点数。
删除一个元素后要删除其 +-1 的所有元素,因此可以将整个数组进行排序;同时因为删除的选择除了和本身的值有关还和出现次数有关,所以还需要统计每个数字的出现次数。
这样这道题就转化为按摩师:每个数字都是一个客人,选择后都可以获得 次数*值 的点数,同时相邻的都不可以选择
-
选当前位置,上一个只能不选
-
不选当前位置,上一个也不选,上一个位置刚选完这个不能选
java
class Solution {
public int deleteAndEarn(int[] nums) {
int n = nums.length;
Arrays.sort(nums);
int[] points = new int[nums[n - 1] + 1];
for(int i = 0; i < n; i++){
points[nums[i]] += nums[i];
}
return massage(points);
}
public int massage(int[] nums) {
int n = nums.length;
if(n == 0){
return 0;
}
int[] dpa = new int[n]; //选
int[] dpb = new int[n]; //不选
dpa[0] = nums[0];
dpb[0] = 0;
for(int i = 1; i < n; i++){
dpa[i] = dpb[i - 1] + nums[i];
dpb[i] = Math.max(dpa[i - 1], dpb[i - 1]);
}
return Math.max(dpa[n - 1], dpb[n - 1]);
}
}
19.3 买卖股票的最佳时机含冷冻期
给定一个整数数组prices,其中第 prices[i] 表示第 i 天的股票价格 。
设计一个算法计算出最大利润。在满足以下约束条件下,你可以尽可能地完成更多的交易(多次买卖一支股票):
- 卖出股票后,你无法在第二天买入股票 (即冷冻期为 1 天)。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
示例 1:
输入: prices = [1,2,3,0,2]
输出: 3
解释: 对应的交易状态为: [买入, 卖出, 冷冻期, 买入, 卖出]
示例 2:
输入: prices = [1]
输出: 0
首先列举出所有状态:
-
持有股票
-
可交易(未持有股票)
-
冷冻期
接下来推导状态转移
-
持有股票:要么继续持有股票,要么买入股票(从可交易)
-
可交易:要么继续可交易,要么从冷冻期变到可交易
-
冷冻期:从持有股票卖出
java
class Solution {
//三种状态:持有、不持有、冷冻期
public int maxProfit(int[] prices) {
int n = prices.length;
int[] dpf = new int[n];
int[] dpg = new int[n];
int[] dph = new int[n];
dpf[0] = 0 - prices[0];
for(int i = 1; i < n; i++){
dpf[i] = Math.max(dpf[i - 1], dpg[i - 1] - prices[i]);
dpg[i] = Math.max(dpg[i - 1], dph[i - 1]);
dph[i] = dpf[i - 1] + prices[i];
}
return Math.max(dpg[n - 1], dph[n - 1]);
}
}
19.4 买卖股票的最佳时机Ⅲ
给定一个数组,它的第 i 个元素是一支给定的股票在第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你最多可以完成 两笔 交易。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
示例 1:
输入:prices = [3,3,5,0,0,3,1,4]
输出:6
解释:在第 4 天(股票价格 = 0)的时候买入,在第 6 天(股票价格 = 3)的时候卖出,这笔交易所能获得利润 = 3-0 = 3 。
随后,在第 7 天(股票价格 = 1)的时候买入,在第 8 天 (股票价格 = 4)的时候卖出,这笔交易所能获得利润 = 4\-1 = 3 。
示例 2:
输入:prices = [1,2,3,4,5]
输出:4
解释:在第 1 天(股票价格 = 1)的时候买入,在第 5 天 (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。
注意你不能在第 1 天和第 2 天接连购买股票,之后再将它们卖出。
因为这样属于同时参与了多笔交易,你必须在再次购买前出售掉之前的股票。
状态分为两种:持有股票或者可交易,根据完成交易的次数(买股票、卖出算一次)0次、1次、2次又可以分出来各三种情况
-
交易0次:可交易,没进行操作;持有股票,只能是继续持有股票(0次)或者从可交易(0次)买了股票
-
交易1次:可交易,从持有股票(0次)卖出股票完成第一次交易或者继续可交易(1次);持有股票(1次),继续持有股票(1次)或者从可交易(1次)买了股票
-
交易2次:可交易,从持有股票(1次)卖出股票完成第二次交易或者继续可交易(2次);剩下的可以不更新,因为只允许完成两次交易
java
class Solution {
//是否持有股票 - 交易次数
//持有股票:0 1 2
//可交易:0 1 2
public int maxProfit(int[] prices) {
int n = prices.length;
int[][] dpf = new int[n][3];
int[][] dpg = new int[n][3];
for(int i = 0; i < n; i++){
for(int j = 0; j < 3; j++){
dpf[i][j] = -1 - (int)Math.pow(10, 5);
dpg[i][j] = -1 - (int)Math.pow(10, 5);
}
}
dpf[0][0] = 0 - prices[0];
dpg[0][0] = 0;
for(int i = 1; i < n; i++){
dpg[i][0] = dpg[i - 1][0];
dpf[i][0] = Math.max(dpf[i - 1][0], dpg[i - 1][0] - prices[i]);
dpg[i][1] = Math.max(dpg[i - 1][1], dpf[i - 1][0] + prices[i]);
dpf[i][1] = Math.max(dpf[i - 1][1], dpg[i - 1][1] - prices[i]);
dpg[i][2] = Math.max(dpg[i - 1][2], dpf[i - 1][1] + prices[i]);
}
return Math.max(dpg[n - 1][1], Math.max(dpg[n - 1][2], dpg[n - 1][0]));
}
}
二十、动态规划-子数组系列
20.1 环形子数组最大和
给定一个长度为 n 的环形整数数组 nums ,返回* nums 的非空 子数组 的最大可能和 *。
环形数组 * *意味着数组的末端将会与开头相连呈环状。形式上, nums[i] 的下一个元素是 nums[(i + 1) % n] , nums[i] 的前一个元素是 nums[(i - 1 + n) % n] 。
子数组 最多只能包含固定缓冲区 nums 中的每个元素一次。形式上,对于子数组 nums[i], nums[i + 1], ..., nums[j] ,不存在 i <= k1, k2 <= j 其中 k1 % n == k2 % n 。
示例 1:
**输入:**nums = 1,-2,3,-2
**输出:**3
**解释:**从子数组 3 得到最大和 3
难处理的地方在于,怎样找出来子数组横跨首尾时的子数组最大和。正难则反,数组所有元素的和是确定的,那么我们如果能找到以当前位置为结尾的子数组的最小和,那么我们就求得了对应的"子数组补集"的最大和。
所以我们可以把这道题分成两部分来做:
-
每个位置不看横跨首尾的子数组,找以当前位置为结尾的子数组的最大和
-
找以该位置为结尾的子数组的最小和,那么我们就找到了补集的最大和
-
两者取最大值,即是 dpi 的值
-
遍历整个 dp 表,拿到最大和
java
class Solution {
public int maxSubarraySumCircular(int[] nums) {
int n = nums.length;
int[] dp = new int[n];
dp[0] = nums[0];
int gmin = nums[0];
int sum = nums[0];
for(int i = 1; i < n; i++){
dp[i] = Math.min(dp[i - 1] + nums[i], nums[i]);
gmin = Math.min(gmin, dp[i]);
sum += nums[i];
}
int fmax = maxSubArray(nums);
return sum == gmin ? fmax : Math.max(sum - gmin, fmax);
}
public int maxSubArray(int[] nums) {
int n = nums.length;
int[] dp = new int[n];
int ret = nums[0];
dp[0] = nums[0];
for(int i = 1; i < n; i++){
dp[i] = Math.max(dp[i - 1] + nums[i], nums[i]);
ret = Math.max(ret, dp[i]);
}
return ret;
}
}
20.2 乘积为正数的最长子数组长度
给你一个整数数组 nums ,请你求出乘积为正数的最长子数组的长度。一个数组的子数组是由原数组中零个或者更多个连续数字组成的数组。
请你返回乘积为正数的最长子数组长度。
示例 1:
**输入:**nums = 1,-2,-3,4
**输出:**4
**解释:**数组本身乘积就是正数,值为 24 。
如果当前数字是正数的话,那么当前位置为结尾的乘积为正数的子数组长度就是上一个位置为结尾的乘积为正数的子数组长度 +1;以当前位置为结尾的乘积为负数的最长子数组长度就是上一个位置为结尾的乘积为负数的最长子数组长度 +1,如果前面是零,则这一位也只能是零了。
如果当前数字是负数,那么当前位置为结尾的乘积为正数的子数组长度就是上一个位置为结尾的乘积为负数的最长子数组长度 +1;以当前位置为结尾的乘积为负数的最长子数组就是上一个位置为结尾的乘积为正数的最长子数组长度 +1,如果前面是零,那么这一位也只能是零了
java
class Solution {
//如果当前为负数,那么需要找当前位置为结尾最长乘积为负的子数组长度;如果当前为正数,那么需要找当前位置为结尾的最长乘积为正子数组长度
public int getMaxLen(int[] nums) {
int n = nums.length;
int[] dpf = new int[n + 1];
int[] dpg = new int[n + 1];
dpf[0] = 0;
dpg[0] = 0;
int fmax = 0;
for(int i = 1; i <= n; i++){
if(nums[i - 1] > 0){
dpf[i] = dpf[i - 1] + 1;
dpg[i] = dpg[i - 1] == 0 ? 0 : dpg[i - 1] + 1;
}
else if(nums[i - 1] < 0){
dpf[i] = dpg[i - 1] == 0 ? 0 : dpg[i - 1] + 1;
dpg[i] = dpf[i - 1] + 1;
}
fmax = Math.max(fmax, dpf[i]);
}
return fmax;
}
}
二十一、动态规划-子序列问题
子序列问题和子数组问题差别就在于整个数组的部分是否是连续的:子序列不一定连续,只要都是数组中的元素即可;子数组是整个数组的连续的一部分
21.1 摆动序列
如果连续数字之间的差严格地在正数和负数之间交替,则数字序列称为** 摆动序列 。**第一个差(如果存在的话)可能是正数或负数。仅有一个元素或者含两个不等元素的序列也视作摆动序列。
-
例如,
[1, 7, 4, 9, 2, 5]是一个 摆动序列 ,因为差值(6, -3, 5, -7, 3)是正负交替出现的。 -
相反,
[1, 4, 7, 2, 5]和[1, 7, 4, 5, 5]不是摆动序列,第一个序列是因为它的前两个差值都是正数,第二个序列是因为它的最后一个差值为零。
子序列 可以通过从原始序列中删除一些(也可以不删除)元素来获得,剩下的元素保持其原始顺序。
给你一个整数数组 nums ,返回 nums 中作为 **摆动序列 **的 最长子序列的长度 。
示例 1:
**输入:**nums = 1,7,4,9,2,5
**输出:**6
**解释:**整个序列均为摆动序列,各元素之间的差值为 (6, -3, 5, -7, 3) 。
两个 DP 表,一个用来表示当前位置为增,另一个用来表示当前位置为减以此来进行状态推导。
java
class Solution {
public int wiggleMaxLength(int[] nums) {
int n = nums.length;
if(n <= 1){
return n;
}
int left = 0, right = 0;
int ret = 1;
for(int i = 0; i < n - 1; i++){
right = nums[i] - nums[i + 1];
if(right == 0){
continue;
}
if(left * right <= 0){
ret++;
}
left = right;
}
return ret;
}
}
21.2 最长递增子序列的个数
给定一个未排序的整数数组 nums , 返回最长递增子序列的个数 。
注意 这个数列必须是 严格 递增的。
示例 1:
输入: 1,3,5,4,7
输出: 2
解释: 有两个最长递增子序列,分别是 1, 3, 4, 7 和1, 3, 5, 7。
有两种状态需要更新:一个是当前位置为结尾,最长递增子序列的长度,另一个是以当前位置为结尾最长递增子序列的数量。
从当前位置往前遍历,如何长度为当前位置长度加1,那么累加数量;如果长度 +1 比当前位置长度更大,那么更新长度,并且重新计算长度。计算出当前位置结尾的最长递增子序列长度,和当前位置结尾的最长递增子序列个数,更新结果:最大长度和最大长度子序列的个数:如果当前长度比最大长度更长,更新长度并重新计算结果;如果当前长度和最大长度相同,那么累加计算结果
java
class Solution {
public int findNumberOfLIS(int[] nums) {
int n = nums.length;
int[] len = new int[n];
int[] count = new int[n];
for(int i = 0; i < n; i++){
len[i] = count[i] = 1;
}
int maxLen = 1, maxCount = 1;
for(int i = 1; i < n; i++){
for(int j = 0; j < i; j++){
if(nums[j] < nums[i]){
if(len[j] + 1 == len[i]){
count[i] += count[j];
}
else if(len[j] + 1 > len[i]){
len[i] = len[j] + 1;
count[i] = count[j];
}
}
}
if(maxLen == len[i]){
maxCount += count[i];
}
else if(maxLen < len[i]){
maxLen = len[i];
maxCount = count[i];
}
}
return maxCount;
}
}
21.2 最长定差子序列
给你一个整数数组 arr 和一个整数 difference,请你找出并返回 arr 中最长等差子序列的长度,该子序列中相邻元素之间的差等于 difference 。
子序列 是指在不改变其余元素顺序的情况下,通过删除一些元素或不删除任何元素而从 arr 派生出来的序列。
示例 1:
**输入:**arr = 1,2,3,4, difference = 1
**输出:**4
**解释:**最长的等差子序列是 1,2,3,4。
在重新遍历前面的内容时,可以进行优化:因为要找到 arr[i] - difference 的最长定差子序列,所以可以放到哈希表中保存,节省遍历的时间
java
class Solution {
public int longestSubsequence(int[] arr, int difference) {
int n = arr.length;
int[] dp = new int[n];
for(int i = 0; i < n; i++) dp[i] = 1;
HashMap<Integer, Integer> hash = new HashMap<>();
int retLen = 1;
hash.put(arr[0], 1);
for(int i = 1; i < n; i++){
if(hash.containsKey(arr[i] - difference)){
dp[i] = hash.get(arr[i] - difference) + 1;
hash.put(arr[i], dp[i]);
}
if(hash.getOrDefault(arr[i], 0) < dp[i]){
hash.put(arr[i], dp[i]);
}
retLen = Math.max(dp[i], retLen);
}
return retLen;
}
}
21.4 等差数列划分Ⅱ-子序列
给你一个整数数组 nums ,返回 nums 中所有 等差子序列 的数目。
如果一个序列中 至少有三个元素 ,并且任意两个相邻元素之差相同,则称该序列为等差序列。
-
例如,
[1, 3, 5, 7, 9]、[7, 7, 7, 7]和[3, -1, -5, -9]都是等差序列。 -
再例如,
[1, 1, 2, 5, 7]不是等差序列。
数组中的子序列是从数组中删除一些元素(也可能不删除)得到的一个序列。
- 例如,
[2,5,10]是[1,2,1,2,4,1,5,10]的一个子序列。
题目数据保证答案是一个 32-bit 整数。
示例 1:
**输入:**nums = 2,4,6,8,10
**输出:**7
**解释:**所有的等差子序列为:
2,4,6
4,6,8
6,8,10
2,4,6,8
4,6,8,10
2,4,6,8,10
2,6,10
-
如果只是看当前位置为结尾的最长等差数列(一维dp),那么无法确定是哪个等差数列(即 gap 无法确定),所以要使用二维 dp 表
-
i 为当前位置,j 为当前位置的后续位置,
nums[j] - nums[i]即为 gap,如此可以确定出等差数列在nums[i]前面的元素 -
找到 i 前面的所有目标数字位置等差数列个数。为了提高效率,使用哈希表记录每个
nums[i]对应的所有下标,更新 count 即可
java
class Solution {
public int numberOfArithmeticSlices(int[] nums) {
int n = nums.length;
if(n < 3) return 0;
int[][] dp = new int[n][n];
HashMap<Long, ArrayList<Integer>> hash = new HashMap<>();
for(int i = 0; i < n; i++){
long tmp = nums[i];
if(!hash.containsKey(tmp)){
hash.put(tmp, new ArrayList<Integer>());
}
hash.get(tmp).add(i);
}
int count = 0;
for(int i = 0; i < n; i++){
for(int j = i + 1; j < n; j++){
long gap = (long)nums[j] - (long)nums[i];
long tar = (long)nums[i] - gap;
if(hash.containsKey(tar)){
for(int k: hash.get(tar)){
if(k < i){
dp[i][j] += dp[k][i] + 1;
}
else{
break;
}
}
}
count += dp[i][j];
}
}
return count;
}
}
二十二、动态规划-回文串
22.1 分割回文串
给定一个字符串 s,请将 s 分割成一些子串,使每个子串都是回文串。
返回符合要求的 最少分割次数 。
示例 1:
**输入:**s = "aab"
**输出:**1
**解释:*只需一次分割就可将 s *分割成 "aa","b" 这样两个回文子串。
如果还是使用经典的"以...位置为结尾",那么无法进行状态转移方程的推导;同时因为我们需要操作的是子串,所以需要使用二维dp表。
对于 [i, j] 结尾的子串,如果 i 和 j 处的字符相同,那么如果长度为 1 或 2,就不需要分割,如果更长,就取决于 [i + 1, j - 1] 的分割次数;如果不相同,那么就需要去看 [i + 1, j] 和 [i, j - 1] 的情况
java
class Solution {
// dp[i][j] 表示 [i, j] 分割所需最少次数
public int minCut(String s) {
int n = s.length();
boolean[][] is = new boolean[n][n];
int[] dp = new int[n];
for(int j = 0; j < n; j++){
for(int i = 0; i <= j; i++){
if(s.charAt(i) == s.charAt(j)){
if(i == j || i + 1 == j){
is[i][j] = true;
}
else{
is[i][j] = is[i + 1][j - 1];
}
}
}
}
for(int j = 0; j < n; j++){
dp[j] = Integer.MAX_VALUE;
if(is[0][j]){
dp[j] = 0;
}
else{
for(int i = j; i > 0; i--){
if(is[i][j]){
dp[j] = Math.min(dp[j], dp[i - 1] + 1);
}
}
}
}
return dp[n - 1];
}
}
二十三、动态规划-两个数组的dp问题
23.1 正则表达式匹配
给你一个字符串 s 和一个字符规律 p,请你来实现一个支持 '.' 和 '*' 的正则表达式匹配。
-
'.'匹配任意单个字符 -
'*'匹配零个或多个前面的那一个元素
返回一个布尔值,表示匹配是否覆盖整个输入字符串(而非部分)。
示例 1:
**输入:**s = "aa", p = "a"
**输出:**false
解释:"a" 无法匹配 "aa" 整个字符串。
示例 2:
**输入:**s = "aa", p = "a*"
**输出:**true
**解释:**因为 '*' 代表可以匹配零个或多个前面的那一个元素, 在这里前面的元素就是 'a'。因此,字符串 "aa" 可被视为 'a' 重复了一次。
java
class Solution {
public boolean isMatch(String s, String p) {
int m = s.length(), n = p.length();
s = ' ' + s;
p = ' ' + p;
boolean[][] dp = new boolean[m + 1][n + 1];
dp[0][0] = true;
for(int j = 2; j <= n; j += 2){
if(p.charAt(j) == '*'){
dp[0][j] = true;
}
else{
break;
}
}
for(int i = 1; i <= m; i++){
for(int j = 1; j <= n; j++){
char ch = p.charAt(j);
char prev = p.charAt(j - 1);
if(ch == '*'){
dp[i][j] = dp[i][j - 2] || (prev == '.' || prev == s.charAt(i)) && dp[i - 1][j];
}
else if(ch == '.' || ch == s.charAt(i)){
dp[i][j] = dp[i - 1][j - 1];
}
}
}
return dp[m][n];
}
}
两个子数组使用二维 dp 表的方式,表示以两个数组相应位置为结尾的部分的情况。
对于 dp[i][j] 来说,如果 s[i] == p[j],那么当前位置是否匹配就取决于前面的状态即 dp[i - 1][j - 1] 的状态;如果 p[j] == '.',那么当前位置也是匹配,取决于 dp[i - 1][j - 1];如果 p[j] == '*',则看再前一个位置
-
如果
p[j - 1] == '.',那么表示.*可以匹配零个或者任意个字符,可以有两种匹配方式,一种是不匹配dp[i][j] = dp[i][j - 2],另一种是匹配字符,可以匹配任意多个,即dp[i][j] = dp[i - 1][j - 2] || dp[i - 2][j - 2] || dp[i - 3][j - 2]....。另外,可以优化为dp[i][j] = dp[i][j - 2] || dp[i - 1][j](递推优化) -
如果
p[j - 1] == '字母',那么也分两种,匹配零个字符dp[i][j] = dp[i][j - 2];匹配多个字符,此时需要p[j - 1] == s[i],并且dp[i][j] = dp[i -1][j]表示可以匹配最后一个相同字符,或者继承前面匹配的情况(多个相同字符)
23.2 最长重复子数组
给两个整数数组 nums1 和 nums2 ,返回 *两个数组中 公共的 、长度最长的子数组的长度 *。
示例 1:
**输入:**nums1 = 1,2,3,2,1, nums2 = 3,2,1,4,7
**输出:**3
**解释:**长度最长的公共子数组是 3,2,1 。
示例 2:
**输入:**nums1 = 0,0,0,0,0, nums2 = 0,0,0,0,0
**输出:**5
java
class Solution {
public int findLength(int[] nums1, int[] nums2) {
int m = nums1.length, n = nums2.length;
int[][] dp = new int[m + 1][n + 1];
int ret = 0;
for(int i = 1; i <= m; i++){
for(int j = 1; j <= n; j++){
if(nums1[i - 1] == nums2[j - 1]){
dp[i][j] = dp[i - 1][j - 1] + 1;
ret = Math.max(dp[i][j], ret);
}
else{
dp[i][j] = 0;
}
}
}
return ret;
}
}
子数组不等同于子序列,当 nums1[i - 1] != nums2[j - 1] 那么以当前位置为结尾的子数组长度就是0;在做状态推导时,需要同时维护最长重复子数组的记录
(重点就在于子数组连续,子序列可以不连续)
二十四、动态规划-01 背包
24.1 模板
你有一个背包,最大容量为 V。现有 n 件物品,第 i 件物品的体积为 vi,价值为 wi。研究人员提出以下两种装填方案:
-
不要求装满背包,求能获得的最大总价值;
-
要求最终恰好装满背包,求能获得的最大总价值。若不存在使背包恰好装满的装法,则答案记为 0
从某种意义上说 01 背包的问题和子序列的问题有些像,不过后者找的是极值,后者找的是某个指定的值,是不确定的,所以 01 背包的问题需要通过二维 dp 保存状态
dp[i][j] 表示在第 i 个物品时,最大容量为 j 时,能够选择的最大价值
不要求装满背包
对于每一个物品,都有存和不存两种状态。不存,继承 dp[i - 1][j];存的话,就看前一个物品的最大利润 + 当前物品利润是否大于前者 dp[i][j] = Math.max(dp[i][j], dp[i - 1][j - v[i - 1]]) + w[i - 1]
装满背包
对于每一个物品,同样都由存和不存两种状态。不存,继承 dp[i - 1][j];存的话,需要dp[i - 1][j - v[i - 1]] 是能存下的(用 -1 表示无法正好装满背包)
对于装满背包的情况,初始化的时候,所有物品为零、体积为正的都无法装满
java
import java.util.Scanner;
// 注意类名必须为 Main, 不要有任何 package xxx 信息
public class Main {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
int n = in.nextInt(); //物品数量
int V = in.nextInt(); //背包体积
int[] v = new int[n]; //物品体积
int[] w = new int[n]; //价值
for(int i = 0; i < n; i++){
v[i] = in.nextInt();
w[i] = in.nextInt();
}
//不要求装满背包
//dp[i][j] 只考虑前 i 件物品,背包容量为 j 时,最大价值
int[][] dp = new int[n + 1][V + 1];
for(int i = 1; i <= n; i++){
for(int j = 1; j <= V; j++){
//不选当前物品
dp[i][j] = dp[i - 1][j];
//选当前物品,需要能够选才行
if(j - v[i - 1] >= 0){
dp[i][j] = Math.max(dp[i][j], dp[i - 1][j - v[i - 1]] + w[i - 1]);
}
}
}
System.out.println(dp[n][V]);
//恰好装满背包
dp = new int[n + 1][V + 1];
for(int j = 1; j <= V; j++){
dp[0][j] = -1;
}
for(int i = 1; i <= n; i++){
for(int j = 1; j <= V; j++){
//不选当前物品
dp[i][j] = dp[i - 1][j];
if(j - v[i - 1] >= 0 && dp[i - 1][j - v[i - 1]] != -1){
dp[i][j] = Integer.max(dp[i][j], dp[i - 1][j - v[i - 1]] + w[i - 1]);
}
}
}
System.out.println(dp[n][V] == -1 ? 0 : dp[n][V]);
}
}
24.2 最后一块石头的重量
有一堆石头,用整数数组 stones 表示。其中 stones[i] 表示第 i 块石头的重量。
每一回合,从中选出任意两块石头 ,然后将它们一起粉碎。假设石头的重量分别为 x 和 y,且 x <= y。那么粉碎的可能结果如下:
-
如果
x == y,那么两块石头都会被完全粉碎; -
如果
x != y,那么重量为x的石头将会完全粉碎,而重量为y的石头新重量为y-x。
最后,**最多只会剩下一块 **石头。返回此石头 **最小的可能重量 **。如果没有石头剩下,就返回 0。
重量为 x、y(x <= y),两块石头进行粉碎时,可以认为是 y - x,其中一个数对应 +,一个数对应 -;而我们最后得到的结果就是若干个这些操作,因此所有石头可以分为两组:一组全是正(sum1),一组全是负(sum2),有如下关系:
-
sum1 >= sum2
-
sum = sum1 + sum2
-
ret = sum1 - sum2
dpij 表示:前 i 个物品,重量不超过 j,所能选的最大重量。状态转移:
-
不选当前物品,继承:dpi - 1j
-
选当前物品,去前面的情况和 dpi - 1j - stones\[i - 1] + stonesi - 1 的最大值
java
class Solution {
public int lastStoneWeightII(int[] stones) {
int n = stones.length;
int sum = 0;
for(int num: stones){
sum += num;
}
int target = sum / 2;
int[][] dp = new int[n + 1][target + 1];
for(int i = 1; i <= n; i++){
for(int j = 1; j <= target; j++){
dp[i][j] = dp[i - 1][j];
if(j - stones[i - 1] >= 0){
dp[i][j] = Math.max(dp[i][j], dp[i - 1][j - stones[i - 1]] + stones[i - 1]);
}
}
}
int sum2 = dp[n][target];
int sum1 = sum - sum2;
return sum1 - sum2;
}
}
二十五、动态规划-完全背包
情境和 01 背包类似,不过完全背包的每一个物件可以选任意多个,因此状态转移会有略微的不同:每个物品可以继承不选的情况,或者继承选当前物品但数量少一个的情况
25.1 零钱兑换Ⅱ
给你一个整数数组 coins 表示不同面额的硬币,另给一个整数 amount 表示总金额。
请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0 。
假设每一种面额的硬币有无限个。
题目数据 保证 最终 结果符合 **32 位 **带符号整数。
初始化,不能凑出的情况,直接结果就是零;能凑出的情况,如果 amount 为零,那么只有一种方法(都不选),否则进行推导即可
dp[i][j] 表示 coinsi 及之前的硬币,在总金额为 j 时,可以凑成总金额的组合个数
状态转移其实套路就在于,要么不选当前位置,继承 dp[i - 1][j];要么再看电脑当前物品少选一个的情况,组合数目再加上 dp[i][j - coins[i - 1]]
cpp
class Solution {
public:
int change(int amount, vector<int>& coins) {
int n = coins.size();
vector<vector<unsigned long long>> dp(n + 1, vector<unsigned long long>(amount + 1, 0));
for(int i = 0; i <= n; i++){
dp[i][0] = 1;
}
for(int i = 1; i <= n; i++){
for(int j = 1; j <= amount; j++){
dp[i][j] = dp[i - 1][j];
if(j - coins[i - 1] >= 0){
dp[i][j] += dp[i][j - coins[i - 1]];
}
}
}
return dp[n][amount];
}
};
25.2 完全平方数
给你一个整数 n ,返回 和为 n 的完全平方数的最少数量 。
完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1、4、9 和 16 都是完全平方数,而 3 和 11 不是。
初始化,对于能凑出的情况,如果 amount 为 0,那么最少数量是 0,否则推导即可;不能凑出的情况,不能像上面一样也是用 0(因为上面的题是组合,这个是数量),所以对于 dp[0][j] 使用 -1 初始化
状态推导,一种是不选择,直接继承 dp[i - 1][j];另一种是选择,此时如果当前位置为 -1,那么就直接继承为 dp[i - 1][j - coins[i - 1]] + 1,否则,在不选和少选一个当前硬币取最小值
cpp
class Solution {
public:
int numSquares(int n) {
vector<int> coins;
for(int i = 1; i * i <= n; i++){
coins.push_back(i * i);
}
return coinChange(coins, n);
}
int coinChange(vector<int>& coins, int amount) {
int n = coins.size();
vector<vector<int>> dp(n + 1, vector<int>(amount + 1, -1));
for(int i = 0; i <= n; i++){
dp[i][0] = 0;
}
for(int i = 1; i <= n; i++){
for(int j = 1; j <= amount; j++){
dp[i][j] = dp[i - 1][j];
if(j - coins[i - 1] >= 0 && dp[i][j - coins[i - 1]] != -1){
if(dp[i][j] == -1){
dp[i][j] = dp[i][j - coins[i - 1]] + 1;
}
dp[i][j] = min(dp[i][j], dp[i][j - coins[i - 1]] + 1);
}
}
}
return dp[n][amount];
}
};
二十六、二维费用的背包问题
26.1 盈利计划
集团里有 n 名员工,他们可以完成各种各样的工作创造利润。
第 i 种工作会产生 profit[i] 的利润,它要求 group[i] 名成员共同参与。如果成员参与了其中一项工作,就不能参与另一项工作。
工作的任何至少产生 minProfit 利润的子集称为 盈利计划 。并且工作的成员总数最多为 n 。
有多少种计划可以选择?因为答案很大,所以** 返回结果模 10^9 + 7 的值**。
初始化,对于有工作、没有员工、最小利润为 0 时,计划总数为 1;对于没有工作、有员工、最小利润为 0 时,计划总数为 1
虽然背包变成了二维,但是处理逻辑并没有改变:不选当前工作,直接继承 dp[i - 1][j][k];选当前工作,看当前任务的利润是否已经超过了最小利润,表达式为 dp[i][j - group[i - 1]][max(0, k - profit[i - 1]]
cpp
class Solution {
public:
int profitableSchemes(int n, int minProfit, vector<int>& group, vector<int>& profit) {
int m = group.size();
int MOD = 1e9 + 7;
//工作、员工、最小利润
vector<vector<vector<int>>> dp(m + 1, vector<vector<int>>(n + 1, vector<int>(minProfit + 1, 0)));
for(int i = 0; i <= m; i++){
dp[i][0][0] = 1;
}
for(int j = 0; j <= n; j++){
dp[0][j][0] = 1;
}
for(int i = 1; i <= m; i++){
for(int j = 1; j <= n; j++){
for(int k = 0; k <= minProfit; k++){
dp[i][j][k] = dp[i - 1][j][k];
if(j - group[i - 1] >= 0){
dp[i][j][k] += dp[i - 1][j - group[i - 1]][max(0, k - profit[i - 1])];
}
dp[i][j][k] %= MOD;
}
}
}
return dp[m][n][minProfit];
}
};
二十七、似包非包
27.1 组合总和Ⅳ
给定一个由 不同 正整数组成的数组 nums ,和一个目标整数 target 。请从 nums 中找出并返回总和为 target 的元素组合的个数。数组中的数字可以在一次排列中出现任意次,但是顺序不同的序列被视作不同的组合。
题目数据保证答案符合 32 位整数范围。
示例 1:
**输入:**nums = 1,2,3, target = 4
**输出:**7
解释:
所有可能的组合为:
(1, 1, 1, 1)
(1, 1, 2)
(1, 2, 1)
(1, 3)
(2, 1, 1)
(2, 2)
(3, 1)
请注意,顺序不同的序列被视作不同的组合。
前面我们的完全背包的问题都是排列而不是组合,所以不能直接使用前面的方法。
这里我们发现可以采取子问题的方式,总和为 a + b 的情况可以由总和为 a 的推出。
初始化,什么都不选,也算一种方案;状态转移,看前面的组合数
cpp
class Solution {
public:
int combinationSum4(vector<int>& nums, int target) {
int n = nums.size();
vector<unsigned long long> dp(target + 1, 0);
dp[0] = 1;
for(int i = 1; i <= target; i++){
for(int j = 0; j < n; j++){
if(i - nums[j] >= 0)
dp[i] += dp[i - nums[j]];
}
}
return dp[target];
}
};
27.2 不同的二叉搜索树
给你一个整数 n ,求恰由 n 个节点组成且节点值从 1 到 n 互不相同的 二叉搜索树 有多少种?返回满足题意的二叉搜索树的种数。
示例 1:

**输入:**n = 3
**输出:**5
一个根节点确定时,总共的二叉搜索树的数目,取决于:左孩子为根的子树的不同二叉搜索树数目 * 右孩子为跟的子树的不同二叉搜索树的数目,而我们一个节点时的二叉搜索树种数为 1。初始化,dp0 = 1
cpp
class Solution {
public:
int numTrees(int n) {
vector<int> dp(n + 1, 0);
dp[0] = 1;
for(int i = 1; i <= n; i++){
for(int j = 1; j <= i; j++){
dp[i] += dp[j - 1] * dp[i - j];
}
}
return dp[n];
}
};