25 – 09 – 01
6月13日:刷 LeetCode,写不来。
7月13日:刷 LeetCode,写不来。
8月13日:刷 LeetCode,写不来。
8月31日:Vermosh 啊 Vermosh!你怎么能如此堕落!先前定下的刷题计划你都忘了吗?子曰:“吾日三省吾身。”不能再这样下去了!
9月1日:刷 LeetCode,写不来。
咳,总之就想开个专栏记录一下每日一题的思考过程.
今日题份,也是当上南山校长了(bs:
一所学校里有一些班级,每个班级里有一些学生,现在每个班都会进行一场期末考试。给你一个二维数组
classes
,其中classes[i] = [passi, totali]
,表示你提前知道了第i
个班级总共有totali
个学生,其中只有passi
个学生可以通过考试。给你一个整数
extraStudents
,表示额外有extraStudents
个聪明的学生,他们 一定 能通过任何班级的期末考。你需要给这extraStudents
个学生每人都安排一个班级,使得 所有 班级的 平均 通过率 最大 。一个班级的 通过率 等于这个班级通过考试的学生人数除以这个班级的总人数。平均通过率 是所有班级的通过率之和除以班级数目。
请你返回在安排这
extraStudents
个学生去对应班级后的 最大 平均通过率。与标准答案误差范围在10-5
以内的结果都会视为正确结果。
读题,第一反应是八成要用堆(heap),毕竟涉及到最小值操作,使用堆无疑是最容易想到的方法,那么问题就显而易见了:最小堆的确定指标是什么?
有一说一,我的第一反应是以当前班级的通过率为比较指标,想着拉最小的一把,拉着拉着均值或许就上去了,也就是二分中常见的最小值最大,所以第一稿的代码相当直接:
class ClassInfo implements Comparable<ClassInfo>{
double pass;
int passer;
int total;
ClassInfo(int passer, int total){
this.pass = (double)passer / total;
this.passer = passer;
this.total = total;
}
@Override
public int compareTo(ClassInfo other) {
return Double.compare(this.pass, other.pass);
}
}
// ...
while(curLowestClass.pass < classHeap.peek().pass){
// 拉它一把
curLowestClass = new ClassInfo(curLowestClass.passer + 1, curLowestClass.total + 1);
--extraStudents;
}
看上去似乎没啥问题?如果看到这时和我当时的想法一样,那么恭喜恭喜,想法很美好,Error 没得跑。
为什么这样不对?
重新梳理一下题目,我们需要将 extraStudents
个额外学生分配到各个班级,以最大化所有班级的平均通过率。平均通过率定义为所有班级通过率之和除以班级数目。由于班级数目固定,最大化平均通过率等价于最大化所有班级的通过率之和。
每个班级的通过率函数为 $g_i(x_i) = \frac{\text{pass}_i + x_i}{\text{total}_i + x_i}$,其中 $x_i$ 是分配给班级 $i$ 的额外学生数。目标函数是 $\sum_i g_i(x_i)$,约束条件是 $\sum_i x_i = \text{extraStudents}$ 且 $x_i \geq 0$ 为整数。
我们开始的策略是选择当前通过率最小的班级进行贪心,但这并不总是最优,因为通过率小并不一定意味着边际增益大。边际增益取决于当前通过率和班级总人数等因素。
最简单的来说,考虑:
- 一个通过率很低但总人数很大的班级,其边际增益可能很小。
- 一个通过率较高但总人数很小的班级,其边际增益可能很大。
因此,贪心策略应基于边际增益(通过率增加量)而非当前通过率。
那么能严谨证明嘛?
对于函数 $g(x) = \frac{p + x}{t + x}$(其中 $p \leq t$,且 $x \geq 0$):
一阶导数: $g'(x) = \frac{(t + x) \cdot 1 – (p + x) \cdot 1}{(t + x)^2} = \frac{t – p}{(t + x)^2} \geq 0$(因为 $t \geq p$),所以函数是递增的。
二阶导数: $g”(x) = \frac{d}{dx} \left( \frac{t – p}{(t + x)^2} \right) = -2 \frac{t – p}{(t + x)^3} < 0$(因为 $t – p \geq 0$ 且 $t + x > 0$),所以函数是凹的。
由于每个 $g_i(x_i)$ 都是凹函数,它们的和也是凹函数。对于凹函数最大化问题(under linear constraints),贪心分配策略是最优的。
为什么对于凹函数最大化问题(under linear constraints),贪心分配策略是最优的?
这其实可以通过 Karush-Kuhn-Tucker (KKT) 条件来解释。对于凸优化问题,KKT 条件提供了最优解的必要条件。在凹函数最大化下,这些条件也是充分的
- 那么在确定了以边际增益为比较指标后,算法的流程就很好确定了:$$\Delta_i = \frac{\text{pass}_i + 1}{\text{total}_i + 1} – \frac{\text{pass}_i}{\text{total}_i}$$
- 使用最大堆(优先队列)维护每个班级的边际增益。 对于每个额外学生:
- 从堆中取出边际增益最大的班级。
- 分配一个学生到该班级,更新其 pass 和 total。
- 计算该班级新的边际增益,并重新插入堆中。
- 分配完所有额外学生后,计算总通过率之和并除以班级数,得到平均通过率。
所以给出 pass 代码如下:
import java.util.PriorityQueue;
class Solution {
class ClassInfo implements Comparable<ClassInfo> {
double passRatio;
int pass;
int total;
ClassInfo(int pass, int total) {
this.pass = pass;
this.total = total;
this.passRatio = (double) pass / total;
}
// 计算增加一个学生后的提升值
double getImprovement() {
return ((double) (pass + 1) / (total + 1)) - passRatio;
}
void addStudent() {
this.pass++;
this.total++;
this.passRatio = (double) pass / total;
}
@Override
public int compareTo(ClassInfo other) {
// 按提升值降序排列(提升值大的优先级高)
return Double.compare(other.getImprovement(), this.getImprovement());
}
}
public double maxAverageRatio(int[][] classes, int extraStudents) {
// 最大堆,按提升值降序排列
PriorityQueue<ClassInfo> pq = new PriorityQueue<>();
for (int[] cls : classes) {
pq.offer(new ClassInfo(cls[0], cls[1]));
}
// 每次给提升最大的班级添加学生
while (extraStudents > 0) {
ClassInfo cls = pq.poll();
cls.addStudent();
pq.offer(cls);
extraStudents--;
}
double sum = 0;
while (!pq.isEmpty()) {
sum += pq.poll().passRatio;
}
return sum / classes.length;
}
}
我们需要一些优化
为了易于理解,第二次写的时候用了类,这会显著增加时间与空间开销,而浮点数存在舍入误差,考虑将其转化为乘法再进行比较,所以很明显的一些优化有:
import java.util.PriorityQueue;
class Solution {
public double maxAverageRatio(int[][] classes, int extraStudents) {
// 取消构造自定义类
PriorityQueue<int[]> pq = new PriorityQueue<>(
(a, b) -> {
// 使用交叉相乘避免浮点运算
long left = (long) (b[1] - b[0]) * a[1] * (a[1] + 1);
long right = (long) (a[1] - a[0]) * b[1] * (b[1] + 1);
return Long.compare(left, right);
}
);
for (int[] cls : classes) {
pq.offer(cls.clone());
}
while (extraStudents-- > 0) {
int[] cls = pq.poll();
cls[0]++;
cls[1]++;
pq.offer(cls);
}
double sum = 0;
for (int[] cls : pq) {
sum += (double) cls[0] / cls[1];
}
return sum / classes.length;
}
}
当然还有优化空间,比如考虑使用自定义的比较器以减少重复计算:
import java.util.PriorityQueue;
class Solution {
public double maxAverageRatio(int[][] classes, int extraStudents) {
// 使用缓存提升值的比较器
PriorityQueue<double[]> pq = new PriorityQueue<>(
(a, b) -> Double.compare(b[0], a[0])
);
// 预计算所有提升值
for (int[] cls : classes) {
int pass = cls[0], total = cls[1];
double improvement = ((double) (pass + 1) / (total + 1)) - ((double) pass / total);
pq.offer(new double[]{improvement, pass, total});
}
while (extraStudents-- > 0) {
double[] current = pq.poll();
int pass = (int) current[1] + 1;
int total = (int) current[2] + 1;
double newImprovement = ((double) (pass + 1) / (total + 1)) - ((double) pass / total);
pq.offer(new double[]{newImprovement, pass, total});
}
double sum = 0;
for (double[] cls : pq) {
sum += (double) cls[1] / cls[2];
}
return sum / classes.length;
}
}
问了下 ai,它给出了一个很有意思的思路,通过导数值批量增加某一班级的人员,减少总的增加次数
import java.util.PriorityQueue;
class Solution {
public double maxAverageRatio(int[][] classes, int extraStudents) {
PriorityQueue<int[]> pq = new PriorityQueue<>(
(a, b) -> {
// 使用导数信息进行批量比较
double derA = derivative(a[0], a[1]);
double derB = derivative(b[0], b[1]);
return Double.compare(derB, derA);
}
);
for (int[] cls : classes) {
pq.offer(cls);
}
// 批量处理:一次分配多个学生给提升最大的班级
while (extraStudents > 0) {
int[] bestClass = pq.poll();
// 计算这个班级最多可以分配多少学生
int maxToAdd = Math.min(extraStudents, calculateOptimalAdd(bestClass[0], bestClass[1]));
bestClass[0] += maxToAdd;
bestClass[1] += maxToAdd;
extraStudents -= maxToAdd;
pq.offer(bestClass);
}
double sum = 0;
for (int[] cls : pq) {
sum += (double) cls[0] / cls[1];
}
return sum / classes.length;
}
private double derivative(int pass, int total) {
// 通过率的导数,用于判断提升速度
return (double) (total - pass) / (total * total + total);
}
private int calculateOptimalAdd(int pass, int total) {
// 启发式:当提升值降到一定程度时停止批量分配
return Math.min(100, (int) Math.sqrt(total)); // 经验值
}
}
不过这个在精度上有些许问题,而且基于经验的启发式阈值往往不是最优方案,事实也证明在提交时卡用例了。
以上
发表回复