基于动态规划的思想来做题,我们首先要想到的思维工具就是“倒着分析问题”。“倒着分析问题”分两步走:
- 定位到问题的终点
- 站在终点这个视角,思考后退的可能性
爬楼梯
递归+记忆化搜索
自顶向下
var climbStairs = function(n) {
let map=[]
function dfs(n)
{
if(n<=2) return n
if(map[n] == undefined) map[n]=dfs(n-1)+dfs(n-2)
return map[n]
}
return dfs(n)
};
记忆化搜索可以理解为优化过后的递归。递归往往可以基于树形思维模型来做,
我们基于树形思维模型来解题时,实际上是站在了一个比较大的未知数量级(也就是最终的那个n
),来不断进行拆分,最终拆回较小的已知数量级(f(1)
、f(2)
)。这个过程是一个明显的自顶向下的过程。
动态规划则恰恰相反,是一个自底向上的过程。它要求我们站在已知的角度,通过定位已知和未知之间的关系,一步一步向前推导,进而求解出未知的值。
在这道题中,已知 f(1)
和 f(2)
的值,要求解未知的 f(n)
,我们唯一的抓手就是这个等价关系:
动态规划
自底向上
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]
};
动态规划常见的题型
最优子结构,它指的是问题的最优解包含着子问题的最优解——不管前面的决策如何,此后的状态必须是基于当前状态(由上次决策产生)的最优决策。就这道题来说,f(n)
和f(n-1)
、f(n-2)
之间的关系印证了这一点(这玩意儿叫状态转移方程,大家记一下)。
重叠子问题,它指的是在递归的过程中,出现了反复计算的情况。就这道题来说,图上标红的一系列重复计算的结点印证了这一点。
因此,这道题适合用动态规划来做。
对于动态规划,建议大家优先选择这样的分析路径:
- 递归思想明确树形思维模型:找到问题终点,思考倒退的姿势,往往可以帮助你更快速地明确状态间的关系
- 结合记忆化搜索,明确状态转移方程
- 递归代码转化为迭代表达(这一步不一定是必要的,1、2本身为思维路径,而并非代码实现。若你成长为熟手,2中分析出来的状态转移方程可以直接往循环里塞,根本不需要转换)。
最值问题
站在 amount
这个组合结果上的“后退”—— 我们可以假装此时手里已经有了 36 美分,只是不清楚硬币的个数,把“如何凑到36”的问题转化为“如何从36减到0”的问题
f(36) = Math.min(f(36-c1)+1,f(36-c2)+1,f(36-c3)+1......f(36-cn)+1)
function coinChange(coins, amount) {
//dp[i] 代表总额为i的硬币最小个数
let dp = [0];
for (let i = 1; i <= amount; i++) {
dp[i] = Infinity;
for (let j = 0; j < coins.length; j++) {
if (i >= coins[j]) {
dp[i] = Math.min(dp[i], dp[i - coins[j]] + 1);
}
}
}
if (dp[amount] === Infinity) {
return -1;
}
return dp[amount];
}
01背包问题
“倒推”法明确状态间关系
现在,假设背包已满,容量已经达到了 c
。站在c
这个容量终点往后退,考虑从中取出一样物品,那么可能被取出的物品就有 i
种可能性。我们现在尝试表达“取出一件”这个动作对应的变化,我用 f(i, c)
来表示前 i
件物品恰好装入容量为 c
的背包中所能获得的最大价值。现在假设我试图取出的物品是 i
,那么只有两种可能:
- 第
i
件物品在背包里 - 第
i
件物品不在背包里
dp[i][v] = Math.max(dp[i-1][v], dp[i-1][v-w[i]] + c[i])
从变量入手:变量是 i
和 v
,但本质上来说 v
其实也是随着 i
的变化而变化的,因此我们可以在外层遍历i
、在内层遍历 v
。明白了这一点,我们就可以编码如下:
for(let i=1;i<=n;i++) {
for(let v=w[i]; v<=c;v++) {
dp[i][v] = Math.max(dp[i-1][v], dp[i-1][v-w[i]]+value[i])
}
}
function knapsack(n, c, w, value) {
//dp[i][j]表示前i个物品,背包容量为j时的最大价值
//dp[i][j]=max(dp[i-1][j],dp[i-1][j-w[i]]+value[i])
let dp = [];
//初始化
for (let i = 0; i <= n; i++) {
dp[i] = [];
for (let j = 0; j <= c; j++) {
dp[i][j] = 0;
}
}
for (let i = 1; i <= n; i++) {
for (let j = 1; j <= c; j++) {
if (j >= w[i]) {
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - w[i]] + value[i]);
} else {
dp[i][j] = dp[i - 1][j];
}
}
}
return dp[n][c];
}
最长上升子序列问题
子序列,在原有序列的基础上,删除0个或多个数,其他数的顺序保持不变得到的结果
用f(i)
来表示前i
个元素中最长上升子序列的长度。若想基于 f(i)
求解出 f(i+1)
,我们需要关注到的是第 i+1
个元素和前 i
个元素范围内的最长上升子序列的关系,它们之间的关系有两种可能:
- 若第
i+1
个元素比前i
个元素中某一个元素要大,此时我们就可以在这个元素所在的上升子序列的末尾追加第i+1
个元素(延长原有的子序列),得到一个新的上升子序列。 - 若第
i+1
个元素并不比前i
个元素中所涵盖的最长上升子序列中的某一个元素大,则维持原状,子序列不延长
function lengthOfLIS(nums) {
//dp[i]表示以nums[i]结尾的最长上升子序列的长度
//初始化,每个索引位都存在一个以自己为结尾的子序列,长度为1
let dp = new Array(nums.length).fill(1);
let max = 1;
for (let i = 1; i < nums.length; i++) {
for (let j = 0; j < i; j++) {
if (nums[i] > nums[j]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
if (max < dp[i]) {
max = dp[i];
}
}
return max;
}