【排课小工具】排课程序设计与实现

课表的完整性意味着,可分配的节点的数量大小等于课表周数的累加和大小,为了进行完整性检测我需要两个对象:课表模板以及课程对象,从课表模板中获取可分配的节点数,从课程对象中获取该课程的上课周次。用户要求每个班级的课表模板相同,这使得完整性检测容易很多。

分级填充需求主要和课程的优先级有关,用户特别强调了要把数学、语文和科学尽可能的填充到每天的前两节课,从整体课表来看,数学分布在每天的第一节课或者第二节课,每周一共四节数学课一节科学课,这五节课应该均匀地分配到工作日的每一天中。语文每周有七节课,其中的五节课应该分配到每天的第一节课或者第二节课中,填补数学课和科学课的空缺。在优先级设计上,应该先排数学,然后排科学,接着排语文,剩下的课程放在后面排。

无时间冲突需求是排课项目的核心需求。前面提到的软件原型是这样满足这个需求的:首先创建一个全局课表,该课表是一个嵌套列表,一级子元素是每个班级的课表,二级子元素是指定班级星期,三级子元素是所属星期下的节次,接着通过课程信息表以及教师责任信息表计算出一个新表,该表包括三个重要的属性,分别是班级、课程名、教师名以及课程的周次,接下来开始了四层循环。第一层循环遍历(教师名,课程名)元组集合,并且根据每个元组获取该课程的周次以及该教师教授的班级。第二层循环遍历每个星期,第三层循环遍历每一天的每节课,第四层循环遍历每个班级。

为了满足需求一(无时间冲突),当某个节次被某个班级占据的时候就立刻跳出班级循环,转到下一个节次,即,在同一节次下不能有多个班级并行,而是只能存在一个班级。为了实现需求二(分级填充),第一层遍历的顺序由课程的优先级决定,尽管如此,仍然会出现个别次要科目排在每天的第二节课的情况。为了满足需求四(非连续性分配),我先根据周次计算出每天的最大排课次数,然后在排课的过程中将每天同一课程的排课次数限制在该数字的范围内。在该软件原型中,需求一和需求四得到了满足,需求二以及需求四得到部分满足,需求三并没有被兼顾到,尽管从结果上看系统没有漏掉任何一节课。

部分主程序代码
def process(template, course, duties, save_dir, show_teacher):
    schedules: list[list[list[str]]] = get_init_schedules(template)
    teacher_course_items = get_ordered_teacher_course_items(duties, course)
    q: Queue[int] = Queue()
    for d in [1, 5, 2, 4, 3]:
        q.put(d)
    for teacher_course_item in teacher_course_items:
        teacher_name, course_name = teacher_course_item[0]
        class_course_items = teacher_course_item[1]
        total_rest_lessons: dict[int, int] = {}
        day_rest_lessons: dict[int, int] = {}
        for class_course_item in class_course_items:
            class_id, total_lessons = class_course_item
            total_rest_lessons[class_id] = total_lessons
            day_rest_lessons[class_id] = int(total_lessons / 5) + 1
        for _ in range(5):
            day = q.get()
            q.put(day)
            day_rest_lessons_temp = deepcopy(day_rest_lessons)
            for lesson in range(1, 8):
                for class_id in day_rest_lessons_temp.keys():
                    unit_item_name = course_name if not show_teacher else course_name + f"({teacher_name})"
                    condition_1: bool = day_rest_lessons_temp[class_id] > 0
                    condition_2: bool = total_rest_lessons[class_id] > 0
                    if not (condition_1 and condition_2):
                        continue
                    condition_3: bool = (
                        schedules[class_id - 1][lesson - 1][day - 1] == ""
                    )
                    condition_4: bool = (
                        schedules[class_id - 1][lesson - 2][day - 1] != unit_item_name
                        if lesson > 1
                        else True
                    )
                    condition_5: bool = (
                        schedules[class_id - 1][lesson][day - 1] != unit_item_name
                        if lesson < 7
                        else True
                    )
                    if condition_3 and condition_4 and condition_5:
                        schedules[class_id - 1][lesson - 1][day - 1] = unit_item_name
                        day_rest_lessons_temp[class_id] -= 1
                        total_rest_lessons[class_id] -= 1
                        break
                if sum(day_rest_lessons_temp.values()) == 0:
                    break
            if sum(total_rest_lessons.values()) == 0:
                break
    filename = Path(save_dir).joinpath(f"年级课表.xls")
    save_excel(schedules, str(filename))

