算法第四题:学生出勤记录情况统计 2021-08-19

一、问题描述
可以用字符串表示一个学生的出勤记录,其中的每个字符用来标记当天的出勤情况(缺勤、迟到、到场)。记录中只含下面三种字符:
‘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;
    }
}

五、矩阵快速幂
六、总结
这个题最开始想到的是用回溯的思想,递归的深度优先遍历求解答案,但是递归的次数过多,然后有很多情况没有加以利用,效率很慢。
然后就用记忆化的方法,保存每次的状态,如果后续能用到这种状态,直接调用,就不再递归了,这样一下就把时间复杂度降低了。
现在看代码还是有点迷糊,还是先写倒这吧。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值