目录
动态规划思想概述
动态规划英文全称是Dynamic Programming,故我们通常用DP指代动态规划。动态规划在寻找有许多重叠子问题的情况时,能够有效获得最佳解。它将一个复杂问题拆分成若干简单子问题,然后去解决子问题,问了避免多次重复解决子问题,会将计算结果进行存储,从解决简单的问题直到解决整个复杂问题。因此,相对于普通递归来说,动态规划有效减少了子问题的重复计算,减少了再解决同样问题上花费的时间。
简单来说,动态规划的核心思想在于拆分子问题,记录过往,减少重复计算。
动态规划解题步骤:
1.分析状态。找出dp[i]。
2.列出状态转移方程式。列出dp[i]个状态的转移方程,找出和dp[i-1]、dp[i-2]的联系。
3.初始化状态。找到最底层子状态进行初始化,例如dp[1]、dp[2]。
动态规划思想分析
首先我们通过一个简单的案例来分析动态规划和普通递归之间的优劣
斐波那契数,通常用 F(n) 表示,形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。也就是:
F(0) = 0,F(1) = 1
F(n) = F(n - 1) + F(n - 2),其中 n > 1
给你 n ,请计算 F(n) 。来源:力扣(LeetCode)
链接:力扣
首先我们从递归的角度去分析解决该题:
/**
* @param {number} n
* @return {number}
*/
var fib = function(n) {
if(n<2){
return n;
}else{
return fib(n-1)+fib(n-2);
}
};
若我们用动态递归进行分析,代码如下:
var fib = function(n) {
let arr = new Array(n+1);
arr[0] = 0;
arr[1] = 1;
for(let i=2;i<=n;i++){
arr[i] = arr[i-1] + arr[i-2];
}
return arr[n];
};
两者相比看似递归调用更加简洁,我们来分析一下两者计算第20个斐波拉契数的运行时间,可以利用console.time方法来进行计算。console.time() 方法是作为计算器的起始方法,console.timeEnd()方法为计算器的结束方法,并将执行时长显示在控制台。
console.time('time'); //函数调用前输出开始时间
fib(20);
console.timeEnd('time'); //函数调用后输出结束时间
下面用time1来指代递归方法函数调用时间,time2指代动态规划方式函数调用时间
我们可以发现,动态规划调用函数计算时间远远小于递归方法调用函数计算时间,所以这也是为什么我们选择使用动态规划方法来代替递归方法。更重要的是,由于递归方法会有大量子问题的重叠计算,所以若计算更大数字的斐波拉契数字,是用递归方法计算速度会非常慢,而动态规划有效优化了这个缺点。
动态规划案例
爬楼梯
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
注意:给定 n 是一个正整数。
来源:力扣(LeetCode)
链接:力扣
分析: 要想跳到第10级台阶,有两个可能性,一个是在第9级台阶跳一级,一个是在第8级台阶跳两级,也就是dp(10) = dp(9) + dp(8),同理,对于获得第9级台阶的方法是dp(9) = dp(8) + dp(7),以此类推,获得了状态转移方程式为dp(i) = dp(i-1) + dp(i-2),我们最底层公式是dp(3) = dp(2) + dp(1),因此要获得初始化的底层子状态。
dp(2)和dp(1)我们通过题干可以获得:1级台阶就是只有一种方法,跳一级;2级台阶有两种方法,直接跳两级或者先跳一级,再跳一级。因此可以直接初始化,dp(1)=1,dp(2)=2
所以我们就可以通过动态规划来解决该题:
/**
* @param {number} n
* @return {number}
*/
var climbStairs = function(n) {
let dp = [];
dp[1] = 1;
dp[2] = 2;
for(let i=3;i<=n;i++){
dp[i] = dp[i-1] +dp [i-2];
}
return dp[n];
};
最大子数组和
给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
子数组 是数组中的一个连续部分。
来源:力扣(LeetCode)
链接:力扣
分析: 要求连续子序列和,故将第i-1个子序列和作为第i个子序列和的求值依据,对比第i-1个子序列和与第i个数组元素,也就是dp[i] = Math.max(dp[i-1]+nums[i],nums[i]),这样我们就求得了状态转移方程式,它只需要获得初始值dp[0],也就是数组第一个元素值。
/**
* @param {number[]} nums
* @return {number}
*/
var maxSubArray = function(nums) {
let dp = [];
dp[0] = nums[0];
let max = nums[0]; //记录最大值
for(let i=1;i<nums.length;i++){
dp[i] = Math.max(dp[i-1]+nums[i],nums[i]);
if(dp[i] > max){
max = dp[i];
}
}
return max;
};
乘积最大子数组
给你一个整数数组
nums
,请你找出数组中乘积最大的连续子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。来源:力扣(LeetCode)
链接:力扣
分析:乘积最大子数组与上题最大子数组和最大的不同点在于,需要考虑乘积中复数相乘为正,因此不能用最大子数组和的思想取求解最大子数组积。
对于乘积最大子数组,我们先定义一个值来存放包含nums[i]的乘积和最小值,它的作用是记录最小负数,因为若之后再乘一个负数,可能会变成最大数字。所以,我们有两个数要找,一个是乘积过程中的最大值,一个是乘积过程中的最小值。因此我们可以得到转移方程:
dpMax[i] = Math.max(nums[i],Math.max(dpMax[i-1]*nums[i],dpMin[i-1]*nums[i]));
dpMin[i] = Math.min(nums[i],Math.min(dpMax[i-1]*nums[i],dpMin[i-1]*nums[i]));
max = Math.max(max,dpMax[i]);
只要理解了转移方程,以及它们的作用和关系,接下来的就不难了,正确赋值初始状态就行
/**
* @param {number[]} nums
* @return {number}
*/
var maxProduct = function(nums) {
let dpMax = []; //存放乘积过程中最大值
let dpMin = []; //存放乘积过程中最小值
let max = []; //存放最终的最大值
let n = nums.length;
[dpMax[0],dpMin[0]] = [nums[0],nums[0]];
max = dpMax[0];
if(n === 0){
return 0;
}
for(let i=1;i<n;i++){
dpMax[i] = Math.max(nums[i],Math.max(dpMax[i-1]*nums[i],dpMin[i-1]*nums[i]));
dpMin[i] = Math.min(nums[i],Math.min(dpMax[i-1]*nums[i],dpMin[i-1]*nums[i]));
max = Math.max(max,dpMax[i]);
}
return max;
};
打家劫舍
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
来源:力扣(LeetCode)
链接:力扣
分析:如果打劫第i间房子,那么第i-1间房子就不能打劫,所以在第i间房子前,只需要比较第i间房子加上第i-2间房子之前打劫的金额总和与第i-1间房子打劫金额总和就行。也就是比较dp(i)+dp(i-2)和dp(i-1),所以我们就得出了状态转移方程式dp[i] = Math.max(dp[i-1],dp[i-2]+nums[i]),那么很显然,最底层公式是dp[2] = Math.max(dp[1],dp[0]+nums[2]),所以我们需要获得的初始值是dp(1)、dp(0),dp(0)指代的是偷一间房子(只有一间房子可偷),dp(1)指代的是偷两间房子,选取钱多的那一间
故用动态规划解题步骤如下:
/**
* @param {number[]} nums
* @return {number}
*/
var rob = function(nums) {
let dp = [];
dp[0] = nums[0];
dp[1] = Math.max(nums[0],nums[1]);
for(let i=2;i<nums.length;i++){
dp[i] = Math.max(dp[i-1],dp[i-2]+nums[i]);
}
return dp[nums.length-1];
};
对比贪心算法
之前我们介绍了贪心算法,我们可以对比一下两者
相同点:都是一种递推算法,本质都是将复杂问题拆分成简单的子问题来进行逐步求解
不同点:
1.贪心算法只能保证求解的当前最优解,不能保证是全局最优解;动态规划是通过求解局部最优解来推导全局最优解。
2.贪心算法是由上一步最优解推到下一步最优解,而上一步最优解不作保留;动态规划算法需要记录之前所有最优解。
3.贪心算法通常是自顶向下进行求解子问题;动态规划算法通常是以自底向上的方法进行求解子问题