每次期末考试结束,同学们最关心的就是GPA计算。记得我大二那年,为了搞清楚如何分配四门考试分数才能获得最高总绩点,整整花了三天时间手工计算各种组合。直到后来学习了动态规划,才发现这个问题原来可以用算法优雅解决。
厦门大学的GPA计算规则非常典型:90分以上4.0,85-89分3.7,81-84分3.3...这个阶梯式的转换规则看似简单,但当需要同时考虑四门课程的分数分配时,问题就变得复杂了。给定四门课的总分n(0≤n≤400),我们需要找到使总绩点最大的分数分配方案。
这个问题实际上是一个典型的资源分配问题。我们可以把总分看作总资源,四门课看作四个需要分配资源的项目,每个分数段对应的绩点就是收益。目标就是在资源约束下最大化总收益。
最直观的想法是尝试所有可能的分数组合。假设每门课分数从0到100,四门课的组合总数是101^4≈1亿种。对于每个给定的总分n,我们需要检查所有四数之和等于n的组合,计算其总绩点,然后找出最大值。
python复制def brute_force(total):
max_gpa = 0.0
for a in range(0, 101):
for b in range(0, 101):
for c in range(0, 101):
d = total - a - b - c
if d >= 0 and d <= 100:
gpa = get_gpa(a) + get_gpa(b) + get_gpa(c) + get_gpa(d)
if gpa > max_gpa:
max_gpa = gpa
return max_gpa
这个算法的时间复杂度是O(n^3),当n=400时,最内层循环需要执行约400^3=6400万次。在实际测试中,这个算法对于单个n值就需要几秒钟才能完成,显然无法满足实际需求。
仔细观察GPA转换规则,我们会发现只有某些特定的分数点才有意义。例如,89分和85分的绩点都是3.7,但84分就降到3.3。因此,我们只需要考虑这些关键分数点:90,85,81,78,75,72,68,64,60,0。
这样,每门课只有10种可能的取值,四门课的组合数降为10^4=10000种,大大减少了计算量。这就是原代码中make_table()函数的核心思路。
动态规划是解决这类优化问题的利器。我们需要定义状态和状态转移方程:
定义dp[i][j]表示考虑前i门课,使用j分时能获得的最大绩点。其中i∈[1,4],j∈[0,n]。
我们的目标是求dp[4][n],即四门课使用n分时的最大绩点。
对于第i门课,我们可以尝试所有可能的分数分配(只考虑关键分数点),然后选择使总绩点最大的方案:
dp[i][j] = max(dp[i-1][j-k] + get_gpa(k)),其中k遍历所有关键分数点,且j≥k
初始条件是dp[0][0] = 0,表示0门课0分时绩点为0。
python复制def dp_solution(total):
key_scores = [90, 85, 81, 78, 75, 72, 68, 64, 60, 0]
dp = [[-1.0]*(total+1) for _ in range(5)]
dp[0][0] = 0.0
for i in range(1, 5):
for j in range(total+1):
for score in key_scores:
if j >= score and dp[i-1][j-score] != -1:
current_gpa = dp[i-1][j-score] + get_gpa(score)
if current_gpa > dp[i][j]:
dp[i][j] = current_gpa
return round(dp[4][total], 1)
这个动态规划解法的时间复杂度是O(4×n×10)=O(n),对于n=400只需要约16000次操作,比暴力法快了数千倍。空间复杂度是O(n),可以通过滚动数组优化到O(n)。
有人可能会想:是否可以每次选择"性价比"最高的分数分配?即选择单位分数带来绩点增长最大的方案。
我们可以计算每个关键分数点的"边际效益":
按照这个思路,我们应该优先分配分数到边际效益高的科目。
考虑总分n=179:
这个例子说明贪心算法不能保证全局最优,因为分数分配存在"整数约束",不能简单地按比例分配。
虽然贪心算法不能保证总是最优,但在某些特殊情况下可以快速得到近似解。例如当总分足够大时(如n≥360),可以近似认为每门课都能达到90分以上,此时贪心策略有效。
原代码采用了一种巧妙的预处理方法:预先计算所有可能的四门课关键分数组合,存储它们的总分和对应绩点,然后对每个查询n,只需查找不超过n的最大绩点。
cpp复制map<float,int,MyComp> m; // 绩点到总分的映射,按绩点降序
void make_table() {
for(int i=0;i<10;i++) {
for(int j=0;j<10;j++) {
for(int k=0;k<10;k++) {
for(int d=0;d<10;d++) {
int total=line[i]+line[j]+line[k]+line[d];
float gpa=getGPA(line[i])+getGPA(line[j])+getGPA(line[k])+getGPA(line[d]);
// 存储绩点和对应的最小总分
if(m.find(gpa)==m.end() || total<m[gpa]) {
m[gpa]=total;
}
}
}
}
}
}
预处理后,查询操作变得非常简单:
cpp复制float Get_MAX(int score) {
for(auto it=m.begin();it!=m.end();it++) {
if(it->second<=score) return it->first;
}
return 0;
}
这种方法将时间复杂度转移到了预处理阶段,使得每次查询可以在O(1)时间内完成(因为map是按绩点降序排列的)。
预处理方法虽然查询快,但需要存储大量组合。实际上,我们可以结合动态规划和预处理的思想:只预处理单科和两科的组合,然后在查询时动态组合。
python复制def optimized_solution(total):
# 预处理单科和两科组合
single = [(s, get_gpa(s)) for s in key_scores]
double = [(s1+s2, g1+g2) for s1,g1 in single for s2,g2 in single]
max_gpa = 0.0
# 尝试所有两两组合
for s1,g1 in double:
s2 = total - s1
if s2 >= 0:
for s,g in double:
if s == s2 and g1+g > max_gpa:
max_gpa = g1+g
return round(max_gpa, 1)
这种方法将预处理空间从O(n^4)降到O(n^2),同时保持查询效率。
当科目数量增加到5门或更多时,预处理法的组合数会爆炸式增长。此时动态规划的优势更加明显,只需将状态维度扩展到更高维度即可。
如果各科目学分不同,我们需要在状态转移时考虑权重。定义dp[i][j]为前i门课使用j分时的最大加权绩点,转移方程需要乘以相应学分。
不同学校的GPA转换规则可能不同。算法可以轻松适配新的规则,只需修改get_gpa()函数中的映射关系即可。
解决GPA优化问题的过程给了我很大启发。在大学生活中,我们经常面临类似的资源分配问题:有限的时间如何在课程、科研、社团活动之间分配?动态规划教会我们,全局最优往往需要系统思考,而不是局部贪心。
记得我在实际应用这个算法时发现,有时候稍微降低一门课的分数(从90降到85),可以腾出更多分数给其他课程,最终获得更高的总绩点。这就像生活中,在某些方面适当"妥协",反而能在整体上取得更好的结果。