当前位置: 首页 > news >正文

【算法】动态规划 · 上篇

1. 前缀和

1.1 一维前缀和

        用于快速计算数组中一段区间的和。

        准备前缀和数组:前缀和数组 i 下标位置的值表示原数组 0 ~ i 位置值的和。

        for (int i = 1; i <= 原数组的长度; i++) {dp[i] = dp[i - 1] + arr[i];}

 

        使用前缀和数组:

        arr[a] + arr[a+1] + ...... + arr[b] 即为 dp[b] - dp[a - 1]

1.2 二维前缀和

        给出一个 n 行 m 列的矩阵,x 表示行,y 表示列,求 (x1,y1) 到 (x2,y2) 这个子矩阵内元素的和。

        准备前缀和矩阵:前缀和矩阵中每个位置的值,都等于原矩阵中从 (0,0) 位置到这个位置的值的总和。如图前缀和矩阵中红色位置存放的值,应当为原矩阵的红框中全部值的和。想要快速求这个位置的值可以利用前缀和矩阵中绿色、黄色和蓝色位置的值,还需要原矩阵中这个位置的值。原矩阵中的绿色子矩阵加上蓝色子矩阵后多算了一个黄色子矩阵,因此要减去一个。

        for (int i = 1; i <= n; i++) {for (int j = 1; j <= m; j++) {dp[i][j] = dp[i][j - 1] + dp[i - 1][j] +matrix[i][j] - dp[i - 1][j - 1];}}

        使用前缀和矩阵:

        dp[x2][y2] - dp[x1 - 1][y2] - dp[x2][y1 - 1] + dp[x1 - 1][y1 - 1]

1.3 前缀和与后缀和

724. 寻找数组的中心下标 - 力扣(LeetCode)

        找数组中的一个位置,这个位置左侧所有元素的和等于右侧所有元素的和,返回下标。左侧所有元素的和需要前缀和数组,右侧所有元素的和需要后缀和数组。

        准备两个数组:

       

        for (int i = 1; i <= len; i++) {prefix[i] = prefix[i - 1] + nums[i - 1];postfix[i] = postfix[i - 1] + nums[len - i];}

        使用数组:原数组中定义指针,指向 i 位置时,判断 prefix[i] 是否等于 postfix[len - 1 - i] 即可。

238. 除自身以外数组的乘积 - 力扣(LeetCode)

1.4 前缀和与哈希表

问题转化

560. 和为 K 的子数组 - 力扣(LeetCode)

                

class Solution {public int subarraySum(int[] nums, int k) {Map<Integer, Integer> hash = new HashMap<>();hash.put(0, 1);int sum = 0, count = 0;for (int i = 0; i < nums.length; i++) {sum += nums[i];count += hash.getOrDefault(sum-k, 0);hash.put(sum, hash.getOrDefault(sum, 0)+1);}return count;}
}

同余定理

974. 和可被 K 整除的子数组 - 力扣(LeetCode)

关键点 1:使用同余定理

        若 a - b 能被 c 整除,则 a 和 c 相除的余数 = b 和 c 相除的余数。

        在这道题里利用:

                

        思路:关键是如何利用之前已计算的值。前一道题是 sum - k = prefix,所以应该将前缀和存入 hash;这一道题是 sum % k = prefix % k,所以应该将 prefix % k 存入 hash。

关键点 2:Java 取余运算

                

        商和余数的值:除数和被除数都加绝对值运算。

        商的符号:看除数和被除数的符号是否一致,同正异负。

        余数的符号:跟除数保持一致。

        这道题需要避免余数出现负数,负数余数是 C++、Java 的计算方法,可以转化为正数余数。题目中被除数可能为负数,但除数 k 不可能为负数。

        有两种方法:

// 增加一步的步长以保证余数为正
1. int r = (sum % k + k) % k;
// Math.floorMod用于计算数学上正确的模运算,它在处理负数时能提供更符合数学直觉的结果。
// 推荐确保除数为正。
2. int r = Math.floorMod(sum, k);
class Solution {public int subarraysDivByK(int[] nums, int k) {Map<Integer, Integer> hash = new HashMap<>();hash.put(0, 1);int sum = 0, count = 0;for (int i = 0; i < nums.length; i++) {sum += nums[i];int r = (sum % k + k) % k;count += hash.getOrDefault(r, 0);hash.put(r, hash.getOrDefault(r, 0) + 1);}return count;}
}