需求一(无时间冲突)与需求四(一天中非连续性分配)以及需求五(一周之内均匀分布)息息相关,它们都是取决于排课的算法是什么,因为在我的设定中,当一个课程一旦被安排到某个节点上,那么该节点就无法修改了,没有后期调整或者优化,所有的一切都是一次性完成。

在最新的设计中,只是对上述主程序代码做了稍许变动,最主要的变动是指针在课表上的运动路径。

最初的设计是这样的:首先遍历根据优先级排好序的(教师-课程-班级集合-课程周次-优先级)集合,这样就获取到了相关的数据,这一顺序就是在满足功能需求中的需求二(分级填充),优先级高的课程先分配。接下来第一个指针从星期一出发,依某种次序指向工作日的每一天。第二个指针从每天的上课的第一节课出发,依次指向一天中的每一节课,在该指针指向的每一个课次下,又一个指针指向课程集合的每一个班级,对于每一个班级,如果能够在该班级下实现课程的分配则立刻移动上一个指针(即指向节次的指针),这样就避免了在同一天的同一节课上一个教师出现在两个或两个以上的班级中,也就满足了需求一(无时间冲突)。为了实现需求四(同一天内非连续分配),在每个班级的指针下面添加了一个判断条件,该条件约束了如果上一个课程与当前课程相同,则放弃该节点,移动班级指针。综上,可以看到指针的顺序是:先移动星期,在移动节次,最后移动班级指针。在此模式下无法很好的满足需求五(一周内均匀分配:同一学科的课程应尽可能均摊到每一天中)。另外,也没有考虑需求三(完整整性检测)。

处理程序
def process(self):
        template_schedule = self.get_template_data()
        weekly_class_days = len(template_schedule[0])
        number_of_classes_per_day = len(template_schedule)
        ordered_items = self.get_ordered_courses_items()
        for ordered_item in ordered_items:
            course, teacher, classes, number_of_classes_per_week, priority = (
                ordered_item
            )
            number_of_remaining_courses = {
                class_id: number_of_classes_per_week for class_id in classes
            }
            if not self.teacher_schedule.get(teacher):
                self.teacher_schedule[teacher] = deepcopy(
                    self.get_teacher_schedule_tempalte()
                )
            for class_id in classes:
                if not number_of_remaining_courses[class_id] > 0:
                    continue
                if not self.class_schedule.get(class_id):
                    self.class_schedule[class_id] = deepcopy(self.get_template_data())
                for section in range(number_of_classes_per_day):
                    if not number_of_remaining_courses[class_id] > 0:
                        break
                    for day in range(weekly_class_days):
                        if not number_of_remaining_courses[class_id] > 0:
                            break
                        condition_1 = (
                            self.class_schedule[class_id][section][day] is None
                        )
                        condition_2 = (
                            (self.class_schedule[class_id][section - 1][day] != course)
                            if section > 0
                            else True
                        )
                        condition_3 = self.teacher_schedule[teacher][section][day] == ""
                        condition_4 = (
                            (
                                self.teacher_schedule[teacher][section - 1][day][0]
                                != class_id
                            )
                            if (section > 0)
                            and (priority < CONTINUOUS_BOUNDARY)
                            and isinstance(
                                self.teacher_schedule[teacher][section - 1][day], tuple
                            )
                            else True
                        )
                        if condition_1 and condition_2 and condition_3 and condition_4:
                            self.class_schedule[class_id][section][day] = course
                            self.teacher_schedule[teacher][section][day] = (
                                class_id,
                                course,
                            )
                            number_of_remaining_courses[class_id] -= 1
                        if (section == number_of_classes_per_day - 1) and (
                            day == weekly_class_days - 1
                        ):
                            self.missing_items.append(
                                (
                                    course,
                                    teacher,
                                    class_id,
                                    number_of_remaining_courses[class_id],
                                )
                            )

需求三(完整新检测)很容易满足,只需要计算出课表模板中有多少可以分配的节点,然后再计算出课程信息表中需要分配的课程节次总数,最后比对一下这两个数值是否相等就可以了,如果相等则说明课程信息数据是完整的,不多也不少。在新的设计中需要解决需求五(一周内均匀分配),为了满足这个需求,我改变了指针的运动方向:

