在讲文章之前,我们可以来看一下这道题。
剑指 Offer 10- II. 青蛙跳台阶问题
一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶。求该青蛙跳上一个 n 级的台阶总共有多少种跳法。
答案需要取模 1e9+7(1000000007),如计算初始结果为:1000000008,请返回 1。
思路
这种一看,“啊,好难!”的题,基本上从反面来看就很简单:
我们来看青蛙到了顶部的时候,他是不是一定要么是从倒数第二阶跳上来,要么就是从倒数第一阶跳上来?而且可能性只有这两种?
那么就能得出一个结论:
青蛙跳上一个n级台阶的跳法 = 青蛙跳上n-1级台阶的跳法 + 青蛙跳上n-2级台阶的跳法
这样我们就不难想到,递归解决这种题一定很简单。因为递归一定是从后往前思考的嘛,符合我们的思路。正在观看的同学如果不会递归也没关系,我会从头开始讲。
第一种解法:递归
首先,我们要清楚一个事情,递归并不难。说复杂其实仅仅是因为我们太过纠结计算机是怎么执行他的了。但大部分时间,我们不需要这么做。
所以,想解决递归问题,我们千万不要去模拟,啊计算机是怎么怎么执行的,这太容易被绕进去了。我们只需要知道,解决递归问题,我们只需要知道递归的要素:方程和终止条件。
方程是什么?
比如说斐波那契数列,定义是:f(n) = f(n-1)+f(n-2) 这个不就是一个递归的条件嘛,我们就把他叫做方程。
而在本题中,我们也推出了青蛙跳上一个n级台阶的跳法 = 青蛙跳上n-1级台阶的跳法 + 青蛙跳上n-2级台阶的跳法。如果转化成函数的话其实也是f(n) = f(n-1) + f(n-2) 嘛。这就是这道题所需要的方程。
终止条件是什么?
在这种需要递归的题中,我们总是知道几个条件的。比如说斐波那契数列中,
题目一就告诉了我们,斐波那契数列第一项是0,第二项是1,那么你就能通过方程算出其他所有项的数了。
这里所需要的数,就叫做终止条件,因为递归最终也是执行到这里停止的嘛。
在本题中,虽然没明说,但是我们可以想象一下,假如说楼梯有1阶,是不是只有一种跳法?
实例中还说了,如果0阶的楼梯我们还有一种跳法。那么我们就可以靠这两个通过方程算出其他所有的项了。
顺便借用网友的一句话吐槽一下。
有了方程和终止条件,我们要怎么写代码呢?
很简单,伪代码如下
if(终止条件){
return 终止条件对应的值;
}
return 方程把f(n)放在左边,右边的值;
比如说本题我们就可以写成
class Solution {
public int numWays(int n) {
if(n == 0 || n == 1){
return 1;
}
return numWays(n-1)+numWays(n-2);
}
}
这样就可以解决到这道题了?很遗憾还不能(毕竟这还不是一个讲递归的文章),虽然这么写结果没有任何错误,但是呢,如果你提交代码,就会报出这样的错误。
这是为什么呢?其实很简单,我们来浅谈一下递归的原理。
假如说我们要计算f(5) 然后系统根据你的代码就会发现,哦那我需要去计算f(4)和f(3),把他们加和就是f(5)。然后去计算f(4)的时候,又根据你的代码发现,哦那我需要去计算f(3)和f(2),把他们加和才是f(4)....一直到计算f(1)或者f(0)的时候,系统一看,哦不用再计算了,你告诉我这是1了,然后再根据这个1,去算出f(2),f(3)...
这里就有一个问题,就像我图中画圈的两个,你会发现,好多值都重复计算了。比如为了算f(4)的时候,我们已经算过f(3)了呀。那算f(5)的时候又要去算一遍,那岂不是很白费时间?
那么该如何解决呢,这就要说到我们的主角了:动态规划。
动态规划
什么是动态规划?
动态规划(英语:Dynamic programming,简称 DP),是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。动态规划常常适用于有重叠子问题和最优子结构性质的问题。
dynamic programming is a method for solving a complex problem by breaking it down into a collection of simpler subproblems.
以上定义来自维基百科,看定义感觉还是有点抽象。简单来说,动态规划其实就是,给定一个问题,我们把它拆成一个个子问题,直到子问题可以直接解决。然后呢,把子问题答案保存起来,以减少重复计算。再根据子问题答案反推,得出原问题解的一种方法。
一般这些子问题很相似,可以通过函数关系式递推出来。然后呢,动态规划就致力于解决每个子问题一次,减少重复计算,比如斐波那契数列就可以看做入门级的经典动态规划问题
简单理解
这样说起来有点抽象,我们来简单理解一下。
动态规划最核心的思想,就在于拆分子问题,记住过往,减少重复计算。
比如本题,计算f(n)比较难,但是计算f(n-1)+f(n-2)比较容易,这就把一个问题分解成了两个子问题,符合第一个核心思想,接下来的记住过往,减少重复计算,就是我们接下来要做的事儿了。
那么,如何保存这个“过往”呢?最常见的做法是数组,当然这个数组可以是1维的,也可能是二维的,就本题来说,一维的就足够保存了。我们定义一个n+1个长度的数组,然后0阶的跳法个数保存在0的索引位置,1级的跳法保存在1的索引位置.....一直到n。这样如果计算f(5)用到了f(3),那么直接从数组中取出索引为3的元素,就是对应f(3)的个数。
在原来的代码中,我们使用了递归,也就是真正我们想保存的值在return的位置,这显然不太适合保存。所以我们改写一下代码让他变成循环,所有的递归都能改成循环,而且很简单,如果不会的话可以看我的代码理解一下。
class Solution {
public int numWays(int n) {
//为什么我们既然设置了后续的arr[0]和arr[1],还需要判断这个呢?
//因为如果n等于0的话,后面arr[1] = 1代码会报错(数组索引溢出)
if(n == 0){
return 1;
}
//定义保存状态的数组
int[] arr = new int[n+1];
//首先设置初始条件。其实就是把之前的终止条件写在这里
arr[0] = 1;
arr[1] = 1;
//执行循环,
//问题1:从哪循环呢:我们已经知道0和1的值了,自然从2循环就可以
//问题2:到哪结束呢?我们要算到第n个,自然要到n结束,而且要包括第n个
for(int i = 2;i<=n;i++){
//就是把之前的方程写到这里
arr[i] = (arr[i-1] + arr[i-2])%1000000007;
}
return arr[n];
}
}
这样执行,就能得到一个很快的速度了。这也就是动态规划的核心思想。
但是我们会注意到,为什么内存消耗这么大呢,其实这个也能优化。因为我们想象一下,我们是不是计算f(4)的时候只需要f(3)和f(2),就不需要f(1)了。计算f(5)的时候也只需要f(4)和f(3),就不需要f(1),f(2)了....
其实我们一直需要的也只是两个变量,而不是整个数组,这样就比较费空间,所以我们可以使用两个变量来代替数组。
class Solution {
public int numWays(int n) {
//a和b代替了原来的arr数组,temp仅仅是个两个数交换时候需要用的暂时变量。
int a = 1, b = 1, temp;
//这里有个小技巧,因为我们循环体里面的计算不需要i,所以我们可以直接想一下,我们需要循环几次?
//我们可以设想,如果n等于2,那么循环两次能得到结果,如果n等于3,则要循环3次。所以我们需要循环n次就显而易见了,所以就i<n了。
for(int i = 0; i < n; i++){
//交换a和b变量 b = a和b的加和 然后a等于原来那个b,这样就进行了一次循环
temp = (a + b) % 1000000007;
a = b;
b = sum;
}
return a;
}
}
这个代码就没有数组那个好理解。不过也不是很难,动态规划整体的思想其实也就是数组那样的感觉。
接下来我用一道例题来讲一下一般解题思路。
解题思路
例题
剑指 Offer 42. 连续子数组的最大和
输入一个整型数组,数组中的一个或连续多个整数组成一个子数组。求所有子数组的和的最大值。
要求时间复杂度为O(n)。
首先我们拿出一道题来看看,他是否应该用动态规划来做,或者说用递归来做,都是要考虑:这个问题是否能拆解成子问题?
比如说青蛙那道题,我们把f(n)拆分成了f(n-1)+f(n-2)。那么这道题我们也可以试着来思考一下。
首先,我们设数组里有9个元素,如图:
我们先把0 - 7的索引的元素抽离出来。
假如说,我们把 0 到 n 中所有子数组的和的最大值记为f(n),那么我是否可以把0 到 n-1的所有子数组的和的最大值记为f(n-1)呢?这样我们是不是就有点思路了,我们只需要找到f(n)和f(n-1)的关系,就能找到方程了。
那么关系是什么呢,这其实更好想了。假如说0 到 n-1中所有子数组的和的最大值是个正数,那么我们可以判断一下,这个子数组是否是以n-1索引的元素结束的呢?如上图,就不是,那就必须做个抉择了:
是f(n-1)大,还是n所在的元素大。比如图中(图中n相当于等于8 , 6就是f(7) = 6 ,4是数组中的最后一个数),因为6 > 4 所以哪怕数组中多了个元素4,整个数组的所有子数组之后的最大值还是6。也就是f(8) = f(8-1) = 6.
如果f(n-1)是个正数,而且最大子数组的右边界紧挨着第n个数呢?
比如说数组就这八个数。
我们已经知道了f(6) = 6, 那么f(7)等于什么就取决于第七个是正还是负了,如果是正,那么加上他之后变成的新数组值会更大,也就是此时f(7) = f(6) + 数组最后一个数,而如果是负数的话,那么还不如不加,也就是f(7) = f(6).
那么如果f(n-1)整体是个负数呢?那么就只要判断f(n-1)和数组最后一个数谁大谁小了。反正他俩不会加到一起,因为只会起到负作用。
这样一来,整个方程可以写成这样
根据这个我们可以写出答案
public int maxSubArray(int[] nums) {
//获取递归的第一个结果,就是最大值。
return f(nums,nums.length-1).get(0);
}
List<Integer> f(int[] arr, int n){
if(n == 0){
return Arrays.asList(arr[0],0);
}
//取出上一次递归的两个结果,最大值,还有右边界
List<Integer> lastResult = f(arr,n-1);
Integer maxValue = lastResult.get(0);
Integer right = lastResult.get(1);
//首先判断f(n-1)的正负
if(maxValue>=0){
//如果是正数,判断是否邻接
if(right +1 == n){
//邻接,那么判断arr[n]的正负
if(arr[n] >=0){
//这个时候f(n) = f(n-1) + f(n)
//Arrays.asList的作用是把几个数放在一个列表里返回这个列表,我们把f(n)和右边界放进去
return Arrays.asList(maxValue + arr[n],n);
}
else{
//arr[n]是负数,这个时候f(n) = f(n-1)
return lastResult;
}
}
else {
//如果不邻接,那么f(n) = max(f(n-1),arr[n])
int max = Math.max(maxValue, arr[n]);
int right1;
if(max == maxValue ){
//说明上一次的大,直接返回
return lastResult;
}
else {
//说明这一次的大
return Arrays.asList(arr[n],n);
}
}
}
else {
//如果f(n-1)是负的 这时候 f(n) = max(f(n-1),arr[n]);
//和上面是同样的代码段,理应重构成函数,但我懒了
int max = Math.max(maxValue, arr[n]);
if(max == maxValue ){
//说明上一次的大,直接返回
return lastResult;
}
else {
//说明这一次的大
return Arrays.asList(arr[n],n);
}
}
}
看上去不错吧,但其实是错误的!
如果你用这个去跑案例({-2, 1, -3, 4, -1, 2, 1, -5, 4}),他只会得到4,而真正的答案(4 -1 2 1)组成的6因为其中有负数被他舍弃了。
因为这更像贪心算法,每一步的最优并不是全局的最优,我们必须要想办法做到全局的最优。
要怎么做到呢,在我们的上个做法中,比较麻烦的就是处理边界的问题,我们也知道,子数组肯定有一个右边界,那么其实我们可以设置一个数组arr[i],在第i个位置上保存右边界为i的所有子数组的和的最大值。这样下来我们推导方程就容易多了。
设置题里给的数组叫做nums,那么
arr[i] = max(nums[i],arr[i-1] + nums[i])
因为如果arr[i-1]是负数的话,那么右边界为i的所有子数组的和的最大值还不如是nums[i]本身呢,因为arr[i-1]加起来算的话只会起反作用。
这样在循环一次后,以什么为右边界的所有子数组的最大值我们都得到了。接下来只要遍历一次找最大值即是答案(当然,不用真的遍历,以后在代码里会提到)。
为了方便,我们这里不再先用递归来解决,而是直接用动态规划。
class Solution {
public int maxSubArray(int[] nums) {
//保存右边界为i的所有子数组之和的最大值的数组
int[] arr = new int[nums.length];
//首先处理终止条件,即如果是数组的第一位为右边界,那只能有一种子数组就是它本身,所以最大值也就是arr[0]
arr[0] = nums[0];
//保存最终结果的变量,没算出一次arr[i]都判断一下是否比他大,如果是的话就替换。
int num = nums[0];
//循环次数还是像我之前说的那么找,我们已经有一个终止条件了,所以从1开始循环,结束条件就是答案也就是数组的最后一位的索引,所以i<arr.length
for(int i = 1;i<arr.length;i++){
//以下是arr[i] = max((arr[i-1] + nums[i]),nums[i])的具体写法。
//用Math.max实现
arr[i] = Math.max((arr[i-1]+nums[i]),nums[i]);
if(arr[i]>num){
num = arr[i];
}
}
return num;
}
}
这样在时间上就没什么问题了,空间上还有一点点可以优化的,其一是我之前说的可以优化成两个变量的形式,其二其实也可以直接操作他给的数组,因为他的数据用完之后就不再有用了嘛。我们可以直接把我们的值赋到里面。这样省去了创建一个数组的开销。