leetcode 之动态规划

270 阅读4分钟

动态规划用于解决最值问题,先阅读 动态规划笔记

53. 最大子数组和/子序和

题目 53. 最大子数组和

dp数组的含义 「以nums[i]结尾的子数组的最大和」,当前状态只和前一个状态有关,不涉及 「选择」,只需要确定好状态方程即可。

状态转移方程dp[i] = Math.max(nums[i], dp[i - 1] + nums[i])

const maxSubArray = function(nums) {
  let len = nums.length;
  // dp[i]表示以nums[i]结尾的子数组的最大和
  const dp = new Array(len);  
  dp[0] = nums[0];   // base case
  for (let i = 1; i < len; i++) {  // 填充dp
    dp[i] = Math.max(nums[i], dp[i - 1] + nums[i]);  // 注意:单独的nums[i]
  }
  return Math.max(...dp);  // 注意,数组必须展开,否则报错
}

时间复杂度:O(n)。

空间复杂度:O(n)。

509.斐波那契数列 - 空间复杂度O(1)

题目509.斐波那契数

1. 动态规划

const fib = function(n) {
  if (n < 2) return n;  
  let prev = 0, curr = 1;  
  let sum = 0;
  
  for (let i = 2; i <= n; i++) {  
    sum = prev + curr;
    prev = curr;  
    curr = sum;  
  }
  return sum;
}

时间复杂度:O(n)。

空间复杂度:O(1)。

2. 递归

const fib = function(n) {
  if (n < 2) return n;  
  return fib(n - 1) + fib(n -2);
}

时间复杂度:O(n^2)。

剑指10-I.斐波那契数列 - 空间复杂度O(1)

题目 剑指10-I.斐波那契数列

const fib = function(n) {
  if (n < 2) return n;  
  let prev = 0, curr = 1;  
  let sum = 0;
  
  for (let i = 2; i <= n; i++) { 
    // 经验证,虽然先取模和后取模结果是相同的,但是后取会导致超int范围
    sum = (prev + curr) % (1e9 + 7);  // 科学计数法
    prev = curr;  
    curr = sum;  
  }
  return sum;
}

70. 爬楼梯 - 空间复杂度O(1)

题目 70. 爬楼梯

转换成斐波那契数列 f(n) = f(n - 1) + f(n - 2)。题目中 n 的范围 n >= 1,当 n = 1时有1种,当 n = 2时有2种。

假设现在有 n 阶台阶,那么它是上一步要么是从 n - 2 阶走上来的,要么是从 n - 1 阶走上来的。

var climbStairs = function(n) {
    if (n <= 2)  return n;
    let prev = 1, curr = 2;  
    let sum = 0;
    
    for (let i = 3; i <= n; i++) {
        sum = prev + curr;
        prev = curr;
        curr = sum;
    }
    return sum;
};

剑指10-II.青蛙跳台阶 - 空间复杂度O(1)

题目 剑指10-II.青蛙跳台阶

和爬楼梯思路一样,区别在于n的范围 n >= 0,当 n = 0时有1种,当 n = 1时有1种。

const numWays = function(n) {
  if (n < 2)  return 1;
  let prev = 1, curr = 1;  
  let sum = 0;
  
  for (let i = 2; i <= n; i++) {
    sum = (prev + cur) % (1e9 + 7);  // 科学计数法
    prev = cur;
    cur = sum;
  }
  return sum;
}

121. 买卖股票的最佳时机 - minPrice/maxProfit

题目121.买卖股票的最佳时机

由题意可知,股票买入和卖出的日子一定不同。该题不需要 dp 数组记录,只需要两个变量 minPrice 和 maxProfit 即可。 实际上就是找最大差值,然后同步更新"当前最低价格"和"当前最大利润"。

const maxProfit = function(prices) {
    let minPrice = prices[0];  // 历史最低价格
    let maxProfit = 0;  // 最大利润,不能获取利润则为 0
    for (let i = 1; i < prices.length; i++) {
       // 后面两句可以互换顺序,已验证过
        minPrice = Math.min(minPrice, prices[i]);
        maxProfit = Math.max(maxProfit, prices[i] - minPrice);
    }
    return maxProfit;
};

300.最长递增子序列 - 注意条件

题目300. 最长递增子序列

子序列可以不连续。记住状态转移方程! dp[i]表示以i个元素nums[i]结尾的最长上升子序列的长度。

状态转移方程 dp[i] = max(dp[j]) + 1, 其中0 <= j < inums[j] < nums[i]条件很重要!!