这种跨星期的横向移动使得需求五(一周内均匀分布)很容易满足,为了兼顾满足需求四(一天之内非连续分布),我在最后一个指针下面添加了相似的判断条件。

在上一种指针移动方式下很容易规避时间冲突,而在新的设计中则不然,为了解决时间冲突问题,我创建了一个全局变量,该变量用来存放每个教师的时间安排表,这个表和课表一样也是两个维度,分别是星期和节次,只不过和正常课表不同的是,该课表是从教师的视角出发,相应节点元素为班级以及对应的课程。避免时间冲突的方式就是在每次分配的时候都要检查一下该教师视角下的课程表相应节点是否被占据。

到目前为止所有的需求在理论上都得到了满足,但在测试过程中还是出现了意料之外的情况。最终打印出来的课程表出现了空缺,可以确定的是这种空缺一定不是课程数缺失导致的,因为用户输入的课表模板以及课程信息表通过了完整性检测,经过测试发现有两种情况可能导致课表空缺:

第一,因为某课程违反了需求四(一天之内非连续分配)的要求,并且没有任何其他可用的空缺位置可用,以至于该课程在遍历课表的过程中,就算遍历到课表的最后一个节点都没有找到可用的节点。

第二,和第一种情况类似,但是更加棘手,那就是违反了需求一(无时间冲突)的需求。尽管从一开始的设计就试图避免时间冲突,但是仍然可能会发生这种状况。

产生第一种状况的原因是一种比需求四更强的需求,也就是,既不能让同一个班级在同一天中连续上同一个学科的课程,也不能让同一个教师在同一个班级在同一天中连续上课。为什么要添加这一要求呢?因为用户希望数学和科学尽可能不要在同一天上课,因为在该学校里,科学课一般都是数学老师代课,而一周中的科学课和数学课总次数刚好是 5 节课。因为这一额外要求,我增强了需求四,而现在为了解决第一种情况带来的问题,我需要在这一增强条件上做出妥协:当课程的优先级大小小于某个值的时候需要遵循增强要求,反之不需要遵循该要求。这样只需要适当的调整优先级就可以解决这个问题了。如果出现了该状况,我应该提醒用户将出现差错的科目的优先级值大小(值越低,优先级越高,越早被分配)调低。

对于第二种状况。我并没有在代码上有什么调整,和第一种情况一样解决的方案也是调整优先级,应该尽可能地将绑定在同一个教师下的课程设置成相同的优先级。

当前的程序可以允许以下变更:

第一,可以变更课程模板。调整那些固定学科或者特殊课程(类似于班会)的位置,用户在最开始的时候提到每个班级公用一个模板,所以这种变更会影响到所有班级。

第二,可以变更课程每周上课的次数。每次变更都需要确保变更后的上课次数的总数等于模板中可用于分配的节点数目。

第三,可以变更课程名。变更课程名后需要注意将职责信息表中课程哪一类同步更新。

第四,可以变更课程的优先级。更改优先级会使得课表上某些课程的次序向前或者向后排布,需要注意的是,变更优先级可能会造成漏排现象,最后遵循前面提到的规则。

第五可以变更教师的职责,只需要更改教师职责信息表的内容即可,这一更改相对比较自由。

第六,可以更改每天的上课次数,和更改课表模板同理。

用户在界面需求中提到希望能将输出结果设置为 Word 表格的样式,我分析了一下,一种解决思路就是将样板保存为 xml 格式,然后找到 xml 文件中需要填充课程名的位置,将其设置为占位符,然后在使用某些模板渲染引擎比如 jinjia 将课表的值渲染上去,最后在保存为 Word 文件。

先来看一看保存为 Excel 表格的输出模式。在前面提到,在程序中创建了两个全局变量,一个是常规的课程表,另一个为教师视角下的课程表,在最终的产品中,我可以添加一个是否输出教师课表的选项,丰富产品的功能,这一功能可以称之为附加功能。

在用户界面上并没有做太多更改,同样也是一个输入区域一个输出区域,我移除了是否显示教师视角下的课表这一选项,同时输出一般模式下的课表以及教师视角下的课表。

接下来可以开始着手编写用户使用文档了。

posted @ 2024-05-03 15:09  gaotianchi  阅读(49)  评论(0编辑  收藏  举报