动态规划
动态规划的一般形式是求最值,核心问题就是穷举。将所有可行的子问题答案穷举出来(二叉树的分解问题形式),然后在其中寻找最值。
动态规划需要判断问题是否具备最优子问题,还需要写出正确的状态转移方程,另外,由于动态规划存在重叠子问题,需要优化穷举过程(备忘录或 dp 数组),否则会超时。
基础
动态规划问题其实都可以使用暴力递归求解,如何消除重叠子问题就可以分成备忘录或 dp 数组优化。
斐波那契数
暴力递归。
function fib(n: number): number {
  if (n == 0 || n == 1) return n
  return fib(n - 1) + fib(n - 2)
};
使用备忘录,实际上是一个自顶向下的过程。
function fib(n: number): number {
  const memo = new Array(n + 1).fill(0)
  const helper = function(n: number): number {
    if (n == 0 || n == 1) return n
    if (memo[n] != 0) return memo[n]
    memo[n] = helper(n - 1) + helper(n - 2)
    return memo[n]
  }
  return helper(n)
};
使用 dp 数组,实际上是一个自底向上的过程。
function fib(n: number): number {
  if (n == 0) return 0
  const dp: number[] = new Array(n + 1).fill(0)
  dp[0] = 0, dp[1] = 1 
  
  for (let i = 2; i < n + 1; i++) {
    dp[i] = dp[i - 1] + dp[i - 2]
  }
  return dp[n]
};
零钱兑换
暴力递归。
function coinChange(coins: number[], amount: number): number {
  const dp = function(n: number): number {
    if (n == 0) return 0
    if (n < 0) return -1;
    let res = Infinity
    for (const coin of coins) {
      const sub = dp(n - coin)
      if (sub == -1) continue
      res = Math.min(res, 1 + sub)
    }
    return res == Infinity ? -1 : res
  }
  return dp(amount)
};
备忘录。
function coinChange(coins: number[], amount: number): number {
  const memo: number[] = new Array(amount + 1).fill(-100)
  const dp = function(n: number): number {
    if (n == 0) return 0
    if (n < 0) return -1;
    if (memo[n] != -100) return memo[n]
    let res = Infinity
    for (const coin of coins) {
      const sub = dp(n - coin)
      if (sub == -1) continue
      res = Math.min(res, 1 + sub)
    }
    memo[n] = (res == Infinity) ? -1 : res
    return memo[n]
  }
  return dp(amount)
};
dp 数组:当目标金额为 i 时,至少需要 dp[i] 枚硬币凑出。
function coinChange(coins: number[], amount: number): number {
  const dp: number[] = new Array(amount + 1).fill(amount + 1)
  dp[0] = 0
  for (let i = 0; i < dp.length; i++) {
    for (const coin of coins) {
      if (i - coin < 0) continue
      dp[i] = Math.min(dp[i], 1 + dp[i - coin])
    }
  }
  return dp[amount] == amount + 1 ? -1 : dp[amount]
};
爬楼梯
3 阶楼梯可以由 1 阶和 2 阶楼梯的方法数推出来,因此是一个典型的动态规划问题。
function climbStairs(n: number): number {
  // 定义:dp[i] 为爬上 i 阶楼梯方法数
  const dp: number[] = new Array(n + 1).fill(0)
  dp[0] = 0, dp[1] = 1, dp[2] = 2
  for (let i = 3; i <= n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2] 
  }
  return dp[n]
};
使用最小花费爬楼梯
可以选择从 0 或 1 的台阶开始爬,因此到达 0 阶和 1 阶楼梯的最低花费为 0。
function minCostClimbingStairs(cost: number[]): number {
  const n = cost.length
  // 定义:dp[i] 为到达 i 阶楼梯的最低花费,dp[n] 即为所求
  const dp: number[] = new Array(n + 1)
  dp[0] = 0, dp[1] = 0
  for (let i = 2; i <= n; i++) {
    // 到达 i-1 或 i-2 楼梯时,还需花费 cost[i-1] 和 cost[i-2] 才能到 i 阶楼梯
    dp[i] = Math.min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2])
  }
  return dp[n]
};
不同路径
dp 数组。
function uniquePaths(m: number, n: number): number {
  // 定义:dp[i][j] 为 到 i 和 j 的不同路径数
  const dp: number[][] = new Array(m).fill(0).map(() => new Array(n).fill(0))
  for (let i = 0; i < m; i++) {
    dp[i][0] = 1
  }
  for (let j = 0; j < n; j++) {
    dp[0][j] = 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]
};
不同路径Ⅱ
初始化状态时当遇到障碍,则后面的都不可能到达,应该赋值为 0。
function uniquePathsWithObstacles(obstacleGrid: number[][]): number {
  const m = obstacleGrid.length, n = obstacleGrid[0].length
  // dp[i][j] 为 到达 i-1, j-1 的不同路径
  const dp: number[][] = new Array(m).fill(0).map(() => new Array(n).fill(0))
  for (let i = 0; i < m && obstacleGrid[i][0] == 0; i++) {
    dp[i][0] = 1
  }
  for (let j = 0; j < n && obstacleGrid[0][j] == 0; j++) {
    dp[0][j] = 1
  }
  for (let i = 1; i < m; i++) {
    for (let j = 1; j < n; j++) {
      if (obstacleGrid[i][j] == 1) {
        dp[i][j] = 0
      } else {
        dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
      }
    }
  }
  return dp[m - 1][n - 1]
};
整数拆分
function integerBreak(n: number): number {
  const dp: number[] = new Array(n + 1).fill(0)
  dp[2] = 1
  for (let i = 3; i <= n; i++) {
    for (let j = 1; j < i; j++) {
      dp[i] = Math.max(dp[i], dp[i - j] * j, (i - j) * j)
    }
  }
  return dp[n]
};
子序列问题
子序列和最值考察的基本都是动态规划。
最长递增子序列
dp 数组定义为:以第 i 个数结尾的最长递增子序列长度为 dp[i]。
function lengthOfLIS(nums: number[]): number {
  const dp: number[] = new Array(nums.length).fill(1)
  for (let i = 0; i < nums.length; i++) {
    for (let j = 0; j < i; j++) {
      if (nums[j] < nums[i]) {
        dp[i] = Math.max(dp[i], dp[j] + 1)
      }
    }
  }
  let res = 0
  for (let i = 0; i < dp.length; i++) {
    res = Math.max(res, dp[i])
  }
  return res
};
最大子数组和
动态规划
定义好 dp 数组后,dp[i] 要么自成一个子数组,要么和 dp[i - 1] 形成一个子数组,判断二者最大值即为状态转移方程。
function maxSubArray(nums: number[]): number {
  // dp[i]: 第 i 个数结尾的最大连续子数组和
  const dp: number[] = new Array(nums.length)
  // base case
  dp[0] = nums[0]
  // 状态转移方程
  for (let i = 1; i < nums.length; i++) {
    dp[i] = Math.max(nums[i], nums[i] + dp[i - 1])
  }
  
  // 比较最大和
  let res = -Infinity
  for (let i = 0; i < dp.length; i++) {
    res = Math.max(res, dp[i])
  }
  return res
};
滑动窗口
滑动窗口就是专门处理子串/子数组问题的方法,关键找到窗口收缩的条件,本题收缩的条件就是和小于 0 时,因为这无论如何都满足不了最大和。
function maxSubArray(nums: number[]): number {
  let res = -Infinity, sum = 0
  let left = 0, right = 0
  while(right < nums.length) {
    // 移入
    const n1 = nums[right]
    // 增大窗口
    right++
    // 更新窗口
    sum += n1
    res = Math.max(res, sum)
    // 当和为负数时,肯定不可能满足条件,需要收缩窗口
    while(sum < 0) {
      // 移除
      const n2 = nums[left]
       // 缩小窗口
       left++
       // 更新窗口
       sum -= n2
    }
  }
  return res
};
最长公共子序列
算法题不能从整体看问题,应该考虑每个点需要做什么,再应用迭代或者递归解决问题。这个题应该看每个字符是什么情况。
暴力递归,会超时。
function longestCommonSubsequence(text1: string, text2: string): number {
  // 定义:计算 s1[i..] 和 s2[j..] 的最长公共子序列长度
  const dp = function(s1: string, i: number, s2: string, j: number): number {
    // base case
    if (i == s1.length || j == s2.length) return 0
    if (s1[i] == s2[j]) {
      // 相等则必然在 LCS 中
      return 1 + dp(s1, i + 1, s2, j + 1)
    } else {
      return Math.max(
        // 1. s1[i] 不在 LCS 中
        dp(s1, i + 1, s2, j),
        // 2. s2[j] 不在 LCS 中
        dp(s1, i, s2, j + 1),
        // 3. s1[i] 和 s2[j] 都不在 LCS 中
        dp(s1, i + 1, s2, j + 1)
      )
    }
  }
  return dp(text1, 0, text2, 0)
};
备忘录。
function longestCommonSubsequence(text1: string, text2: string): number {
  const m = text1.length, n = text2.length
  const memo: number[][] = new Array(m).fill(0).map(() => new Array(n).fill(-1))
  // 定义:计算 s1[i..] 和 s2[j..] 的最长公共子序列长度
  const dp = function(s1: string, i: number, s2: string, j: number): number {
    // base case
    if (i == s1.length || j == s2.length) return 0
    if (memo[i][j] != -1) return memo[i][j]
    if (s1[i] == s2[j]) {
      // 相等则必然在 LCS 中
      memo[i][j] = 1 + dp(s1, i + 1, s2, j + 1)
    } else {
      memo[i][j] = Math.max(
        // 1. s1[i] 不在 LCS 中
        dp(s1, i + 1, s2, j),
        // 2. s2[j] 不在 LCS 中
        dp(s1, i, s2, j + 1),
        // 3. s1[i] 和 s2[j] 都不在 LCS 中
        dp(s1, i + 1, s2, j + 1)
      )
    }
    return memo[i][j]
  }
  return dp(text1, 0, text2, 0)
};
dp 数组:注意索引位置。
function longestCommonSubsequence(text1: string, text2: string): number {
  const m = text1.length, n = text2.length
  // 定义:dp[i][j] 为 s1[0 .. i - 1] 和 s2[0 .. j - 1] 的最长公共子序列长度
  // 目标:dp[m][n]
  // base case: dp[i][0] = 0, dp[0][j] = 0
  const dp: number[][] = new Array(m + 1).fill(0).map(() => new Array(n + 1).fill(0))
  for (let i = 1; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
      if (text1[i - 1] == text2[j - 1]) {
        dp[i][j] = 1 + dp[i - 1][j - 1]
      } else {
        dp[i][j] = Math.max(dp[i][j - 1], dp[i - 1][j])
      }
    }
  }
  return dp[m][n]
};
两个字符串的删除操作
计算出最长公共子序列即可算出最小需要删除次数。
function minDistance(word1: string, word2: string): number {
  const m = word1.length, n = word2.length
  
  const LCS = function(s1: string, s2: string): number {
    const m = s1.length, n = s2.length
    const dp = new Array(m + 1).fill(0).map(() => new Array(n + 1).fill(0))
    for (let i = 1; i <= m; i++) {
      for (let j = 1; j <= n; j++) {
        if (s1[i - 1] == s2[j - 1]) {
          dp[i][j] = 1 + dp[i - 1][j - 1]
        } else {
          dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
        }
      }
    }
    
    return dp[m][n]
  }
  return m + n - 2 * LCS(word1, word2)
};
两个字符串的最小ASCII删除和
和上两题类似,但是需要改变 base case。
function minimumDeleteSum(s1: string, s2: string): number {
  const m = s1.length, n = s2.length;
  const dp: number[][] = new Array(m + 1).fill(0).map(() => new Array(n + 1).fill(0));
  
  // 当 s2 为空,那么 s1 需要全部删除
  for (let i = 1; i <= m; i++) {
    dp[i][0] = dp[i - 1][0] + s1.charCodeAt(i - 1);
  }
  // 当 s1 为空,那么 s2 需要全部删除
  for (let j = 1; j <= n; j++) {
    dp[0][j] = dp[0][j - 1] + s2.charCodeAt(j - 1);
  }
  for (let i = 1; i <= m; i++) {
    const code1 = s1.charCodeAt(i - 1);
    for (let j = 1; j <= n; j++) {
      const code2 = s2.charCodeAt(j - 1);
      if (code1 === code2) {
        // 两个字符串相等,不需要删除
        dp[i][j] = dp[i - 1][j - 1];
      } else {
        // 不等,取最小值
        dp[i][j] = Math.min(dp[i - 1][j] + code1, dp[i][j - 1] + code2);
      }
    }
  }
  
  return dp[m][n];
};
编辑距离
对 A 进行增,实际上相当于对 B 进行删除,那么对单词 A 的增、删、改三个操作:可以理解为:
- B 删除一个单词(增)
 - A 删除一个单词(删)
 - A 替换一个单词(改)
 
