js 动态规划

314 阅读5分钟

首先要搞明白 动态规划(dynamic programming), 就是一个算法思想
但是为什么要用动态规划?
因为用它可以更好的解决一些 最优解类型的问题\

话题又到了最优解类型 什么类型算是最优解, 也就是说什么样的问题能够使用动态规划?
有这么说的
(1)最优子结构,就是指问题可以通过子问题最优解得到;体现为找出所有的子问题最优解,然后取其中的最优;
(2)重叠子问题,就是子问题是会重复的。而不是一直产生新的子问题(比如分治类型的问题)。

也有这么说的
要解决一个给定的问题,我们需要解决其不同部分(即解决子问题),再合并子问题的解以得出原问题的解。 
通常许多子问题非常相似,为此动态规划法试图只解决每个子问题一次,从而减少计算量。
一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。 
这种做法在重复子问题的数目关于输入的规模呈指数增长时特别有用。
动态规划有三个核心元素:
1.最优子结构
2.边界
3.状态转移方程

概念读了一大堆,上手还是不会,并且动态规划好像分一维和二维数组,我觉得想要真正弄懂动态规划,还是先看够10道都可以用动态规划来解决的题目以后在来说什么是动态规划.

第一题 (一维数组)

509. 斐波那契数

这道题典型的动态规划, 下面两张图片斗鱼用了动态规划的思想,但是递归明显比较消耗时间,这个动态规划明显就是倒推来理解,求f(n)倒推到最后就是求f(n-1)+f(n-2)

image.png

image.png

第二题 (一维数组)

70. 爬楼梯

这道题经过分析,其实就是斐波那契数列,这不过这个是从前两项1,2开始的,而斐波那契数列是从前两项0,1开始,还有就是最后返回只需要返回f(n-1)就行\

image.png 动态规划还有一个更优解就是,不需要数组这样需要缓存n之前的每一个步骤\

image.png

上面图片中两个方法也行, 但是定义的变量没有语意话,下面方法直接语意化变量名

   // 滚动数组 
    let pre  = 0, cur = 0, res = 1;
    // 因为又res = 1, 所以要从i = 1 开始;
    for(let i = 1; i <= n; i ++){
        // 先改变pre,cur 的值 往后移动一位 
        // 然后 后一位 res 的值 就是前两个值相加
        pre = cur;
        cur = res;
        res = cur + pre;
    }
    return res
const climbStairs = (n) => {
  let prev = 1;
  let cur = 1;
  for (let i = 2; i < n + 1; i++) {
    const temp = cur;   // 暂存上一次的cur
    cur = prev + cur;   // 当前的cur = 上上次cur + 上一次cur
    prev = temp;        // prev 更新为 上一次的cur
  }
  return cur;
}

上面这两个方法都是动态规划的优化,只不过一个定义的变量多一个定义的变量少,思想类似

第三题 (一维数组)

746. 使用最小花费爬楼梯

image.png 这道题我理解不了,我真是服了,先把官网的思路贴上来\

假设数组 \textit{cost}cost 的长度为 nn,则 nn 个阶梯分别对应下标 00 到 n-1n−1,楼层顶部对应下标 nn,问题等价于计算达到下标 nn 的最小花费。可以通过动态规划求解。

创建长度为 n+1n+1 的数组 \textit{dp}dp,其中 \textit{dp}[i]dp[i] 表示达到下标 ii 的最小花费。

由于可以选择下标 00 或 11 作为初始阶梯,因此有 \textit{dp}[0]=\textit{dp}[1]=0dp[0]=dp[1]=0。

当 2 \le i \le n2≤i≤n 时,可以从下标 i-1i−1 使用 \textit{cost}[i-1]cost[i−1] 的花费达到下标 ii,或者从下标 i-2i−2 使用 \textit{cost}[i-2]cost[i−2] 的花费达到下标 ii。为了使总花费最小,\textit{dp}[i]dp[i] 应取上述两项的最小值,因此状态转移方程如下:

\textit{dp}[i]=\min(\textit{dp}[i-1]+\textit{cost}[i-1],\textit{dp}[i-2]+\textit{cost}[i-2]) dp[i]=min(dp[i−1]+cost[i−1],dp[i−2]+cost[i−2])

依次计算 \textit{dp}dp 中的每一项的值,最终得到的 \textit{dp}[n]dp[n] 即为达到楼层顶部的最小花费。

image.png

第四题(二维数组)

62. 不同路径

image.png

var uniquePaths = function(m, n) {
    // 先建立矩阵
    const f = new Array(m).fill(1).map(() => new Array(n).fill(1));
    // 通过分析知道第mn个数 要不是从上下来 要不就是从左过来
    // 而第[0][0]个数属于其实位置
    for (let i = 1; i < m; i++) {
        for (let j = 1; j < n; j++) {
            f[i][j] = f[i - 1][j] + f[i][j - 1];
        }
    }
    // m-1和n-1属于下标也就是第m*n个网格所在位置
    return f[m - 1][n - 1];
};

第五题 (二维数组)

63. 不同路径 II

image.png 这道题跟上面的思路一样 只需要多处理障碍物,遇到障碍物让dp[i][j]其值遍为0,从新计算就行,不要问我为什么 分析得来