2. 斐波那契数列模型

面试题 08.01. 三步问题 - 力扣(LeetCode)

        状态表示:dp[i] 表示小孩上 i 阶楼梯共有多少方法。

        状态转移方程:对于任意一阶楼梯,小孩要么是跨一阶楼梯上来的,要么是跨两阶楼梯上来的,要么是跨三阶楼梯上来的。因此,dp[i] = dp[i-1] + dp[i-2] + dp[i-3]。

        

        初始化:数组前三个元素作为辅助节点,n=1 在第四个位置。n=1 时只有一种方法,所以 dp[0] + dp[1] + dp[2] 应该等于 1,n=2 时有两种方法,dp[1] + dp[2] + dp[3] 应该等于 2。综上,初始化三个辅助节点的值分别为 1,0,0。

        细节:可以循环数字来降低空间复杂度。

91. 解码方法 - 力扣(LeetCode)

        状态表示:dp[i] 表示到第 i 个位置共有多少种编码方法。

        状态转移方程

                对于 dp[i] 来说,要么它可以自己被编码,要么它可以和前一位组合编码,要么它就是既可以自己编码,也可以和前一位组合编码。

                情况1:dp[i] 只能单独编码。此时说明 dp[i] 一定不等于 0,且 dp[i-1] 和 dp[i] 组合成的数字不在 10 - 26 之间。这个时候,dp[i] 应当等于 dp[i-1]。

                情况2:dp[i] 只能组合编码。此时说明 dp[i] 一定等于 0,且 dp[i-1] 和 dp[i] 组合成的数字在 10 - 26 之间。这个时候,dp[i] 应当等于 dp[i-2]。

                情况3:dp[i] 既能单独编码,也能组合编码。此时说明 dp[i] 一定不等于 0,且 dp[i-1] 和 dp[i] 组合成的数字在 10 - 26 之间。这个时候,dp[i] 应当等于 dp[i-1] + dp[i-2]。

                情况4:dp[i] 既不能单独编码,也不能组合编码。此时说明 dp[i] 一定等于 0,且 dp[i-1] 和 dp[i] 组合成的数字不在 10 - 26 之间。这个时候,说明在这个位置无法被编码,应当直接返回 0。

        初始化:对于题目给的字符串的每一位,我们在判断时都需要看它的前一位。对于 dp 数组的每个元素来说,我们每次都需要看它的前两位。为保证不越界,循环应当从字符串的第二位,dp 的第三位开始。因此字符串的第一位和 dp 的前两位应当特殊考虑。

class Solution {public int numDecodings(String ss) {// 准备工作char[] s = ss.toCharArray();int len = s.length;int[] dp = new int[len + 1];// 初始化if (s[0] == '0') {return 0;}dp[0] = 1;dp[1] = 1;// 填表for (int i = 2; i < len + 1; i++) {// 单独编码if (s[i - 1] != '0') {dp[i] += dp[i - 1];}// 组合编码int temp = (s[i - 2] - '0') * 10 + (s[i - 1] - '0');if (temp >= 10 && temp <= 26) {dp[i] += dp[i - 2];}/* 循环到这里,如果前面的 if 都没有满足,说明在这个位置已经* 无法被编码,直接返回 0 即可 */if (dp[i] == 0) {return 0;}}return dp[len];}
}

3. 路径问题

62. 不同路径 - 力扣(LeetCode)

        状态表示:dp[i][j] 表示从 (1, 1) 到 (i, j) 共有多少条路线。

        状态转移方程:要么从上面的格子走到 dp[i][j],要么从左面的格子走到 dp[i][j]。因此 dp[i][j] = dp[i - 1][j] + dp[i][j - 1]。

        

        初始化:使用辅助节点,dp[1][1] 应该等于 1,所以将 dp[1][0] 初始化为 1,其他地方依然为 0。

        细节:填 dp 表时,从左向右从上往下依次填即可,因为对于每个位置我们只需要它上面的元素和左面的元素。

931. 下降路径最小和 - 力扣(LeetCode)

        状态表示:dp[i][j] 表示下降到此位置,花费的最小值。

