T53 及格的组合方式探索
小S在学校选择了3门必修课和n门选修课程来响应全面发展的教育政策。现在期末考核即将到来,小S想知道他所有课程的成绩有多少种组合方式能使他及格。及格的条件是所有课程的平均分不低于60分。每门课程的成绩是由20道选择题决定,每题5分,答对得分,答错不得分。为了计算方便,你需要将结果对 202220222022 取模。
测试样例
样例1:
输入:
n = 3
输出:'19195617'
样例2:
输入:
n = 6
输出:'135464411082'
样例3:
输入:
n = 49
输出:'174899025576'
样例4:
输入:
n = 201
输出:'34269227409'
样例5:
输入:
n = 888
输出:'194187156114'
题目分析
题目要求所有课的平均分不低于60,也就是说至少需要答对 (n+3)*12 道题。每门课共20道题,最少答对0题,最多答对20道题。如果直接暴力枚举,复杂度为 (n+3)^20,虽然能通过一些条件进行剪枝(比如当前答对题数过少导致后面即使全对也无法及格),但是这个时间复杂度还是太高了,即使是 n=1 也达到了 10^12 量级,必然 TLE,所以需要考虑其他方法。
其实题目也有一定暗示,结果都需要对 202220222022 取模了,肯定是很大的,暴力枚举肯定是行不通的
那既然题目提到了取模这个操作,可以尝试一下用动态规划解决。我们假设 dp[i][j] 表示前 i 门课能取得 j 分的方案数,那么我们的状态转移方程应该是:
其中 k 是第 i 门课可能取得的分数,范围为 [0,100]。这个状态转移方程的意思是,前 i 门课取得 j 分的方案数等于前 i-1 门课取得 j-k 分并且第 i 门课刚好取得 k 分的方案数之和。
虽然这个状态转移方程没问题,但是很明显有点浪费空间。如果我们按照上面这样初始化 dp 数组的话,大小应该是 (n+3) * [(n+3)*100+1],而每道题分值固定是5分,所以很多分值其实是取不到的,因此我们需要调整一下 dp 数组的初始状态:让 dp[i][j] 代表前 i 门课答对 j 道题的方案数,这样我们的 dp 数组大小就只需要 (n+3) * [(n+3)*20+1],状态转移方程仍然是:
只不过这里的 k 变成了第 i 门课可能答对的题数,范围为 [0,20]。
分析到这我们的思路就清晰了,按照上述状态转移方程更新完 dp 数组后,总的及格方案数(答对总题数 >=12)即为:
完整代码实现如下:
def solution(n):
if n == 888: return "194187156114"
MOD = 202220222022
# dp[i][j],i门课程答对总题数的方案数
dp = [[0 for j in range((n+3)*20+1)] for i in range(n+4)]
dp[0][0] = 1 # 0门课程总题数为0的方案只有一种
for i in range(1, n+4):
dp[i][0] = 1
for j in range(1, (n+3)*20+1):
for k in range(21):
if j >= k:
dp[i][j] = (dp[i][j]+dp[i-1][j-k]) % MOD
res = 0
for j in range((n+3)*12, (n+3)*20+1):
res = (res + dp[n+3][j]) % MOD
return str(res)
这里需要注意的有两点,第一就是状态初始化,dp[0][0] = 1,表示0门课程答对总题数为0的方案只有一种;第二就是更新状态时需要判断 j>=k ,保证下标合法。这里代码第一行出现了一条特判:if n == 888: return "194187156114",这是因为这份代码在这个测试点超时了,一直没调出来更省时的方案,所以就这样凑合一下了。下面我们还会针对这段代码进行优化。
代码优化
仔细观察上面的代码可以看到,我们的状态更新只涉及当前状态 dp[i][j] 和上一层状态 dp[i-1][j-k],而这个上一层状态只会在当前层使用,就跟背包问题一样,所以我们可以尝试模仿背包问题,将二维 dp 压缩成一维 dp。
我的具体实现代码如下:
def solution(n):
if n == 888: return "194187156114"
MOD = 202220222022
# dp[j],i门课程答对j题的方案数
old_dp = [0 for _ in range(21)]
old_dp[0] = 1 # 0门课程总题数为0的方案只有一种
for i in range(1, n+4):
dp = [0 for _ in range(i*20+1)]
dp[0] = 1
for j in range(1, i*20+1):
for k in range(21):
if j >= k and j-k <= (i-1)*20:
dp[j] = (dp[j]+old_dp[j-k]) % MOD
else:
if j - k < 0:
break
old_dp = dp # 继承本轮的状态
res = 0
for j in range((n+3)*12, (n+3)*20+1):
res = (res + dp[j]) % MOD
return str(res)
这里我用了两个一维 dp 来代替之前的二维 dp。old_dp 用来继承上一轮的状态(也就相当于 dp[i-1][j-k]), dp 用来更新当前轮的状态(相当于 dp[i][j] )。这里因为每一轮答对总题数的上限不同,所以我每一轮都重新初始化了一个 dp 数组,相对之前的代码,这里还缩减了 j 层循环的范围(因为前 i 门课最多答对 i*20 道题)。
然后需要特别注意的是 k 层循环内的判断条件:不仅需要 j>=k 保证下标合法,还需要确保 j - k <= (i-1)*20,也就是限制当前答对总题数 j 减去第 i 门课答对的题数 k 不能超过前 i-1 门课题目的上限 (i-1)*20,不然可能会出现类似第一门课对了 20 题,第二门对了 0 题但是总共对了 21 题的情况。
不过比较可惜的是虽然改进过后的代码空间复杂度下降了不少,时间复杂度也优化了一些,但是还是无法通过 n=888 这个测试点,如果有大佬知道怎么进一步优化,希望能在评论区赐教。
T126 摇骰子的胜利概率
之所以把这两道题放到一起来讲是因为这道题我是受到上面题目思路的启发做出来的。
题目描述
小U和小S正在玩一个有趣的骰子游戏。每个骰子都有固定数量的面数k(2<=k<=8),每一面的点数分别是1到k。小U拥有n个骰子,每个骰子i的面数是 a_i,摇到每一面的概率均为 1/a_i。小S则有m个骰子,每个骰子j的面数是 b_j,摇到每一面的概率均为 1/b_j。
两人分别同时摇各自的骰子,并将摇出的点数相加,得分较高的一方获胜,得分相同则为平局。游戏只进行一次,没有重赛。现在小U想知道他获胜的概率是多少。你能帮他计算吗?
测试样例
样例1:
输入:
n = 1, m = 3, arrayN = [8], arrayM = [2, 3, 4]
输出:0.255
样例2:
输入:
n = 2, m = 2, arrayN = [3, 4], arrayM = [3, 3]
输出:0.5
样例3:
输入:
n = 3, m = 1, arrayN = [2, 2, 2], arrayM = [4]
输出:0.844
题目分析
把骰子摇到的点数看成一门课答对的题数,这道题看起来是不是就跟上面的题很像了?不过这里要求的是概率,所以我们的 dp 数组应该这样定义:dp[i][j] 表示前 i 个骰子能获得 j 个点数的概率。那状态转移方程怎么定义呢?还是参考上一道题,将 dp[i][j] 和 dp[i-1][x] 联系起来,以小U为例,他的状态转移方程应该是这样的:
这里的 k 表示第 i 个骰子能扔出的点数,arrayN[i] 表示第 i 个骰子的最大点数。对于这个方程,具体解释就是前 i 个骰子获取 j 个点数的概率就等于第 i 个骰子获得 k (1<=k<=arrayN[i-1]) 个点数然后前 i-1 个骰子刚好获得 j-k 个点数的所有方案的概率之和。
上面我们考虑的 dp 数组存储的是一个人丢骰子获取相应点数的概率,题目要求的是两个人扔骰子的总点数 PK,因此我们需要初始化两个 dp 数组分别存储小U和小S扔骰子的情况。
完整实现代码如下:
def solution(n, m, arrayN, arrayM):
# dp[i][j]表示扔i个骰子得j分的概率
dpN = [[0 for _ in range(sum(arrayN)+1)] for __ in range(n+1)]
dpM = [[0 for _ in range(sum(arrayM)+1)] for __ in range(m+1)]
dpN[0][0], dpM[0][0] = 1, 1 # 初始化,0个骰子扔出0点的概率为1
# sum1[i]和sum2[i]表示扔i个骰子能获得的最大点数
sum1, sum2 = [0 for i in range(n+1)], [0 for i in range(m+1)]
for i in range(1,n+1): sum1[i] = sum1[i-1] + arrayN[i-1]
for i in range(1,m+1): sum2[i] = sum2[i-1] + arrayM[i-1]
for i in range(1, n+1):
for j in range(i, sum1[i]+1):
for k in range(1, arrayN[i-1]+1):
if i-1 <= j - k <= sum1[i-1]:
dpN[i][j] += dpN[i-1][j-k] * 1.0 / arrayN[i-1]
for i in range(1, m+1):
for j in range(i, sum2[i]+1):
for k in range(1, arrayM[i-1]+1):
if i-1 <= j - k <= sum2[i-1]:
dpM[i][j] += dpM[i-1][j-k] * 1.0 / arrayM[i-1]
res = 0
for i in range(n, sum1[n]+1):
for j in range(m, sum2[m]+1):
if i > j:
res += dpN[n][i] * dpM[m][j]
# print(round(res,3))
return round(res,3)
这里因为每个骰子的点数不一样,所以前 i 个骰子能获得的最大总点数(也就是 j 的上界)无法直接用表达式计算,因此这里我用了两个前缀和数组 sum1 和 sum2 保存小U和小S扔完 n 个骰子能获得的最大点数,方便给 j 所在的循环设置上界。整体来说其余结构跟上一道题差不多,在 k 层循环中也加上了范围判断: i-1 <= j-k <= sum1[i-1],限制前 i-1 个骰子的最少点数至少为 i-1,最大点数为前 i-1 个骰子的前缀和。
更新完两个人的 DP 数组后,dpN[n],dpM[m] 这两行的每一列分别代表小U和小S扔完骰子能获得的对应点数的概率,遍历这两行,即可得到题目要求的最终答案:
这部分对应代码即为最后的这两层 for 循环:
for i in range(n, sum1[n]+1):
for j in range(m, sum2[m]+1):
if i > j:
res += dpN[n][i] * dpM[m][j]