本文分为设计架构和统计分析,第三次作业详细分析,测试和代码对比,调度,以及感悟(废话)
调度部分单开章节叙述,不在设计架构内详述。
三次作业的设计架构
第一次作业
第一次作业时,我对多线程的理解不深刻,且充满恐惧。所以基本是在本地迭代来完成一点的的开发。基本源于经典的Producer-Consumer,一步一步确定阻塞条件和等待队列的处理方法。因为整体架构中,电梯·和Request作为生产者和消费者两端,具有更加主动的地位。故而也没有思考过于复杂的调度算法,只是简单的LOOK,并且没有判断是否同向
此时的架构基本是完全的生产-消费模式,电梯只关心运行,与CustomerList以moveto()相连,相对来说,moveto()是电梯的get()方法变体;而add()方法,我以 !(isrunnig()||isopen()) 来确定,这事实上是多余的。
此外,调度器还可以得到isrun,isopen等简单状态,大多数信息都是非透明的。
另外,我为了防止手癌,将输出封装为Output类的一系列方法,后来还能起到其它好作用。
可以看到,此时各个类的复杂度不算很高,其中比较复杂的是处理 Request 的WaitingThread类。这是由于其具体处理较为简单,故而完全放在run()方法内实现的缘故。
第二次作业
架构调整
第一次作业的总队列在第二次作业的多电梯时不太好用。但电梯的moveto()又是由电梯自有和等待队列两部分综合判断的。故而我给Customer第一次设计的flag承担了两部分责任:判断等待/下场,以及标记在哪一部电梯。而在电梯内部,则copy电梯内乘客作为private类,来保证线程安全。
之后保留电梯的主动地位,安排电梯抢人。而抢人的时候会出现一个问题,就是多个电梯同时到达抢人的问题,于是修正了needopen()的判断方法,加入:
if(isopen[floor]){
return;
}
同时将isopen修改为以楼层为下标的数组。
其他细节修改
此外,在判断move和开门时,我没有简单遍历,而是设计了一个脑袋被门挤了的 wait数组,其可以看做一个楼层和朝向映射到等待人数的函数。是一个“特化”函数,下次完全用不上。
这次由于人数限制,捎带反向乘客必然需要修改,故而在Customer里加入up变量
第三次作业
第三次作业和第二次作业的不同之处十分有限,需要修改的几个部分都较为清晰:
换乘的拆分,电梯限制的实现,和Output的线程安全。
换乘的拆分
综合考虑,我在前期输入时直接拆分请求,采取较为粗略的办法,只把不能直达的请求拆成两个(由于通用换成层的存在,故而确定必然可以拆成两个),封装了单独的Judge类其保存了三种类型电梯的到达情况(是直接打表,但却是拿C程序输出的表,这些东西还是C顺手啊)和判断直达的方法。
电梯限制的实现
很简单,把最高最低楼层,容量等等都在构造时写好,最后把judge里的表复制一份。并把move判断加一个 while(canstop(now)) 就完了。之后把调度器里的一些常数换成这些变量就好了。
输出安全
更简单,给Output方法都加 syncronized 就完了(本地写了一个更容易诱导出事的print()测过,是可以的)
其他更改
由于Customer和Judge的扩充,一些判断诸如iswait(),canloadby()等,分给了Customer类,把任务进一步向下分了。
但由于输入处理和调度的高度集中,复杂度自然还是高。
关于bug
我的bug
三次的互测和强测没出事。(就是感谢弱侧够难)
第一次提交了好几次,但是是因为测试时使用了两种输入手段导致的,不是bug。
第三次出现了几个bug:
2-3没有途经换乘点,而我没有考虑绕路换乘。
B和C的capacity写反了
没给输出上锁
别人的bug
由于我的测试手段有点瘸(而且没出安全问题)导致我的hack极其失败,没有测出结果。观察同屋hack的结论,基本可以确定基本是线程安全出现了问题。
我的测试手段
我有一个简单的subprocess模块,和一个数据生成代码。但结果判定的逻辑不够详细,而导致我事实上出了结果还是很可能要手动判定。下次需要改进。
别人的经验
大家在基础架构上较为相近,而导致代码差异的部分主要分成几个部分叙述。
类的划分
大多数人和我的划分粒度较为相近。区别在于,有一些人的调度设计较为复杂,故而和list拆分。这样做使得调度修改的灵活度变强,但共享区又增加了一大块,对于安全问题要十分审慎。
还有只分elevator,tray和main的,只能祝他好运。
我看到的同学代码,有人还做了详细的分包,层次也更分明,这是我值得学习的。
设计区别
更多的人把调度器放在更为主动的地位,这一点和前文现象有所重叠。这样其实有一定的好处,即在分发和调度上,不会像我这样掣肘。
我的调度策略,基本是没有调度策略,依靠谁抢到算谁的,来实现了一个look算法,当时是考虑到它是较为简单不易出错的方法(外加懒),于是3次都没有大改这个设计策略。然而令人惊讶的是,在3次测试中分别得到97.8,97.1,99.5的成绩,令摸鱼者十分惶恐(愉悦)。故而设单章来解释一下摸鱼的正当性无为而治的一些优势。由于有些马后炮性质,故而此篇只能算作猜测。
调度方法评估的”元理论“
这是一个理论上最优的最优算法,即不存在的RMB玩家 算法:
考虑到RMB玩家直接偷到了stdin,就可以遍历找最短方法。这时由于输入的有限且确定,则可以得到最佳的最短方法。
以这个算法为参考,可以估算各个策略的优劣。
但这个算法会存在一个问题(除了显然的未来不可测),就是此时电梯采用的策略并非“从一而终”,例如在面对1-2,2-3,4-5之类的请求时,采取look显然毫无问题,但在考虑到1-10,2-11,1-15这样显然中途转向的数据时,便需要更改新的算法。此外一些细节也无法唯一判定,例如闲时电梯停在哪一层等待。
此时,就引入算法估计的具体做法:
评估的要素
当一个算法针对某些情况做了优化以后,很可能会导致另一些情况变差,尤其是在未来不可预测的情况下,若对一部分情况进行优化而导致另一部分情况造成了不小的“负优化”,那么会导致该做法"得不偿失“。至于如何衡量一个优化是否合算,几个参考要素如下:
-
被优化的部分和被负优化的部分,谁才是测评姬爱的数据
-
做出这个优化,会对已有的设计尤其是共享数据,产生多大的影响?
-
做出这个优化,对于程序猿
摸鱼掉头发(因脑力和体力及长度作用导致的手癌),有多大影响?耗时的期望
这时,很自然地想到用期望来实现优化效果的评估,是合适的。由于测评姬数据的未知性,可以认为数据是纯随机数据,即概率分布均匀,之后就可以依靠简单计算(
实际上是直觉),来估测优化部分的概率了。我为什么不像某些大神一样让调度器做复杂调度
由于我的“调度器”其实只是一个等待队列的封装,它事实上对电梯的情况知之甚少。若设计一个总的调度器,会导致电梯运行的细节信息过多地被传递给调度器,而电梯类的大部分可变数据在设计时未考虑共享,没有做安全保护,故而这时做复杂调度很有可能导致安全问题爆发,且与低耦合的原则违背,故而我尽量把自主权下放给电梯,而调度器只给出基于等待队列的建议。
复杂算法导致测试难度上升
如我惨痛的第三次作业,有一个极长的主类和一些不细致的优化,这种过长的片段,分支,细节,很可能会造成一些难以察觉的问题:它们只在特殊情况触发,甚至会导致在满足一系列特殊情况时才满足。这时有可能会造成一些bug的出现概率极低,而在测试时不被cover。
LOOK算法
LOOK算法是在实际运行中使用最为广泛的算法,其在顶层和底层之间循环接人,在所有可以停下来捎带的人旁边停下并带走,是在考虑到运行速度和用户响应两方面后得到的主流解法(可以理解成
会投诉的非纸片人眼中的最佳算法)。总之,它是一个“不差”的算法。
第一次作业的算法
第一次作业,最开始的设计是scan的算法,之后优化了最高层的判断(从最高,变为需求最高),形成了基本的look算法。这时由于电梯容量无限,甚至没有判断是否同向 ,只要到了就塞进去,然后在楼里来回滚……
这不算是一种最差的算法,但肯定不是最优。相比起来,这次的问题较为简单,故而某些同学采取的贪心算法(考虑谁近来判断往哪边,而非一味地上到顶才下)得到了更优的成绩(99+吧)。
第二次作业
第二次作业相比起第一次感觉实在是没什么进步,故而我花了一些精力思考(空想)调度算法,在讨论中,我可以得出几条结论:
-
若要实现贪心算法,必然会导致调度器需要随时知晓电梯和乘客两方面的信息,动态进行判断。这会导致共享区域扩大,对于线程安全很怂的我,希望尽量不要这般。
-
完全平均的调度算法,必然会导致一拨人一起来时,性能大开倒车,不可
-
如祭祖老师所言,“随机肯定不是最坏的”,于是还是抢吧。"抢"其实解决了几个问题,第一第一次的绝大部分内容得到继承,只需做扩展而不必做修改。
保证乘客的等待时间尽可能短,因为是最快的电梯抢到了乘客。
最后,这样的分配方案会在单电梯无法解决时,强行开始“均分”,因为你追我赶的几部电梯,最起码在数据不是非常多时,有着较为接近的运行楼层;而在可以一波带走时,它也可以实现“一波带走”。
第三次作业
这一次,我们的乘客
有了人权,把等待时间也纳入在内。使得我继续摸鱼骗分成为可能。前文提到过,LOOK算法是考虑过公平性的问题的。故而此次,LOOK算法很可能能与比我性能分高2分的那些(很可能)罔顾人权的算法匹敌。
而这次增添的新问题,则是需求的拆分。
我的需求拆分,说起来很简单:
直达就直达,否则找到他们之间的第一个换乘点,拆开。
之所以是"第一个换乘点",是考虑到数据较为随机,这样的拆分策略也会导致拆分结果较为随机,从而形成平衡(
当然要是测评姬爱你你就可以把数据拿来直接shuffle)。这样避免了部分电梯的空闲。此外,关于乘客乘坐哪一步电梯,我也未做硬性规定。考虑到很多同学做了最短路,而动态的最短路我是完全做不动,故而和静态最短路做一个比较:
即使是C和A的差距,也只有200,而假设此时A在15层,C在3层,而需求在1层,这时,分给C显然是比A更好的方案。考虑到“回头费”比路程权更加昂贵,故而我选择继续抢。
此外,由于第二次作业的抢法设计,第三次电梯依旧会在有需求是就冲,即使那个需求并不是它的。我当时考虑更改这一部分,却发现这时会导致瞎跑的电梯很可能追着第二段需求到了之后需要解送换乘乘客的位置,而实现了“预判”的效果,故而干脆当做feature保留。
-
感悟和总结
感悟
这一单元的学习,相比上一单元要成功不少,相比起上一个单元的“暴死”,这一单元可以算得上是“大喜”,一方面的分数得益于骗到了性能分,另一方面则来自于我部分实现了上一一次所言的”多想“战略,在每次作业都有花时间做设计和思考,也没有为了混一两次作业而做一些限定操作,而是考虑了关于扩展的实现,也部分得益于在第一次作业后报了研讨课,使得我读了大半本的设计模式,并仔细思考了不少关于”方法论“的玄学,还把他们落到了纸面(ppt)上,使得我成功预测了加入线程池采取WorkerThread等官方安利,还在第二次作业之前就解决了第一次考虑不够完善的部分重构思路。实际实现时间,基本上都在一天(一个半夜)到两天(半个下午和两个半夜)内解决了。
在第一次作业前,我写了很多demo,包括多线程的基本手段,Executor等Java自带属性的学习;以及多线程设计方法的尝试,和第一次作业的本地迭代基础框架等。这些为我后续的迭代造下了较好的基础。
此外,由于第一次作业此次重构,使得我这次心情十分平静,甚至感觉压力有些太小了。
总结和建议
这一单元的学习,令我对多线程有了一个基础认识,同时归功于设计模式的优越性,使得我得后期修改较为简单,让我这种喜欢琢磨玄学“方法论”的人一本满足。
但是,三次作业基本只介绍了Producer-Consumer的设计方式,此后加入线程的动态管理实现了Worker-Thread模式,还是较为单一(加一个线程池而已),感觉我的后几次考虑的内容和“线程”毫无关系。我除此之外也只考虑了Read-Write-Lock和CopyOnWrite等简单手段。(硬说的话,书上把 if(……)return; 和 while(……)wait();也做了区分算作设计模式)。但我也以自己浅薄的知识思考了修改手段,发现要是考虑迭代的话,强行引入其他模式也不现实。
此外,对于lock的应用,实际上是比syncronized更灵活的方式,希望多做介绍(我的意思是,实验加大力度)。