        状态转移方程:对于每个位置,只有可能从这三个方向到达,再加上自己本身,就是到这个位置花费的最小值了。因此,dp[i][j] = dp 表中这三个位置的最小值 + matrix[i - 1][j - 1]。

        

        初始化:使用辅助节点,上面一行,左右各一列。最左和最右列设置为不可能被取到的值。

        

        细节:返回最后一行中最小的元素。

174. 地下城游戏 - 力扣(LeetCode)

        状态表示:这道题需要使用不同的状态表示方式,dp[i][j] 表示以该位置为起点,到终点所需最低血量。

        状态转移方程:对于每个元素,只有向右和向下移动这两种情况,选择 dp 表中这两个位置中较小的,再减去自己本身,就是这个元素到终点所需最低血量。如果这个所需最低血量小于等于 0,说明从这个位置到终点不需要初始血量,由于题目规定骑士的血量必须是正整数,因此此时将血量设为 1。

        

        初始化:使用辅助节点,最右一列和最下一行均设为无法取到的元素,但除了 (m-1, n) 或 (m, n-1) 的位置应设为 1。因为对于终点位置的元素,如果为负数,初始血量应为其倒数加一。

        细节:填表时要倒序。

4. 简单多状态

面试题 17.16. 按摩师 - 力扣(LeetCode)

        

213. 打家劫舍 II - 力扣(LeetCode)

        思路与上一道题基本类似,只是这道题变成了环形数组。因此,偷 1 号房间则不能偷 2 号房间和最后一间房间,那么,1 号房间的逻辑实际上不同于其他房间,因为 1 号房间会影响两间。为了将问题一般化,我们可以分别考虑 1 号房间和最后的房间,也就是不要在一次循环中同时考虑这两间。

class Solution {public int rob(int[] nums) {int len = nums.length;if (len == 1)return nums[0];// 调用两次 plunder,第一次包括第一间不包括最后一间,第二次不包括第一间包括最后一间return Math.max(plunder(nums, 0, len - 1), plunder(nums, 1, len));}private int plunder(int[] nums, int start, int end) {int len = nums.length;int[] dp1 = new int[len + 1];int[] dp2 = new int[len + 1];for (int i = start + 1; i < end + 1; i++) {dp1[i] = dp2[i - 1] + nums[i - 1];dp2[i] = Math.max(dp1[i - 1], dp2[i - 1]);}return Math.max(dp1[end], dp2[end]);}
}

740. 删除并获得点数 - 力扣(LeetCode)

        每次操作中,选择任意一个 nums[i] ,删除它并获得 nums[i] 的点数。之后,你必须删除所有等于 nums[i] - 1 和 nums[i] + 1 的元素。

        题目分析:在数组排好序的情况下,如果删除 nums[i],则可以获得全部等于 nums[i] 的元素的点数,然后与其点数相邻的元素就都不能选了。这道题可以转化为打家劫舍问题,它们都是相邻不选。这道题需要先将数据预处理,因为单独考虑每一个元素其实比较麻烦,我们可以直接先统计好每组相等元素的总和。之后就是打家劫舍问题了。

        

class Solution {public int deleteAndEarn(int[] nums) {// 将数据预处理到 hash,转化为打家劫舍// 使用 size 来做优化int size = 0;for (int n : nums) {size = Math.max(size, n) + 1;}int[] hash = new int[size];for (int i = 0; i < nums.length; i++) {hash[nums[i]] += nums[i];}// 打家劫舍int[] dp1 = new int[size];int[] dp2 = new int[size];for (int i = 1; i < size; i++) {dp1[i] = dp2[i - 1] + hash[i];dp2[i] = Math.max(dp1[i - 1], dp2[i - 1]);}return Math.max(dp1[size - 1], dp2[size - 1]);}
}

LCR 091. 粉刷房子 - 力扣(LeetCode)

        这是比较简单的一道题。与之前的不同点是,这道题需要使用三个状态表示,但思路是完全一致的。每一栋房子都可能被漆成三种颜色,分别表示即可。

309. 买卖股票的最佳时机含冷冻期 - 力扣(LeetCode)

