这是我参与11月更文挑战的第7天,活动详情查看:2021最后一次更文挑战
基础动态规划算是基础算法里面比较难的一块,动态规划不像贪心二分这样,只要判断能使用对应的算法,一般套用模板就能解决,动态规划没有一个统一的模板可以用.
不过动态规划有一个底层的规律,就是定义状态,以及状态的递推公式(状态转移方程),通过状态的一步步转移来求出最后的解.
只是因为不同题之间的状态千变万化,所以导致即使知道一道题是使用动态规划来解,也未必就能解出来.
如何确定状态
解动态规划的题,如果能找到正确定义状态,则相当于已经解出一大半了,状态转移方程一般在找到状态时,也基本上都明确了.那这个状态是怎么找的呢?
主要可以通过两种方式:
- 数学归纳法: 通过小规模数据的例子,进行推演,找到其中的规律,总结出状态.
- 记忆化搜索: 先使用递归解法(朴素,回溯,分治),一般这样的时间复杂度都会比较高,其中可能存在很多重复的计算,通过将这些重复的计算缓存起来提高效率,而这个缓存大多数时候其值就是我们要找的状态.
数学归纳法
第一种方式需要一些经验,或者相关的数学知识,能从题目的信息以及示例中找到规律推导出状态.
比如 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] 时的递归树
可以看到其中有很多重复访问的结点,这个解法的时间复杂度 ,是指数级的,我们可以通过添加缓存来优化这个解法,将时间复杂度优化到多项式级别:
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)
}
优化后的状态树,少掉很多重复的访问,时间复杂度是
这里的缓存就可以直接用来当作 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]
}
当然,以上只是用来举例说明如何从朴素解一步步优化到动态规划.
本题的更优解
本题的数据量为 ,这就必须要以 的时间复杂度完成才行,可以继续优化得到更快的解法.
并不是所有题目都能继续优化,有些题目只要能做到 即可.至于怎么判断的话,可以根据题意去推导.如果题目有给出数据范围的话,可以用比较取巧的方式,根据测试数据的量来判断题目要求的算法复杂度.
通过分析组合得分的公式,来进一步优化,根据题意可以做以下推导
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
}