1 引子---斐波那契数列
对该数列有公式:f(1)=1,f(2)=1,f(n)=f(n-1)+f(n-2);根据该公式可以很快写出递归调用的代码如下:
public class main { public static void main(String[] args) { int x = fib(3); System.out.println(x); } public static int fib(int n){ if (1 == n || 2 == n) { return 1; } return fib(n-1)+fib(n-2); } }
计数的时候是否是从0开始的可能会有造成代码所不同。分析这段代码,当n变的很大时整体的性能就开始下降了,可以画出其递归树如下:(这棵树是计算到0也就是认为第一个数的序号为0,,这不影响分析)
当n为5时就有一部分数据是重复计算的,那么当n更大时重复计算的数据将会更多,其实该算法的时间复杂度为O(2^n)指数级别,不是一个好的算法。
2 记忆化搜索
为了解决上述问题,一个想法是尽量减少重复的计算。那么如何减少重复的计算?一个思路就是把已经计算过的数据保存起来,当再次用到该数据时直接读取即可,这样的方法就成为记忆化搜索,这是一种自上向下解决问题的方法。
public class main { //使用一个全局的数组来保存计算的结果 private static int[] arr; public static void main(String[] args) { int n = 40; arr = new int[n+1]; //数组空间为n+1是为了方便对应数列 int x = fib(n); System.out.println(x); } public static int fib(int n){ if (1 == n || 2 == n) { return 1; } //对应数组中没有计算的位置进行计算 if (arr[n] == 0){ arr[n] = fib(n-1)+fib(n-2); } //如果计算过则直接返回,不再重复计算了 return arr[n]; } }
采用记忆化搜索,每个数据只会计算一次,这样就把指数级的时间复杂度变为线性O(n)级别的
3 动态规划
上面的记忆化搜索是自上向下解决问题,如果自下向上的考虑问题,那么便是动态规划了。
public class main { public static void main(String[] args) { int n = 40; int x = fib(n); System.out.println(x); } public static int fib(int n){ int[] arr = new int[n+1]; //先知道了最简单 arr[1]=1; arr[2]=1; //从最简单出发,不断的进行递推 for (int i = 3; i <=n ; i++) { arr[i] = arr[i-1]+arr[i-2]; } return arr[n]; } }
注:自上而下与自下向上的区别
“自上而下”的算法设计,就是先概要地设计算法的第一层(顶层),然后步步深入,逐层细分,逐步细分,逐步求精,直到整个问题可用程序设计语言明确的描述出来为止。最常见的便是递归,我们先有f(n)=f(n-1)+f(n-2),然后一直递归下去,知道找到递归的终止条件。这里公式便是最顶层的设计。自下向上则是先知道最简单的数据然后不断的递推下去,在这里可以理解为先知道了f(1)=1,f(2)=1然后不断的进行递推的,最终递推到我们需要的数据,一般用for循环的形式实现。
4 动态规划的理解
定义:将原问题拆解成若干子问题,同时保存子问题的答案,使得每个子问题只求解一次,最终获得原问题的答案。
根据前面的流程可以得到如下的图示:
一般在求解递归问题的时候,发现有好多部分是重复求解的,因此需要采用优化算法,自顶向下是最容易思考到的方法,其实在多数情况下这种方法已经可以满足了题目的要求,使用动态规划则可以使代码变得更加简洁,一般是先想出来自顶向下的方法然后再想到自底向上方法的解决方案。
注:几个相关概念的理解
每个阶段只有一个状态->递推;
每个阶段的最优状态都是由上一个阶段的最优状态得到的->贪心;
每个阶段的最优状态是由之前所有阶段的状态的组合得到的->搜索;
每个阶段的最优状态可以从之前某个阶段的某个或某些状态直接得到而不管之前这个状态是如何得到的->动态规划。
5 基础问题
例1:LeetCode 70 爬楼梯都可以采用上面讲述的类似模型求解。爬楼梯和斐波那契数列问题类似都是很容易知道递推公式和最后的终止条件,但在后面两题中是不容易分析出递推公式的,更像对树的遍历因此可以采用逐步分析的方法,先写出来递归的方法再优化。
class Solution { public int climbStairs(int n) { if (n==1) return 1; if (n==2) return 2; int[] arr = new int[n+1]; arr[1]=1; arr[2]=2; for (int i = 3; i <= n ; i++) { arr[n] = arr[i-1]+arr[i-2]; } return arr[n]; } }
与此类似题目:LeetCode 120、64。
例2:LeetCode 343。在这里因为要拆成几部分是不清楚的因此使用for循环肯定是不可行的,画出如下递归图,因为有较多的重叠部分,因此可以考虑使用记忆化搜索。
在这里有一个概念:最优子结构。同过求解子问题的最优解,可以获得原问题的最优解。在这里如果对n-1、n-2 ...1这些子问题的最优解都已经清楚了,那么原问题的最优解也就很明显了。那么可以把上面的动态规划流程进行修改:
下面给出采用记忆化搜索的方法解决问题:
class Solution { // 记录已经计算过的值 private int[] memo; public int integerBreak(int n) { memo = new int[n+1]; return breakInteger(n); } private int breakInteger(int n) { if (n == 1){ return 1; } if (memo[n] != 0){ return memo[n]; } int res = -1; for (int i = 1; i <= n-1 ; i++) { // 因为题目中说至少为两部分,所以也可以为两部分i*(n-i)就是分割为了两部分 res = max3(res,i*(n-i),i*breakInteger(n-i)); } memo[n] = res; return res; } // 返回三个数中的最大值 private int max3(int a, int b, int c) { return Math.max(a,Math.max(b,c)); } }
下面给出动态规划的解决方法:
class Solution { // 记录已经计算过的值 private int[] memo; public int integerBreak(int n) { // memo[i]表示将数字i分割(至少为两部分)后得到的最大乘积 memo = new int[n+1]; for (int i = 2; i <= n ; i++) { // 求解memo[i]即对memo[i]进行分割 for (int j = 1; j <= i-1; j++) { // 把memo[i]分割成j与(i-j)两个部分,或者j和memo[i-j]多个部分,因为从小到大因此memo[i-j]一定计算出来 memo[i] = max3(memo[i],j*(i-j),j*memo[i-j]); } } return memo[n]; } // 返回三个数中的最大值 private int max3(int a, int b, int c) { return Math.max(a,Math.max(b,c)); } }
与此类似题目:LeetCode 279、91、62、63
例3:LeetCode 198。可以画出递归树如下图所示,可以看出有较多的重叠子问题并且子问题的最优解也是整个问题的最优解即是最优子结构,那么便可以使用记忆化搜索或者动态规划的方法解决问题。
对其中的状态进行如下定义:状态(函数的定义)决定了我们要做什么,状态转移决定我们怎么做,这是在动态规划中最重要的体现。
在一开始先写出简单的递归形式:
class Solution { public int rob(int[] nums) { return tryRob(nums,0); } // 考虑抢劫nums[index...nums.size())这个范围的所有房子,这里使用了开区间 private int tryRob(int[] nums, int index) { if (index >= nums.length){ return 0; } // 最大收益 int res = 0; for (int i = index; i < nums.length; i++) { res = Math.max(res,nums[i]+tryRob(nums,i+2)); } return res; } }
可以看出上面的递归形式有较多的重叠子问题,因此可以考虑利用记忆化搜索方式进行优化:
class Solution { // memo[i] 表示考虑抢劫nums[i...n)所能获得的最大收益 private int[] memeo; public int rob(int[] nums) { memeo = new int[nums.length]; // 把数组中所有元素初始化为-1 Arrays.fill(memeo,-1); return tryRob(nums,0); } // 考虑抢劫nums[index...nums.size())这个范围的所有房子,这里使用了开区间 private int tryRob(int[] nums, int index) { if (index >= nums.length){ return 0; } // 如果已经计算过了直接返回数值即可,不用再重复计算了 if (memeo[index] != -1){ return memeo[index]; } // 最大收益 int res = 0; for (int i = index; i < nums.length; i++) { res = Math.max(res,nums[i]+tryRob(nums,i+2)); } memeo[index] = res; return res; } }
与此类似题目:LeetCode 213、337、309
6 0-1背包问题
对于这道题目可能想到的是能否采用贪心算法,即优先放入平均价值最高的物品,但这样做的话很有可能是无法达到一个全局最优解的很有可能只能得到一个局部最优解。如下图所示:
假设背包容量为5,那么采用贪心算法得到结果是6+10,但可以看出选择10+12才是最优的方法,在这里首先排除的就是平均价值最高的物品。前面讲的状态定义都只涉及到一个参数,但对于本题而言有容量和价值两个需要考虑的,因此应有两个参数。对于每一个新来的物品我们应该是有两种选择的:放入或者不放入然后去最大的。
根据状态转移方程其实就是一个自顶向下的求解方式,代码如下:
class Solution { public int knapsack01(int[] w,int[] v,int C){ int n = w.length; // 从0到n-1这些物品装入容积为C的背包中求最大价值 return bestValue(w,v,n-1,C); } // 用[0...index]的物品,填充容积为c的背包的最大价值 private int bestValue(int[] w, int[] v, int index, int c) { //当没有物品可选,或者背包没有足够容量时返回 if(index < 0 || c <= 0){ return 0; } // 策略1 当前物品不放入,直接考虑下一个物品 int res = bestValue(w,v,index-1,c); // 策略2 放入当前物品,放入之前先判断是否有足够容量 if (c >= w[index]){ //要比较后选择一个最大的 res = Math.max(res,v[index] + bestValue(w,v,index-1,c-w[index])); } return res; } }
因为有较多重叠子问题,因此采用记忆化搜索优化:
class Solution { // 因为有两个遍历,因此采用二维数组进行保存记录 private int[][] memo; public int knapsack01(int[] w,int[] v,int C){ int n = w.length; // 对记录数组进行初始化,因为容量时从0到C的,因此可以第二维的长度应该是C+1 memo = new int[n][C+1]; for (int i = 0; i < n; i++) { for (int j = 0; j < C+1; j++) { memo[i][j] = -1; } } // 从0到n-1这些物品装入容积为C的背包中求最大价值 return bestValue(w,v,n-1,C); } // 用[0...index]的物品,填充容积为c的背包的最大价值 private int bestValue(int[] w, int[] v, int index, int c) { //当没有物品可选,或者背包没有足够容量时返回 if(index < 0 || c <= 0){ return 0; } if (memo[index][c] != -1){ return memo[index][c]; } // 策略1 当前物品不放入,直接考虑下一个物品 int res = bestValue(w,v,index-1,c); // 策略2 放入当前物品,放入之前先判断是否有足够容量 if (c >= w[index]){ //要比较后选择一个最大的 res = Math.max(res,v[index] + bestValue(w,v,index-1,c-w[index])); } memo[index][c] = res; return res; } }
对于二维的动态规划可以先画出一个表格分析:行代表考虑的物品,列数代表容量,当填充第一行时(0,0)代表只考虑0号物品,背包容量为0,那么最大价值也就为0。(0,1)则是容量为1时的最大价值为6,因此第一行全为6。当第二行时表示考虑0,1两个物品(1,0)为0,当(1,1)时只能放0号物品因此为6,即上一列对应的数值,当(1,2)时有两种策略放1号或者0号,因此最优解为10(容量为0时的最优价值+当前物品最优价值),当(1,3)时即(0,1)+当前物品和(0,3)比较选取最大的价值。依次类推可以退出其余的。(2,5)即(1,2)+当前物品和(1,5)比较选取最大的价值。
class Solution { public int knapsack01(int[] w,int[] v,int C){ int n = w.length; if (n == 0){ return 0; } // 对记录数组进行初始化,因为容量时从0到C的,因此可以第二维的长度应该是C+1 int[][] memo = new int[n][C+1]; for (int i = 0; i < n; i++) { for (int j = 0; j < C+1; j++) { memo[i][j] = -1; } } // 先把初始的填充了 for (int j = 0; j <= C ; j++) { memo[0][j] = (j >= w[0] ? v[0] : 0); } for (int i = 1; i < n; i++) { for (int j = 0; j <= C; j++) { // 策略1 不放新物品 memo[i][j] = memo[i-1][j]; // 策略2 放新物品但要先判断 if (j >= w[i]){ memo[i][j] = Math.max(memo[i][j],v[i]+memo[i-1][j-w[i]]); } } } return memo[n-1][C]; } }
经过上述分析对于0-1背包问题,其时间复杂度:O(n*C) 、空间复杂度:O(n*C)。对其状态转换图如下所示,可以发现第i行只依赖于第i-1行元素,那么整个算法的空间复杂度为:O(2*C)=O(C)。对于两行元素进行分析可以发现第一行存放的是偶数行相关数据,第二行存放的奇数行相关数据,根据以上分析可以对代码进行优化:
public int knapsack01(int[] w,int[] v,int C){ int n = w.length; if (n == 0){ return 0; } // 此时数组中只包含两行元素 int[][] memo = new int[2][C+1]; for (int i = 0; i < 2; i++) { for (int j = 0; j < C+1; j++) { memo[i][j] = -1; } } // 先把初始的填充了 for (int j = 0; j <= C ; j++) { memo[0][j] = (j >= w[0] ? v[0] : 0); } for (int i = 1; i < n; i++) { for (int j = 0; j <= C; j++) { // 策略1 不放新物品 memo[i%2][j] = memo[(i-1)%2][j]; // 策略2 放新物品但要先判断 if (j >= w[i]){ // memo中要对2取余数,但是v中是第i个商品因此不用取余 memo[i%2][j] = Math.max(memo[i%2][j],v[i]+memo[(i-1)%2][j-w[i]]); } } } // 返回的时候要对记忆中的数组取余 return memo[(n-1)%2][C]; }
上面采用了两行数组进行存储,那么能否进一步优化只使用一行数组,对其分析如下图所示:(1,2)处位置依赖的是(0,0)、1号物品和(0,2)处元素即和右边的元素没有任何关系,那么一个思路是能否从右向左遍历进行优化那。
只考虑一行,当为需要1号物品需要纳入考虑范围时5-2=3即容量为3对应的列值+当前元素的价值与容量为5进行对比,返现16大于6,因此更新为16,以此类推可以依次向前进行递推。 当背包容量小于2时便不用再考虑更新了,因为本来存储的就是上一次的最大价值。
public int knapsack01(int[] w,int[] v,int C){ int n = w.length; if (n == 0){ return 0; } // 此时数组中可以开辟为一位数组 int[] memo = new int[C+1]; Arrays.fill(memo,-1); // 先把初始的填充了 for (int j = 0; j <= C ; j++) { memo[j] = (j >= w[0] ? v[0] : 0); } for (int i = 1; i < n; i++) { // 当j小于w[i]时已经不可能再放入物品了 for (int j = C; j >= w[i]; j--) { memo[j] = Math.max(memo[j],v[i]+memo[j-w[i]]); } } // 返回的时候要对记忆中的数组取余 return memo[C]; }
背包问题的变种:完全背包问题:每个物品可以无限使用。多重背包问题:每个物品不止1个,有num[i]个。多维费用背包问题:要考虑物品的体积和重量两个维度。物品间加入更大约束:物品可以相互排斥,也可以相互依赖。
7 背包问题的实际使用
例1:LeetCode 416。这道题目可以转换为背包问题即:在n个物品中选出一定物品,填满容量为sum/2的背包,sum为各元素之和。与背包问题不同的是:需要完全填满背包,但是不用考虑价值。其状态定义和状态转换如下图所示,因为考虑的是能否填满,因此在状态转移中采用的是布尔值类型进行或运算。在题目中也有一些限定提示,其中每个数字的最大值合在一起便是背包的容量 。
采用递归+记忆化搜索:
class Solution { //虽然只记录的有成功不成功两种状态,但还要辨别是否计算过,因此直接声明为boolean型是不合适的 // -1 表示未计算 0 表示不可以填充 1 表示可以填充 private int[][] memo; public boolean canPartition(int[] nums) { int sum = 0; // 计算出数组中所有元素之和 for (int i = 0; i < nums.length; i++) { sum += nums[i]; } // 如果sum不能平分肯定无法分割 if (sum %2 != 0){ return false; } // 初始化memo memo = new int[nums.length][sum/2+1]; for (int i = 0; i < memo.length; i++) { for (int j = 0; j < memo[0].length; j++) { memo[i][j] = -1; } } return tryPartition(nums,nums.length - 1,sum/2); } // 使用nums[0...index],是否可以完全填充一个容量为sum的背包 private boolean tryPartition(int[] nums, int index, int sum) { if (sum == 0){ return true; } // 当sum<0时说明背包容量不够,当index<0时说明背包容量太大,此时都不能完全填充 if (sum < 0 || index < 0){ return false; } // 当已经计算过了便从记录中寻找 if (memo[index][sum] != -1){ // 此处也可以不用判断是否等于1,因为会有一个隐式的类型转换但写出来方便理解 return memo[index][sum] == 1; } memo[index][sum] = (tryPartition(nums,index - 1,sum) || tryPartition(nums,index - 1,sum - nums[index])) ? 1 : 0; return memo[index][sum] == 1; } }
采用动态规划:
class Solution { public boolean canPartition(int[] nums) { int sum = 0; // 计算出数组中所有元素之和 for (int i = 0; i < nums.length; i++) { sum += nums[i]; } // 如果sum不能平分肯定无法分割 if (sum %2 != 0){ return false; } int n = nums.length; int C = sum/2; // 初始化memo,这里直接采用前面讲的优化的方法使用一维数组,因为动态规划中采用从小到大的问题解决,遍历的肯定都是没有访问过的元素 // 因此不存在还要判断是否遍历过的问题,因此可以声明为boolean类型 boolean[] memo = new boolean[C+1]; Arrays.fill(memo,false); // 第一个元素是否可以把背包填满 for (int i = 0; i <= C; i++) { memo[i] = (nums[0] == i); } //动态规划,状态转移过程 for (int i = 1; i < n; i++) { for (int j = C; j >= nums[i]; j--) { // 两种情况相或 memo[j] = memo[j] || memo[j-nums[i]]; } } return memo[C]; } }
与此类似题目:LeetCode 322、377、474、139、494
8 最长上升子序列问题
例:LeetCode 300。其状态定义和状态转移如下图所示,与前面不同的是LIS(i)在[0...i]范围内中的i是必须要被选上的,而不是单纯的作为一个边界定义。
举例说明上述定义,在初始化时LIS(i)的初值均为1
遍历后数据更改如上图所示,步骤:以10结尾的上升子序列只有它本身因此为1不变,10比9大,因此以9结尾的也只有本身,同样的2也是1不变。当5时因为前面2比5小,因此以5结尾的位1+1=2,同样3也是2,当为7时只需要考虑以5结尾和以3结尾的上升子序列即可,因为5、3结尾的都是2,因此以7结尾的便是2+1=3,当101时,只需要考虑7即可为3+1=4,当18时同样以7为基准为3+1=4。根据上面的分析似乎可以得出这样一个结论:以7为例,似乎只需要从右向左找到第一个比它小的数字再加1即可,但是是没有这样的规律的,其反例如下,因为子序列不一定是递增关系的所以上述的结论是错误的,如101。
class Solution { public int lengthOfLIS(int[] nums) { // 当为一个空数组时直接返回0 if (nums.length == 0){ return 0; } // memo[i]表示以nums[i]为结尾的最长上升子序列的长度 int[] memo = new int[nums.length]; // 这里的初始化相当于对最基本的问题也进行了初始化因为nums[0]结尾的也为1 Arrays.fill(memo,1); // 动态规划过程 for (int i = 1; i < nums.length; i++) { for (int j = 0; j < i; j++) { if (nums[j] < nums[i]){ memo[i] = Math.max(memo[i],1+memo[j]); } } } // 不能简单的返回memo中最后一个元素,我们要取的是memo中最大的元素 int res = 1; for (int i = 0; i < nums.length; i++) { res = Math.max(res,memo[i]); } return res; } }
本题还要一种解法其时间复杂度为O(nlogn)当其并不是动态规划的问题,因此没有在这里记录。与此类似题目:LeetCode 376
0