        这道题的状态比较难考虑。根据题意,分为三种状态:持有股票(hold),处于冷冻期(lock),可以正常参与交易(free)。题目中其实给了我们三种状态,分别是买入、卖出和冷冻期。但是这样表示比较乱,因为有可能在这个位置什么都没做,没买入也没卖出,也没处于冷冻期。买入和卖出是动作,不是状态。

        接下来分析这三种状态之间的关系:

        hold:

        -> 保持状态:原先持有股票,希望继续持有,什么都不做即可。

        -> free:把手头的股票卖了,再经历一天的冷冻期,才能继续参与交易。不能直接从 hold 转化成 free,必须经历冷冻期。

        -> lock:抛售股票后就会到这个状态。

        free:

        -> 保持状态:什么都不做即可。

        -> hold:买入股票则变为持有股票的状态。

        -> lock:free 变不到 lock。

        lock:

        -> 保持状态:冷冻期规定为 1 天,不可能延长,所以不能保持状态。

        -> hold:lock 不能直接变为 hold,必须先变为 free,free 才是可交易状态。

        -> free:一个周期后 lock 解除,变为 free。

        

class Solution {public int maxProfit(int[] prices) {int len = prices.length;int[] hold = new int[len];int[] lock = new int[len];int[] free = new int[len];hold[0] = -prices[0];for (int i = 1; i < len; i++) {hold[i] = Math.max(hold[i - 1], free[i - 1] - prices[i]);lock[i] = hold[i - 1] + prices[i];free[i] = Math.max(free[i - 1], lock[i - 1]);}int ret = Math.max(Math.max(hold[len - 1], lock[len - 1]), free[len - 1]);return ret;}
}

188. 买卖股票的最佳时机 IV - 力扣(LeetCode)

        状态分析:

        这道题限制了交易的次数,因此状态不只有 hold 和 free 两个,而是要将交易的次数也作为状态考量。对于 hold 来说,考虑这是第几次持有股票;对于 free 来说,考虑这是第几次处于自由状态。因此使用二维数组来分别表示 hold 和 free。

        每一次交易是一个 free 和 hold 之间的循环,并且,将 free 设为循环的起点会使问题比较简单。此时,状态变化类似这样:第一次处于自由状态 -> 第一次持有股票 -> 第二次处于自由状态 -> 第二次持有股票 -> 第三次处于自由状态 -> 第三次持有股票 ……

        另外,处于 free 状态的情况应当比 hold 多一次。hold 状态只能出现 k 次,但第 k 次 hold 后依然可以有第 k + 1 次 free。并且,状态变化的终点肯定是 free,因为在最后一个位置买入股票只会亏钱。因此,数组最后一个位置的 hold 状态不需要计算。

        状态表示:

        free[i][j] 表示截止到第 i 天,进行 j 次交易后,处于自由状态,此时可以获得的最大利益。hold[i][j] 表示截止到第 i 天,进行 j 次交易后,处于持有股票的状态,此时可以获得的最大利益。

        状态转移方程:

free[i][j] = Math.max(free[i - 1][j], hold[i - 1][j - 1] + prices[i]);
hold[i][j] = Math.max(hold[i - 1][j], free[i - 1][j] - prices[i]);

        初始化:

        使用分类讨论和直接初始化,不使用辅助节点。free(1,0) 需要 free(0,0) 和 hold(0,-1),这里使用分类讨论。当 j < 1 时,直接让 free[i][j] = free[i - 1][j] 即可,其他时候再考虑 hold[i - 1][j - 1]。

        

        想让初始状态为 free,什么都不做即可,free(0,0) 应初始化为 0。想让初始状态为 hold,买入首日股票即可,hold(0,0) 应初始化为 -prices[0]。在第一日(i = 0),不可能达到第二次自由状态或第二次持有股票的状态,j > 0 的地方都应该设为无法被取到的值(MIN)。 这些 MIN 会通过状态转移方程传递。

        

        返回值:

        由于状态变化的终点肯定是 free,找到截止最后一天,进行几次交易的 free 值最大。

