2019面向对象程序设计——目的选乘电梯之优化篇
作者:1723 🐺
优化前言:
经过了第二单元三次电梯的历练,可能不会有特别多的人比我更加深切体会到一个优秀架构的重要性。因为在优化策略上耗费巨大心血的我,虽然强测测试点的确拿了一些100,但却第二次电梯作业却因为架构设计上的不足和犯懒没做更多的自主测试,强测惨痛爆点,直奔C屋,捡了芝麻丢了西瓜。在第二次作业后,我在架构设计上进行了许多思考,并且广泛参考了许多16级和17级高工巨佬们的架构设计。在保证正确性的基础上,优化策略的价值便体现出来,因此,本文也会介绍一些优化策略。
一个优秀的架构也许不足以让你强测拿顶级分,若没有优秀的架构,对于数据较少的强测,也许能拿顶级分,但对于大批量数据测试,性能和安全性的问题就会暴露出来。对多电梯来说,架构设计尤其重要。
一.架构设计要点:主线程退出问题
这个问题和输入线程、调度器、电梯的死亡问题不是同一个问题。
在第一次作业中,指导书中在“输入接口.md”中明确指出:建议单独开一个线程处理输入。而在之后的第三次指导书中,则改为了:建议主线程处理输入。我猜测,这个修改可能是由于大多数同学在拥有了输入线程之后,却忽视了主线程的作用。
对于单开输入线程的同学,这个设计要点,你可能会感到诧异:我的主线程负责创造其他线程,创造完之后,它就死了,不会造成其他影响,也不会引起正确性的问题啊?
没错,确实可能在这次作业中不会引起正确性上的问题,甚至那些难以复现的bug也不是因此而产生,但这不符合工程规范,架构的逻辑性也存在问题。
下面这句话,是一个优秀架构所需要的重要设计规范之一:(注:这句话不是我说的,源于一位大佬助教的分享)
保证所有非主线程的生命周期都直接或间接被主线程严密控制,即主线程第一个出生,最后一个死亡。
如何能保证主线程最后一个死亡?方法之一就是线程的联合:即join()方法。
在此仅简介,具体请自己深入研究。
一个线程A在占有CPU资源期间,可以让其他线程调用join()方法和本线程联合,例如B.join();称A在运行期间联合了B。如果线程A在占有CPU资源期间联合了B线程,那么A线程将立刻中断执行,一直等到它联合的线程B执行完毕,A线程再重新排队等待CPU资源,以便恢复执行。
以第二次作业为例,除了主线程之外,由于电梯线程是最后死亡的,所以主线程在执行最后一条语句之前联合电梯线程,在电梯线程死亡后再死亡。
//创建调度器线程
Dispatch dispatch = new Dispatch(eleNum,
inputDeadFlag, dispatchDeadFlag, upReqList, downReqList);
//创建电梯线程
Elevator elevator = new Elevator(1, dispatchDeadFlag);
//创建输入线程
InputThread inputThread = new InputThread(
upReqList, downReqList, inputDeadFlag);
dispatch.addElevator(elevator, 0);
//start电梯线程
elevator.start();
//start调度器线程
dispatch.start();
//start输入线程
inputThread.start();
try {
//主线程联合电梯线程
elevator.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
二.架构设计要点:主线程、调度器线程、电梯线程的安全死亡问题
这个问题谈不上架构优化,是所有同学都必须解决好的一个问题,但是大家的解决方法各不相同。有些原始方法在第二、三次作业中可能会遇到线程安全问题,比如在第三次作业中,直接将 null put到请求队列中,电梯读到null自动终止的方法有可能是不安全的(只是对于部分设计来说不安全)。
以下是一个利用同步锁的个人推荐(并不一定真的好)的方法:
执行逻辑:
输入线程在没有input时终止(即读到null终止);
调度器线程将在输入线程终止且一级请求队列(调度器请求队列)没有待分配的请求时终止;
电梯线程将在调度器终止且二级请求队列(电梯内部请求队列)没有待执行请求且电梯内没人时终止。
线程控制:
托盘对象类:
public class DeadFlag {
private boolean deadFlag;
DeadFlag() {
this.deadFlag = false;
}
boolean getDeadFlag() {
synchronized (this) {
return this.deadFlag;
}
}
void setDeadFlag() {
synchronized (this) {
this.deadFlag = true;
}
}
}
输入线程和调度器共享一个托盘对象;调度器和电梯共享一个托盘对象
//输入线程死亡
try {
synchronized (inputDeadFlag) {
inputDeadFlag.setDeadFlag();
inputDeadFlag.notifyAll();
}
} catch (Exception e) {
e.printStackTrace();
}
//调度器线程死亡
if (inputDeadFlag.getDeadFlag() && upReqList.isEmpty()
&& downReqList.isEmpty()) {
break;
}
//在break之后
synchronized (dispatchDeadFlag) {
dispatchDeadFlag.setDeadFlag();
dispatchDeadFlag.notifyAll();
}
//电梯线程死亡
if (dispatchDeadFlag.getDeadFlag()
&& upReqList.isEmpty() &&
downReqList.isEmpty() && personIn.isEmpty()) {
break alive;
}
其实调度器没有必要单开线程,可以作为附属组合模块,这样也会大大降低了线程安全控制的难度,以上是我第二次作业写的电梯之一,调度器线程是为了第三次作业的复用做准备(其实可完全可以不用)。
三.架构设计要点:CPU时间
关于评测机的CPU超时检测是一大谜题,但一部分仅有wait()¬ifyAll或await()&signalAll()的架构在这强测中出现了CPU连环爆点的情况。(我在本地做过CPU测试,然而还是被hack了CPU超时.....)
以下是三种降低CPU方法,推荐使用①+③组合或方法②
方法①:wait()+notifyAll()或利用ReentrantLock&Condition类的await()+signalAll()
ReentrantLock&Condition类的await()+signalAll()比wait()+notifyAll()更加灵活精确,推荐尝试。同步方法与加锁在此不再赘述,详情请见老师上课的课件或上网自学。
方法②(推荐):电梯自主自杀与调度器激活法
这一方法虽然暴力,并且没太用到互斥控制,但经过测试,这种方法降低CPU时间上有奇效,推荐使用(要求使用Runnable接口)。
何谓电梯自主自杀?
答:使用Runnable接口创建的电梯线程在执行完所有请求后自动死亡。
何谓调度器激活?
答:调度器每分发一个请求给电梯后,会调用isAlive()方法检查电梯线程是否alive,如果电梯线程已经死亡,则重新电梯创建一个线程并激活。
具体实现
public class Dispatcher {
//初始化,每一个elevator对应一个thread,起初都是null。
LinkedList<Elevator> elevators;
LinkedList<Thread> threads = new LinkedList<>();
public Dispatcher(LinkedList<Elevator> elevators) {
this.elevators = elevators;
for (int i = 0; i < elevators.size(); i++) {
threads.add(null);
}
}
//剩余部分省略
}
//调度器先把请求加进i号电梯。
getElevators().get(i).addRequest(request);
//检查i号电梯对应的线程状态,若是死的线程则为电梯重新创建线程并激活。
if (getThreads().get(i) == null || !getThreads().get(i).isAlive()) {
getThreads().set(i,new Thread(getElevators().get(i)));
getThreads().get(i).start();
}
方法③:电梯线程sleep(1)
强烈建议想使用方法①,并且把请求分给所有电梯让电梯自由抢的同学,再使用方法③作为辅助方案。
为什么多电梯使用wait()与notifyAll()仍会炸点?我认为原因有二:
其一:电梯wait()的机会少。
其二:电梯刚wait()就被唤醒。这样电梯会很生气
如果是电梯抢请求,请求分给多个电梯,甚至同一请求按照不同拆分方式分给电梯,电梯请求队列的请求数目较多,基本不会休息,这样一来,大量线程争夺CPU资源,爆点也在意料之中。
当我们设置sleep时,等于告诉CPU,当前的线程不再运行,持有当前对象的锁。那么这个时候CPU就会切换到另外的线程了,因此让电梯线程sleep(1)也是能缓解CPU时间的一个辅助方法。
四.顶级架构模式:调度策略与电梯运行的完全抽象剥离
调度策略与电梯运行相剥离的抽象剥离的架构,是顶级设计架构之一,但我在做作业时死🐟安乐并没有去尝试,只能事后诸葛,我系计算机专业的wsz巨佬基本实现了这一架构,以下设计参考wsz同学的思路。
何谓调度策略与电梯运行的完全抽象剥离?
答:电梯只负责上下楼,调度策略完全由外部决定
只有不到10行的电梯类!基类Base也只有100行不到,只负责上下楼和安置Strategy。
public class Elevator extends Base {
public Elevator() {
super();
setStrategy(new Strategy());
}
}
Strategy是电梯的调度策略,是外部类,可直接安置给电梯。
为什么被广泛认可为顶级架构?
答:你可以把各种sao操作,奇技淫巧,优化策略分别写成一个单独的Strategy类,电梯想换策略直接换一个Strategy类就可以,极大地与Solid原则吻合。
对于不同的Strategy,其基础功能也可以定义接口
public interface Strategy {
LinkedList<Job> getFinishJobs(int curFloor);
void addJob(PersonRequest request);
String getDirection(int curFloor);
boolean isEmpty();
}
若想多策略并行择优选择,这一架构非常合适
五.单电梯优化策略:LOOK算法+条件折返
只谈性能,用户体验为0
LOOK算法的平均运行时间会明显优于纯贪心算法以及先来先服务的ALS算法。使用LOOK算法,强测基本以及可以拿到90的成绩,但LOOK算法本身有性能上的弊端,即无论人怎么怼门,它也不折返。处理好何时该折返、何时不折返问题,强测数据点甚至可以拿一半以上的满分。
比如:
233-FROM-1-TO-4
2333-FROM-3-TO-4
23333-FROM-8-TO-13
这样的测试点,ALS电梯会明显优于LOOK电梯,如果不加处理,LOOK会优化分爆零。
如果你的电梯沿运行方向已经走过了某个楼层,却有生气的乘客疯狂怼门,你开不开门呢?以下提出两种优化观点,一为模拟,二为预测。
模拟:
看到有dalao已经实现了多策略电梯,即每个请求给多个电梯都跑一遍,择优输出,这样的电梯性能想必是极强无比的。在这里,我介绍另外一种优化观点:模拟。
对于我的LOOK电梯,每做一个方向上的任务时,有三个属性:
private EleStatus status;
private EleStatus dirc;
//注:初始折返次数:0,最大折返次数:1
private int backtrack;
status表示的是电梯想处理哪个方向的一趟请求,dirc是电梯当前运行方向,backtrack记录折返次数
电梯每到一层楼时,如果status与dirc是同向的(dirc与status不同向时意味着电梯正在反向去接最上或最下楼层的请求,接完后开始做任务),且有需要折返的请求时,若这趟任务的折返次数为0,则进行模拟折返和模拟不折返,提前暴力算出完成当前全部请求,折返与不折返的时间开销,不允许一趟请求多次折返。如何计算并不困难,可直接暴力拿电梯开关门时间,电梯运行一层时间莽算即可。
//用于模拟折返,返回折返稍人的完成全部请求的总运行时间
private int simulateBack(int curFloor);
//用于模拟不折返,返回不折返的完成全部请求的总运行时间
private int simulateNotBack(int curFloor);
以上行举个例子
//条件1
if (dirc.equals(EleStatus.up) && status.equals(EleStatus.up))
//条件2
遍历上行请求队列发现存在request满足request.getFromFloor()<curFloor(当前楼层)
//条件3
this.backtrack = 0;
满足以上请求则进行模拟
显然电梯折返的运行情况是:
status:up->down
dirc:down->up->down
不折返的运行情况是:
status:up->down->up
dirc:up->down->up
统计表明:电梯折返占优与不折返的占优数据测试为三七开,要想拿那3成的优化分,模拟是很重要的。
预测:
我没这么干,也没能力让电梯机器学习
一个会机器学习的电梯于本次作业是无用的,因为强测数据点很随机。
对于大规模有一定规律性数据的测试,这也与平时生活类似。电梯可以根据实时统计结果或累计统计结果动态调整策略,达到在空乘状态下预测未来请求分布从而优化性能的目的。
有兴趣的同学们可以在电梯单元结束之后尝试一下,(反正我懒,摸了)。
六.多电梯优化策略
1.全拆分
经过联合测试,发现本来期望度极高的图算法拆分在大量随机数据面前却败下阵来。原因估计是电梯运行环境太过于复杂,换乘仅进行一次拆分的图算法虽然本身没有任何问题,但还是难以预料电梯究竟是怎么走的。但本次强测孰优孰劣倒是不好说,数据点很多是[0.0]或者其他时间点集中投放,或者间隔极短投放,这时电梯处于起步状态,运行是可预料的,单一拆分就会有优势。
经过询问,采用下面这种方法的同学也都全部91+,不失为一种不错的策略,而我,本次作业换乘只进行了一次盲目自信的豪赌拆分,并没拿到很高的优化分,哭辽。
①如果无需换乘,这个人把A, B, C电梯都摁一遍
②如果需要换乘,先尽量在相同方向上拆分请求,但所有电梯组合(A&B、B&A、A&C、C&A、B&C、C&B)以及每种电梯组合的全部拆分可能都拆一遍,并且全部投入电梯,达到最混沌的状态。
比如请求1-FROM--3-TO-2对于A,B电梯组合会被分成:
1-FROM--3-TO--2(A) + 1-FROM--2-TO-2(B)
1-FROM--3-TO--1(A) + 1-FROM--1-TO-2(B)
1-FROM--3-TO-1(A) + 1-FROM-1-TO-2(B)
全部加进请求队列
//A,B电梯的上行请求拆分为例
floorsS = floorA
floorsE = floorB;
if (judgeIn(floorsS, fromFloor) & judgeIn(floorsE, toFloor)) {
for (int p = 0; p < floorsS.length; p++) {
for (int q = 0; q < floorsE.length; q++) {
if (floorsS[p] == floorsE[q] && floorsS[p] >
fromFloor && floorsE[q] < toFloor) {
int[] req1 = {id, fromFloor, floorsS[p], i, j, 1};
int[] req2 = {id, floorsE[q], toFloor, j, j, 0};
request.add(req1);
request.add(req2);
flag = true;
}
}
}
}
③如果无法同向换乘,则反向拆分到距离请求楼层最近的楼层。
④一个电梯拿到请求,需要把其他电梯请求队列中id相同且非下一阶段的请求remove掉。
全拆分本身并不是一种强大的算法优化,而只是一种减少平均损失的折中策略,它之所以能在强测中占重要一席之地,是因为电梯运行的随机性和数据点的随机性造成各类算法运行时间的不稳定性,甚至单一拆分的电梯抢人的运行时间仍然不够稳定,减小平均损失的折中可能不会拿到顶级分,但也会拿到很不错的优化分(91+)。
2.请求分配策略
原则:①不完全平摊。快的电梯还是应该多拿请求的,毕竟运行速度摆在那,但决不能鸽(🕊)了电梯C。
②不集中。也尽可能不能让电梯一次吃足。
为什么要提出这样的问题?
下面分析这样一个情景:
假设在1楼,B电梯吃饱了请求,C可能只吃了很少的请求。
B电梯拍拍肚子往2楼跑,这时有一个id=2333的人突然怼门
2333-FROM-2-TO-3
A:不去
C:不去
B:饱了不去,等下波吧
id=2333的人:当场暴毙
如果B电梯把C电梯可共享的请求多分担一部分,就可以搭上这个请求了。
所以说,一个合理的调度,除了换乘之外,应当是电梯速度+负载状态的选择。
七.总结
2019年OO电梯单元的设计可谓独出心裁,我在其中得到了诸多历练,以上的优化点只是九牛一毛,更多的希望大家不吝分享。总之:架构重要,架构重要,架构重要(哭唧唧)。