var uniquePathsWithObstacles = function(obstacleGrid) {
    const m = obstacleGrid.length
    const n = obstacleGrid[0].length
    const dp = Array(m).fill(0).map(item => Array(n).fill(0))
    // 最后i++ 和 ++i都行
    for (let i = 0; i < m && obstacleGrid[i][0] === 0; i ++) {
        dp[i][0] = 1
    }
    
    for (let i = 0; i < n && obstacleGrid[0][i] === 0; ++i) {
        dp[0][i] = 1
    }
    
    for (let i = 1; i < m; i++) {
        for (let j = 1; j < n; ++j) {
            // 判断障碍物 遇到了变成0 由分析得来
            dp[i][j] = obstacleGrid[i][j] === 1 ? 0 : dp[i - 1][j] + dp[i][j - 1]
        }
    }
        
    return dp[m - 1][n - 1]
};

第六题 (二维数组)

343. 整数拆分

image.png 这个题的难点就是怎么拆分正整数n,具体思路可以参考力扣官方题解的解释,我下面写的题解也有核心分析

var integerBreak = function (n) {
	// 首先 先搞明白 dp[i]表示; 整数n拆分成整数为i的数字 得到的最大成绩dp[i]

	const dp = new Array(n + 1);
	// dp[1]的赋值 加不加都行
	dp[1] = 1;
	// 重点是这个 表示2数字只能拆分成正整数1和1 1*1 等于1
	dp[2] = 1;
	// 从正整数3开始
	for (let i = 3; i <= n; i++) {
		dp[i] = 0;
		// 对于数字 i,它可以分为两份:j 和 i-j,j 的范围是 1 到 i-j
		// 这个for循环终止条件也可以写成j < i;
		// for (let j = 1; j < i ; j++) {
		for (let j = 1; j <= i-j; j++) {
			// # 假设对正整数 i 拆分出的第一个正整数是 j(1 <= j < i),则有以下两种方案:
			//  # 1) 将 i 拆分成 j 和 i−j 的和,且 i−j 不再拆分成多个正整数,此时的乘积是 j * (i-j)
			//  # 2) 将 i 拆分成 j 和 i−j 的和,且 i−j 继续拆分成多个正整数,此时的乘积是 j * dp[i-j]
			dp[i] = Math.max(dp[i], j * (i - j), j * dp[i - j]);
		}
	}
	return dp[n];
};

第七题 (二维数组)

416. 分割等和子集

image.png

这个题是典型的背包问题,背包问题,分析出来有一个通用的公式
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
大多数背包问题都可以这么理解,这个背包问题参考 代码随想录人家分析的,但是我不好理解

var canPartition = function(nums) {
    const sum = (nums.reduce((p, v) => p + v));
    //   // 如果 nums 的总和为奇数则不可能平分成两个子集
    // if (sum & 1) return false;
     if (sum % 2 == 1) return false;

    const dp = Array(sum / 2 + 1).fill(0);
     //物品 i 的重量是 nums[i],其价值也是 nums[i]
    for(let i = 0; i < nums.length; i++) {
        for(let j = sum / 2; j >= nums[i]; j--) {
            dp[j] = Math.max(dp[j], dp[j - nums[i]] + nums[i]);
            if (dp[j] === sum / 2) {
                return true;
            }
        }
    }
    return dp[sum / 2] === sum / 2;
};

第八题 (一维数组)

518. 零钱兑换 II

image.png 这个零钱兑换,分析好了代码比较简单,问题是分析

var change = function(amount, coins) {
    const dp = new Array(amount+1).fill(0);
    // dp[j]:凑成总金额j的货币组合数为dp[j]
    // 这一步很重要
    dp[0] = 1;
    for(const coin of coins){
        for(let i = coin; i<= amount; i++){
            dp[i] += dp[i - coin]
        }
    }
    return dp[amount];
};

第9题 (一维数组)

674. 最长连续递增序列

image.png

var findLengthOfLCIS = function(nums) {
    const dp = new Array(nums.length).fill(1);
    for(let i = 0; i < nums.length; i ++ ){
        if(nums[i+1] > nums[i]){
            dp[i+1] = dp[i] + 1;
        }
    }

    return Math.max(...dp);
};

第10题 (一维数组)

300. 最长递增子序列

image.png

var lengthOfLIS = function(nums) {
    // 思路 dp[i] 表示 nums 前i个元素的最长子序列是dp[i]  假如 nums长度为5 前4个最长子序列为3 那么前5个如果存在最长子序列肯定是3+1=4个
    // 初始化dp数组的值都为1
    const dp = new Array(nums.length).fill(1);
    let res = 1;
    for(let i = 0; i < nums.length; i++){
        // 用i和i前的元素挨着比较 如果发现比i小的 则dp[j]加一 然后和dp[i]比较 取最大值赋值给dp[i]
        for(j = 0; j < i; j ++){
            if(nums[i] > nums[j]){
                dp[i] = Math.max(dp[i], dp[j] + 1)
            }
        }
        res = Math.max(res,dp[i])
    }

    return res;
};

第9,10两道题,总结,都是一维数组动态规划,显而易见,第九题,只需要考虑i前面的一个数字就行了,而第十题是不要求连续的,所以需要重复循环i之前的所有数字.

每道都是看着解析才能勉强弄明白,动态规划,对于我目前是掌握不了了