BUAA-OO-2021 第二单元作业
BUAA OO 第二单元总结
第一次作业
总体设计
RequestDispatcher 为总分配器(线程类),负责将请求分配给每个电梯(本次作业只有一个电梯,不能发挥真正作用)。
RequestQueue 为自己写的一个线程安全的队列(非线程类),负责作为Input输入和总分配器之间的缓冲区。
ElevatorManager 为电梯控制器(非线程类),负责接受来着总分配器的请求和控制单个电梯,同时维护电梯所有信息(包括当前位置、请求队列、电梯内外乘客、电梯运行方向等)。
Elevator 为电梯(线程类),每次调用 ElevatorManager.elevatorAction() 获得指令 (比如open、close、move)等,然后根据指令睡眠相应时间后调用ElevatorManager.elevatorMove() 等函数更新电梯状态,然后再获取下一个指令。
为方便起见,没有另设Input线程而是直接将该任务交给了主线程。
类图

顺序图

调度器策略
总分配器策略
由于目前只有一个电梯,所以RequestDispatcher 直接无脑将请求分配给 ElevatorManager 即可。
电梯控制器策略
本来打算用look算法的,后来一时冲动想挑战一下自己,于是写了一个贪心算法。
该贪心算法通过一个函数,根据电梯当前位置、电梯内部乘客的目标楼层、电梯外部乘客的起始楼层以及上行或下行信息来计算出电梯的运行方向。
这使得电梯的行为变的更加灵活,例如当电梯上行到第 \(x\) 楼时第 \(x-1\) 楼来了一名乘客,则电梯有可能会回到第 \(x-1\) 楼接收乘客后继续上行。
另外该电梯在中测中出现了反复上下运动的问题,经检验发现是该电梯在某一楼层的贪心函数到达临界值,即当该电梯上行时贪心函数值小于 \(0\) 使得电梯需要下行,当该电梯下行时贪心函数值大于 \(0\) 使得电梯需要上行,后来通过对电梯的行为增加限制来解决了这一问题。
最后该算法在强测中性能分被look算法暴打,写的累得分低,以后再也不干这种傻事了。
不同模式下的策略
Morning模式下电梯在空闲后会返回 \(1\) 楼。
Random模式和Night模式电梯无特别优化。
特殊优化
1、根据规定开门和关门各占 \(0.2s\),但为了充分利用纸片人乘客的优势,可以将开门设置成瞬间开门,关门设置成 \(0.4s\),开门时将电梯内的所有到底目的地的乘客全部请出电梯,而在关门的最后一刻再将需要进入电梯的乘客送入电梯,可以最大限度的使得乘客(包括关门的 \(0.4s\) 期间来的乘客)进入电梯。
2、事实上测评机判定合法行仅关心电梯在指定时刻的输出,而不关心电梯在此前的真实行为。因此电梯在关门后处于一种薛定谔猫的状态,即该电梯可以停留在该层,也可以上行,也可以下行。例如该电梯在 \(t\) 时刻输出 [t]CLOSE-10 信息,此时乘客列表为空。如果 \(t+0.3\) 时刻一名乘客出现在第 \(9\) 层,则电梯只需要在第 \(t+0.4\) 时刻输出 [t+0.4]ARRIVE-9 即可。如果 \(t+0.3\) 时刻一名乘客出现在第 \(11\) 层,则电梯只需要在第 \(t+0.4\) 时刻输出 [t+0.4]ARRIVE-11 即可。如果 \(t+0.3\) 时刻一名乘客出现在第 \(11\) 层,则电梯只需要在第 \(t+0.3\) 时刻输出 [t+0.3]OPEN-10 即可,利用该策略,电梯可以在一定程度上预知未来,也希望现实生活中能尽早开发出量子电梯。
线程同步设计
本次作业使用了 java 提供的 Lock 和 Condition 实现了线程安全。
其中 RequestQueue 类显然只需要一个 Lock 即可实现线程安全。
然后就是 ElevatorManager 类,该类需要两个锁,一个锁维护乘客列表,一个锁维护电梯状态(如方向位置等)。更新电梯方向需要用到贪心算法也就需要访问乘客列表,判定电梯是否快关门也需要用到乘客列表,电梯释放和加入乘客也需要访问乘客列表,最后干脆就把两个锁二合一了。其实无脑锁也不要紧,只要注意两件事:一、避免死锁;二、避免在sleep时拿着锁。只要能避开这两点,由于本次作业运算量并不大,所以对性能不会过分影响。
测试相关
本人强测与互测均未被发现BUG,并成功hack别人一次,hack时没有看别人代码而是进行黑盒测试。
hack方式为自动生成一组夜晚模式的乘客数据,但把输入模式改成Random。被hack的同学测试信息为 RUNTIME_ERROR,但我并不清楚这位同学为什么会有这个BUG。
第二次作业
总体设计
RequestManager 为请求总管理器,拥有成员变量RequestDispatcher 和 RequestQueue ,同时存储了当前的达到模式。因为学了单例模式,于是就用了单例模式来编写 RequestManager 。
RequestDispatcher 为总分配器(线程类,抽象类),负责将请求分配给每个电梯。本来打算为Morning、Night、Random写三个不同的分配器,后来发现效果还不如单个Random模式分配器 RandomDispatcher 效果好,于是将 MorningDispatcher 和 NightDispatcher 类删除了。
RequestQueue 为自己写的一个线程安全的队列(非线程类),负责作为Input输入、总分配器以及乘客回收线程 RecyclingThread 之间的缓冲区,至于 RecyclingThread 类是什么会在调度器策略中解释。
ElevatorManager 为电梯控制器(非线程类),负责接受来着总分配器的请求和控制单个电梯,同时维护电梯所有信息(包括当前位置、请求队列、电梯内外乘客、电梯运行方向等)。
ElevatorPerson 为电梯乘客列表的封装类(非线程类),用于控制乘客进出以及根据电梯情况确认是否开门等,主要用于分担ElevatorManager 的任务,降低程序复杂度。
ElevatorThread 为电梯(线程类),每次调用 ElevatorManager.getCmd() 获得指令 (比如open、close、move)等,然后根据指令睡眠相应时间后调用ElevatorManager.toMove() 等函数更新电梯状态,然后再获取下一个指令。
为方便起见,没有另设Input线程而是直接将该任务交给了主线程。
类图

