力扣动态规划问题总结
1. 动态规划
按照比较学术化的解释,动态规划是
动态规划在查找有很多重叠子问题的情况的最优解时有效。它将问题重新组合成子问题。为了避免多次解决这些子问题,它们的结果都逐渐被计算并被保存,从简单的问题直到整个问题都被解决。因此,动态规划保存递归时的结果,因而不会在解决同样的问题时花费时间。
动态规划只能应用于有最优子结构的问题。最优子结构的意思是局部最优解能决定全局最优解(对有些问题这个要求并不能完全满足,故有时需要引入一定的近似)。简单地说,问题能够分解成子问题来解决。
以上描述简单来就是,一个问题的答案可以由其子问题递推得来,谈到递推,相信很容易想到高中的数列知识点。比如斐波那契数列就是一个典型的一维DP问题,而它的概念也很好阐述了DP问题——由之前的两数相加得出。另外比较常见的一维DP问题还有,最大(小)和问题、爬楼梯问题,以及常见的二维DP问题0/1背包问题、LCS问题以及大多数字符串比较的问题。
不难看出,求解一个DP问题即求解一个“其答案可以由其子问题递推而来的问题”关键点是找到“这种递推是如何进行的”,我们使用递推关系式来描述。比如斐波那契数列我们可以用一个简单的公式表示
很显然递推公式并不完整,当需要计算F0与F1时我们并不能找到等号右边的项,所以完整的递推公式还需要找到边界值F0与F1的值,而在DP问题中这往往也是关键,根据斐波那契数列的描述,将大多数DP问题分为简单的三个求解步骤:
- 找到普适的递推公式
- 给边界值赋值或者说初始化
- 迭代
2. 例题
2.1 leetcode 70 爬楼梯
题目描述
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
本问题其实常规解法可以分成多个子问题,爬第n阶楼梯的方法数量,等于 2 部分之和,两种情况分别是
- 爬上 n−1 阶楼梯的方法数量,再爬1阶就能到第n阶。
- 爬上 n−2 阶楼梯的方法数量,再爬2阶就能到第n阶。
所以,很容易得到递推公式
另外需要初始化,dp[0]=1,dp[1]=1。(事实上不难发现,爬楼梯问题即一个斐波那契数列)
下面看代码:
public int climbStairs(int n) {
int[] dp=new int[n+1];
dp[0]=1;
dp[1]=1;
for(int i=2;i<=n;i++){
dp[i]=dp[i-2]+dp[i-1];
}
return dp[n];
}
2.2 leetcode 198 打家劫舍
题目描述
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
很显然,本题是需要求解不相邻数字和的最大值问题。在偷第i间房子时,不能偷其相邻的房子,显然我们只需要考虑第i间房子是否偷窃即可,倘若偷窃第i间房子,那么当前价值总和为前i-2间房子价值总和加第i间房子的价值,倘若不偷窃第i间房子,那么当前价值总和为前i-1间房子的价值总和。所以很容易得到状态转移方程:
接下来对边界值进行赋值,方程中出现了i-2
,显然需要对dp[0]
以及dp[1]
进行赋值,只有一间房子时(数组下标从0开始,故dp[0]即以为前1间房子的状态),我们令dp[0]=nums[0]
,有两间房子时
即可,下面看代码
public int rob(int[] nums) {
int n = nums.length;
if(n == 0) return 0;
if(n == 1) return nums[0];
int[] dp = new int[n];
dp[0] = nums[0];
dp[1] = Math.max(nums[0],nums[1]);
for(int i = 2; i < n; i++){
dp[i] = Math.max(dp[i-1],dp[i-2] + nums[i]);
}
return dp[n-1];
}
2.3 leetcode 416 分割等和子集
给定一个只包含正整数的非空数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
示例 1:
输入: [1, 5, 11, 5]
输出: true
解释: 数组可以分割成 [1, 5, 5] 和 [11].
实际上,本问题可以归约成一个0/1背包问题,令数组所有数字之和为sum,则原问题即可等价于在nums.length
件物品下,每件物品只能装入背包一次,找到恰好装满背包容量为sum/2
的情况。所以显然,本问题为一个0/1背包问题。
不同的是不需要计算物品价值,只需要记录当前容量下是否能够找到恰好装入的情况,所以考虑第i
件物品是否能够在容量为j
的情况下装入与否,可知,状态转移方程为
关于初始化,本题目,只需要对dp[0][0]
初始化即可,并不需要对第一行(二维情况下即dp[0]][nums[0]]
)进行初始化,因为由题意易知,问题解必有两组,所以我们找到一组即可。
二维DP代码
class Solution {
public boolean canPartition(int[] nums) {
int length = nums.length;
int sum = 0;
for (int n : nums) sum += n;
if (sum % 2 != 0) return false;
int capacity = sum / 2;
boolean[][] dp = new boolean[length][capacity + 1];
//合理
dp[0][0] = true;
for (int i = 1; i < length; i++){
for (int j = 0; j <= capacity; j++){
dp[i][j] = dp[i-1][j];
if(nums[i] <= j)
dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i]];
}
if(dp[i][capacity])
return true;
}
return false;
}
}
优化空间复杂度
直接使用一个一维数组对其进行覆盖即可,另,并非使用的滚动数组,所以注意在对容量进行枚举时,需要逆向进行,状态转移方程如下
一维dp代码
class Solution {
public boolean canPartition(int[] nums) {
int length = nums.length;
int sum = 0;
for(int n : nums) sum += n;
if(sum % 2 != 0) return false;
int capacity = sum / 2;
boolean[] dp = new boolean[capacity + 1];
dp[0] = true;
for(int i = 1; i < length; i++){
for(int j = capacity; j >= nums[i]; j--){
dp[j] = dp[j] || dp[j-nums[i]];
}
if(dp[capacity]) return true;
}
return false;
}
}
2.4 视频拼接
你将会获得一系列视频片段,这些片段来自于一项持续时长为 T
秒的体育赛事。这些片段可能有所重叠,也可能长度不一。
视频片段clips[i]
都用区间进行表示:开始于clips[i][0]
并于 clips[i][1]
结束。我们甚至可以对这些片段自由地再剪辑,例如片段 [0, 7] 可以剪切成 [0, 1] + [1, 3] + [3, 7]
三部分。
我们需要将这些片段进行再剪辑,并将剪辑后的内容拼接成覆盖整个运动过程的片段([0, T]
)。返回所需片段的最小数目,如果无法完成该任务,则返回 -1
。
示例 1:
输入:clips = [[0,2],[4,6],[8,10],[1,9],[1,5],[5,9]], T = 10
输出:3
解释:
我们选中 [0,2], [8,10], [1,9] 这三个片段。
然后,按下面的方案重制比赛片段:
将 [1,9] 再剪辑为 [1,2] + [2,8] + [8,9] 。
现在我们手上有 [0,2] + [2,8] + [8,10],而这些涵盖了整场比赛 [0, 10]。
此处可以使用复杂度为O(N+T)的贪心法来做,也可以使用复杂度为O(N*T)的dp方法来做,dp空间定义大小定义为T+1,我们需要得到最小值,所以以T的最大值填充,得到状态转移方程
class Solution {
public int videoStitching(int[][] clips, int T) {
int[] dp = new int[T + 1];
Arrays.fill(dp, 101);
dp[0] = 0;
for (int i = 1; i <= T; i++) {
for (int clip[] : clips) {
if (i > clip[0] && i <= clip[1]) {
dp[i] = Math.min(dp[i], dp[clip[0]] + 1);
}
}
}
return dp[T] == 101 ? -1 : dp[T];
}
}
2.5 单词拆分 II
先使用动态规划方法判断单词是否可以拆分,等价于139题,若可拆分,则进行回溯处理,想象成一颗树,然后对树进行深度遍历,并回溯。
代码:
class Solution {
public List<String> wordBreak(String s, List<String> wordDict) {
Set<String> wordSet = new HashSet(wordDict);
int length = s.length();
boolean[] dp = new boolean[length + 1];
dp[0] = true;
for (int i = 1; i <= length; i++) {
for (int j = 0; j < i; j++) {
if (dp[j] && wordSet.contains(s.substring(j, i))) {
dp[i] = true;
break;
}
}
}
List<String> res = new ArrayList<>();
if (dp[length]) {
Deque<String> path = new LinkedList<>();
dfs(s, length, wordSet, dp, path, res);
return res;
}
return res;
}
public void dfs(String s, int length, Set<String> wordSet, boolean[] dp, Deque<String> path, List<String> res) {
if (length == 0) {
res.add(String.join(" ", path));
return;
}
for (int i = length - 1; i >= 0; i--) {
String tempWord = s.substring(i, length);
if (wordSet.contains(tempWord) && dp[i]) {
path.push(tempWord);
dfs(s, i, wordSet, dp, path, res);
path.pop();
}
}
}
}