本文主要是总结力扣中动态规划问题的共同点,并得到一个通用的解决方案和算法框架,依照这一方案和框架去解决更多问题。也算是labuladong的算法小抄笔记
原文链接:https://labuladong.gitee.io/algo/
题目形式:求最值,什么最长递增子序列balabalabala。核心问题就是穷举,因为要求最值就得把所有答案找出来再找最值。
特点:存在重叠子问题和最优子结构(通过子问题最值得到原问题最值)
关键:状态转移方程。
基本算法框架
# 初始化 base case
dp[0][0][...] = base
# 进行状态转移
for 状态1 in 状态1的所有取值:
for 状态2 in 状态2的所有取值:
for ...
dp[状态1][状态2][...] = 求最值(选择1,选择2...)
基本思想:数学归纳——用于寻找状态转移方程
给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
你可以认为每种硬币的数量是无限的。
输入
coins = [1, 2, 5], amount = 11
输出
3
解释
11 = 5 + 5 + 1
这是一个动态规划问题——求最值,具有最优子结构,子问题之间相互独立
比如你想求 amount = 11 时的最少硬币数(原问题),如果你知道凑出 amount = 10 的最少硬币数(子问题),你只需要把子问题的答案加一(再选一枚面值为 1 的硬币)就是原问题的答案。因为硬币的数量是没有限制的,所以子问题之间没有相互制,是互相独立的。
那么就开始列状态方程按照如下流程:
d p ( n ) = { 0 , n = 0 − 1 , n < 0 m i n { d p ( n − c o i n ) + 1 ∣ c o i n ∈ c o i n s } , n > 0 dp(n) = \begin{cases}0,n=0\\-1,n<0\\min\{dp(n-coin)+1|coin \in coins\},n>0\end{cases} dp(n)=⎩⎪⎨⎪⎧0,n=0−1,n<0min{dp(n−coin)+1∣coin∈coins},n>0
//暴力递归
//时间复杂度是指数级别(可以化递归树,时间复杂度就是递归树节点个数×每一次递归的时间复杂度)
//在力扣里面超时,因为子问题重复计算了
public int coinChange(int[] coins, int amount) {
if(amount==0) return 0;
if(amount<0) return -1;
int res = Integer.MAX_VALUE;
for(int coin:coins){
int sub = coinChange(coins,amount-coin);
if(sub == -1) continue;
res = Math.min(res,sub+1);
}
if(res!=Integer.MAX_VALUE)
return res;
else
return -1;
}
思路一因为大量子问题被重复计算,可以用一个“备忘录”来记录子问题的解。这样在状态转移时先查找备忘录看看有没有子问题的解可以拿来直接用。这样可以把子问题数量缩小到总金额数n,处理自问题时间不变还是k,时间复杂度就是 O ( k n ) O(kn) O(kn)。
//带备忘录递归
public int coinChange2(int[] coins, int amount) {
if(amount<1) return 0;
return coinChange2(coins,amount,new int[amount+1]);
}
public int coinChange2(int[] coins, int amount,int[] memory) {
//base case
if(amount==0) return 0;
if(amount<0) return -1;
//查看备忘录避免重复计算
if(memory[amount]!=0) return memory[amount];
int res = Integer.MAX_VALUE;
for(int coin:coins){
int sub = coinChange2(coins,amount-coin,memory);
if(sub == -1) continue;
res = Math.min(res,sub+1);
}
if(res!=Integer.MAX_VALUE) {
memory[amount] = res;
}
else{
memory[amount] = -1;
}
return memory[amount];
}
思路一和思路二都是自顶向下进行状态转移,思路二使用备忘录来存储所有子问题的解,我们也可以使用dp table来记录子问题的解,解决子问题重叠的问题。也就是自底向上
思路一思路二通过定义递归函数实现状态转移:
dp函数:输入目标金额,返回凑出目标金额最少的硬币数
思路三通过定义递归数组实现状态转移:
dp数组:当目标金额为 i 时,至少需要 dp[i] 枚硬币凑出。
代码就根据状态转移方程写
//dp数组迭代
public int coinChange3(int[] coins, int amount) {
//base case
if(amount==0) return 0;
if(amount<0) return -1;
int[] dp = new int[amount+1];
int max = amount+1;//使用硬币的数量是不会超过amount+1的,实现最差也是用amount个1元硬币凑
Arrays.fill(dp,max);//先全部置为最大
dp[0] = 0;
for(int i=1;i<=amount;i++){
for(int coin:coins){
if(i - coin<0) continue;
dp[i] = Math.min(dp[i],1+dp[i-coin]);
}
}
return dp[amount] > amount ? -1:dp[amount];
}
给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。
子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。
输入
nums = [10,9,2,5,3,7,101,18]
输出
4
解释
最长递增子序列是 [2,3,7,101],因此长度为 4 。
动态规划的核心设计思想是数学归纳法。
数学归纳法:我们先假设这个结论在 k
定义dp:dp[i] 表示以 nums[i] 这个数结尾的最长递增子序列的长度。
base case:dp[i] 初始值为 1,因为以 nums[i] 结尾的最长递增子序列起码要包含它自己。
状态转移:已知dp[0…4]推dp[5]。只要找到前面以nums[j]结尾的子序列其中nums[j]
public int lengthOfLIS(int[] nums) {
int n = nums.length;
int[] dp = new int[n];
for(int i=0;i<n;i++){
dp[i] = 1;
for(int j = 0;j<i;j++){
if(nums[j]<nums[i]){
dp[i] = Math.max(dp[i],dp[j]+1);
}
}
}
int res = 0;
for(int i=0;i<n;i++){
res = Math.max(res,dp[i]);
}
return res;
}
比较难想到,稍作了解就好
纸牌游戏(耐心排序)
像遍历数组那样从左到右一张一张处理扑克牌,最终要把这些牌分成若干堆。
给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
输入
nums = [-2,1,-3,4,-1,2,1,-5,4]
输出
6
解释
连续子数组 [4,-1,2,1] 的和最大,为 6 。
定义dp:dp[i]表示nums[0…i]中以nums[i]结尾的最大子数组和
base case:dp[0] = nums[0]
状态转移:已知dp[i-1]怎么算dp[i]?dp[i]有两种选择,要么与前面的相邻子数组连接,形成一个和更大的子数组;要么不与前面的子数组连接,自成一派,自己作为一个子数组。
d p [ i ] = m a x ( d p [ i ] , d p [ i − 1 ] + n u m s [ i ] ) dp[i] = max(dp[i],dp[i-1]+nums[i]) dp[i]=max(dp[i],dp[i−1]+nums[i])
状态压缩:注意到 dp[i] 仅仅和 dp[i-1] 的状态有关,可以只用两个状态变量
public int maxSubArray(int[] nums) {
int n = nums.length;
if(n==0) return 0;
int dp0 = nums[0],dp1 = nums[0];
int res = dp0;
for(int i=1;i<n;i++){
dp1 = Math.max(nums[i],dp0+nums[i]);
res = Math.max(res,dp1);
dp0 = dp1;
}
return res;
}