顺序图

调度器策略
总分配器策略
对每个待分配器乘客,分配器尝试询问每个电梯是否愿意接受乘客(拒绝原因有电梯接受该乘客会导致超载,电梯加入新乘客会导致电梯运动路径增加过大等)。
如果有电梯愿意接受乘客,则将所以愿意接受该乘客的电梯按损失函数排序,然后取损失函数最小的电梯。其中损失函数的计算方式是假定电梯接受该乘客后的负载量再加上电梯增加乘客后的电梯负载量的增量,负载量的计算方式为电梯送完所有乘客需要走完的总路程加上电梯当前负责的乘客数。
如果没有电梯愿意接受乘客,则将乘客丢给 RecyclingThread 类。RecyclingThread 为守护线程,每隔 \(0.3s\) 会将所有 RecyclingThread 类中的乘客送还给 RequestQueue 进行重新分配。
利用该策略,可以避免将大量乘客一次性分配给当前所有电梯,导致后加入的电梯空闲。同时暂不决定所有电梯拒绝捎带的乘客应该分配的电梯也使得分配策略更加灵活,可以一段时间后再分配给合适的电梯。
电梯控制器策略
look算法,yyds。
不同模式下的策略
Morning模式下电梯在空闲后会返回 \(1\) 楼。
Night模式电梯会将所有乘客请求读入并根据所在楼层排序后再分配乘客。
特殊优化
在第一次作业的基础上进行了改进,直接在电梯 getCmd() 前先记录系统时间,然后在电梯线程睡眠前再次获取系统时间,根据两次时间差值减少睡眠时间。本次改进顺便可以消除getCmd() 演算过程造成的延迟。
线程同步设计
本次作业使用 synchronized 代替了上次作业的 Lock 和 Condition ,理由是本人作业并未使用 Lock 的高级特性,同时 synchronized 优化不错,感觉延迟比 Lock 更短。
本人的 RequestQueue 和 ElevatorManager 的 synchronized 对象都只有一个,理由是根据优化算法需要,这两个类的大部分方法都需要访问该类中的大部分成员变量,所以就直接无脑 synchronized。
测试相关
本人强测与互测均未被发现BUG,并未成功hack别人。
第三次作业
总体设计
RequestManager 为用单例模式编写的请求总管理器,下属成员变量 RequestDispatcher,同时存储了当前的达到模式。
RequestDispatcher 为总分配器(线程类),负责将请求分配给每个电梯,同时顺便将 RequestQueue 也合并到该类,方便对乘客进行管理。
ElevatorManager 、ElevatorPerson 以及 ElevatorThread 的设计同上一次作业,顺便设计了一个简单工厂模式的 ElevatorFactory 生产电梯。
Person 类封装了一个 PersonRequest 和一个目的楼层 goal。
RecycleThread 类负责接受电梯运送到目的地的 Person 类,同时将 Person 类送给 RequestDispatcher 。没有直接由电梯将 Person 类送给 RequestDispatcher 是为了避免死锁。 RequestDispatcher 根据 goal 是否等于 PersonRequest 来判定是否完成了该 PersonRequest,如果未完成则将生成一个新的 PersonRequest,新 PersonRequest 的 fromFloor 等于目的楼层,然后再次进行分配。
为方便起见,没有另设Input线程而是直接将该任务交给了主线程。
类图

