BUAA OO 第二单元总结

🛰️BUAA OO 第二单元总结

✍第五次作业

🏠程序架构

(UML类图中省略了一些不重要的getter&setter方法和构造方法,下同)

💬重要类描述 + 调度器设计

😶RequestQueue

​ 第一次作业对于Request的处理比较常规,就是和大部分同学一样写了线程安全类RequestQueue以保证重要结构被多线程同时访问时的数据安全。内部包含ConcurrentLinkedQueue<PersonRequest>作为请求载体,直接包装其自带的线程安全方法(poll()peek()remove()等等)即可。线程安全的数据结构除此之外还有ArrayBlockingQueueConcurrentHashMap/ConcurrentHashSetVectorCopyOnWriteArrayList等等,可以满足绝大部分的需要了。

😶Elevator

​ 电梯的功能的实现我采用了状态模式,因为电梯在这里只有四种状态:开门、关门、移动、等待,并且其之间的转换都遵循严格的条件,于是电梯可以拥有一个State接口字段,在状态转换的时候用setter方法切换一个new StateXXX(),并调用其中的方法behave()的实现即可。一开始准备使用工厂模式来新建电梯的,但是由于本次作业对新增电梯没有考察且运行的电梯只有一种,所以为了简便就暂时搁置。

😶InputHandler & RequestDispatcher

​ 这两个类共同持有一个RequestQueue对象waitQueue,表示等待队列。InputHandler从输入中获取的Request被首先放入此队列等待,然后RequestDispatcher根据请求的楼座分配给对应的电梯即可。在此过程中要注意共享对象的线程安全问题并使用notifyAll()及时唤醒等待在此对象上的线程。RequestDispatcher还持有一个ConcurrentHashMap<Elevator.ElevatorType, RequestQueue>类型的requestQueue,它存储了楼座和电梯请求队列之间的映射关系,在本次作业中每个楼座只有一台电梯,所以可以说一个楼座共用一个请求队列,也可以说一部电梯拥有一个请求队列。

😶Strategy

​ 考虑到后续可能出现不同种类的电梯,它们运行的策略各不相同,于是创建了Strategy接口,让每部电梯持有一个其实现并调用getNext()方法,这样就可以适应多种电梯的状况。在这里的策略我的设计是:电梯在StateWaiting状态判断是否需要运动,如果需要运动,则根据当前外部请求队列(等待被接的请求)和内部请求队列(等待送达的请求)来判断下一层该去往哪里,然后直接切换到StateMoving状态并更改楼层&输出,在此过程中不会理睬外部队列增加的请求,即不会因为外部队列增加的请求而在运动途中更改目的地楼层。总体来说算是一种静态策略,但是效率尚可,同时避免了动态规划造成的线程不安全或者死锁之类的问题。

​ 大致的电梯调度策略参考了look算法:优先判断电梯运行的方向前方是否还有同方向的目的地楼层(内部请求的目的地楼层和外部请求的起始楼层),如果有则去接/送,否则看是否有反方向需要接的请求,有则去接请求,没有就调转方向。注意去往目的地楼层后如果要接请求,不能把请求都接进来,而是只接同方向的请求。这里Strategy参考两个队列的请求数据计算去往楼层时要同时考虑电梯的方向和请求的方向,并且有很多临界条件,所以比较容易出bug(比如在同一个楼层反复开关门,因为没有可以接到的请求而又没有调转方向)。

🔒锁的选择和同步块设置

​ 本单元三次作业我都只使用了synchronized修饰同步块,没有使用如ReentrantLock或者Condition之类的多线程控制类。在同步块的设置上主要注意:synchronized括起来的部分应当尽可能小以提高多线程运行的效率,同时又需要保证对共享对象的操作要被完全包含以保证线程安全,这是需要一些权衡的。在本次作业中大面积synchronized块主要出现在各种State接口的实现类中,因为电梯并没有写线程安全方法(虽然其中包含的RequestQueue是线程安全类),所以要在状态转换之间自己实现。在本次作业中我由于synchronized块的范围设置不当而出现了巨大的bug,追悔莫及

⛔出现的bug

本次作业的bug体现在两方面:

1️⃣(上面提到的)Strategy逻辑错误,可能导致在特殊情况下电梯被卡在同一层反复开关门。

解决方法:改进策略类,对边界条件和特殊数据充分考虑。

2️⃣同步块范围设置过大,导致电梯效率大幅度下降而超时。

​ 在状态模式中,切换状态的语句如下:(由此可以推出电梯内有一个State接口,而接口的实现类中又持有一部电梯)

elevator.setState(new StateOpen(elevator));
elevator.getStatus().behave();
return;

