算法导论16章贪心算法习题活动教室分配问题(区间图着色问题)分析详解与代码实现

一、题目

在算法导论第三版的第16章贪心算法的思考题16.1-4中,作者提出了这样一个问题:

二、初步分析与具体案例

看到这个题目,第一时间的想法是用教程中的贪心算法进行循环,但这不是最佳的算法。不仅时间复杂度为O(n^{2}),且并不能得到最优解。比如对于如下活动:

会得出需要3个教室,活动安排为:

Room1: [1,4), [6,7)

Room2: [2,5)

Room3: [4,8)

而最优解只需2个教室:

Room1: [1,4), [4,8)

Room2: [2,5),[6,7)

具体解释如下,我就不翻译了:

三、正确的解法

那么,正确的解法是什么呢?在网上搜了很多博客,写的不是很明白,有的代码逻辑也不对,因此给出笔者参考各种文章及教程答案后自己的实现。

核心算法一句话概括如下:

对活动按开始时间升序排序,依次迭代;对已安排活动的教室列表按照可用时间创建一个小顶堆,每次有新活动时,判断堆顶的教室是否可用,可用则用,否则开辟一个新教室安排活动,并将该教室加入小顶堆。(也可用优先队列、有序数组实现小顶堆同样的功能)。

第一步:对活动排序

先对所有的活动按照开始时间升序排序。注意,这里和书上教程例子中的按结束时间排序就不同了。这里要用到排序,快排吧,时间成本 O(n\lg n).

因为按照正常的思维,我们安排一个活动时,也是看这个活动什么时间开始。结合着下面看。

第二步:安排合适的教室

这一步很关键,并且有几种情况,还要引入新的数据结构小顶堆。

Case 1:安排第一个活动,也即现在所有的教室都是可用的。这时随便选取一个教室即可,然后把这个教室的可用时间更新为该活动的结束时间。

Case 2:安排第 m 个活动,且原来已经有一些教室被安排了活动,假设这些教室组成了列表busy_list。由于题目让求的是最小的教室数量,那么要充分利用已经被安排过活动的教室。因此,这一步的关键在于,将所有被安排过活动的教室的可用时间做升序排序。排在首位的是最早释放的,我们假设该教室的编号为R1。然后和活动m的开始时间相比:

如果m的开始时间大于等于R1的可用时间(也即此时R1的上个活动已结束),则将m安排进R1的活动列表,同时更新R1的可用时间,并重新对 busy_list 按照可用时间做升序排序(这样保证每次总是选到最早释放的教室,让教室空置时间尽量的少)。

如果m的开始时间小于R1的可用时间,由于 busy_list 是按升序排序的,那么其他活动教室当前更不可用。此时,开辟一个新的教室R_m,并将m安排进R_m的活动列表,同时更新R_m的可用时间,并将R_m加入 busy_list,重新对 busy_list 按照可用时间做升序排序。

举个具体的栗子,更好理解:假设当前 busy_list 已经有三个教室R_1, R_2, R_3,可用时间分别是下午 3点、4点、5点(已升序)。这时进来一个活动A_m

第一种情况:A_m的活动时间为:(下午3:30-下午4:30);遍历 busy_list,发现此时 R_1 已释放,将 A_m 安排进 R_1,更新R_1的可用时间为下午4:30,同时更新 busy_list 的顺序为 R_2, R_1, R_3

第二种情况:A_m的活动时间为:(下午1点-下午3:30),此时 busy_list 中没有可用教室(最早的R_1要3点才释放),则重新开辟一个教室R_m,并将A_m安排进R_m的活动列表,同时更新R_m的可用时间为下午3:30,将R_m加入 busy_list,重新对 busy_list 按照可用时间做升序排序为:R_1, R_m, R_2, R_3供后续活动安排选择使用。

按照以上步骤,对开始时间已升序排序的活动列表循环,直至所有活动都安排了教室。

通过以上分析发现,本算法的关键在于 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程序员节日,祝所有程序员节日快乐!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值