最后,整个数组的最长上升子序列就是所有dp[i]中的最大值 max(dp[i]),其中0 <= i < n

const lengthOfLIS = function(nums) {
    let n = nums.length;
    // dp[i]表示以nums[i]结尾的最长严格递增子序列的长度
    const dp = new Array(n).fill(1);  // 最小值是 1
    for (let i = 1; i < n; i++) {
        // 条件 0 <= j < i 且 nums[j] < nums[i] 
        for (let j = 0; j < i; j++) {  
            if (nums[j] < nums[i]) {   
                dp[i] = Math.max(dp[j] + 1, dp[i]);  // 注意
            }
        }
    }
    return Math.max(...dp);  // 注意
};

时间复杂度:O(n^2),其中n是数组nums的长度。

空间复杂度:O(n),需要使用长度为n的dp数组。

322.零钱兑换

零钱兑换的题解

注意事项:

  1. dp 数组初始化长度为 amount + 1,并且填充 Infinity
  2. for...of 遍历 coins。
  3. 返回值和Infinity进行比较。

718. 最长重复子数组:二维网格 + 斜线长度

题目 718.最长重复子数组。这题与1143.最长公共子序列的区别在于,子数组要求是连续的。

题解:二维网格 + 左上到右下的线长度

考虑到dp[0][i]dp[i][0] 即第一行和第一列也要适用状态方程,所以初始化二维数组的大小为 (len1 + 1) * (len2 + 1)。用maxLength表示最大长度。

例如求数组[1, 2, 3, 2, 1][3, 2, 1, 4, 7],则对应的表格如下。也就是nums[0, len - 1]的元素对应 dp 二维数组中的 dp[0][1] - dp[0][len]

image.png

const findLength = (nums1, nums2) => {
  let len1 = nums1.length, len2 = nums2.length;
  const dp = new Array(len1 + 1).fill(0).map(() => new Array(len2 + 1).fill(0));  
  let maxLength = 0;
  
  for (let i = 1; i <= len1; i++) {  
    for (let j = 1; j <= len2; j++) {
      // nums1 的 [0, len1-1] 个元素实际排列在 dp 的[1, len1] 的位置
      if (nums1[i - 1] == nums2[j - 1]) {
        dp[i][j] = dp[i - 1][j - 1] + 1; // 左上角的数字 + 1
        maxLength = Math.max(maxLength, dp[i][j]);
      }
    }
  }
  return maxLength;
};

1143. 最长公共子序列:二维网格 + 扩张填满

题目1143. 最长公共子序列

该题和【最长重复子数组】相似,区别在于子数组要求元素连续,该题的子序列不要求连续。

image.png

题解

思路和 最长重复子数组 类似,只是状态转移方程有区别,通过不断扩张达到不连续的目的。

image.png

const longestCommonSubsequence = function(text1, text2) {
    let len1 = text1.length, len2 = text2.length;
    const dp = new Array(len1 + 1).fill(0).map(() => new Array(len2 + 1).fill(0));
    let maxLength = 0;

    for (let i = 1; i <= len1; i++) { // 从第2行和第2列赋值
        for (let j = 1; j <= len2; j++) {
            if (text1[i - 1] == text2[j - 1]) {
                dp[i][j] = dp[i - 1][j - 1] + 1;
            } else {
                dp[i][j] = Math.max(dp[i][j - 1], dp[i - 1][j]);
            }
            
            maxLength = Math.max(maxLength, dp[i][j]);
        }
    }

    return maxLength;
};

62. 不同路径:边界条件

题目62. 不同路径

和青蛙跳思路类似。状态转移方程:dp[i][j] = dp[i, j - 1] + dp[i - 1, j]。

「边界条件」 :如果机器人在第0行dp[0][i] ,那么它之前的行进方向一定是一直向右的(从dp[0][0]dp[0][i]),路线是唯一的。所以我们给边界第0行和第0列的网格,都赋值为 1

对于不在第 0 行或第 0 列的元素,初始赋值为 0,然后不断倒推,直到第 0 行 或 第 0 列。

const uniquePaths = function(m, n) {
    const dp = new Array(m).fill(0).map(() => new Array(n).fill(0));   // 初始赋值为0
    for (let i = 0; i < m; i++) { dp[i][0] = 1; }  // 第0列赋值1
    for (let i = 0; i < n; i++) { dp[0][i] = 1; }  // 第0行赋值1
    
    for (let i = 1; i < m; i++) {
        for (let j = 1; j < n; j++) {
            dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
        }
    }
    return dp[m - 1][n - 1];
};

