说实话,dp一学就会,一做就懵的一个很重要的原因就是所谓递推不总是一件合乎思维的事。爬楼梯的题我们想象说dp[i]是爬后i级楼梯的种数,这样递推公式,数据结构,划分方法纷纷水到渠成,那像这样的题呢?
https://leetcode.cn/problems/student-attendance-record-ii/description/
可以用字符串表示一个学生的出勤记录,其中的每个字符用来标记当天的出勤情况(缺勤、迟到、到场)。记录中只含下面三种字符:
‘A’:Absent,缺勤
‘L’:Late,迟到
‘P’:Present,到场
如果学生能够 同时 满足下面两个条件,则可以获得出勤奖励:
按 总出勤 计,学生缺勤(‘A’)严格 少于两天。
学生 不会 存在 连续 3 天或 连续 3 天以上的迟到(‘L’)记录。
给你一个整数 n ,表示出勤记录的长度(次数)。请你返回记录长度为 n 时,可能获得出勤奖励的记录情况 数量 。答案可能很大,所以返回对 109 + 7 取余 的结果。
我们或许可以很快的识别到这个大问题中包含着重复的子问题,那么,这是个几维的动态规划?它的每一个维度代表什么?状态转移方程怎么写?
这时候就体现了dfs转dp的重要。正着想是符合人类的思维的,递归转递推才是正路,直接从递推开始,太困难了。
先看看这个dfs怎么写。假设我们现在已经有一段确定的序列,当然这段序列还没有到n长,那么自然有三种将它延长的方式,加’A’、加’L’和加’P’。对于A,如果序列中已经有一个A了,那么再加A就不合适了,是0,否则继续;对于L,如果序列的后两位刚好都是L,那就组成了连续三个L,是不行的,返回0,否则继续;对于P,没什么触发禁忌的事,直接都继续
因此可以提炼出两个需要在dfs中用到的变量,已有序列中A的个数,已有序列末尾有连续几个L。手动再加一个dfs进行到第几层的深度变量,用于让这个递归停止
const dfs = (Acnt, tailLcnt, deep) => {
if (deep == n) return 1;
const chooseA = Acnt === 1 ? 0 : dfs(Acnt + 1, 0, deep + 1);
const chooseL = tailLcnt === 2 ? dfs(Acnt, tailLcnt + 1, deep + 1);
const chooseP = dfs(Acnt, 0, deep + 1);
return chooseA + chooseL + chooseP;
}
dfs(0, 0, 0)
我总结的 dfs 转 dp 的方法有四个步骤:
- deep 变量反扣,作为 dp 的第一维度
- 其他变量保留,作为 dp 的其他维度
- dfs 返回表达式迁移为状态转移式
- dfs 的调用迁移为 dp 的某项
也就是说,本题的 dp 是三维 dp,dp[i][Acnt][tailLcnt],状态转移如下:
const dpfun = () => {
// init 0 相关的省略
for (let i = 1; i <= n; i++) {
for (let Acnt = 0; Acnt < 2; Acnt++) {
for (let tailLcnt = 0; tailLcnt < 3; tailLcnt++) {
const chooseA = Acnt === 1 ? 0 : dp[i - 1][Acnt + 1][0];
const chooseL = tailLcnt === 2 ? dp[i - 1][Acnt][tailLcnt + 1];
const chooseP = dp[i - 1][Acnt][0];
dp[i][Acnt][tailLcnt] = chooseA + chooseL + chooseP;
}
}
}
return dp[n][0][0];
}
deep 变量反扣很容易理解,正常来说 dfs 是一个问题规模逐渐缩小的一个过程,这个过程中 deep 从小到大,dp 要想反过来,当然要让规模逐渐增大,deep 就要从大到小,作为 dp 数组从最后一行转移到第一行就不大方便了,因此要将 deep 反扣,n -> 0 转为 0 -> n
dfs 的调用迁移为 dp 的某项,在原本的 dfs 代码中,返回的是 dfs(0, 0, 0),对应的 Acnt == 0, tailLcnt === 0, deep == 0,迁移后则是 i == n,Acnt == 0, tailLcnt === 0,即 dp[n][0][0]
这样问题就迎刃而解了
有些读者可能会去思考,那么这里的dp[i][Acnt][tailLcnt]是什么?dfs是从左往右的在左串的最后加字符,那么dp反过来理应是从右往左在右串的开头加字符,然而dp数组的状态转移却显然不是这个意思,仍然是在这个子串的后面加’A’‘L’‘P’,和dfs一个逻辑,这不是很怪异吗?
和dfs是一个逻辑就对了!我们本来dp的转移逻辑就是从dfs迁移来的(见步骤三)。dp的内涵不在于将dfs的思路彻底逆转,而在于在dfs的过程中不重复计算相同的部分,将算过的子问题的答案存起来作为数组,在计算的时候可以直接拿到数据而非递归进去进行计算。我们不必纠结对于每个状态,dp数组的那一项具体代表什么意思,因为经过了转换(尤其还有出于方便目的对于deep的反扣),这种对每一项的解释已经很难表达了,我们只需要抓住在这个过程中,算法的内核没有发生变化,初始的状态没有发生变化,要求解的状态也是同一个状态即可