# 【题库】动态规划DP刷题

# 爬楼梯(每次只能走一阶或两阶)

f(n) = f(n - 1) + f(n - 2)
f(1) = 1
f(2) = 2

实现也非常简单:

const fn = n => {
  if (n <= 0) return 0
  if (n === 1) return 1
  if (n === 2) return 2
  return fn(n - 1) + fn(n - 2)
}

# 最小编辑距离

最小编辑距离是指一个单词通过新增、替换、删除三种操作方法,变为另一个单词的最小操作数。使用DP动态规划解决,算法如下:

最小编辑距离

代码实现如下:

const dp = (a, b) => {
  // 1. 初始化二维矩阵
  let lenA = a.length;
  let lenB = b.length;
  let d = [];
  d[0] = [];

  // 2. 二维矩阵填值

  // 填充第一行
  for (let j = 0; j <= lenB; j++) {
    d[0].push(j);
  }

  // 填充第一列
  for (let i = 0; i <= lenA; i++) {
    if (d[i]) {
      d[i][0] = i;
    } else {
      d[i] = [];
      d[i][0] = i;
    }
  }

  // 3. DP求解矩阵每个位置的值
  for (let i = 1; i <= lenA; i++) {
    for (let j = 1; j <= lenB; j++) {
      if (a[i - 1] === b[j - 1]) {
        d[i][j] = d[i - 1][j - 1];
      } else {
        let m1 = d[i - 1][j] + 1;
        let m2 = d[i][j - 1] + 1;
        let m3 = d[i - 1][j - 1] + 1;
        d[i][j] = Math.min(m1, m2, m3);
      }
    }
  }

  return d[lenA][lenB];
}

关于这道题目详细的讨论在这里,可自行阅读:最小编辑距离问题:递归与动态规划 (opens new window)

# 最长回文子串

回文天然具有「状态转移」性质:一个长度严格大于 2 的回文去掉头尾字符以后,剩下的部分依然是回文。反之,如果一个字符串头尾两个字符都不相等,那么这个字符串一定不是回文。

定义状态

dp[i][j] 表示子串 s[i..j] 是否为回文子串,这里子串 s[i..j] 定义为左闭右闭区间,即可以取到 s[i] 和 s[j]。

定义状态转移方程

dp[i][j] = (s[i] == s[j]) and dp[i + 1][j - 1]

代码实现:

const longestPalindrome = s => {
  let len = s.length;
  if (len < 2) {
    return s;
  }

  let maxLen = 1;
  let begin = 0;

  // dp[i][j] 表示 s[i, j] 是否是回文串
  let dp = new Array(len).fill([new Array(len).fill(false)])
  const charArray = s.split('')

  for (let i = 0; i < len; i++) {
    dp[i][i] = true;
  }
  for (let j = 1; j < len; j++) {
    for (let i = 0; i < j; i++) {
      if (charArray[i] != charArray[j]) {
        dp[i][j] = false;
      } else {
        if (j - i < 3) {
          dp[i][j] = true;
        } else {
          dp[i][j] = dp[i + 1][j - 1];
        }
      }

      // 只要 dp[i][j] == true 成立,就表示子串 s[i..j] 是回文,此时记录回文长度和起始位置
      if (dp[i][j] && j - i + 1 > maxLen) {
        maxLen = j - i + 1;
        begin = i;
      }
    }
  }
  return s.substring(begin, begin + maxLen);
}

# 二维矩阵计算走法种类

问题1:有一个每排m、每列n个元素的二维矩阵,请问从左上角走到右下角总共有多少种不同的路线?

/**
 * 
 * @param {*} m 一排有多少个
 * @param {*} n 一列有多少个
 * @returns 在有从左上角走到右下角的路径总和
 */
const transStepNoBlock = (m, n) => {
  // 方法1:排列组合
  // 总共要走 m+n-2 步
  // 其中有 m-1 步向右,n-1步向左
  // 只需奥在 m+n-2 中选择 m-1 步即可

  // 方法2:动态规划
  // 状态定义:DP[i,j] 表示从左上角走到第i行第j列的全部路线总数
  // 初始状态:DP[0,0]=1
  // 状态转移方程:DP[i,j]=
  //    1. DP[i,j]=DP[i-1,j]+DP[i,j-1]    if i>0,j>0  非首行首列元素,需要累加从左侧元素和从上侧元素到达的路径总和
  //    2. DP[i,j]=DP[i-1,j]   if j=0   首列非首个元素,直接取从上侧元素达到的总数即可
  //    3. DP[i,j]=DP[i,j-1]    if i=0   首行非首个元素,直接取从左侧元素达到的总数即可

  // 填充完二维数组,取 DP[m-1,n-1] 即可



  // 边界判断
  if (m < 1 || n < 1) {
    return 0
  }
  // 初始化dp矩阵
  const dp = new Array(n).fill([]).map(i => new Array(m).fill(0))

  // 填充左上角的一个值
  dp[0][0] = 1

  // 填充第一行
  for (let i = 1; i < m; i++) {
    dp[0][i] = dp[0][i - 1]
  }
  // 填充第一列
  for (let j = 1; j < n; j++) {
    dp[j][0] = dp[j - 1][0]
  }
  // 填充非首行首列
  for (let i = 1; i < n; i++) {
    for (let j = 1; j < m; j++) {
      dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
    }
  }
  // 返回结果
  return dp[n - 1][m - 1]
}

