动态规划基础

146 阅读6分钟

这是我参与11月更文挑战的第7天,活动详情查看:2021最后一次更文挑战

基础动态规划算是基础算法里面比较难的一块,动态规划不像贪心二分这样,只要判断能使用对应的算法,一般套用模板就能解决,动态规划没有一个统一的模板可以用.

不过动态规划有一个底层的规律,就是定义状态,以及状态的递推公式(状态转移方程),通过状态的一步步转移来求出最后的解.

只是因为不同题之间的状态千变万化,所以导致即使知道一道题是使用动态规划来解,也未必就能解出来.

如何确定状态

解动态规划的题,如果能找到正确定义状态,则相当于已经解出一大半了,状态转移方程一般在找到状态时,也基本上都明确了.那这个状态是怎么找的呢?

主要可以通过两种方式:

  1. 数学归纳法: 通过小规模数据的例子,进行推演,找到其中的规律,总结出状态.
  2. 记忆化搜索: 先使用递归解法(朴素,回溯,分治),一般这样的时间复杂度都会比较高,其中可能存在很多重复的计算,通过将这些重复的计算缓存起来提高效率,而这个缓存大多数时候其值就是我们要找的状态.

数学归纳法

第一种方式需要一些经验,或者相关的数学知识,能从题目的信息以及示例中找到规律推导出状态.

比如 509. 斐波那契数 这题,我们可以直接从题目中给出的信息直接确定 dp[i] 是用第 i 个数裴波那契数,而递推公式则是 dp[i]=dp[i-1]+dp[i-2];像 70. 爬楼梯 这题,我们可以通过列出前面几个台阶的解,猜测是一个裴波那契数列,可以直接套用裴波那契数的状态和递推公式去尝试解,最终证明确实是一个裴波那契数列.

0     1
1     1
2     2 = 1+1
3     3 = 1+2
4     5 = 2+3

或者也可以根据信息去推导,题目中给出的信息是可以爬 1 或 2 个台阶,那能到达第 n 个台阶的方法数则是到第 n-1 个台阶的方法加上到第 n-2 个台阶的方法,可以得出跟裴波那契数列相同的公式.

记忆化搜索

而有些题可能没法比较容易的总结出状态,则可以通过第二种方式去解.不过这种方式也比较依赖于如何去拆分子问题.

例子讲解

1014. 最佳观光组合 这题为例.我们可以根据题意,先写出分治解法.根据题意,每个位置进行两两配对,求出观光景点的得分:

function maxScoreSightseeingPair(values: number[]): number {
  const n = values.length
  const helper = (i: number, j: number) => {
    if (i >= n || j >= n || i >= j) return 0
    let res = values[i] + values[j] + i - j
    res = Math.max(res, helper(i + 1, j), helper(i, j + 1))
    return res
  }
  return helper(0, 1)
}

values = [8, 1, 5, 2, 6] 时的递归树

maxScoreSightseeingPair1.png

可以看到其中有很多重复访问的结点,这个解法的时间复杂度 O(3n)O(3^n),是指数级的,我们可以通过添加缓存来优化这个解法,将时间复杂度优化到多项式级别:

function maxScoreSightseeingPair(values: number[]): number {
  const n = values.length
  const cache = new Array(n).fill(0).map(() => new Array(n).fill(0))
  const helper = (i: number, j: number) => {
    if (i >= n || j >= n || i >= j) return 0
    if (cache[i][j]) return cache[i][j]

    let res = values[i] + values[j] + i - j
    res = Math.max(res, helper(i + 1, j), helper(i, j + 1))
    cache[i][j] = res
    return res
  }
  return helper(0, 1)
}

优化后的状态树,少掉很多重复的访问,时间复杂度是 O(n2)O(n^2)

maxScoreSightseeingPair2.png

这里的缓存就可以直接用来当作 DP 数组使用,其中的值既为状态,定义为景点 i 到景点 j 之间观光组合的最高得分.我们使用动态规划写出来:

function maxScoreSightseeingPair(values: number[]): number {
  const n = values.length
  const dp: number[][] = new Array(n).fill(0).map(() => new Array(n).fill(0))

  for (let i = n - 2; i >= 0; i--) {
    for (let j = n - 1; j > i; j--) {
      dp[i][j] = Math.max(values[i] + values[j] + i - j, dp[i + 1][j] ?? 0, dp[i][j + 1] ?? 0)
    }
  }

  return dp[0][1]
}

当然,以上只是用来举例说明如何从朴素解一步步优化到动态规划.

本题的更优解

本题的数据量为 51045*10^4,这就必须要以 O(n)O(n) 的时间复杂度完成才行,可以继续优化得到更快的解法.

并不是所有题目都能继续优化,有些题目只要能做到 O(n2)O(n^2) 即可.至于怎么判断的话,可以根据题意去推导.如果题目有给出数据范围的话,可以用比较取巧的方式,根据测试数据的量来判断题目要求的算法复杂度.

通过分析组合得分的公式,来进一步优化,根据题意可以做以下推导

f(0,i)=values[0] + 0 + values[i] - i
...
f(k,i)=values[k] + k + values[i] - i
...
f(i-1,i)=values[i-1] + i-1 + values[i] - i

可以分析出 f(0,i)f(i-1,i) 的区别只有前面一部分,既 values[k] + k 是不同的

定义状态 dp[i] 为以 i 为结尾的组合中,能获得的最高分.另外用一个变量 max 去保存从 0 到 i-1 的 values[k] + k 的最大值,则 dp[i]=max+values[i] - i

function maxScoreSightseeingPair(values: number[]): number {
  const n = values.length
  const dp: number[] = new Array(n).fill(0)
  let max = 0,
    res = 0

  for (let i = 0; i < n; i++) {
    dp[i] = max + values[i] - i
    res = Math.max(res, dp[i])
    max = Math.max(max, values[i] + i)
  }

  return res
}

其实到这里已经不需要这个 dp 数组了(实际上这里并没有用到前面的状态,相比起来 max 变量更适合定义为 dp,不过 max 每次直接取最优,使用贪心即可.).

很多时候,当状态转移方程只跟前一两项相关时,就可以进行空间优化,如果是二维数组则可以降为一维数组,如果一维数组,则可以直接使用几个变量来保存状态.另外一个技巧是使用状态压缩,当每一组状态不大于 32,并且每个位置只需要存两种状态的信息时,则可以考虑使用二进制来保存信息(比如 N 皇后中的棋子,每行不超过 9,并且只需要保存棋子是否能放置的状态).

最终优化版本:

function maxScoreSightseeingPair(values: number[]): number {
  let [max, res] = [0, 0]

  for (let i = 0; i < alues.length; i++) {
    res = Math.max(res, max + values[i] - i)
    max = Math.max(max, values[i] + i)
  }

  return res
}