课程表 III
冬泳怪鸽有句话说得好。
无论遇到什么样的困难,都不要怕,微笑着面对它,消除恐惧的最好办法就是面对恐惧,后面省略……奥利给!
1、康康题
题目在这
我和各位算法水平一般的朋友一样,看到困难题时都是一哆嗦的。
但题目越难,攻克后的成就感也越大。
就算这次败了,我们吸取他人的思路,下次或许就会成功。
总之干了!奥利给!
2、审题
今天的题目描述倒是很清晰,但奈何读完后是一脸蒙蔽的。
总之,还是先来分析下我们能从题目和示例中获取的信息:
- 课程从第1天开始,不再从0计数
- 每天仅能选修1门课,且选修后必须连续读完,这意味着你修读的课程开始结束时间不能重合,并且不能中途安插其他课程。
courses
是无序的,且数量为1 <= courses.length <= 10^4
- 根据示例,会有无效的干扰数据(即课程持续时间比结课日期要长,就算从第一天开始修读也无法完成)
- 返回仅仅是可以选修的课程数量,不用在意具体哪些课程。
然后,就没有了……
从题目里仅得到了一些边界数据和特殊数据,仍然没有详细的思路产生。
总之还是硬着头皮上吧。
3、思路
于是我又拿起了小本本写写画画。
再次建议各位活用手边的工具,越是难的题目,小本本越能给予帮助。
我们应该能够很轻易的就发觉这是一个贪心问题要你说,人家题目标签上就写好了贪心 。
而且是反悔贪心,类似大部分背包问题。
但不同于固定容量的背包,课程的截止日期各不相同。
辣么回到贪心上来。
我们首先要明确以下信息:
- 如何确认课程的优先级?
- 什么情况下要选择这门课程?
- 什么情况下又要择优而丢弃已选的课程?
顺着上述三点,让我们来慢慢展开思路。
3.1、如何确认课程的优先级
贪心算法的要点在于从每步得到的局部最优解扩大到全局的最优解。
于是优先级是算法中很重要的概念。
由于题解要求的仅仅是课程的数量,因而重点不在于特定的课程上。
我们应尽可能多的选修课程,故要注意的便是课程的时长和截止时间。
此时,我们不妨带入到自身生活中来考虑。
在现实中,我们选择有时限的任务来完成时,是不是往往会优先完成deadline最近的任务呢?
社畜的压力开始大起来了……
于是,我们可以很自然的推断出,我们在选取时,应优先选取最早截止的课程。
对原始数组,按课程的截止时间正序排列!
3.2、什么情况下要选择这门课程
由于我们应尽可能的多选择课程,于是,这个问题的真正题面应该是——什么情况才不能选择此课程?
实际上答案在审题中就有列出:
- 课程无法同时选修。于是,如果某一时刻已经排上了其他课程后,无法再排入这个课程。
- 课程有截止时间,超过截止时间则无法选修。(由于选修后必须连续修完,所以可以看成开始时间必须在截止时间-课程长度之前)
- 课程安排本身就无法选修。(详见题目中的示例3)
所以综上,我们的条件语句似乎已经形成。
3.3、什么情况下要择优而丢弃已选的课程
重中之重!
面临择优,我们又得回到了优先级问题上了
无论长课程,还是短课程,选修后的计数都是1。
于是,我们又可以很快得出取舍:在排除其他条件下,我们会尽可能地选取时长较短的课程。
再次带入现实。
假设你很多个拥有deadline的任务都面临到期,为了不被boss教训,你肯定得尽可能多的完成它们(假设任务没有重要性区分)。辣么,在优先选择deadline较近的任务后,你会不会再去先完成耗时短的任务呢?
社畜的血压开始高起来了……
于是,在择优问题上我们再次得出结论:
在可选取的情况下,优先选择耗时较短的课程。
4、开工!
public int scheduleCourse(int[][] courses) {
//按照截止日期从小到大排序
Arrays.sort(courses, Comparator.comparingInt(o -> o[1]));
//优先级队列按照课程耗时倒排
PriorityQueue<int[]> queue = new PriorityQueue<>((o1, o2) -> o2[0] - o1[0]);
int sumT = 0;
for (int[] course : courses) {
//如果某课程根本无法完成,则直接排除
if (course[1] - course[0] < 0) {
continue;
}
if (sumT + course[0] <= course[1]) {
queue.offer(course);
sumT += course[0];
} else {
//队列头部为耗时最高的课程,判断此课程与当前课程的耗时
int[] head = queue.peek();
if (head[0] > course[0]) {
//如果头部的耗时高于当前课程耗时,则将该课程排除
queue.poll();
sumT -= head[0];
//加入当前课程
queue.offer(course);
sumT += course[0];
}
}
}
return queue.size();
}
提交成功的第一版,依旧是原汁原味的思路。
尝试优化了一些后发现对耗时影响并不大,于是就放弃了。
5、解读
//按照截止日期从小到大排序
Arrays.sort(courses, Comparator.comparingInt(o -> o[1]));
在3.1中提到,优先选择快要截止的课程,于是我们对原始数组进行排序。
我初始化了一个优先级队列,队列的元素就是课程本身(int[]
)。
此处和官解以及部分大牛的解法不同,他们的优先级队列的元素仅为课程的时长(
int
)。
我想了下,觉得很对。不过由于数组是引用传递,对耗时和空间的影响都不大。
//优先级队列按照课程耗时倒排
PriorityQueue<int[]> queue = new PriorityQueue<>((o1, o2) -> o2[0] - o1[0]);
然后我们循环课程,并以sumT
记录当前已选课程的总时长,同时也是下一课程的开始时间。(回头看看3.2)
这一块的思路来源于3.2,但作用实则不大,后续的条件语句足以过滤。
//如果某课程根本无法完成,则直接排除
if (course[1] - course[0] < 0) {
continue;
}
同样如3.2所说,如果课程的情况满足,则尽可能选择该课程。
在选择后将课程放入优先级队列,可以以此来获得所选课程中耗时最长的课程。
同时更新课程的总时长。
if (sumT + course[0] <= course[1]) {
queue.offer(course);
sumT += course[0];
}
下面,则是我们的核心择优思路了。
但其实就如3.3所写,我们在当前课程无法选修的情况下,抛弃已选的时长最长的课程。
else {
//队列头部为耗时最高的课程,判断此课程与当前课程的耗时
int[] head = queue.peek();
if (head[0] > course[0]) {
//如果头部的耗时高于当前课程耗时,则将该课程排除
queue.poll();
sumT -= head[0];
//加入当前课程
queue.offer(course);
sumT += course[0];
}
}
由于在排序后的原始数组中,截止时间晚的课程会排列在后面。所以在遍历中,当前课程比已入队的课程的截止时间只长不短。于是如果队列中最长的修读时长比当前课程时长要长的话,不用担心去除该课程并选修当前课程后,会超过当前课程的截止时间。
于是,最后留在队列中的课程,就是我们选修的所有课程。
return queue.size();
6、提交
坏消息是耗时排名略低。
更坏的消息时,我没有更好的思路来优化时长。
7、咀嚼
第一遍对原始数组的排序,我用的是java的api(快排),时间复杂度为O(logN)。
之后,遍历原始数组,并将元素放入优先级队列,每次入队列时会有一次排序,时间复杂度也是O(logN),于是这一过程是O(N*logN).
所以,整体的时间复杂度为O(N*logN)。
使用了一个优先级队列,空间复杂度为O(N)
鉴于内存消耗排名挺高,我猜测是有什么牛逼的空间换时间的思路。
或者,可以省略优先级队列的排序?
8、他人的智慧
很遗憾的是,我没有从官解和其他大牛的题解里看到时间排名很靠前的题解。
而我的解法也与大多数的解法相似。
但是官解从数学的角度上分析了一大串,把我看得一愣一愣的。
我当初咋就没有考虑这么多,果然是瞎猫碰上了死耗子嘛?
9、总结
第一次写困难题的博客。
也不知道能不能把我脑筋里的这点水倒清楚。
只希望日后自己看这内容时,能够还原出原本的思路吧。
今天的困难题还是挺有压迫的,好在顺着思路还是能够缕清。
我对于困难题的要求是15分钟内能有大致的思路今天超时了 。
如果你不幸在面试中遇到了困难题,并且完全没有接触过的话,别指望面试官会给你时间让你慢慢思索(面试过程的高压也很难让人放松头脑打开思路)。
所以还是那句话,注意时间!保证速度!
怎么做到呢?
如果你没有天才的头脑,那就只有靠勤奋的手了。
多刷题吧!
共勉共勉!奥利给!