        完整代码:

class Solution {public int maxProfit(int k, int[] prices) {// 准备工作int len = prices.length;if (len == 1) {return 0;}int MIN = -0x3f3f3f3f;int[][] free = new int[len][k + 1];int[][] hold = new int[len-1][k];// 初始化for (int j = 1; j < k + 1; j++) {free[0][j] = MIN;if (j < k) {hold[0][j] = MIN;}}free[0][0] = 0;hold[0][0] = -prices[0];// 二维 dpfor (int i = 1; i < len; i++) {for (int j = 0; j < k + 1; j++) {if (j >= 1) {free[i][j] = Math.max(free[i - 1][j], hold[i - 1][j - 1] + prices[i]);} else {free[i][j] = free[i - 1][j];}if (j < k && i < len - 1) {hold[i][j] = Math.max(hold[i - 1][j], free[i - 1][j] - prices[i]);}}}// 返回结果int ret = MIN;for (int j = 0; j < k + 1; j++) {ret = Math.max(free[len - 1][j], ret);}return ret;}
}

        空间优化:

        在 i 天内,最多完成 i / 2 次交易,若 k > i / 2,就一定会有多余的可交易次数,此时使 k 直接等于 i / 2 即可。 

        在没有交易次数限制的时候,两个一维 dp 数组可以简化为两个 int,在这里也是同理,二维 dp 数组可以简化为一维 dp 数组。

        简化后的状态表示:free[j] 表示进行 j 次交易后,处于自由状态,此时可以获得的最大利益。hold[j] 表示进行 j 次交易后,处于持有股票状态,此时可以获得的最大利益。

        简化后的状态转移方程:

hold[j] = Math.max(hold[j], free[j - 1] - price);
free[j] = Math.max(free[j], hold[j] + price);

        简化后的返回值:hold 和 free 都是严格递增的,因此返回 free 的最后一个值即可。

        优化后的代码: 

class Solution {public int maxProfit(int k, int[] prices) {// 准备工作k = Math.min(k, prices.length / 2);int[] hold = new int[k + 1];int[] free = new int[k + 1];// 初始化int MIN = -0x3f3f3f3f;Arrays.fill(hold, MIN);// 二维 dpfor (int price : prices) {for (int j = 1; j < k + 1; j++) {hold[j] = Math.max(hold[j], free[j - 1] - price);free[j] = Math.max(free[j], hold[j] + price);}}// 返回值return free[k];}
}

5. 子串问题

918. 环形子数组的最大和 - 力扣(LeetCode)

        

class Solution {public int maxSubarraySumCircular(int[] nums) {int overall = 0;int len = nums.length;for (int i = 0; i < len; i++) {overall += nums[i];}int temp = overall - minSubArray(nums);if (temp == 0) {return maxSubArray(nums);} else {return Math.max(temp, maxSubArray(nums));}}private int maxSubArray(int[] nums) {int len = nums.length;if (len == 1) {return nums[0];}int dp = nums[0];int ret = nums[0];for (int i = 1; i < len; i++) {dp = Math.max(nums[i], dp + nums[i]);ret = Math.max(ret, dp);}return ret;}private int minSubArray(int[] nums) {int len = nums.length;if (len == 1) {return nums[0];}int dp = nums[0];int ret = nums[0];for (int i = 1; i < len; i++) {dp = Math.min(nums[i], dp + nums[i]);ret = Math.min(ret, dp);}return ret;}
}

1567. 乘积为正数的最长子数组长度 - 力扣(LeetCode)

        状态表示:

        以该位置为结尾的,乘积为正的最长子数组。

        以该位置为结尾的,乘积为负的最长子数组。

        分析:

        正数不会改变正负号,所以如果碰到正数,直接在其前一个位置记录的最长子数组基础上 + 1 即可。但负数会改变正负号,如果碰到负数,仅仅记录乘积为正的最长子数组是不够的,状态转移不过去。

class Solution {public int getMaxLen(int[] nums) {// 准备工作int len = nums.length;int[] pos = new int[len + 1];int[] neg = new int[len + 1];int tempMax = 0;// 初始化pos[0] = 0;neg[0] = -1;// dpfor (int i = 1; i < len + 1; i++) {if (nums[i - 1] > 0) {pos[i] = pos[i - 1] + 1;neg[i] = neg[i - 1] == -1 ? -1 : neg[i - 1] + 1;} else if (nums[i - 1] < 0) {pos[i] = neg[i - 1] + 1;neg[i] = pos[i - 1] + 1;} else {pos[i] = 0;neg[i] = -1;}tempMax = Math.max(tempMax, pos[i]);}// 返回结果return tempMax;}
}

978. 最长湍流子数组 - 力扣(LeetCode)