问题2:有一个每排m、每列n个元素的二维矩阵,其中存在障碍物,数值为1代表畅通,数值为0代表障碍,请问从左上角走到右下角总共有多少种不同的路线?

/**
 * 
 * @param {*} matrix 二维数组,0表示阻碍,1表示畅通能走
 */
const transStepWithBlock = (matrix) => {
  // 方法:动态规划
  // 状态定义:DP[i,j] 表示从左上角走到第i行第j列的全部路线总数
  // 初始状态:DP[0,0]=1
  // 状态转移方程:DP[i,j]=
  //    1. DP[i,j]=DP[i-1,j]+DP[i,j-1]    if i>0,j>0 && DP[i-1,j]=1 && DP[i,j-1]=1  非首行首列元素,需要累加从左侧元素和从上侧元素到达的路径总和
  //       DP[i,j]=DP[i-1,j]              if i>0,j>0 && DP[i-1,j]=1 && DP[i,j-1]=0  非首行首列元素,需要累加从左侧元素和从上侧元素到达的路径总和
  //       DP[i,j]=DP[i,j-1]              if i>0,j>0 && DP[i-1,j]=0 && DP[i,j-1]=1  非首行首列元素,需要累加从左侧元素和从上侧元素到达的路径总和
  //    2. DP[i,j]=DP[i-1,j]   if j=0 && DP[i-1,j]=1  首列非首个元素,直接取从上侧元素达到的总数即可
  //       DP[i,j]=0           if j=0 && DP[i-1,j]=0  首列非首个元素,直接取从上侧元素达到的总数即可
  //    3. DP[i,j]=DP[i,j-1]    if i=0 && DP[i,j-1]=1  首行非首个元素,直接取从左侧元素达到的总数即可
  //       DP[i,j]=0            if i=0 && DP[i,j-1]=0  首行非首个元素,直接取从左侧元素达到的总数即可

  // 填充完二维数组,取 DP[m-1,n-1] 即可



  const m = matrix[0].length // 列数
  const n = matrix.length // 行数
  // 边界判断
  if (m < 1 || n < 1) {
    return 0
  }
  // 初始化dp矩阵
  const dp = new Array(n).fill([]).map(i => new Array(m).fill(0))
  // 填充左上角的一个值
  dp[0][0] = 1
  // 填充第一行
  for (let i = 1; i < m; i++) {
    dp[0][i] = matrix[0][i - 1] === 1 ? dp[0][i - 1] : 0
  }
  // 填充第一列
  for (let j = 1; j < n; j++) {
    dp[j][0] = matrix[j - 1][0] === 1 ? dp[j - 1][0] : 0
  }
  // 填充非首行首列
  for (let i = 1; i < n; i++) {
    for (let j = 1; j < m; j++) {
      const top = matrix[i][j - 1]
      const left = matrix[i - 1][j]
      if (top === 1) {
        if (left === 1) { // 上、左都通
          dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
        } else { // 上通、左不通
          dp[i][j] = dp[i][j - 1]
        }
      } else {
        if (left === 1) { // 上不通,左通
          dp[i][j] = dp[i - 1][j]
        } else { // 上、左都不通
          dp[i][j] = 0
        }
      }

    }
  }
  // 返回结果
  return dp[n - 1][m - 1]
}

# 三角形最小路径之和(m 层)

状态定义:DP[i][j]——从底部走到[i][j]的所有路径最小和

状态转移方程:

dp[i][j] = min(dp[i + 1][j], dp[i + 1][j + 1]) + triangle[i][j]

定义初始值:

DP[m-1][j]=T[m-1][j]

// 返回dp[0][0]即可

