什么是动态规划
动态规划,英文:Dynamic Programming
,简称DP
,将问题分解为互相重叠的子问题,通过反复求解子问题来解决原问题就是动态规划,如果某一问题有很多重叠子问题,使用动态规划来解是比较有效的。
求解动态规划的核心问题是穷举,但是这类问题穷举有点特别,因为这类问题存在「重叠子问题」,如果暴力穷举的话效率会极其低下。动态规划问题一定会具备「最优子结构」,才能通过子问题的最值得到原问题的最值。另外,虽然动态规划的核心思想就是穷举求最值,但是问题可以千变万化,穷举所有可行解其实并不是一件容易的事,只有列出**正确的「状态转移方程」**才能正确地穷举。重叠子问题、最优子结构、状态转移方程就是动态规划三要素
动态规划和其他算法的区别
- 动态规划和分治的区别:动态规划和分治都有最优子结构 ,但是分治的子问题不重叠
- 动态规划和贪心的区别:动态规划中每一个状态一定是由上一个状态推导出来的,这一点就区分于贪心,贪心没有状态推导,而是从局部直接选最优解,所以它永远是局部最优,但是全局的解不一定是最优的。
- 动态规划和递归的区别:递归和回溯可能存在非常多的重复计算,动态规划可以用递归加记忆化的方式减少不必要的重复计算
动态规划的解题方法
- 递归+记忆化(自顶向下)
- 动态规划(自底向上)
解动态规划题目的步骤
- 根据重叠子问题定义状态
- 寻找最优子结构推导状态转移方程
- 确定dp初始状态
- 确定输出值
斐波那契的动态规划的解题思路
暴力递归
//暴力递归复杂度O(2^n)
var fib = function (N) {
if (N == 0) return 0;
if (N == 1) return 1;
return fib(N - 1) + fib(N - 2);
};
递归 + 记忆化
var fib = function (n) {
const memo = {
}; // 对已算出的结果进行缓存
const helper = (x) => {
if (memo[x]) return memo[x];
if (x == 0) return 0;
if (x == 1) return 1;
memo[x] = helper(x - 1) + helper(x - 2);
return memo[x];
};
return helper(n);
};
动态规划
const fib = (n) => {
if (n <= 1) return n;
const dp = [0, 1];
for (let i = 2; i <= n; i++) {
//自底向上计算每个状态
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
};
滚动数组优化
const fib = (n) => {
if (n <= 1) return n;
//滚动数组 dp[i]只和dp[i-1]、dp[i-2]相关,只维护长度为2的滚动数组,不断替换数组元素
const dp = [0, 1];
let sum = null;
for (let i = 2; i <= n; i++) {
sum = dp[0] + dp[1];
dp[0] = dp[1];
dp[1] = sum;
}
return sum;
};
动态规划 + 降维,(降维能减少空间复杂度,但不利于程序的扩展)
var fib = function (N) {
if (N <= 1) {
return N;
}
let prev2 = 0;
let prev1 = 1;
let result = 0;
for (let i = 2; i <= N; i++) {
result = prev1 + prev2; //直接用两个变量就行
prev2 = prev1;
prev1 = result;
}
return result;
};
509. 斐波那契数(easy)
视频讲解:传送门
斐波那契数 (通常用 F(n) 表示)形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。也就是:
F(0) = 0,F(1) = 1
F(n) = F(n - 1) + F(n - 2),其中 n > 1
给定 n ,请计算 F(n) 。示例 1:
输入:n = 2
输出:1
解释:F(2) = F(1) + F(0) = 1 + 0 = 1
示例 2:输入:n = 3
输出:2
解释:F(3) = F(2) + F(1) = 1 + 1 = 2
示例 3:输入:n = 4
输出:3
解释:F(4) = F(3) + F(2) = 2 + 1 = 3提示:
0 <= n <= 30
方法1.动态规划
- 思路:自底而上的动态规划
- 复杂度分析:时间复杂度
O(n)
,空间复杂度O(1)
Js:
var fib = function (N) {
if (N <= 1) {
return N;
}
let prev2 = 0;
let prev1 = 1;
let result = 0;
for (let i = 2; i <= N; i++) {
result = prev1 + prev2;
prev2 = prev1;
prev1 = result;
}
return result;
};
152. 乘积最大子数组 (medium)
视频讲解:传送门
给你一个整数数组 nums ,请你找出数组中乘积最大的非空连续子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。
测试用例的答案是一个 32-位 整数。
子数组 是数组的连续子序列。
示例 1:
输入: nums = [2,3,-2,4]
输出: 6
解释: 子数组 [2,3] 有最大乘积 6。
示例 2:输入: nums = [-2,0,-1]
输出: 0
解释: 结果不能为 2, 因为 [-2,-1] 不是子数组。提示:
1 <= nums.length <= 2 * 104
-10 <= nums[i] <= 10
nums 的任何前缀或后缀的乘积都 保证 是一个 32-位 整数
方法1.动态规划
-
思路:
-
状态定义:
dp[i][0]
表示从第 0 项到第 i 项范围内的子数组的最小乘积,dp[i][1]
表示从第 0 项到第 i 项范围内的子数组的最大乘积 -
初始状态:
dp[0][0]=nums[0], dp[0][1]=nums[0]
-
分情况讨论:
- 不和别人乘,就
nums[i]
自己 num[i]
是负数,希望乘上前面的最大积num[i]
是正数,希望乘上前面的最小积
- 不和别人乘,就
-
状态转移方程:
- dp[i] [0]=min(dp[i−1] [0]∗num[i] , dp[i−1] [1] ∗ num[i], num[i])
- dp[i] [1]=max(dp[i−1] [0]∗num[i] , dp[i−1] [1] ∗ num[i], num[i])
-
状态压缩:
dp[i][x]
只与dp[i][x]-1
,所以只需定义两个变量,prevMin = nums[0]
,prevMax = nums[0]
-
状态压缩之后的方程:
- prevMin = Math.min(prevMin * num[i], prevMax * num[i], nums[i])
- prevMax = Math.max(prevMin * num[i], prevMax * num[i], nums[i])
-
-
复杂度:时间复杂度
O(n)
,空间复杂度O(1)
js:
var maxProduct = (nums) => {
let res = nums[0]
let prevMin = nums[0]
let prevMax = nums[0]
let temp1 = 0, temp2 = 0
for (let i = 1; i < nums.length; i++) {
temp1 = prevMin * nums[i]
temp2 = prevMax * nums[i]
prevMin = Math.min(temp1, temp2, nums[i])
prevMax = Math.max(temp1, temp2, nums[i])
res = Math.max(prevMax, res)
}
return res
}
322. 零钱兑换 (medium)
视频讲解:传送门
给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。
你可以认为每种硬币的数量是无限的。
示例 1:
输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1
示例 2:输入:coins = [2], amount = 3
输出:-1
示例 3:输入:coins = [1], amount = 0
输出:0提示:
1 <= coins.length <= 12
1 <= coins[i] <= 231 - 1
0 <= amount <= 104
不能用贪心做,反例,coins=[1, 3, 5, 6, 7]
,amount=30
,用贪心先用最大的面额7,在用2个1,4 * 7 + 2 * 1 = 30
,但是我们用5个6,5 * 6 = 30
就能用最少的硬币兑换完成
方法1.动态规划
- 思路:
dp[i]
表示兑换面额i
所需要的最少硬币,因为硬币无限,所以可以自底向上计算dp[i]
,对于dp[0~i]
的每个状态,循环coins
数组,寻找可以兑换的组合,用i
面额减去当前硬币价值,dp[i-coin]
在加上一个硬币数就是dp[i]
,最后取最小值就是答案,状态转移方程就是dp[i] = Math.min(dp[i], dp[i - coin] + 1)
; - 复杂度分析:时间复杂度是O(sn),s是兑换金额,n是硬币数组长度,一共需要计算s个状态,每个状态需要遍历n个面额来转移状态。空间复杂度是
O(s)
,也就是dp数组的长度
Js:
var coinChange = function (coins, amount) {
let dp = new Array(amount + 1).fill(Infinity);//初始化dp数组
dp[0] = 0;//面额0只需要0个硬币兑换
for (let i = 1; i <= amount; i++) {
//循环面额
for (let coin of coins) {
//循环硬币数组
if (i - coin >= 0) {
//当面额大于硬币价值时
//dp[i - coin]: 当前面额i减当前硬币价值所需要的最少硬币
//dp[i] 可由 dp[i - coin] + 1 转换而来
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}
return dp[amount] === Infinity ? -1 : dp[amount];//如果dp[amount] === Infinity,则无法兑换
};
70. 爬楼梯 (medium)
视频讲解:传送门
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
示例 1:
输入:n = 2
输出:2
解释:有两种方法可以爬到楼顶。
- 1 阶 + 1 阶
- 2 阶
示例 2:
输入:n = 3
输出:3
解释:有三种方法可以爬到楼顶。
- 1 阶 + 1 阶 + 1 阶
- 1 阶 + 2 阶
- 2 阶 + 1 阶
提示:
1 <= n <= 45
方法1.动态规划
- 思路:因为每次可以爬 1 或 2 个台阶,所以到第n阶台阶可以从第n-2或n-1上来,其实就是斐波那契的dp方程
- 复杂度分析:时间复杂度
O(n)
,空间复杂度O(1)
Js:
var climbStairs = function (n) {
const memo = [];
memo[1] = 1;
memo[2] = 2;
for (let i = 3; i <= n; i++) {
memo[i] = memo[i - 2] + memo[i - 1];//所以到第n阶台阶可以从第n-2或n-1上来
}
return memo[n];
};
//状态压缩
var climbStairs = (n) => {
let prev = 1;
let cur = 1;
for (let i = 2; i < n + 1; i++) {
[prev, cur] = [cur, prev + cur]
// const temp = cur; // 暂存上一次的cur
// cur = prev + cur; // 当前的cur = 上上次cur + 上一次cur
// prev = temp; // prev 更新为 上一次的cur
}
return cur;
}
72. 编辑距离 (hard)
视频讲解:传送门
给你两个单词 word1 和 word2, 请返回将 word1 转换成 word2 所使用的最少操作数 。
你可以对一个单词进行如下三种操作:
插入一个字符
删除一个字符
替换一个字符示例 1:
输入:word1 = “horse”, word2 = “ros”
输出:3
解释:
horse -> rorse (将 ‘h