        与上一道题类似的思路

139. 单词拆分 - 力扣(LeetCode)

        dp[i] 表示以 i 为结尾,之前的部分能不能利用字典表示。因此 dp 中存 boolean。

        假设 i 位于如图所示位置,那么此时就需要另一个指针 j,来与 i 共同尝试截出一个词典中已有的单词。如果截不出来,则以 i 为结尾的部分不能用字典表示。如果能截出来,那表示从 j 到 i 是一个合法单词,然后再根据 dp 表,查看 j - 1 的位置能不能用字典表示,如果能的话,说明 i 位置为结尾的部分能用字典表示。

        

class Solution {public boolean wordBreak(String s, List<String> wordDict) {// 将 List 中的元素复制到 Set 中,方便查找Set<String> hash = new HashSet<>(wordDict);// 初始化int len = s.length();boolean[] dp = new boolean[len + 1];dp[0] = true;// dpfor (int i = 1; i < len + 1; i++) {for (int j = i; j > 0; j--) {if (hash.contains(s.substring(j - 1, i))) {dp[i] = dp[j - 1];if (dp[i] == true) {break;}}}}// 返回结果return dp[len];}
}

467. 环绕字符串中唯一的子字符串 - 力扣(LeetCode)

        dp[i] 表示,有多少以 i 为结尾的子字串,在 base 中出现。

        需要注意的是,s 中会有重复的字符。但在统计的时候,我们并不知道哪一个能构成合法子串的数量比较多。比如,bcabc,以第一个 c 为结尾的合法子串为 c、bc 这两个。而以第二个 c 为结尾的合法子串为 c、bc、abc 这三个。因此,我们必须统计 s 中的每一个字符,重复的字符保留最大值即可。

class Solution {public int findSubstringInWraproundString(String s) {// 特殊讨论if (s.length() == 1) {return 1;}// 初始化int[] hash = new int[26];hash[(int)s.charAt(0) - 97] = 1;int dp = 1;// dpfor (int i = 1; i < s.length(); i++) {int pre = (int)s.charAt(i - 1);int cur = (int)s.charAt(i);if (cur == pre + 1 || (cur == 'a' && pre == 'z')) {dp++;} else {dp = 1;}hash[cur - 97] = Math.max(hash[cur - 97], dp);}// 返回值int sum = 0;    for (int val : hash) {sum += val;}return sum;}
}
http://www.dtcms.com/a/298577.html

相关文章:

  • Python day24
  • CRM 系统:实现商机全流程管理的关键
  • Qt XML 与 JSON 数据处理方法
  • 文搜图/图搜图
  • OSPF 协议(多区域)
  • Inception网络架构:深度学习视觉模型的里程碑
  • 去甲基化药物联合DLI治疗AML1-ETO阳性
  • 图书推荐-由浅入深的大模型构建《从零构建大模型》
  • 【C++避坑指南】vector迭代器失效的八大场景与解决方案
  • 管理系统模板
  • 应用程序无法正常启动(0xc000007b)怎么办 解决方法详解
  • Matlab学习笔记:结构基础
  • 数仓规范体系的构建
  • SVM多分类实战:从理论到应用的完整指南
  • Linux的磁盘存储管理实操——(下二)——逻辑卷管理LVM的扩容、缩容
  • 5.2.1 javascript 事件对象.内容补充.
  • 从零本地部署使用Qwen3-coder进行编程
  • 1.1.4 建筑隔震减震设计结构要求
  • SpringBoot创建项目的方式
  • Spring AOP `MethodInvocation` 工作原理
  • JavaScript 数组的 every() 和 some() 方法使用
  • Web前端:JavaScript Math内置对象
  • 个人财务记录应用
  • SEC_FirePower 第一天作业
  • 2025年07月25日Github流行趋势
  • 【IDEA】IDEA中如何通过分支/master提交git?
  • haproxy篇
  • 扫描电镜全面解析:工作原理、应用领域与样品制备技巧
  • macbook安装homebrew
  • 为什么数组可以做到时间复杂度为O(1)的随机访问