function minPath(triangle) {
  if (triangle.length === 0) {
    return;
  }
  let dp = [];
  for (let i = 0; i < triangle.length; i++) {
    dp[i] = [];
    for (let j = 0; j < triangle[i].length; j++) {
      dp[i].push(0);
    }
  }
  // 初始化
  for (let i = 0; i < dp.length; i++) {
    dp[dp.length - 1][i] = triangle[dp.length - 1][i];
  }
  // 递归
  for (let i = dp.length - 2; i >= 0; i--) {
    for (let j = 0; j < dp[i].length; j++) {
      dp[i][j] = Math.min(dp[i + 1][j], dp[i + 1][j + 1]) + triangle[i][j];
    }
  }
  return dp[0][0];
}

# 乘积最大子序列 (2 3 -2 4)

这里的状态是二维的,不只是一维,需要同时存 max 和 min,因为元素可能为负数

形式1:采用二维数组

DP[i, j] = [max, min]
if (a[i] > 0) DP[i, 0] = DP[i - 1, 0] * a[i]
if (a[i] < 0) DP[i, 0] = DP[i - 1, 1] * a[i]

if (a[i] > 0) DP[i, 1] = DP[i - 1, 1] * a[i]
if (a[i] < 0) DP[i, 1] = DP[i - 1, 0] * a[i]

​结果: 遍历 DP[i, 0],找出最大值

形式2:采用两个变量max、min存储

每有一个新的数字加入,最大值为当前最大值 x 新数,或当前最小值(负数) x 新数(负数),否则为当前值

function maxProduct(nums) {
  if (typeof nums[0] === 'undefined' || nums.length === 0) {
      return 0;
  };
  let max = nums[0],
      min = nums[0],
      result = nums[0];
  for (let i = 1; i < nums.length; i++) {
      let temp = max;
      // 最大值的几种情况
      // 1. 当前最大值max x 新数num[i]  if num[i]>0
      // 2. 当前最小值min x 新数num[i]  if num[i]<0
      // 3. 当前值 num[i]
      max = Math.max(Math.max(max * nums[i], min * nums[i]), nums[i]);
      // 最小值的几种情况
      // 1. 当前最大值max x 新数num[i]  if num[i]<0
      // 2. 当前最小值min x 新数num[i]  if num[i]>0
      // 3. 当前值 num[i]
      min = Math.min(Math.min(temp * nums[i], min * nums[i]), nums[i]);
      if(max > result) {
          result = max;
      };
  };
  return result;
};

# 最长上升子序列(位置不变)

状态定义: DP[i] 表示到第 i 个数(选上 i 元素)的最优子结构 结果: DP[0]到 DP[n-1]的最大值 状态转移方程: DP[i]=max(DP[j])+1 (j 从 0 到 i-1,且 a[j]<a[i])

// dp[i] 表示以第i个元素结尾的最长上升子序列的长度
// 如果dp[i]<dp[j]+1,则dp[i]=dp[j]+1
// 初始化dp[0]=1
// 返回max(dp[0],dp[1],……,dp[n-1])的最大值
function lengthOfLIS(nums) {
  if (nums.length === 0) return 0;
  let dp = [1];
  for (let i = 1; i < nums.length; i++) {
    dp.push(0);
  }
  let LIS = 1;
  for (let i = 1; i < dp.length; i++) {
    dp[i] = 1;
    for (let j = 0; j < i; j++) {
      if (nums[i] > nums[j] && dp[i] < dp[j] + 1) {
        dp[i] = dp[j] + 1;
      }
    }
    if (LIS < dp[i]) {
      LIS = dp[i];
    }
  }
  return LIS;
}

# 零钱兑换问题

状态定义:DP[i] 表示凑成 i 的最小零钱总数 状态转移方程:DP[i] = min(DP[i - coin[j]]) + 1

// 给定4种面额的硬币1分,2分,5分,6分,如果要找11分的零钱,怎么做才能使得找的硬币数量总和最少。
// 动态方程: dp[i] = max(dp[i - 1], dp[i - 2], dp[i - 5], dp[i - 6]) + 1
function coinChange(coins, amount) {
  let dp = [];
  for (let i = 0; i <= amount; i++) {
    dp.push(-1);
  }
  dp[0] = 0;
  for (let i = 1; i <= amount; i++) {
    for (let j = 0; j < coins.length; j++) {
      if (i - coins[j] >= 0 && dp[i - coins[j]] !== -1) {
        if (dp[i] === -1 || dp[i] > dp[i - coins[j]] + 1) {
          dp[i] = dp[i - coins[j]] + 1;
        }
      }
    }
  }
  return dp[amount];
}

# 背包问题

# 01背包

有n件物品和1个容量为W的背包。每种物品均只有一件,第i件物品的重量为weights[i],价值为values[i],求解将哪些物品装入背包可使价值总和最大。