function minDistance(word1: string, word2: string): number {
  const m = word1.length, n = word2.length
  // 定义:dp[i][j] 表示 word1[0..i-1] 和 word2[0..j-1] 最小编辑距离
  const dp = new Array(m + 1).fill(0).map(() => new Array(n + 1).fill(0))
  for (let i = 1; i <= m; i++) {
    dp[i][0] = i
  }
  for (let j = 1; j <= n; j++) {
    dp[0][j] = j
  }
  for (let i = 1; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
      if (word1[i - 1] == word2[j - 1]) {
        dp[i][j] = dp[i - 1][j - 1]
      } else {
        dp[i][j] = Math.min(
          // 在 B 中删除
          dp[i][j - 1] + 1,
          // 在 A 中删除
          dp[i - 1][j] + 1,
          // 改
          dp[i - 1][j - 1] + 1
        )
      }
    }
  }
  return dp[m][n]
};
最长回文子序列
function longestPalindromeSubseq(s: string): number {
  const n = s.length
  // 定义:dp[i][j] 为 s[i..j]内的最长回文子序列长度
  const dp: number[][] =  new Array(n).fill(0).map(() => new Array(n).fill(0))
  for (let i = 0; i < n; i++) {
    dp[i][i] = 1
  }
  for (let i = n - 1; i >= 0; i--) {
    for (let j = i + 1; j < n; j++) {
      if (s[i] == s[j]) {
        dp[i][j] = dp[i + 1][j - 1] + 2
      } else {
        dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1])
      }
    }
  }
  return dp[0][n - 1]
};
让字符串成为回文串的最少插入次数
function minInsertions(s: string): number {
  const n = s.length
  // 定义:dp[i][j] 为 s[i-1..j-1] 成文回文串的最少插入次数
  const dp: number[][] = new Array(n).fill(0).map(() => new Array(n).fill(0))
  // base case
  for (let i = 0; i < n; i++) {
    dp[i][i] = 0
  }
  // 反着遍历保证正确的状态转移
  for (let i = n - 1; i >= 0; i--) {
    for (let j = i + 1; j < n; j++) {
      if (s[i] == s[j]) {
        // 相等,不需要插入
        dp[i][j] = dp[i + 1][j - 1]
      } else {
        // 不相等,比较 s[i+1..j] 和 s[i..j-1] 插入次数大小
        // 然后还需插入一个使 s[i..j] 为回文串
        dp[i][j] = Math.min(dp[i + 1][j], dp[i][j - 1]) + 1
      }
    }
  }
  return dp[0][n - 1]
};
还可以用字符串长度减最长回文子序列。
function minInsertions(s: string): number {
  return s.length - longestPalindromeSubseq(s)
};
01 背包
分割等和子集
可以讲问题转换为对于集合 nums,是否能满足 sum/2 的背包容量。
定义 dp[i][j]: 对于前 i 个数是否能装满背包容量 j。
function canPartition(nums: number[]): boolean {
  const n = nums.length
  const sum = nums.reduce((pre, cur) => pre + cur)
  // 和为奇数不可能分成两个相等的集合
  if (sum % 2 != 0) return false
  // dp[i][j]: 对于前 i 个数是否能装满背包容量 j
  const dp: boolean[][] = new Array(n + 1).fill(0).map(() => new Array(sum + 1).fill(false))
  
  // 对于容量为 0 的背包,已经装满了,即为 true
  for (let i = 0; i <= n; i++) {
    dp[i][0] = true
  }
  // 对于重量为 0 的物品,不可能装满,即为 false
  for (let j = 0; j <= sum; j++) {
    dp[0][j] = false
  }
  for (let i = 1; i <= n; i++) {
    for (let j = 1; j <= sum; j++) {
      if (j - nums[i - 1] < 0) {
        // 背包容量不足,不放入第 i 个物品
        dp[i][j] = dp[i - 1][j]
      } else {
        // 背包容量充足,选择放入或不放入背包
        dp[i][j] = dp[i - 1][j - nums[i - 1]] || dp[i - 1][j]
      }
    }
  }
  return dp[n][sum / 2]
};
还可以定义 dp[i][j]: 对于前 i 个数装进背包容量 j 的最大值。
function canPartition(nums: number[]): boolean {
  const n = nums.length
  const sum = nums.reduce((pre, cur) => pre + cur)
  // 和为奇数不可能分成两个相等的集合
  if (sum % 2 != 0) return false
  // dp[i][j]: 对于前 i 个数装进背包容量 j 的最大值
  const dp: number[][] = new Array(n + 1).fill(0).map(() => new Array(sum + 1).fill(0))
  
  // 对于容量为 0 的背包(没有空间),最大值为 0
  // dp[i][0] = 0
  // 对于前 0 个物品(没有物品),最大值当然也为 0
  // dp[0][j] = 0
  for (let i = 1; i <= n; i++) {
    for (let j = 1; j <= sum; j++) {
      if (j - nums[i - 1] < 0) {
        // 背包容量不足,不放入第 i 个物品
        dp[i][j] = dp[i - 1][j]
      } else {
        // 背包容量充足,选择放入或不放入背包(择优)
        dp[i][j] = Math.max(dp[i - 1][j - nums[i - 1]] + nums[i - 1], dp[i - 1][j])
      }
    }
  }
  // 最大值为 sum / 2,能凑成
  if (dp[n][sum / 2] == sum / 2) return true
  return false
};
最后一块石头的重量 II
转换为 01 背包。
function lastStoneWeightII(stones: number[]): number {
  const n = stones.length
  const sum = stones.reduce((pre, cur) => pre + cur)
  // dp[i][j] 为前 i 个石头中在背包容量为 j 的最大容量
  const target = Math.floor(sum / 2)
  const dp: number[][] = new Array(n + 1).fill(0).map(() => new Array(target + 1).fill(0))
  for (let i = 1; i <= n; i++) {
    for (let j = 1; j <= target; j++) {
      if (j - stones[i - 1] < 0) {
        dp[i][j] = dp[i - 1][j]
      } else {
        dp[i][j] = Math.max(dp[i - 1][j - stones[i - 1]] + stones[i - 1], dp[i - 1][j])
      }
    }
  }
  return sum - 2 * dp[n][target]
};