顺序图

调度器策略
分配时仅考虑不换乘和仅换乘一次的情况,其中取预计时间最短的情况优先分配。
对于不换乘的情况,电梯预计时间为电梯速度乘以乘客起点到终点的距离,电梯速度为电梯初始速度加上一个电梯当前负责人数相关的惩罚函数。
对于一次换乘的情况,设换乘的两段路径使用的电梯为电梯A,B。则电梯预计时间为电梯A电梯速度乘以乘客起点到换乘点 + 电梯B电梯速度乘以乘客换乘点到终点 + 换乘惩罚。
其中电梯当前负责人数相关的惩罚函数和换乘惩罚都是需要调参的,比较玄学。
其他设计
同上一次作业。
测试相关
本人强测与互测均未被发现BUG,并成功hack别人一次,hack时没有看别人代码而是进行黑盒测试。
hack方式为针对性地构造了临界数据,hack了换乘策略表现不佳的类ALS电梯。
三次作业总结
作业可拓展性分析
本次作业对新类型电梯的可拓展性还是比较好的,如果需要新增一个自定义的电梯类,重写以下几种方法即可。
本人在做优化和人员分配时也仅调用以下函数,因此加入新类型电梯不需要额外修改分配器代码,也不会对程序功能不会产生影响。
但由于电梯当前负责人数相关的惩罚函数和换乘惩罚涉及超参数,因此每次加入新类型电梯后需要更新超参数,否则会对程序性能产生影响。
public class ElevatorC extends Elevator {//以C电梯为例
//这里C电梯的可达楼层、移动、开关门时间、容量已经在题目描述中定义,所以不需要作为参数传入
//如果这些信息是在输入中定义,那作为参数传入即可
public ElevatorC(String id) {
super(id);
}
@Override
public boolean legalFloor(int position) {
return position <= 3 || 18 <= position;
}
@Override
public int getMoveTime() {
return 200;
}
@Override
public int getCloseTime() {
return 400;
}
@Override
public int getCapacity() {
return 4;
}
}
测试总结
相对于第一单元的单线程作业,本人发现多线程测试、调试的难度变大了不小,代码编写过程中遇到的 bug 主要是死锁,不过幸运的是都比较好复现。
关于自动测评机,本人仅编写了自动定时输入的测试程序,并没有编写输出正确性检测脚本,主要用来检验自己程序是否含有死锁、性能如何,以及用来 hack 别人的死锁BUG。
可能因为代码写的比较谨慎,因此本人的代码都没在强测和互测过程中出现BUG。
关于 hack 别人BUG的策略,感觉还是用边界数据靠谱,自动测评机的随机数据一般抓不到人。
心得体会
线程安全
主要是要注意死锁,特别是循环依赖的类之间非常容易产生死锁,该问题一般可以通过增加缓冲区避免。
另外需要控制好锁的范围,不能太大也不能太小。还有线程sleep前尽量是否没有必要的锁,否则非常影响性能。
层次化设计
层次化设计体现了分治的思想,将一个大问题差分成若干小问题,交给不同模块处理。不同层次的模块的任务互不干扰,大大降低编程难度和模块之间的耦合度,同时提高程序的可拓展性。
其他感想
我也认识到了工程项目和算法题之间的差异:自己平时写算法题时总希望能找到最优解,但电梯调度问题可能并不存在最优解。一种策略总是仅在一部分数据上表现得比另一种策略优秀,而在另一部分数据上表现不佳。
与此同时我还理解了简单策略的优势:一般来讲一个策略表现越优秀,那该策略需要获取的信息就越多演算就越复杂。需要获取的信息量越多,则线程的锁的范围也会增大,程序的拓展性也可能下降,另外复杂的演算也会拖慢电梯调度速度。因此复杂的策略有时也可能有时不如简单的策略。

浙公网安备 33010602011771号