dp( i,j ) = Max( dp( i-1, j ), dp( i-1, j-w[i] ) + v[i] ) // DP[k,w]表示当背包容量还剩 w 时,在前 k 个物品中选择的最大价值
DP[k, w] = DP[k - 1, w] //(当第 k 件过重, 超出总容量)
DP[k, w] = max(DP[k - 1, w], DP[k - 1][w() - w(k)] + v[k]) //前者表示放入第 k 个物品, 后者表示不放入第 k 个物品
/**
 * 
 * @param {*} n 总共有n件物品
 * @param {*} W 背包能承受的最大重量为W
 * @param {*} weights 重量数组,n个元素
 * @param {*} values 价值数组,n个元素
 * @param {*} selected 是否选择数组,n个元素,0代表未被选中,1代表被选中
 * @returns 背包能装物品的最大总价值
 */
function knapsack(n, W, weights, values, selected) {
  //当物品数量为0,或者背包容量为0时,最优解为0
  if (n == 0 || W == 0) {
    return 0;
  }
  //从当前所剩物品的最后一个物品开始向前,逐个判断是否要添加到背包中
  for (let i = n - 1; i >= 0; i--) {
    //如果当前要判断的物品重量大于背包当前所剩的容量,那么就不选择这个物品
    if (weights[i] > W) {
      return knapsack(n - 1, W, weights, values, selected);
    } else {
      //不选择物品i的情况下的最优解
      let a = knapsack(n - 1, W, weights, values, selected);

      //选择物品i的情况下的最优解
      let b = values[i] + knapsack(n - 1, W - weights[i], weights, values, selected);

      //返回选择物品i和不选择物品i中最优解大的一个
      if (a > b) {
        selected[i] = 0; //物品i未被选取
        return a; // 最大总价值
      } else {
        selected[i] = 1; //物品i被选取
        return b; // 最大总价值
      }
    }
  }
}

const selected = []
const ws = [2, 2, 6, 5, 4]
const vs = [6, 3, 5, 4, 6]
let maxValue = knapsack(5, 10, ws, vs, selected)
console.log(maxValue) //15
selected.forEach((el, i) => {
  if (el) {
    console.log("选择了物品" + i + ",其重量为" + ws[i] + ",其价值为" + vs[i])
  }
})

# 完全背包

有n件物品和1个容量为W的背包。每种物品没有上限,第i件物品的重量为weights[i],价值为values[i],求解将哪些物品装入背包可使价值总和最大。

与01背包的区别是,这里每一种物品的个数都没有上限,01背包却规定了每一种物品只有一件。

function unboundedKnapsack(weights, values, W) {
  // 总共有n类物品
  let n = weights.length,

    // f[i]代表前i类物品中能放入背包的最大价值总和
    f = new Array(W + 1).fill(0);

  for (let i = 0; i < n; i++) {
    for (j = weights[i]; j <= W; j++) {
      let tmp = f[j - weights[i]] + values[i]; // 将第i件物品也放入背包的最大价值总和
      f[j] = (f[j] > tmp) ? f[j] : tmp;
    }
  }
  return f[W];
}
let a = unboundedKnapsack([3, 2, 2], [5, 10, 20], 5); //输出40
console.log(a);
let b = unboundedKnapsack([2, 3, 4, 7], [1, 3, 5, 9], 10); //输出12
console.log(b);

# 多重背包

有n件物品和1个容量为W的背包。每种物品最多有numbers[i]件可用,第i件物品的重量为weights[i],价值为values[i],求解将哪些物品装入背包可使价值总和最大。

与完全背包的区别是,这里每一种物品都规定了使用上限,而不是无限使用。

function knapsack(weights, values, numbers, W) {
  let n = weights.length;
  let f = new Array(W + 1).fill(0)
  for (let i = 0; i < n; i++) {
    for (let k = 0; k < numbers[i]; k++) //其实就是把这类物品展开,调用numbers[i]次01背包代码  
      for (let j = W; j >= weights[i]; j--)//正常的01背包代码  
        f[j] = Math.max(f[j], f[j - weights[i]] + values[i]);
  }
  return f[W];
}
let maxValue = knapsack([2, 3, 1], [2, 3, 4], [1, 4, 1], 6)
console.log(maxValue) // 9

以上都是一些很经典的动态规划题,练习了这么多,相信你已经理解了动态规划解题的精髓了吧?总结一下,动态规划的题目一般的解题步骤是:(1)定义状态,即DP[i]代表什么含义;(2)定义状态转移方程,即DP数组的各值是如何求得的;(3)定义初始化状态,即DP数组有哪些初始值;(4)写代码~ 其实在我们解题的过程中,如果发现求解需要不断依赖之前计算的结果,这时可以考虑DP算法,能大大提高求解效率。