​ 而我一开始将这三行写在了synchronized块内,导致这部电梯切换状态并behave()的时候还处在上一个状态的锁中,这样状态反复切换,锁一层层叠加,而每个状态共享的对象都相同(即电梯的外部等待队列),导致共享对象被加了若干层锁,访问和操作的效率极低。而弱测和中测的数据强度不够,导致没有超时的情况发生。

@Override
public void run() {
    synchronized(outsideQueue) {
        ...
        elevator.setState(new StateOpen(elevator));
		elevator.getStatus().behave();	//即使切换了状态,但状态behave的时候依然在这层锁内
		return;
        ...
    }
}

解决方法:大面积检查synchronized块的范围,尽可能减小代码块规模。

✍第六次作业

🏠程序架构

(第二次作业的时序图和第一次作业基本相同,故不再重复展示)

​ 第六次作业相比第五次作业主要增加了横向电梯和请求,并且可以动态增加横向和纵向的电梯。(P代表parallelV代表vertical,下同)

💬重要类描述 + 调度器设计

(一些类和上一次作业基本相同或改动较小,故不再赘述)

😶InputHandler & RequestDispatcher

​ 本次作业中一个楼层或一个楼座可以有多部电梯,我给每部电梯都分配了一个队列,这样它可以根据自己队列的信息计算要去往的楼层,不必自由竞争(虽然听说自由竞争的策略效果还不错)。

​ 每一部电梯都对于横向和纵向电梯,都维护了一个ConcurrentHashMap<Integer,CopyOnWriteArrayList<Integer>>ConcurrentHashMap<Integer, RequestQueue>,前者记录了floor / building ---> SET{id}的映射,体现每一层或每一栋楼有哪些电梯在工作;后者记录了id ---> outsideQueue的映射。这样InputHandler在添加电梯的时候对这两个数据结构进行添加操作,RequestDispatcher分配请求的时候根据floor / building找到SET{id},再根据电梯此时的压力insideQueue.size() + outsideQueue.size()排序,将请求分配给压力最小的电梯。

