一、题目
在算法导论第三版的第16章贪心算法的思考题16.1-4中,作者提出了这样一个问题:
二、初步分析与具体案例
看到这个题目,第一时间的想法是用教程中的贪心算法进行循环,但这不是最佳的算法。不仅时间复杂度为O(),且并不能得到最优解。比如对于如下活动:
会得出需要3个教室,活动安排为:
Room1: [1,4), [6,7)
Room2: [2,5)
Room3: [4,8)
而最优解只需2个教室:
Room1: [1,4), [4,8)
Room2: [2,5),[6,7)
具体解释如下,我就不翻译了:
三、正确的解法
那么,正确的解法是什么呢?在网上搜了很多博客,写的不是很明白,有的代码逻辑也不对,因此给出笔者参考各种文章及教程答案后自己的实现。
核心算法一句话概括如下:
对活动按开始时间升序排序,依次迭代;对已安排活动的教室列表按照可用时间创建一个小顶堆,每次有新活动时,判断堆顶的教室是否可用,可用则用,否则开辟一个新教室安排活动,并将该教室加入小顶堆。(也可用优先队列、有序数组实现小顶堆同样的功能)。
第一步:对活动排序
先对所有的活动按照开始时间升序排序。注意,这里和书上教程例子中的按结束时间排序就不同了。这里要用到排序,快排吧,时间成本 .
因为按照正常的思维,我们安排一个活动时,也是看这个活动什么时间开始。结合着下面看。
第二步:安排合适的教室
这一步很关键,并且有几种情况,还要引入新的数据结构小顶堆。
Case 1:安排第一个活动,也即现在所有的教室都是可用的。这时随便选取一个教室即可,然后把这个教室的可用时间更新为该活动的结束时间。
Case 2:安排第 m 个活动,且原来已经有一些教室被安排了活动,假设这些教室组成了列表busy_list。由于题目让求的是最小的教室数量,那么要充分利用已经被安排过活动的教室。因此,这一步的关键在于,将所有被安排过活动的教室的可用时间做升序排序。排在首位的是最早释放的,我们假设该教室的编号为R1。然后和活动m的开始时间相比:
如果m的开始时间大于等于R1的可用时间(也即此时R1的上个活动已结束),则将m安排进R1的活动列表,同时更新R1的可用时间,并重新对 busy_list 按照可用时间做升序排序(这样保证每次总是选到最早释放的教室,让教室空置时间尽量的少)。
如果m的开始时间小于R1的可用时间,由于 busy_list 是按升序排序的,那么其他活动教室当前更不可用。此时,开辟一个新的教室,并将m安排进
的活动列表,同时更新
的可用时间,并将
加入 busy_list,重新对 busy_list 按照可用时间做升序排序。
举个具体的栗子,更好理解:假设当前 busy_list 已经有三个教室,可用时间分别是下午 3点、4点、5点(已升序)。这时进来一个活动
:
第一种情况:的活动时间为:(下午3:30-下午4:30);遍历 busy_list,发现此时
已释放,将
安排进
,更新
的可用时间为下午4:30,同时更新 busy_list 的顺序为
。
第二种情况:的活动时间为:(下午1点-下午3:30),此时 busy_list 中没有可用教室(最早的
要3点才释放),则重新开辟一个教室
,并将
安排进
的活动列表,同时更新
的可用时间为下午3:30,将
加入 busy_list,重新对 busy_list 按照可用时间做升序排序为:
,供后续活动安排选择使用。
按照以上步骤,对开始时间已升序排序的活动列表循环,直至所有活动都安排了教室。
通过以上分析发现,本算法的关键在于 Case 2 情形中对 busy_list 中的教室进行选择、更新可用时间和排序。活动按开始时间快排不算,那个是一次性的。每次安排一个活动,都要找到busy_list中可用时间最早的教室。因此,为了方便快速的查找并更新 busy_list,我们引入一种新的数据结构——小顶堆(也有博客介绍的是优先队列,作用差不多)。堆顶总是 可用时间最早的那个教室。其实我们并不需要对 busy_list 做完全的升序排序(那样太浪费时间,我们只关心第一个),因此在这里用小顶堆效率更高。
这一点,是原书的答案以及很多博文中根本没讲清楚的点,弄了两个free_list 和 busy_list,在那里移来移去,不知所以;或者没有对 busy_list 做某种排序,只是简单的遍历,逻辑是有缺陷的(比如某活动4:30开始,如果 busy_list 没有排序,那么可能选到的教室是 R2(4点结束那个),而3点结束的R1一直在空置)。
四、详细的代码实现
数据结构定义
为了避免几个数组移来移去,我们定义几个数据结构:活动和教室
// 活动定义
final class Activity {
final int number; // 活动序号,方便使用
final int startTime;
final int endTime;
Activity(this.number, this.startTime, this.endTime);
@override
String toString() => '$number: ($startTime - $endTime)'; // 打印用
}
// 教室定义,实现 Comparable接口,小顶堆排序用
final class Room implements Comparable<Room> {
final int number; // 教室编号
int _availableTime; // 教室可用时间,也即上个活动结束时间
final List<Activity> activities; // 该教室安排的活动列表
Room(this.number)
: _availableTime = 0,
activities = [];
int get availableTime => _availableTime;
void addActivity(Activity a) {
activities.add(a);
_availableTime = a.endTime; // 每次安排活动,都更新 可用时间
}
@override
int compareTo(Room other) => _availableTime - other._availableTime;
}
小顶堆的实现
这里仅是用到此数据结构,不做详细解释,大家有不清楚的地方请自行Google。
// 采用数组实现的小顶堆
class MinHeap<E extends Comparable<E>> {
late List<E> _heap;
late int _size;
MinHeap([Iterable<E>? elements]) {
elements ??= [];
_heap = elements.toList();
_size = elements.length;
_buildMinHeap();
}
bool get isEmpty => _size == 0;
int get size => _size;
// 访问堆顶元素(不弹出)
E get top {
if (isEmpty) throw StateError('The heap is empty!');
return _heap[0];
}
// 弹窗堆顶元素
E popTop() {
if (isEmpty) throw StateError('The heap is empty!');
var e = _heap[0];
_swap(0, --_size);
_minHeapify(0);
return e;
}
// 按顺序依次弹出 堆顶的 n 个元素.
Iterable<E> pop(int n) sync* {
if (n > _size) throw StateError('The heap size is smaller than $n!');
while (n-- > 0) {
var e = _heap[0];
_swap(0, --_size);
_minHeapify(0);
yield e;
}
}
// 压新元素入堆
void push(E value) {
_heap.insert(_size, value);
_bubble(_size++);
}
// 自底向上初始化堆
void _buildMinHeap() {
for (var i = (_size >> 1) - 1; i >= 0; i--) _minHeapify(i);
}
// 最小堆化函数
void _minHeapify(int i) {
// 初始化左右子节点
var mi = i, l = (i << 1) + 1, r = (i << 1) + 2;
// 如果左子节点存在,且比当前节点小,则更新mi
if (l < _size && _heap[l].compareTo(_heap[mi]) < 0) mi = l;
// 如果右子节点存在,且比当前节点小,则更新mi
if (r < _size && _heap[r].compareTo(_heap[mi]) < 0) mi = r;
// 如果mi不等于当前节点,则交换mi和当前节点,并递归调用_minHeapify函数
if (mi != i) {
_swap(i, mi);
_minHeapify(mi);
}
}
// 位置 i 的元素上浮
void _bubble(int i) {
if (i == 0) return;
var pi = (i - 1) >> 1;
if (_heap[i].compareTo(_heap[pi]) < 0) {
_swap(i, pi);
_bubble(pi);
}
}
void _swap(int i, int j) {
var t = _heap[i];
_heap[i] = _heap[j];
_heap[j] = t;
}
}
核心算法:活动安排
具体代码实现中,对一些情况做了合并,代码中用到了 Dart 3.0 以后的 Pattern模式 和 switch 表达式。注释详细,不多说。
// 安排活动并返回所有安排过活动的房间迭代列表。
Iterable<Room> arrange(List<Activity> activities) {
// Sort activities by start time
activities.sort((a, b) => a.startTime - b.startTime);
// Create a variable to keep track of the room number and a min heap to store the rooms
var roomNumber = 0, heap = MinHeap<Room>();
for (var activity in activities) {
// Check if the heap is not empty and if the start time of the activity is greater than the available time of the top room in the heap
var room =
switch (!heap.isEmpty && activity.startTime >= heap.top.availableTime) {
// If true, pop it.
true => heap.popTop(),
// else create a new room.
_ => Room(roomNumber++)
};
// Add the activity to the room
room.addActivity(activity);
// Push the room to the heap
heap.push(room);
}
// Return the rooms from the heap in order of available time.
return heap.pop(heap.size);
}
验证代码:
void main() {
// 所有活动的开始时间数组
final s = [1, 3, 0, 5, 4, 6, 7, 9, 8, 2, 12];
// 所有活动的结束时间数组
final f = [4, 5, 6, 7, 9, 9, 10, 11, 12, 8, 16];
// final s = [1, 2, 6, 4];
// final f = [4, 5, 7, 8];
final activities = <Activity>[];
// 根据开始时间数组和结束时间数组生成活动列表,避免逐个手输麻烦。
for (var i = 0; i < s.length; i++) activities.add(Activity(i, s[i], f[i]));
print('Detailed arrangement:');
var n = 0; // 用来计总教室数
// 注意这里是按照教室的可用时间升序输出的。
for (var room in arrange(activities)) {
print('Room ${room.number}: ${room.activities}');
n++;
}
print('Total of $n rooms needed.');
}
输出如下:
Detailed arrangement:
// 房间编号:[活动编号:(活动开始时间-活动结束时间)... ]
Room 3: [1: (3 - 5), 3: (5 - 7), 6: (7 - 10)]
Room 1: [0: (1 - 4), 4: (4 - 9), 7: (9 - 11)]
Room 2: [9: (2 - 8), 8: (8 - 12)]
Room 0: [2: (0 - 6), 5: (6 - 9), 10: (12 - 16)]
Total of 4 rooms needed.
Exited.
即使用教科书答案给的例子验证:
把main函数中的如下注释的 两行放开即可:
// final s = [1, 2, 6, 4];
// final f = [4, 5, 7, 8];
输出结果如下:
Detailed arrangement:
Room 1: [1: (2 - 5), 2: (6 - 7)]
Room 0: [0: (1 - 4), 3: (4 - 8)]
Total of 2 rooms needed.
Exited.
以上,就是贪心算法多教室活动安排(区间图着色问题)的详细解释和实现。(代码中部分注释是CodeGeeX插件自动生成的,中英文是随便选择的)。开发语言为 Dart。
最后,今天是1024程序员节日,祝所有程序员节日快乐!