递归和动态规划都是将原问题拆成多个子问题然后求解,他们之间最本质的区别是,动态规划保存了子问题的解,避免重复计算。
斐波那契数列
1. 爬楼梯
70. Climbing Stairs (Easy)
题目描述:有 N 阶楼梯,每次可以上一阶或者两阶,求有多少种上楼梯的方法。
定义一个数组 dp 存储上楼梯的方法数(为了方便讨论,数组下标从 1 开始),dp[i] 表示走到第 i 个楼梯的方法数目。
第 i 个楼梯可以从第 i-1 和 i-2 个楼梯再走一步到达,走到第 i 个楼梯的方法数为走到第 i-1 和第 i-2 个楼梯的方法数之和。
考虑到 dp[i] 只与 dp[i - 1] 和 dp[i - 2] 有关,因此可以只用两个变量来存储 dp[i - 1] 和 dp[i - 2],使得原来的 O(N) 空间复杂度优化为 O(1) 复杂度。
1 | public int climbStairs(int n) { |
2. 强盗抢劫
198. House Robber (Easy)
题目描述:抢劫一排住户,但是不能抢邻近的住户,求最大抢劫量。
定义 dp 数组用来存储最大的抢劫量,其中 dp[i] 表示抢到第 i 个住户时的最大抢劫量。
由于不能抢劫邻近住户,如果抢劫了第 i -1 个住户,那么就不能再抢劫第 i 个住户,所以
1 | public int rob(int[] nums) { |
3. 强盗在环形街区抢劫
213. House Robber II (Medium)
1 | public int rob(int[] nums) { |
4. 信件错排
题目描述:有 N 个 信 和 信封,它们被打乱,求错误装信方式的数量。
定义一个数组 dp 存储错误方式数量,dp[i] 表示前 i 个信和信封的错误方式数量。假设第 i 个信装到第 j 个信封里面,而第 j 个信装到第 k 个信封里面。根据 i 和 k 是否相等,有两种情况:
- i==k,交换 i 和 j 的信后,它们的信和信封在正确的位置,但是其余 i-2 封信有 dp[i-2] 种错误装信的方式。由于 j 有 i-1 种取值,因此共有 (i-1)*dp[i-2] 种错误装信方式。
- i != k,交换 i 和 j 的信后,第 i 个信和信封在正确的位置,其余 i-1 封信有 dp[i-1] 种错误装信方式。由于 j 有 i-1 种取值,因此共有 (i-1)*dp[i-1] 种错误装信方式。
综上所述,错误装信数量方式数量为:
5. 母牛生产
题目描述:假设农场中成熟的母牛每年都会生 1 头小母牛,并且永远不会死。第一年有 1 只小母牛,从第二年开始,母牛开始生小母牛。每只小母牛 3 年之后成熟又可以生小母牛。给定整数 N,求 N 年后牛的数量。
第 i 年成熟的牛的数量为:
矩阵路径
1. 矩阵的最小路径和
64. Minimum Path Sum (Medium)
1 | [[1,3,1], |
题目描述:求从矩阵的左上角到右下角的最小路径和,每次只能向右和向下移动。
1 | public int minPathSum(int[][] grid) { |
2. 矩阵的总路径数
62. Unique Paths (Medium)
题目描述:统计从矩阵左上角到右下角的路径总数,每次只能向右或者向下移动。
1 | public int uniquePaths(int m, int n) { |
也可以直接用数学公式求解,这是一个组合问题。机器人总共移动的次数 S=m+n-2,向下移动的次数 D=m-1,那么问题可以看成从 S 中取出 D 个位置的组合数量,这个问题的解为 C(S, D)。
1 | public int uniquePaths(int m, int n) { |
数组区间
1. 数组区间和
303. Range Sum Query - Immutable (Easy)
1 | Given nums = [-2, 0, 3, -5, 2, -1] |
求区间 i ~ j 的和,可以转换为 sum[j + 1] - sum[i],其中 sum[i] 为 0 ~ i - 1 的和。
1 | class NumArray { |
2. 数组中等差递增子区间的个数
413. Arithmetic Slices (Medium)
1 | A = [0, 1, 2, 3, 4] |
dp[i] 表示以 A[i] 为结尾的等差递增子区间的个数。
当 A[i] - A[i-1] == A[i-1] - A[i-2],那么 [A[i-2], A[i-1], A[i]] 构成一个等差递增子区间。而且在以 A[i-1] 为结尾的递增子区间的后面再加上一个 A[i],一样可以构成新的递增子区间。
1 | dp[2] = 1 |
综上,在 A[i] - A[i-1] == A[i-1] - A[i-2] 时,dp[i] = dp[i-1] + 1。
因为递增子区间不一定以最后一个元素为结尾,可以是任意一个元素结尾,因此需要返回 dp 数组累加的结果。
1 | public int numberOfArithmeticSlices(int[] A) { |
分割整数
1. 分割整数的最大乘积
343. Integer Break (Medim)
题目描述:For example, given n = 2, return 1 (2 = 1 + 1); given n = 10, return 36 (10 = 3 + 3 + 4).
1 | public int integerBreak(int n) { |
2. 按平方数来分割整数
279. Perfect Squares(Medium)
题目描述:For example, given n = 12, return 3 because 12 = 4 + 4 + 4; given n = 13, return 2 because 13 = 4 + 9.
1 | public int numSquares(int n) { |
3. 分割整数构成字母字符串
91. Decode Ways (Medium)
题目描述:Given encoded message “12”, it could be decoded as “AB” (1 2) or “L” (12).
1 | public int numDecodings(String s) { |
最长递增子序列
已知一个序列 {S1, S2,…,Sn},取出若干数组成新的序列 {Si1, Si2,…, Sim},其中 i1、i2 … im 保持递增,即新序列中各个数仍然保持原数列中的先后顺序,称新序列为原序列的一个 子序列 。
如果在子序列中,当下标 ix > iy 时,Six > Siy,称子序列为原序列的一个 递增子序列 。
定义一个数组 dp 存储最长递增子序列的长度,dp[n] 表示以 Sn 结尾的序列的最长递增子序列长度。对于一个递增子序列 {Si1, Si2,…,Sim},如果 im < n 并且 Sim < Sn,此时 {Si1, Si2,…, Sim, Sn} 为一个递增子序列,递增子序列的长度增加 1。满足上述条件的递增子序列中,长度最长的那个递增子序列就是要找的,在长度最长的递增子序列上加上 Sn 就构成了以 Sn 为结尾的最长递增子序列。因此 dp[n] = max{ dp[i]+1 | Si < Sn && i < n} 。
因为在求 dp[n] 时可能无法找到一个满足条件的递增子序列,此时 {Sn} 就构成了递增子序列,需要对前面的求解方程做修改,令 dp[n] 最小为 1,即:
对于一个长度为 N 的序列,最长递增子序列并不一定会以 SN 为结尾,因此 dp[N] 不是序列的最长递增子序列的长度,需要遍历 dp 数组找出最大值才是所要的结果,max{ dp[i] | 1 <= i <= N} 即为所求。
1. 最长递增子序列
300. Longest Increasing Subsequence (Medium)
1 | public int lengthOfLIS(int[] nums) { |
使用 Stream 求最大值会导致运行时间过长,可以改成以下形式:
1 | int ret = 0; |
以上解法的时间复杂度为 O(N2),可以使用二分查找将时间复杂度降低为 O(NlogN)。
定义一个 tails 数组,其中 tails[i] 存储长度为 i + 1 的最长递增子序列的最后一个元素。对于一个元素 x,
- 如果它大于 tails 数组所有的值,那么把它添加到 tails 后面,表示最长递增子序列长度加 1;
- 如果 tails[i-1] < x <= tails[i],那么更新 tails[i] = x。
例如对于数组 [4,3,6,5],有:
1 | tails len num |
可以看出 tails 数组保持有序,因此在查找 Si 位于 tails 数组的位置时就可以使用二分查找。
1 | public int lengthOfLIS(int[] nums) { |
2. 一组整数对能够构成的最长链
646. Maximum Length of Pair Chain (Medium)
1 | Input: [[1,2], [2,3], [3,4]] |
题目描述:对于 (a, b) 和 (c, d) ,如果 b < c,则它们可以构成一条链。
1 | public int findLongestChain(int[][] pairs) { |
3. 最长摆动子序列
376. Wiggle Subsequence (Medium)
1 | Input: [1,7,4,9,2,5] |
要求:使用 O(N) 时间复杂度求解。
1 | public int wiggleMaxLength(int[] nums) { |
最长公共子序列
对于两个子序列 S1 和 S2,找出它们最长的公共子序列。
定义一个二维数组 dp 用来存储最长公共子序列的长度,其中 dp[i][j] 表示 S1 的前 i 个字符与 S2 的前 j 个字符最长公共子序列的长度。考虑 S1i 与 S2j 值是否相等,分为两种情况:
- 当 S1i==S2j 时,那么就能在 S1 的前 i-1 个字符与 S2 的前 j-1 个字符最长公共子序列的基础上再加上 S1i 这个值,最长公共子序列长度加 1,即 dp[i][j] = dp[i-1][j-1] + 1。
- 当 S1i != S2j 时,此时最长公共子序列为 S1 的前 i-1 个字符和 S2 的前 j 个字符最长公共子序列,或者 S1 的前 i 个字符和 S2 的前 j-1 个字符最长公共子序列,取它们的最大者,即 dp[i][j] = max{ dp[i-1][j], dp[i][j-1] }。
综上,最长公共子序列的状态转移方程为:
对于长度为 N 的序列 S1 和长度为 M 的序列 S2,dp[N][M] 就是序列 S1 和序列 S2 的最长公共子序列长度。
与最长递增子序列相比,最长公共子序列有以下不同点:
- 针对的是两个序列,求它们的最长公共子序列。
- 在最长递增子序列中,dp[i] 表示以 Si 为结尾的最长递增子序列长度,子序列必须包含 Si ;在最长公共子序列中,dp[i][j] 表示 S1 中前 i 个字符与 S2 中前 j 个字符的最长公共子序列长度,不一定包含 S1i 和 S2j。
- 在求最终解时,最长公共子序列中 dp[N][M] 就是最终解,而最长递增子序列中 dp[N] 不是最终解,因为以 SN 为结尾的最长递增子序列不一定是整个序列最长递增子序列,需要遍历一遍 dp 数组找到最大者。
1. 最长公共子序列
1143. Longest Common Subsequence
1 | public int longestCommonSubsequence(String text1, String text2) { |
0-1 背包
有一个容量为 N 的背包,要用这个背包装下物品的价值最大,这些物品有两个属性:体积 w 和价值 v。
定义一个二维数组 dp 存储最大价值,其中 dp[i][j] 表示前 i 件物品体积不超过 j 的情况下能达到的最大价值。设第 i 件物品体积为 w,价值为 v,根据第 i 件物品是否添加到背包中,可以分两种情况讨论:
- 第 i 件物品没添加到背包,总体积不超过 j 的前 i 件物品的最大价值就是总体积不超过 j 的前 i-1 件物品的最大价值,dp[i][j] = dp[i-1][j]。
- 第 i 件物品添加到背包中,dp[i][j] = dp[i-1][j-w] + v。
第 i 件物品可添加也可以不添加,取决于哪种情况下最大价值更大。因此,0-1 背包的状态转移方程为:
1 | // W 为背包总体积 |
空间优化
在程序实现时可以对 0-1 背包做优化。观察状态转移方程可以知道,前 i 件物品的状态仅与前 i-1 件物品的状态有关,因此可以将 dp 定义为一维数组,其中 dp[j] 既可以表示 dp[i-1][j] 也可以表示 dp[i][j]。此时,
因为 dp[j-w] 表示 dp[i-1][j-w],因此不能先求 dp[i][j-w],防止将 dp[i-1][j-w] 覆盖。也就是说要先计算 dp[i][j] 再计算 dp[i][j-w],在程序实现时需要按倒序来循环求解。
1 | public int knapsack(int W, int N, int[] weights, int[] values) { |
无法使用贪心算法的解释
0-1 背包问题无法使用贪心算法来求解,也就是说不能按照先添加性价比最高的物品来达到最优,这是因为这种方式可能造成背包空间的浪费,从而无法达到最优。考虑下面的物品和一个容量为 5 的背包,如果先添加物品 0 再添加物品 1,那么只能存放的价值为 16,浪费了大小为 2 的空间。最优的方式是存放物品 1 和物品 2,价值为 22.
| id | w | v | v/w |
|---|---|---|---|
| 0 | 1 | 6 | 6 |
| 1 | 2 | 10 | 5 |
| 2 | 3 | 12 | 4 |
变种
完全背包:物品数量为无限个
多重背包:物品数量有限制
多维费用背包:物品不仅有重量,还有体积,同时考虑这两种限制
其它:物品之间相互约束或者依赖
1. 划分数组为和相等的两部分
416. Partition Equal Subset Sum (Medium)
1 | Input: [1, 5, 11, 5] |
可以看成一个背包大小为 sum/2 的 0-1 背包问题。
1 | public boolean canPartition(int[] nums) { |
2. 改变一组数的正负号使得它们的和为一给定数
494. Target Sum (Medium)
1 | Input: nums is [1, 1, 1, 1, 1], S is 3. |
该问题可以转换为 Subset Sum 问题,从而使用 0-1 背包的方法来求解。
可以将这组数看成两部分,P 和 N,其中 P 使用正号,N 使用负号,有以下推导:
1 | sum(P) - sum(N) = target |
因此只要找到一个子集,令它们都取正号,并且和等于 (target + sum(nums))/2,就证明存在解。
1 | public int findTargetSumWays(int[] nums, int S) { |
DFS 解法:
1 | public int findTargetSumWays(int[] nums, int S) { |
3. 01 字符构成最多的字符串
474. Ones and Zeroes (Medium)
1 | Input: Array = {"10", "0001", "111001", "1", "0"}, m = 5, n = 3 |
这是一个多维费用的 0-1 背包问题,有两个背包大小,0 的数量和 1 的数量。
1 | public int findMaxForm(String[] strs, int m, int n) { |
4. 找零钱的最少硬币数
322. Coin Change (Medium)
1 | Example 1: |
题目描述:给一些面额的硬币,要求用这些硬币来组成给定面额的钱数,并且使得硬币数量最少。硬币可以重复使用。
- 物品:硬币
- 物品大小:面额
- 物品价值:数量
因为硬币可以重复使用,因此这是一个完全背包问题。完全背包只需要将 0-1 背包的逆序遍历 dp 数组改为正序遍历即可。
1 | public int coinChange(int[] coins, int amount) { |
5. 找零钱的硬币数组合
518. Coin Change 2 (Medium)
1 | Input: amount = 5, coins = [1, 2, 5] |
完全背包问题,使用 dp 记录可达成目标的组合数目。
1 | public int change(int amount, int[] coins) { |
6. 字符串按单词列表分割
139. Word Break (Medium)
1 | s = "leetcode", |
dict 中的单词没有使用次数的限制,因此这是一个完全背包问题。
该问题涉及到字典中单词的使用顺序,也就是说物品必须按一定顺序放入背包中,例如下面的 dict 就不够组成字符串 “leetcode”:
1 | ["lee", "tc", "cod"] |
求解顺序的完全背包问题时,对物品的迭代应该放在最里层,对背包的迭代放在外层,只有这样才能让物品按一定顺序放入背包中。
1 | public boolean wordBreak(String s, List<String> wordDict) { |
7. 组合总和
377. Combination Sum IV (Medium)
1 | nums = [1, 2, 3] |
涉及顺序的完全背包。
1 | public int combinationSum4(int[] nums, int target) { |
股票交易
1. 需要冷却期的股票交易
309. Best Time to Buy and Sell Stock with Cooldown(Medium)
题目描述:交易之后需要有一天的冷却时间。
1 | public int maxProfit(int[] prices) { |
2. 需要交易费用的股票交易
714. Best Time to Buy and Sell Stock with Transaction Fee (Medium)
1 | Input: prices = [1, 3, 2, 8, 4, 9], fee = 2 |
题目描述:每交易一次,都要支付一定的费用。
1 | public int maxProfit(int[] prices, int fee) { |
3. 只能进行两次的股票交易
123. Best Time to Buy and Sell Stock III (Hard)
1 | public int maxProfit(int[] prices) { |
4. 只能进行 k 次的股票交易
188. Best Time to Buy and Sell Stock IV (Hard)
1 | public int maxProfit(int k, int[] prices) { |
字符串编辑
1. 删除两个字符串的字符使它们相等
583. Delete Operation for Two Strings (Medium)
1 | Input: "sea", "eat" |
可以转换为求两个字符串的最长公共子序列问题。
1 | public int minDistance(String word1, String word2) { |
2. 编辑距离
72. Edit Distance (Hard)
1 | Example 1: |
题目描述:修改一个字符串成为另一个字符串,使得修改次数最少。一次修改操作包括:插入一个字符、删除一个字符、替换一个字符。
1 | public int minDistance(String word1, String word2) { |
3. 复制粘贴字符
650. 2 Keys Keyboard (Medium)
题目描述:最开始只有一个字符 A,问需要多少次操作能够得到 n 个字符 A,每次操作可以复制当前所有的字符,或者粘贴。
1 | Input: 3 |
1 | public int minSteps(int n) { |
1 | public int minSteps(int n) { |
- 本文作者: LHS
- 本文链接: https:/LiuHuAshen.github.io/2021/06/01/动态规划/
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!