​ 其实这种排序的权重分配不见得完全合理,因为内外请求对电梯运行时间的影响可能不同,可以改成a * insideQueue.size() + b * outsideQueue.size()这种形式,多跑几个测试点看看哪个参数对\((a,b)\)可以让时间最小(x

😶LiftV & LiftP

​ 横向和纵向电梯的基本架构是类似的,仍然使用状态模式,只是把状态类写在了电梯类内部以增加电梯类字段的可见性。

​ 纵向电梯的策略仍然沿用上一次作业的look算法。整体改动很小,故不再赘述。

​ 横向电梯的特殊之处在于可以循环运动,这次作业中我规定横向电梯只能按照一个方向循环移动,在构造方法内的实现如下:

Random random = new Random();
this.direction = random.nextInt(2) == 1 ? UP : DOWN;	
// UP = "A -> B -> C -> ...", DOWN = "A -> E -> D -> ..."

​ 由上可知电梯请求的分配中没有考虑横向电梯的方向,这种方法看似暴力,但是在强测的大量随机数据测试中表现还不错,得到了96+分。

✍第七次作业

🏠程序架构

​ 第七次作业在第六次作业的基础上添加了需要换乘的请求和横向电梯的可达性(即不一定可以在每个楼座开门)。

💬重要类描述 + 调度器设计

😶MyPersonRequest

​ 可以说这是本次作业最重要的一个类,它包装了官方包的Request类,含有下述字段:

private final PersonRequest request;
private final boolean vertical;		//	是否是纵向请求(默认不需要换乘)
private boolean hasFirstShifted;	//	是否完成换乘第一阶段
private boolean hasSecondShifted;	//	是否完成换乘第二阶段
private final int shiftFloor;		//	需要换乘的楼层

​ “包装”的含义就是,一个MyPersonRequest对象在创建之初就存储了所有的信息:换乘阶段、换乘楼层和PersonRequest对象的相关信息。但是可以根据三个boolean选择将哪些信息暴露给外面,这样其他类不需要拆包,也便于修改请求的状态:

public char getFromBuilding() {
    return (!vertical & hasSecondShifted) ? request.getToBuilding() : request.getFromBuilding();}
public char getToBuilding() {
    return (!vertical & hasFirstShifted) ? request.getToBuilding() : request.getFromBuilding();}
public int getFromFloor() {
    return (!vertical & hasFirstShifted) ? shiftFloor : request.getFromFloor();}
public int getToFloor() {
    return (vertical || hasSecondShifted) ? request.getToFloor() : shiftFloor;}

​ 本次作业可以让请求进行多次换乘,但是考虑到电梯运行和开关门的时间损耗,我只实现了一次换乘。一些动态的规划算法可以实现多次换乘,但是由于需要多次开关门,可能会得不偿失。请求的换乘可以看作三个阶段:UP / DOWN -> CHANGE BUILDING -> UP / DOWN,其中的两个箭头就标志了两个阶段的结束,UP / DOWN在这里并不是必须的,因为有可能出现只需要横向移动就可以完成或者只需要一次纵向移动的请求。

shiftFloor的计算是请求分配过程中最重要的一步,单个请求在不同楼层换乘的时间差别不会太大,但是如果在强测的环境下,几十个请求同时到来,如何妥当地安排它们是很重要的事情。身边有些同学使用了图算法,计算如何换乘可以实现最短路,我认为这样有些复杂(其实是自己懒),只是写了一个根据电梯相关信息计算代价的函数,然后排序找代价最小的楼层(别忘了可达性)

private int weight(LiftP lift, PersonRequest req) {	// 注意这里的参数类型仍然是PersonRequest
    int upper = Integer.max(req.getFromFloor(), req.getToFloor());
    int lower = Integer.min(req.getFromFloor(), req.getToFloor());
    int floor = lift.getFloor();
    int speed = lift.getMoveDur();
    int boundWeight = (upper < floor || lower > floor) ?
            (upper < floor) ? 100 + 10 * (floor - upper) :
                    100 + 10 * (lower - floor) : 0;
    int speedWeight = speed / 25;
    int insidePressureWeight = lift.getInsideNum() * 13;
    int outsidePressureWeight = lift.getOutsideNum() * 7;
    return (boundWeight + speedWeight +
            insidePressureWeight + outsidePressureWeight);
}

​ 这里的所有数字都是后来捏出来觉得比较合适的。其实也很玄学,可能改了也不会出什么问题,而且也有一定的运气成分,后来调参调不下去感觉找不到最优的参数(当然找不到),就填了个感觉还凑合的提交了,没想到强测中表现还不错。

😶Counter

​ 一个很自然的想法是:需要换乘的请求在某个阶段完成之后,修改对应的状态再返回给调度器中的等待队列进行分发。但是RequestDispatcher线程结束的条件是等待队列inputQueue为空且其reachEnd标记被设置,而后者是InputHandler线程设置的,条件为request == null。这样的结束条件显然不能满足换乘请求,因为有可能出现从文件中输入的请求被读完且inputQueue中的请求都被分配,但是正在运行的电梯里有需要换乘的请求,而此时输入和调度器线程都提前结束了,这就导致会出现请求不能彻底完成的情况。

​ 为了解决这个问题,可以增加一个计数器模块,它在InputHandler读入新请求的时候自增,在电梯彻底完成一个请求的时候自减。这样InputHandler在计数器为0且所有请求已经被读入的情况下才可以停止。其实现很简单,只需要注意线程安全即可。

🐛发现bug的策略

🤳自己的bug

​ 其实也没有什么成体系的策略,主要是随机数据点测试和自己手写小规模的测试点来检验特定模块的正确性。另外多线程的特点是bug不一定能复现,所以一个测试点多跑几次比较好。后来室友写了一个可以把输出根据不同电梯来拆分的程序,这使得debug过程效率提高了很多:原来只能肉眼观察小规模数据点的输出,最多只能看看有无死循环和异常,有了辅助程序就可以细致检查每部电梯是否正常运行,非常方便。

⛳互测的bug

​ 不得不说这单元的互测我没有第一单元积极,主要是没有数据生成器,手写数据点也很繁琐。所以互测中我只是提交了一些小规模的边界数据测试点,比如第一秒输入一个请求,等一分钟再加一部电梯这种(有的同学的程序不会正常结束)。

💭心得体会

​ 电梯月终于结束了,不过个人感觉这单元的压力并没有比第一单元多很多(除了有次差点通宵以外)。第一次接触多线程编程,入门就花了很长时间,还好第一次作业赶上假期,课程组延长了几天,否则我是一定写不完的。因为第一次作业建立了一个还算合理的架构和电梯调度的算法(后面就没有动过,LOL),所以后面两次作业就比较顺利,线程之间如何沟通、同步等等都比较熟练了。

​ 本单元作业一个最大的体会就是:大道至简(x)。在开始着手调度和请求分配算法的编写前,我想了很多实现的方法,身边的朋友和研讨课的同学也提出过很复杂但是效率很高的算法,一方面是因为自己比较懒,另一方面是觉得在强测这种大规模随机数据的测试中也许不一定越高级越好,所以第三次作业中的请求换乘楼层计算我只是写了个计算代价的函数,虽然看起来很原始,但是强测中没有低于92分的点,大部分都是98分上下。我的一个室友写了个更简短的计算代价的函数(可能只有几行或者十几行),最后强测分数比我高。因此电梯调度这种大规模且不确定性比较强的场景中不存在最佳的算法,但是可能存在代码效率最高的算法(指几行核心的调度算法得到强测99+😆)。

posted @ 2022-04-29 17:20  alxzzz  阅读(53)  评论(0编辑  收藏  举报