时间复杂度和空间复杂度均为 O(mn)。

64. 最小路径和:边界条件

题目64. 最小路径和

和前面的62. 不同路径思路相同。区别在于对边界的赋值,例如 dp[0][i] 等于从grid[0][0]grid[0][i] 的所有数字相加之和。

const minPathSum = function(grid) {
    let row = grid.length, col = grid[0].length;
    const dp = new Array(row).fill(0).map(() => new Array(col).fill(0)); // 初始赋值为 0

    dp[0][0] = grid[0][0]; // 初始化
    for (let i = 1; i < row; i++) { // 第0列赋值
        dp[i][0] = dp[i - 1][0] + grid[i][0];
    }
    for (let i = 1; i < col; i++) { // 第0行赋值
        dp[0][i] = dp[0][i - 1] + grid[0][i];
    }

    for (let i = 1; i < row; i++) {
        for (let j = 1; j < col; j++) {
            dp[i][j] = Math.min(dp[i - 1][j] + grid[i][j], dp[i][j - 1] + grid[i][j]);
        }
    }

    return dp[row - 1][col - 1];
};

时间复杂度和空间复杂度都是 O(mn)。 空间复杂度还可以再优化,例如每次只存储上一行的dp值,优化到O(n)

198. 打家劫舍:状态转移方程

题目198. 打家劫舍

需要对 dp数组 的前两个成员赋值,类似斐波那契数列。区别在于 dp[i] 的最大值不一定是以 nums[i]结尾的,可能是以 nums[i - 1]结尾的。

我们计算 dp[1] = max(grid[0], grid[1])时,假设最后取值为 dp[1] = grid[1],再计算 dp[2] = max(dp[0] + nums[2], dp[1])也不会出现同时取nums[1]和nums[2]的情况

状态转移方程:dp[i] = Math.max(dp[i - 2] + nums[i], dp[i - 1]);

const rob = function(nums) {
    let n = nums.length;

    const dp = new Array(n); // dp[i] 表示以 nums[i]结尾 或 nums[i - 1]结尾的最高金额
    dp[0] = nums[0];
    dp[1] = Math.max(nums[0], nums[1]); // 注意!!!
    for (let i = 2; i < n; i++) {
        dp[i] = Math.max(dp[i - 2] + nums[i], dp[i - 1]); // 房间不相邻
    } 
    return dp[n - 1];
};

时间复杂度:O(n)。

「空间复杂度的优化」: 可以用 「状态压缩」 ,用两个变量prev、curr保存最大值去代替整个dp数组。

122. 买卖股票的最佳时机II:贪心

题目 122. 买卖股票的最佳时机II

策略是所有上涨的交易日都买卖(赚到所有利润),所有下降交易日都不买卖(永不亏钱)。 贪心算法题解

只看相邻两天的价格! 如果后一天比前一天价格高,那么就买入!

const maxProfit = function(prices) {
  let profit = 0;
  
  for (let i = 1; i < prices.length; i++) {
    let diff = prices[i] - prices[i - 1];
    if (diff > 0) {
      profit += diff;
    }
  } 
  
  return profit;
}

总结

  1. 对于斐波那契这一类的问题,一般会要求空间复杂度 O(1),因此推荐用 两个变量状态压缩代替dp数组
  • 斐波那契数列
  • 青蛙跳台阶
  • 爬楼梯
  1. 斐波那契变形 + 边界条件
  • 不同路径:第 0 行、第 0 列均赋值为 1
  • 最小路径和:第 0 行、第 0 列赋值路径和
  1. 二维网格(m+1)* (n+1)
  • 最长重复子数组(连续):斜线长度
  • 最长公共子序列(可不连续):数值扩张
  1. 其他 - easy
  • 打家劫舍:dp[i] = Math.max(dp[i - 2] + nums[i], dp[i - 1]
  • 买卖股票:不用设置 dp数组,只需维护两个变量即可。
  1. 其他 - 多刷几遍
  • 最大子数组和:以...结尾,返回的是[...dp]

  • 最长递增子序列:以...结尾,返回的是Math.max(...dp)。dp初始化为1状态转移方程dp[i] = Math.max(dp[j] + 1, dp[i])

  • 零钱兑换:Infinity + amount + 1 + dp[i] = Math.min(dp[i - coin] + 1, dp[i]) + return 判断

补充: 如果对于打家劫舍的题目,要求给出 具体路径,可以参考求解具体路径