一、青蛙跳阶问题
1. 暴力递归
leetcode原题:一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶。求该青蛙跳上一个 10 级的台阶总共有多少种跳法。
有些小伙伴第一次见这个题的时候,可能会有点蒙圈,不知道怎么解决。其实可以试想:
要想跳到第10级台阶,要么是先跳到第9级,然后再跳1级台阶上去; 要么是先跳到第8级,然后一次迈2级台阶上去。
同理,要想跳到第9级台阶,要么是先跳到第8级,然后再跳1级台阶上去; 要么是先跳到第7级,然后一次迈2级台阶上去。
要想跳到第8级台阶,要么是先跳到第7级,然后再跳1级台阶上去; 要么是先跳到第6级,然后一次迈2级台阶上去。
假设跳到第n级台阶的跳数我们定义为f(n),很显然就可以得出以下公式:
f(10) = f(9)+f(8)
f (9) = f(8) + f(7)
f (8) = f(7) + f(6)
...
f(3) = f(2) + f(1)
即通用公式为: f(n) = f(n-1) + f(n-2)
那f(2) 或者 f(1) 等于多少呢?
-
当只有2级台阶时,第一种是直接跳两级,第二种是先跳一级,然后再跳一级, 即 f(2) = 2;
-
当只有1级台阶时,只有一种跳法,即 f(1) = 1;
因此可以用递归去解决这个问题:
class Solution {
public int numWays(int n) {
if(n == 1){
return 1;
}
if(n == 2){
return 2;
}
return numWays(n-1) + numWays(n-2);
}
}
去leetcode提交一下,发现有问题,超出时间限制
为什么超时了呢?递归耗时在哪里呢?先画出 递归树 看看:
-
要计算原问题 f(10),就需要先计算出子问题 f(9) 和 f(8)
-
然后要计算 f(9),又要先算出子问题 f(8) 和 f(7),以此类推。
-
一直到 f(2) 和 f(1),递归树才终止。
我们先来看看这个递归的时间复杂度吧:
递归时间复杂度 = 解决一个子问题时间*子问题个数
-
一个子问题时间 = f(n-1) + f(n-2),也就是一个加法的操作,所以复杂度是 O(1);
-
问题个数 = 递归树节点的总数,递归树的总节点 = 2^n-1,所以是复杂度O(2^n)。
因此,青蛙跳阶,递归解法的时间复杂度 = O(1) * O(2^n) = O(2^n),就是指数级别的,爆炸增长的,如果n比较大的话,超时很正常的了。
回过头来,你仔细观察这颗递归树,你会发现存在大量重复计算,比如 f(8) 被计算了两次,f(7) 被重复计算了3次 ... ...
2. 带备忘录的递归
既然存在大量重复计算,那么我们可以先把计算好的答案存下来,即造一个备忘录,等到下次需要的话,先去备忘录查一下,有则就直接取,没有时才计算获取,那就可以省去重新重复计算的耗时啦!
带备忘录的递归解法(自顶向下),一般使用一个数组或者一个哈希map充当这个备忘录。
-
第一步,f(10) = f(9) + f(8),f(9) 和 f(8) 都需要计算出来,然后再加到备忘录中,如下:
-
第二步, f(9) = f(8)+ f(7) ,f(8) = f(7) + f(6), 因为 f(8) 已经在备忘录中啦,所以可以省掉,f(7), f(6) 都需要计算出来,加到备忘录中
-
第三步, f(8) = f(7) + f(6), 发现 f(8),f(7), f(6) 全部都在备忘录上了,所以都可以剪掉。
所以呢,用了备忘录递归算法,递归树变成光秃秃的树干咯,如下:
带备忘录的递归算法,子问题个数=树节点数=n,解决一个子问题还是O(1),所以带备忘录的递归算法的时间复杂度是O(n)。代码如下:
public class Solution {
//使用哈希map,充当备忘录的作用
Map<Integer, Integer> tempMap = new HashMap();
public int numWays(int n) {
if (n <= 2) {
return n;
}
if (tempMap.containsKey(n)) {
//备忘录有,直接返回
return tempMap.get(n);
} else {
// 备忘录没有,执行递归计算, 并且把结果保存到备忘录
tempMap.put(n, numWays(n - 1) + numWays(n - 2));
return tempMap.get(n);
}
}
}
去leetcode提交一下,如图,稳了:
其实,还可以用动态规划解决这道题。
3. 动态规划
动态规划跟带备忘录的递归解法基本思想是一致的,都是减少重复计算,时间复杂度也都是差不多。区别在于:
-
带备忘录的递归,是从f(10) 往 f(1)方向延伸求解的,所以也称为自顶向下的解法。
-
动态规划从较小问题的解,由交叠性质,逐步决策出较大问题的解,它是从f(1) 往 f(10)方向,往上推求解,所以称为自底向上的解法。
动态规划有几个典型特征,最优子结构、状态转移方程、边界、重叠子问题。在青蛙跳阶问题中:
-
f(n-1) 和 f(n-2) 称为 f(n) 的最优子结构
-
f(n) = f(n-1) + f(n-2) 称为状态转移方程
-
f(1) = 1, f(2) = 2 就是边界
-
比如: f(10)= f(9)+f(8),f(9) = f(8) + f(7) , f(8) 就是重叠子问题
来看下自底向上的解法,从f(1) 往 f(10)方向,想想是不是直接一个for循环就可以解决啦,如下:
带备忘录的递归解法,空间复杂度是O(n),但是呢,仔细观察上图,可以发现,f(n) 只依赖前面两个数,所以只需要两个变量a和b来存储,就可以满足需求了,因此空间复杂度是O(1)。
代码如下:
public class Solution {
public int numWays(int n) {
if (n<= 1) {
return 1;
}
if (n == 2) {
return 2;
}
int a = 1;
int b = 2;
int temp = 0;
for (int i = 3; i <= n; i++) {
// 从归纳的表格可以看出:i=3及以后,后面的跳数等于前两个跳数之和
temp = a + b;
a = b;
b = temp;
}
return temp;
}
}
4. 动态规划的解题套路
4.1 什么样的问题可以考虑使用动态规划解决呢?
★如果一个问题,可以把所有可能的答案穷举出来,并且穷举出来后,发现存在重叠子问题,就可以考虑使用动态规划。
”
比如一些求最值的场景,如最长递增子序列、最小编辑距离、背包问题、凑零钱问题等等,都是动态规划的经典应用场景。
4.2 动态规划的解题思路
动态规划的核心思想就是拆分子问题,记住过往,减少重复计算。 并且动态规划一般都是自底向上的,因此到这里,基于青蛙跳阶问题,我总结了一下我做动态规划的思路:
-
穷举分析
-
确定边界
-
找出规律,确定最优子结构
-
写出状态转移方程
1. 穷举分析
-
当台阶数是1的时候,有一种跳法,f(1) =1
-
当只有2级台阶时,第一种是直接跳两级,第二种是先跳一级,然后再跳一级。即f(2) = 2;
-
当台阶是3级时,想跳到第3级台阶,要么是先跳到第2级,然后再跳1级台阶上去,要么是先跳到第 1级,然后一次迈 2 级台阶上去。所以f(3) = f(2) + f(1) =3
-
当台阶是4级时,想跳到第3级台阶,要么是先跳到第3级,然后再跳1级台阶上去,要么是先跳到第 2级,然后一次迈 2 级台阶上去。所以f(4) = f(3) + f(2) =5
-
当台阶是5级时......
2. 确定边界
通过穷举分析,我们发现,当台阶数是1的时候或者2的时候,可以明确知道青蛙跳法。f(1) =1,f(2) = 2,当台阶n>=3时,已经呈现出规律f(3) = f(2) + f(1) =3,因此f(1) =1,f(2) = 2就是青蛙跳阶的边界。
3. 找规律,确定最优子结构
n>=3时,已经呈现出规律 f(n) = f(n-1) + f(n-2) ,因此,f(n-1)和f(n-2) 称为 f(n) 的最优子结构。什么是最优子结构?有这么一个解释:
★一道动态规划问题,其实就是一个递推问题。假设当前决策结果是f(n),则最优子结构就是要让 f(n-k) 最优, 最优子结构性质就是能让转移到n的状态是最优的, 并且与后面的决策没有关系, 即让后面的决策安心地使用前面的局部最优解的一种性质
”
4, 写出状态转移方程
通过前面3步,穷举分析,确定边界,最优子结构,我们就可以得出状态转移方程:
5. 代码实现
我们实现代码的时候,一般注意从底往上遍历哈,然后关注下边界情况,空间复杂度,也就差不多啦。动态规划有个框架的,大家实现的时候,可以考虑适当参考一下:
dp[0][0][...] = 边界值
for(状态1 :所有状态1的值){
for(状态2 :所有状态2的值){
for(...){
//状态转移方程
dp[状态1][状态2][...] = 求最值
}
}
}
二、最长递增子序列的长度
给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。
示例 1:
输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
示例 2:
输入:nums = [0,1,0,3,2,3]
输出:4
我们按照以上动态规划的解题思路:
-
穷举分析
-
确定边界
-
找规律,确定最优子结构
-
状态转移方程
1.穷举分析
因为动态规划,核心思想包括拆分子问题,记住过往,减少重复计算。 所以我们在思考原问题:数组num[i]的最长递增子序列长度时,可以思考下相关子问题,比如原问题是否跟子问题num[i-1]的最长递增子序列长度有关呢?
自底向上的穷举,这里观察规律,显然是有关系的,我们还是遵循动态规划自底向上的原则,基于示例1的数据,从数组只有一个元素开始分析。
-
当nums只有一个元素10时,最长递增子序列是[10],长度是1.
-
当nums需要加入一个元素9时,最长递增子序列是[10]或者[9],长度是1。
-
当nums再加入一个元素2时,最长递增子序列是[10]或者[9]或者[2],长度是1。
-
当nums再加入一个元素5时,最长递增子序列是[2,5],长度是2。
-
当nums再加入一个元素3时,最长递增子序列是[2,5]或者[2,3],长度是2。
-
当nums再加入一个元素7时,,最长递增子序列是[2,5,7]或者[2,3,7],长度是3。
-
当nums再加入一个元素101时,最长递增子序列是[2,5,7,101]或者[2,3,7,101],长度是4。
-
当nums再加入一个元素18时,最长递增子序列是[2,5,7,101]或者[2,3,7,101]或者[2,5,7,18]或者[2,3,7,18],长度是4。
-
当nums再加入一个元素7时,最长递增子序列是[2,5,7,101]或者[2,3,7,101]或者[2,5,7,18]或者[2,3,7,18],长度是4.
分析找规律,拆分子问题,通过上面分析,我们可以发现一个规律:
如果新加入一个元素nums[i], 最长递增子序列要么是以nums[i]结尾的递增子序列,要么就是nums[i-1]的最长递增子序列。看到这个,是不是很开心,nums[i]的最长递增子序列已经跟子问题 nums[i-1]的最长递增子序列有关联了。
原问题数组nums[i]的最长递增子序列 = 子问题数组nums[i-1]的最长递增子序列/nums[i]结尾的最长递增子序列
是不是感觉成功了一半呢?但是如何把nums[i]结尾的递增子序列也转化为对应的子问题呢?要是nums[i]结尾的递增子序列也跟nums[i-1]的最长递增子序列有关就好了。又或者nums[i]结尾的最长递增子序列,跟前面子问题num[j](0=<j<i)结尾的最长递增子序列有关就好了,带着这个想法,我们又回头看看穷举的过程:
nums[i]的最长递增子序列,不就是从以数组num[i]每个元素结尾的最长子序列集合,取元素最多(也就是长度最长)那个嘛,所以原问题,我们转化成求出以数组nums每个元素结尾的最长子序列集合,再取最大值嘛。哈哈,想到这,我们就可以用dp[i]表示以num[i]这个数结尾的最长递增子序列的长度啦,然后再来看看其中的规律:
其实,nums[i]结尾的自增子序列,只要找到比nums[i]小的子序列,加上nums[i] 就可以啦。显然,可能形成多种新的子序列,我们选最长那个,就是dp[i]的值啦
nums[3]=5,以
5
结尾的最长子序列就是[2,5]
,因为从数组下标0到3
遍历,只找到了子序列[2]
比5
小,所以就是[2]+[5]
啦,即dp[4]=2
nums[4]=3,以
3
结尾的最长子序列就是[2,3]
,因为从数组下标0到4
遍历,只找到了子序列[2]
比3
小,所以就是[2]+[3]
啦,即dp[4]=2
nums[5]=7,以
7
结尾的最长子序列就是[2,5,7]
和[2,3,7]
,因为从数组下标0到5
遍历,找到2,5和3
都比7小,所以就有[2,7],[5,7],[3,7],[2,5,7]和[2,3,7]
这些子序列,最长子序列就是[2,5,7]和[2,3,7]
,它俩不就是以5
结尾和3
结尾的最长递增子序列+[7]来的嘛!所以,dp[5]=3 =dp[3]+1=dp[4]+1
。
很显然有这个规律:一个以nums[i]结尾的数组nums
-
如果存在j属于区间[0,i-1],并且num[i]>num[j]的话,则有:
dp(i) =max(dp(j))+1,
2. 最简单的边界情况
当nums数组只有一个元素时,最长递增子序列的长度dp(0)=1,
当nums数组有两个元素时,dp(1) =2或者1, 因此边界就是dp(0)=1。
3. 确定最优子结构
从穷举分析,我们可以得出,以下的最优结构:
dp(i) = max(dp(j)) + 1,存在j属于区间 [0,i-1],并且num[i] > num[j]。
max(dp(j)) 就是最优子结构。
4. 状态转移方程
通过前面分析,我们就可以得出状态转移方程啦:
所以数组nums[i]的最长递增子序列就是:
最长递增子序列 =max(dp[i])
代码实现:
class Solution {
public int lengthOfLIS(int[] nums) {
if (nums.length == 0) {
return 0;
}
int[] dp = new int[nums.length];
//初始化就是边界情况
dp[0] = 1;
int maxans = 1;
//自底向上遍历
for (int i = 1; i < nums.length; i++) {
dp[i] = 1;
//从下标0到i遍历
for (int j = 0; j < i; j++) {
//找到前面比nums[i]小的数nums[j],即有dp[i]= dp[j]+1
if (nums[j] < nums[i]) {
//因为会有多个小于nums[i]的数,也就是会存在多种组合了嘛,我们就取最大放到dp[i]
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
//求出dp[i]后,dp最大那个就是nums的最长递增子序列啦
maxans = Math.max(maxans, dp[i]);
}
return maxans;
}
}
三、背包问题
有一个背包,容量为4磅 , 现有如下物品:
物品 | 重量 | 价格 |
吉他(G) | 1 | 1500 |
音响(S) | 4 | 3000 |
电脑(L) | 3 | 2000 |
1) 要求达到的目标为装入的背包的总价值最大,并且重量不超出
2) 要求装入的物品不能重复
1. 填表分析
背包问题主要是指一个给定容量的背包、若干具有一定价值和重量的物品,如何选择物品放入背包使物品的价值最大。其中又分01背包和完全背包(完全背包指的是:每种物品都有无限件可用)
这里的问题属于01背包,即每个物品最多放一个。而无限背包可以转化为01背包。
此问题是动态规划的经典题目,动态规划可以通过填表的方式来逐步推进,得到最优解.
解题思路:每次遍历到第i个物品,根据w[i]和v[i]来确定是否需要将该物品放入背包,即对于给定的n个物品:
v[i]:第i个物品的价值
w[i]:第i个物品的重量,C为背包的容量
v[i][j]:表示在i个物品中能够装入容量为j的背包中的最大价值
2. 通过填表,可以总结出
(1) v[i][0] = v[0][j] = 0; //表示 填入表 第一行和第一列是0
(2) 当w[i] > j 时:v[i][j] = v[i-1][j] // 物品重量 大于 当前背包容量时,说明当前物品因背包容量不足不能放入背包,就直接使用上个物品在同容量背包下的装入策略
(3) 当w[i] <= j 时:v[i][j] = max{ v[i-1][j], v[i] + v[i-1][j - w[i]] } // 物品重量 不大于 当前背包容量时,那么:
①获取当前物品放入背包前的最大价值是 v[i-1][j]
②先把当前物品放入背包,再获取背包剩余容量可以存放的物品的最大价值,此时:当前物品的价值 + 背包剩余容量可存放的最大价值,即:v[i] + v[i-1][j - w[i]]
③对比 ①和②,较大值就是当前物品i放入背包j的最大价值,即:v[i][j] = Math.max( v[i-1][j], v[i] + v[i-1][j - w[i]] )
说明:动态规划是把待求解问题划分为多个子阶段(子问题),子阶段往往是相关的而非独立。下个子阶段的求解是建立在上个子阶段求解的基础上进行的进一步求解。此例中,比如:浅蓝色的文字描述的逻辑!!!
public static void main(String[] args) {
int[] w = {1, 4, 3};//物品的重量
int[] val = {1500, 3000, 2000}; //物品的价值
int m = 4; //背包的容量
int n = val.length; //物品的个数
// v[i][j] 表示在i个物品中能够装入容量为j的背包中的最大价值
int[][] v = new int[n+1][m+1];
// 记录背包中可以放入的最大价值的物品的情况,
// 即:path[i][j] 表示在i个物品中能够装入容量为j的背包中的最大价值的情况,值标识为 1
int[][] path = new int[n+1][m+1];
// 将第一列设置为 0 --> 有物品 & 无背包 的情况,最大价值为 0
for(int i = 0; i < v.length; i++) {
v[i][0] = 0;
}
// 将第一行设置为 0 --> 有背包 & 无物品 的情况,最大价值为 0
for(int i=0; i < v[0].length; i++) {
v[0][i] = 0;
}
// 从第二行开始,第二列开始,使用代码还原刚才填表过程
for(int i = 1; i <= n; i++) { // 行 i:当前第i个物品
for(int j=1; j <= m; j++) { // 列 j:当前容量为j的背包
if(w[i-1] > j) {
// w[i-1]:当前第i个物品的重量,因为 i 从1开始
// 1. 当前准备放入背包的物品重量 大于 当前背包容量时,说明当前物品因背包容量不足不能放入背包,就直接使用上个物品在同容量背包下的装入策略
v[i][j] = v[i-1][j];
} else {
// 2. 当前准备放入背包的物品重量 不大于 当前背包容量时,那么:
// ①获取当前物品放入背包前的最大价值是 v[i-1][j]
// ②先把当前物品放入背包,再获取背包剩余容量可以存放的物品的最大价值,此时:当前物品的价值 + 背包剩余容量可存放的最大价值,即:v[i] + v[i-1][j - w[i]]
// ③对比 ①和②,较大值就是当前物品i放入背包j的最大价值,即:v[i][j] = Math.max( v[i-1][j], v[i] + v[i-1][j - w[i]] )
// 因为题目要求输出放入哪些物品的价值最大,所以不能直接使用上述语句,需要改为 if-else 并在其中记录最大价值的情况
// val[i-1]:当前物品价值
// v[i-1][j - w[i-1]]:当前物品放入背包前的 i-1 个物品的最大价值,这个在前个阶段已经求解到
// w[i-1]:当前物品的重量
// j - w[i-1]:把当前物品放入背包j后,背包j的剩余容量
if(v[i-1][j] < val[i-1] + v[i-1][j - w[i-1]]) {
v[i][j] = val[i-1] + v[i-1][j - w[i-1]];
// 使用path数组,记录最大价值的情况
path[i][j] = 1;
} else {
v[i][j] = v[i-1][j];
}
}
}
}
// 输出一下 v 看看目前的情况
for(int i =0; i < v.length;i++) {
for(int j = 0; j < v[i].length;j++) {
System.out.print(v[i][j] + " ");
}
System.out.println();
}
System.out.println("============================");
// 输出下最大价值的情况
for(int i = 0; i < path.length; i++) {
for(int j=0; j < path[i].length; j++) {
System.out.print(path[i][j] + " ");
}
System.out.println();
}
// 输出最后我们是放入的哪些商品
int i = path.length - 1; //行的最大下标
int j = path[0].length - 1; //列的最大下标
while(i > 0 && j > 0 ) { //从path的最后开始找
if(path[i][j] == 1) {
System.out.printf("第%d个商品放入到背包\n", i);
j -= w[i-1]; // 每次减小当前物品的重量 w[i-1]
}
i--;
}
}
输出结果:
0 0 0 0 0
0 1500 1500 1500 1500
0 1500 1500 1500 3000
0 1500 1500 2000 3500
============================
0 0 0 0 0
0 1 1 1 1
0 0 0 0 1
0 0 0 1 1
第3个商品放入到背包
第1个商品放入到背包
四、数组中和为定值的所有组合
给定一个数t,以及n个整数,在这n个数中找到相加和为t的所有组合,例如t=4,n=6,这6个数为[4,3,2,2,1,1],这样输出就有4个不同的组合相加为4: 4, 3+1, 2+2, 2+1+1。
1. 暴力求解
/**
* 方法1:普通方式
*/
private static void findSums(int[] arr, int target) {
// 所有排列组合
List<List<Integer>> all = new ArrayList<>();
for (int i=0; i<arr.length; i++) {
int cur = arr[i];
// 将当前数加入到之前的每个组合,生成所有的新组合,并加入 all
List<List<Integer>> newComs = new ArrayList<>();
for (int k = 0; k < all.size(); k++) {
// copy
List<Integer> newCom = new ArrayList<>();
newCom.addAll(all.get(k));
// 当前数加入到原组合后面,形成新组合
newCom.add(cur);
newComs.add(newCom);
}
all.addAll(newComs);
// 当前数作为一个组合加入 all
List<Integer> curs = new ArrayList<>();
curs.add(cur);
all.add(curs);
}
// 输出所有排列组合
System.out.println(all.size());
System.out.println("------------");
all.forEach(ele -> System.out.println(ele));
System.out.println("------------");
// 输出组合中和为 target 的情况
all.forEach(ele -> {
if (ele.stream().mapToInt(n -> n).sum() == target) {
System.out.println(ele);
}
});
}
2. 动态规划
待补充!!!
部分转载自:看一遍就理解:动态规划详解