动态规划定义
动态规划(Dynamic Programming,简称 DP)是解决最优化问题的一种重要算法思想。它通过将原问题分解为多个子问题,逐步求解子问题,最终合并子问题的解来解决原问题。动态规划在解决具有重叠子问题和最优子结构性质的问题上非常高效,常用于路径规划、背包问题、序列问题等领域。
单看概念没啥用,了解即可,下面来看一道题,分别从递归、递归优化、动态规划的角度理清解题思路。
Leetcode 70. 爬楼梯假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?输入:n = 2
输出:2
解释:有两种方法可以爬到楼顶。
1. 1 阶 + 1 阶
2. 2 阶
Ⅰ 递归思路
接下来先简单的介绍一下递归,已经懂的可以跳过哦~~~
我们可以将递归简单的理解为一个函数不断直接或间接的调用自身,将问题的求解逐渐转换为一个更小的子问题去求解。
在求解相关问题时,我们只关心问题怎么被划分,而不关心子问题如何被解决。因为现在问题的求解和子问题的求解方式是一样的。
举个例子,我们现在想算n!(n的阶乘)的值是多少,顺便理解一下求解递归问题的几个基本要素
① 问题定义(函数定义)
首先,递归需要函数,我们需要明确这个函数是要干什么的,它的功能是什么,要完成一件什么事情。
一般来讲,问题需要求解什么,我们就定义函数代表什么含义,比如,在上面的问题中,我们要求解n的阶乘,那我们就可以定义F(n)代表n的阶乘的值,如下
int F(int n){return n的阶乘;
}
② 递归的关系式(递推式)
在求解阶乘的问题中,可以注意到:
// F(n)= n * (n-1) * (n-2) * (n-3) * ... * 2 * 1;
// 即 F(n) = n * F(n-1), 那么,我们的函数可以这样写:int F(int n){return n * F(n-1);
}
③ 递归结束条件
即递归在什么条件下退出,如果不执行退出,会出现无限递归调用,导致爆栈,即StackOverflow(栈溢出)。
在上述的问题中,我们的阶乘算到1的时候就结束了,那么,补充退出条件,完整的代码如下,非常简洁、优雅:
int F(int n){// 结束条件if(n==1){return 1;}return n * F(n-1);
}
OK,想必你已经理解了递归大概的思路,接下来,我们回到爬楼梯的例子。
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?输入:n = 2
输出:2
解释:有两种方法可以爬到楼顶。
1. 1 阶 + 1 阶
2. 2 阶
三步走,定义函数,找递归关系式,补充退出条件。
① 题目要求什么我们就定义什么,要求有多少种方法能到某个阶梯,于是我们定义:F(n)表示到达第n个阶梯的方法数量
② 找递归关系式。分析题目,我们可以发现,第n个阶梯有两种到达的方式,从它的下一级走一个台阶上来,或者从它的下两级走两个台阶上来。
那么我们可以得到 F(n) = F(n-1) + F(n-2);
翻译过来就是,我有F(n-2)种方法到达第n-2阶,我有F(n-1)种方法到达第n-1阶,那么我就有F(n-1)+F(n-2)种方法到达第n阶
③ 确定退出条件。根据题目,我们要始终保持函数F(n)的n为正数,也就是说,当n=1或者n=2时,递推就结束了,需要直接返回值。
最终的代码如下:
// climbStairs(n) 表示到达第n级阶梯的方法数public int climbStairs(int n) {// 结束条件, 显然F(1) = 1, F(2) = 2, 当递归到这两个值就直接返回即可if(n == 1){return 1;}if(n == 2){return 2;}// 递归关系式return climbStairs(n-1) + climbStairs(n-2);}
Ⅱ 递归优化策略
虽然递归的代码往往简洁、优雅,但其复杂度是相当高的,比如一道简单难度的爬楼梯,如果直接使用递归的写法,就会得到下面的奖励:
递归的流程如下:
我们可以留意到,在递归的过程中出现了大量的重复计算,如F(8)在左边的递归流程已经计算一次了,但是在右边的递归流程又计算了一遍,导致时间复杂度的增加。
因此,我们可以考虑采用一个数组或者哈希表来存储已经计算过的数值,进行优化,一般我们将这种用于存储已经计算过的结果称作记忆(memory)数组。
优化后的递归代码如下所示:
// 哈希表写法 // 数组写法
class Solution {HashMap<Integer, Integer> memo = new HashMap<>(); // ---------------> int[] memo = new int[n+1];// Arrays.fill(memo, -1);public int climbStairs(int n) {// 先检测这个n有没有被计算过,如果计算过,则直接返回其值if(memo.containsKey(n)){ // ---------------> if(memo[n]!=-1){return memo.get(n); // return memo[n];} // }if(n == 1 || n == 2){return n;}// 进行记忆化操作int res = climbStairs(n-1) + climbStairs(n-2);memo.put(n, res); // ---------------> memo[n] = res;return res;}
}
优化后,我们就可以愉快的通过这道题啦~
Ⅲ 动态规划解法
在递归的流程图中,我们可以看到,递归包含递和归的两个过程,从F(n) 一路递到 F(1),再从F(1) 一路往上归到 F(n),那我们能不能只保留归的这一个过程呢?
可以! 某种意义上来说,拆分问题为子问题,保存中间状态的答案,计算下一个状态的答案,这就是动态规划,其实也有一点“记忆化”的味道在。
因此,在写算法题过程中,很多题其实都可以从(递归/回溯) 优化到 (记忆化递归/记忆化搜索) 优化到 (动态规划/递推)上。
由F(n) = F(n-1) + F(n-2) 这个式子可以发现,我们只需要两个状态,就可以推出下一个状态。
F(1)与F(2)可以推出F(3),F(2)与F(3)可以推出F(4),一路递推下去, F(n)也迎刃而解。
而在动态规划中,最重要的就是递推关系式的确定上。
动态规划的解法代码如下。
public int climbStairs(int n) {if(n == 1 || n == 2){return n;}int f1 = 1; // 存放F(n-2)int f2 = 2; // 存放F(n-1)for(int i=3; i<=n; i++){// F(n) = F(n-2) + F(n-1);int fn = f1 + f2;f1 = f2;f2 = fn;// F(1) F(2) F(3) F(4), ...// ↑ ↑ ↑// f1 + f2 = fn// ↑ ↑ ↑// f1 + f2 = fn }return f2;}
我们可以再看一道题加强理解一下。
Leetcode 198. 打家劫舍你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,
如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组,计算你不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。示例 1:
输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。偷窃到的最高金额 = 1 + 3 = 4 。示例 2:
输入:[2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。偷窃到的最高金额 = 2 + 9 + 1 = 12 。
刚才提到,动态规划的核心是划分子问题,寻找递推式。这一道题的递推式怎么构思呢?
题目问什么我们就定义函数去解决什么问题,题目描述:给你n间房屋,一夜之间能够偷窃到的最高金额,那我们就定义F(n)代表前n间房屋能够偷窃到的最高金额。
接下来,我们构思递推式。考虑中间状态F(k),即前k间房屋能够偷窃到的最高金额,如何偷k间房子?我们有两种方案:
我们肯定希望能偷到更多的钱,所以最终的结果取两种方案中结果更大的一种,即递推式为:F(k) = Math.max(F(k-2)+nums[i], F(k-1));
你可能会问:在上面的这个图片中,我怎么知道F(k-1)和F(k-2)内部是怎么偷的?但其实,动态规划根本不关心子问题内部是如何被解决的,只关心子问题的划分是否合理!
F(k-1)和F(k-2)也是由他们前面的状态推导得到,而针对于我当下的状态F(k),我只需要知道F(k-1)【前k-1间屋子能偷到多少钱】和F(k-2)【前k-2间屋子能偷到多少钱】,再结合我当下第k间屋子能偷到多少钱,就能推导出最优的方案,从而得到结果。
解法如下:
class Solution {public int rob(int[] nums) {if(nums.length == 1){return nums[0];}if(nums.length == 2){return Math.max(nums[0], nums[1]);}// 存放结果int[] memo = new int[nums.length];memo[0] = nums[0];memo[1] = Math.max(nums[0], nums[1]);for(int i=2; i<nums.length; i++){// 递推关系式memo[i] = Math.max(memo[i-1], memo[i-2]+nums[i]);}return memo[nums.length-1];}
}