一、问题描述
可以用字符串表示一个学生的出勤记录,其中的每个字符用来标记当天的出勤情况(缺勤、迟到、到场)。记录中只含下面三种字符:
‘A’:Absent,缺勤
‘L’:Late,迟到
‘P’:Present,到场
如果学生能够 同时 满足下面两个条件,则可以获得出勤奖励:
按总出勤 计,学生缺勤(‘A’)严格 少于两天。
学生不会存在 连续 3 天或 连续 3 天以上的迟到(‘L’)记录。
给你一个整数 n ,表示出勤记录的长度(次数)。请你返回记录长度为 n 时,可能获得出勤奖励的记录情况 数量 。答案可能很大,所以返回对 10的9次方加7取余的结果。
二、问题分析
自己想用数学分析的方法做,很明显自己推了很多,但是情况还是很复杂,特别是当A和连续的L在一起的时候,问题变得很复杂。
后来又想到用分治,结果是一样得,子问题划分上不好划分,并且原问题不能单纯由子问题构成。
三、记忆化搜索
枚举所有方案得暴力搜索DFS(深度优先搜索)
设计变量
u | 当前还剩下多少位需要决策 ,其实可以看成还有几天的出勤情况没算 |
---|---|
acnt | 当前方案中A的出现总次数 ,acnt小于2的时候,是可能拿到奖金的 |
lant | 当前方案中结尾L的总出现次数,lant连续出现0,1,2,是有可能拿到奖金 |
cur | 当前方案,看成当前出勤天数下的出勤情况 |
ans | 结果集,当前的出勤天数下的能拿到到奖金的情况数 |
最开始的笨方法当然是暴力搜索,不过要采用回溯的思路,每次加一天的出勤情况,有三种可能,分别判断,如果满足能拿奖金的情况,那么情况数就增加;这样一直加直到满足n,u其实是每次都在减少的。
回溯的解决问题的一般步骤是:
1.针对给定问题,定义问题的解空间,它至少包含问题的一个解
在本题中,拿将金就是一个解情况,满足拿奖金的条件设计就是在定义问题的解空间。
2.确定易于搜索的解空间结构,使得回溯法能够方便地搜索整个解空间
然后每次加一天,然后看看能拿到奖金的情况
3.以深度优先的方式搜索解空间,并在搜索过程中用剪枝函数避免无效搜索
比如当添加一天后不能拿到奖金了,那么这种情况直接就抛弃了,后面从新从能拿将金的情况里选一个继续进行。
设计函数
1.如果新的一天是P,那么可以直接方进来,并把这种情况方入解空间
2.如果新的一天是A,那么只有之前天里A没出现,即acnt<1时,可以把这种情况方入解空间
3.如果新的一天是L,那么L只能连续出现两个,上一天是P或A都是可以的,即lant<2
dfs(int day,int n,int absent,int late)
其中day表示第几天,n表示一共的天数,absent表示当天的出勤情况,late表示最多的连续迟到天数。
//初始化输入
public int checkRecord(int n){
return dfs(0,n,0,0)
}
//函数主题逻辑
private int dfs(int day,int n,int absent,int late){
//退出条件
if(day>=n){
return 1;
}
//回溯开始
int ans=0;
//1.新的一天是P
ans=ans+dfs(day+1,n,absent,0)
//2.新的一天是A
if(absent<1)
ans=ans+dfs(day+1,n,1,0)
//3.新的一天是L
if(late<2)
ans=ans+dfs(day+1,n,absent,late+1)
return ans;
}
//最终返回的是n天的情况数
时间复杂度:O(3^n),n个位置,每个位置有3个选择,就是33…*3
空间复杂度:O(n),递归层数为 n。
实际上,我们不需要枚举所有的方案数,因此我们只需要保留函数签名中的前三个参数即可。
同时由于我们计算某个(u,acnt,lant)的方案数时,其依赖的状态可能会被重复使用,考虑加入记忆化,将结果缓存起来。其实就是记住前一天的情况,通过分析把收索次数降下来。
例如:我们要计算 day=2, absent=1, late=0,它有可能从哪些状态而来呢?
absent=1可能是第 0 天填入的;
absent=1可能是第 1 天填入的;
所以,以上两种情况,到第 2 天的时候的状态是完全一样的,也就产生了重复计算,所以,我们可以声明一个缓存,记录每个状态下计算得到的值,下次再遇到相同的状态,直接返回即可。
看代码
public int checkRecord(int n){
int[][][]memo=new int[n][2][3];
return dfs(0,n,0,0,memo);
}
private int dfs(int day, int n, int absent, int late, int[][][] memo) {
if (day >= n) {
return 1;
}
// 相同的状态计算过了直接返回
if (memo[day][absent][late] != 0) {
return memo[day][absent][late];
}
// 回溯开始
int ans = 0;
// 1. Present随便放
ans = ans + dfs(day + 1, n, absent, 0, memo);
// 2. Absent最多只能放一个
if (absent < 1) {
ans = ans + dfs(day + 1, n, 1, 0, memo);
}
// 3. Late最多连续放2个
if (late < 2) {
ans = ans + dfs(day + 1, n, absent, late + 1, memo);
}
// 记录每一个状态的计算结果
memo[day][absent][late] = ans;
return ans;
}
时间复杂度:O(n),通过memo可以看到有 n * 2 * 3种状态,每个状态只会计算一遍,所以是 6n,时间复杂度为 O(n)。
空间复杂度:O(n),递归层数为 n,memo数组占用 n * 2 * 3 = 6n的空间,两者空间复杂度都是 O(n)。
四、动态规划
好了,有了记忆化,转 DP 就非常简单了,只要把 memo 改成 dp 即可,我们这样定义动态规划:
状态定义:dp[i][j][k]表示第 i 天、在 A 为 j 次、连续的 L 为 k 次的方案数。
状态转移:所有的状态都是从前一天,即 i-1,转移而来,但是对于 j 和 k,
要分三种情况来讨论:
当前填入的是 P,i-1 天的任何状态都能转移过来;
当前填入的是 A,i-1 天即之前肯定没填过 A,同时所有的 late 状态都可以转移到过来。
当前填入的是 L,i-1 天最多只能有一个连续的 L,其他的状态依次转移过来。
为了方便计算,我们把第 0 天的值初始化。
好了,请看代码,加了详细注释:
class Solution {
int MOD = 1000000007;
public int checkRecord(int n) {
long[][][] dp = new long[n][2][3];
// 初始值
dp[0][0][0] = 1;
dp[0][1][0] = 1;
dp[0][0][1] = 1;
for (int i = 1; i < n; i++) {
// 本次填入P,分成前一天累计了0个A和1个A两种情况
dp[i][0][0] = (dp[i - 1][0][0] + dp[i - 1][0][1] + dp[i - 1][0][2]) % MOD;
dp[i][1][0] = (dp[i - 1][1][0] + dp[i - 1][1][1] + dp[i - 1][1][2]) % MOD;
// 本次填入A,前一天没有累计A都能转移过来
// 这行可以与上面一行合并计算,为了方便理解,我们分开,下面会合并
dp[i][1][0] = (dp[i][1][0] + dp[i - 1][0][0] + dp[i - 1][0][1] + dp[i - 1][0][2]) % MOD;
// 本次填入L,前一天最多只有一个连续的L,分成四种情况
dp[i][0][1] = dp[i - 1][0][0];
dp[i][0][2] = dp[i - 1][0][1];
dp[i][1][1] = dp[i - 1][1][0];
dp[i][1][2] = dp[i - 1][1][1];
}
// 计算结果,即最后一天的所有状态相加
long ans = 0;
for (int i = 0; i < 2; i++) {
for (int j = 0; j < 3; j++) {
ans = (ans + dp[n - 1][i][j]) % MOD;
}
}
return (int) ans;
}
}
五、矩阵快速幂
六、总结
这个题最开始想到的是用回溯的思想,递归的深度优先遍历求解答案,但是递归的次数过多,然后有很多情况没有加以利用,效率很慢。
然后就用记忆化的方法,保存每次的状态,如果后续能用到这种状态,直接调用,就不再递归了,这样一下就把时间复杂度降低了。
现在看代码还是有点迷糊,还是先写倒这吧。