Loading

2021-面向对象设计与构造-第二单元总结

第五次作业

UML 类图

homework5UML1

homework5UML2

协作图

homework5sequence

架构与实现

同步块与锁

第一次接触多线程,主要时间花在理解 生产者-消费者模式sychornized 关键字。

参考讨论区的月饼教程,我建立了一个线程安全类 ThreadInteractor,将其中所有的方法都加上 sychornized 关键字,同时保证每个方法的原子性,这样就保证了安全性,同时其它类不需要考虑安全的问题。

本次作业除主线程外存在两个线程,分别为输入线程 InputThread 以及电梯线程 ElevatorThread,两者通过交互器 ThreadInteractor 进行交互。输入线程在接受到乘客请求时使用 addPersonRequest 方法将乘客请求送入到交互器中;电梯线程通过 nextPersonRequestswaitPersonRequests 两个方法从交互器中获取乘客请求,两者都会将当前所有的乘客请求返回,并将它们从交互器中移除,区别在于如果交互器中此时没有乘客请求,后者会进行等待直到输入结束或下一个乘客请求到来,前者则直接返回 null

这里的等待如果直接 while(true) 就轮询了,可以使用 waitnotifyAll 的方法避免这个问题,当新乘客请求到来或者输入结束时进行 notifyAll

为什么需要两个获取乘客的方法呢?因为当电梯还在运作时,只需每次到达楼层时或关门时将所有乘客请求获取进行判断;而当电梯不在运作时,即之前所有乘客请求都处理完时,需要等待下一个关键时刻再进行运作,这里的关键时刻就是新乘客请求到来或是输入结束。

另外,ElevatorThread 是一个线程,其内部创建了一个 Elevator,含有电梯的状态以及运行策略,其实没有必要这样,直接让 Elevator 作为一个线程就可以,这在后面的作业中有所改善。ThreadInteractor 其实可以使用单例模式,而我将它分别传入了两个线程中,实现并不优雅。

调度器

本次作业因为只有一个电梯,所以不需要调度器。但是,从程序的拓展性上进行考虑,一些同学可能选择提前设置一个调度器。我并没有这样做,因为调度器的结构可能会依托于作业的具体内容,在不知道第二次作业内容的情况下,提前设置极大可能会是一个不合适的架构,反而会浪费时间,降低效率。因此,我仅保证了电梯内部的运作是独立的,使得在添加调度器时不需要进行过多改动。

电梯运行算法

电梯运行算法上,我分三种模式进行考虑,并建立了三个不同的电梯类,实现了各自的调度算法:

  • Night 模式,全部读入后按高度排序后进行分段 dp,本以为是真算法,后被 👴👴 hack 掉,即最优分组不一定是按高度连续的,例如下述数据:

    [1.0]Night
    [1.0]1-FROM-2-TO-1
    [1.0]2-FROM-2-TO-1
    [1.0]3-FROM-3-TO-1
    [1.0]4-FROM-3-TO-1
    [1.0]5-FROM-3-TO-1
    [1.0]6-FROM-3-TO-1
    [1.0]7-FROM-3-TO-1
    [1.0]8-FROM-4-TO-1
    [1.0]9-FROM-4-TO-1
    

    最优解是将 1,2,8,9 分为一组,4,5,6,7,8 分为一组。

    目前还没想到多项式复杂度的算法,坐等 dalao 们赐教。

  • Morning 模式,直接贪心,每次满 \(6\) 个人或者输入结束就选到达层数最高的数个人拉走。

  • Random 模式,LOOK 算法,能上就上,不能上就下。

测试与 bug 分析

本次作业没有在强测与互测中出现 bug,但因为 LOOK 算法写假了,丧失了 \(0.11\) 的性能分。

LOOK 算法在向上的过程中不应该接下行的乘客,脑子想到但是没写上去,而且一直也没发现,寄。

本地测试中,因为清明节的原因,时间有限,而且就一个电梯可以规避多线程,因此写了个 C++ 对拍机。随机生成数据 + 定时投放,将全部输出和输入数据排序后离线进行 check。这个想法很好,但是因为 计时同步性误差,很容易就判成「预知未来」,评测时必须将输入数据的时间戳进行一下提前的偏移,这样做虽然很不严谨,但是一般也不会存在预知未来的程序,凑活能用。

互测中,一位同学发生了死锁,直接一发入魂。

度量分析

方法复杂度分析

因为方法过多,这里仅截取复杂度过高的部分方法进行分析。

homework5CMM

类复杂度分析

homework5CCM

类代码行数分析

homework5LOC

度量分析总结

RandomElevator.run() 这个方法过于复杂是因为刚接触多线程,逻辑写的太复杂且无意义,后面有所改善。

NightElevator.init() 则是上述提到的假 dp,故意牺牲拓展性换性能分,完全没有必要硬把 dp 拆成一个个小部分,毕竟这部分不会存在于下一次作业。

至于 ElevatorFactory,这是一个工厂类,里面就是根据模式串返回对应的电梯,用的是 switch,有三个分支,按定义来说确实应该标红,阿哲......

剩下的标红部分则是电梯运行过程中的必要逻辑,当时写的时候感觉很简洁,现在回顾感觉还是有那么一点冗余,可能再细分成几个方法会有利于维护与拓展。

代码行数方面,没有特别长的类,还可以接受。(互测中经常有人四五百行一个类,我直接害怕)

第六次作业

UML 类图

homework6UML1

homework6UML2

协作图

homework6sequence

架构与实现

同步块与锁

本次作业直接将第五次作业中的交互器 ThreadInteractor 改为调度器 Scheduler,该类还是一个线程安全类,所有方法全部上 sychornized,且具有原子性。

输入线程 InputThread 还是老样子,只不过这回可以使用调度器中的 addElevatorRequest 来加电梯了。至于 nextPersonRequestswaitPersonRequests,它们和第五次作业实现的功能相同,但具体细节取决于模式,这会在下面提到。

吸取上次的教训,本次让 Elevator 直接成为一个线程,取代第五次作业中的 ElevatorThread

除此之外,唯一和线程安全相关的地方,就是 RandomElevator.getShadow() 了,这是为了获取一个 RandomElevator 的「影子电梯」,以计算其用时,为了保证信息拷贝的原子性,对该方法进行上 sychornized

调度器

调度器内含一个类型为 HashMap<Integer, ArrayList<PersonRequest>> 的分配方案 scheme,每个电梯在使用 nextPersonRequestswaitPersonRequests 时,只会获得分配给它的乘客请求。

根据三种模式,又细分为三种调度器:

  • Night 模式下,基于电梯算法(后续会讲),可以快速算出每个电梯送完人回到 \(1\) 层还需要多少时间,因此每次收到新乘客请求或是新电梯请求时,会将所有 scheme 中所有还没有被电梯接受的乘客请求进行重新分配,按高度排序后贪心地分配给分配后所需时间最少的电梯。

  • Morning 模式下,和 Night 模式基本相同,只不过时间算法略微不同,且每次等满 \(6\) 个人或者输入结束 waitPersonRequests 才会返回乘客请求。

    基于上述两种调度模式的相似性,使用一个 SpecialScheduler 作为它们的父类,提高代码的复用率。

  • Random 模式下,当新乘客请求到来时,将乘客请求分给「接收该乘客后运送完所有乘客所需时间最少」的电梯。实现方法即创造一个 ShadowRandomElevator,就是上述说的「影子电梯」,它将原电梯的信息全部克隆,新增一个记录时间的变量 time,并将 sleep 全部改为令 time 增加等量的时间,同时在运作时删去了输出的部分,例如 ARRIVE,IN 等。这样就可以快速得到一个电梯的运行时间,而不必再去一步步真实模拟。

    而当新电梯请求到来时,为了防止所有乘客请求都被分出去了,我给每个电梯设置了一个标志位 rebuildTag,新电梯到来时会将其它所有电梯的该标志位置 true,从而其它电梯在下一次调用 nextPersonRequests 时会将所有还没进电梯的乘客请求回退到调度器进行重新分配,并将标志位置 false

    相比 Night 和 Morning,没有选择每次新乘客请求到来后全部进行重分配,一方面是因为这样做可能导致耗费大量 CPU 时间,另一方面是如果进行重排,那么要么不能将全部乘客请求分配给每个电梯,要么需要频繁将已分配的电梯送回调度器,不管是那一种都会降低 LOOK 算法的效率,因此我没有选择这么做。

电梯运行算法

电梯运行算法上,类似于第五次作业,分三种模式进行考虑,并建立了三个不同的电梯类,实现了各自的调度算法:

  • Night 模式,既然 dp 假了,和贪心差的也不多,干脆直接乱搞,每次选出楼层最高的数个人拉走,可以发现这样是可以算出具体时间的。
  • Morning 模式,和第五次作业一样,每次满 \(6\) 个人或者输入结束就选到达层数最高的数个人拉走,也可以发现这样是可以算出具体时间的。
  • Random 模式,和第五次作业一样,LOOK 算法,能上就上,不能上就下,这回总算写真了。

测试与 bug 分析

本次作业没有在强测与互测中出现 bug,但是 Night 模式的处理出现了严重失误,因此性能分惨痛地损失了 \(1.5\) 分。

一方面,Night 模式并不保证电梯请求也会同时到达,因此不能等所有数据都读入再进行处理。我的处理方法是让调度器线程 sleep 一个微小的时间,以便所有数据都读入,但是我图方便写主线程里面了,而此时输入线程并没有启动,因此相当于白 sleep 了,这导致一开始每个电梯可能就接收了一个乘客,白白损失了很多时间。

另一方面,仔细思考 Night 模式的调度算法,三个电梯均为空时,高度最高的三个乘客请求分别分给了三个不同的电梯,这显然是非常劣的。

本地测试中,写了一个 Java 评测机,支持数据生成 + 实时评测,最后会详细叙述。

互测中,一位同学出现死锁,一发入魂。另有两位同学的程序也存在问题,但是我没拍出来,最终它们被其他人 hack 的数据也并非边界数据,因此对应 bug 应该是属于个例问题,除非去阅读代码,否则能不能拍出来纯看脸。

度量分析

方法复杂度分析

因为方法过多,这里仅截取复杂度过高的部分方法进行分析。

homework6CMM

类复杂度分析

homework6CCM

类代码行数分析

homework6LOC

度量分析总结

复杂度方面,相比第五次作业好很多,红色的地方还是因为电梯运行逻辑没有进行拆分。

代码行数方面,仅增加了 \(200\) 行,且没有出现行数过多的类。

第七次作业

UML 类图

homework7UML1

homework7UML2

协作图

homework7sequence

架构与实现

同步块与锁

直接沿用了第六次作业的架构,因此同步块与锁方面和第六次作业完全相同。

调度器

电梯种类出现了变化,停靠楼层越多,速度越慢,容量越大。

同时本次性能分计算方式也发生了变化,额外添加了每个人的等待时间之和。思考了一些算法,一个比一个假,还不好写,索性怎么简单怎么来。

因为每个人等待时间之和的加入,Morning 模式中乘客的等待不一定更优,因此直接将 Random 和 Morning 合并,并沿用第六次作业中的 Random 调度器。唯一发生变化的是,为了让速度更快但停靠楼层有限制的两种电梯也发挥作用,每种电梯会在可以接受该乘客的前提下,将他们送到距离目的地最近的可停靠楼层。具体实现方法为添加一些简单的判断函数,并在乘客离开电梯时判断如果乘客没到达目的地,则将一个新的请求加入调度器。

Night 模式吸取上次失败的教训,保持按最短时间分配的策略,但不再一个个分,而是一次分 \(4/6/8\) 个(取决于电梯容量),这样就可以大幅提高效率。

电梯算法

继续沿用之前的算法。

Night 模式,每次选出楼层最高的数个人拉走。

Morning 和 Random 模式,LOOK 算法,能上就上,不能上就下。

拓展性

可以发现,本次作业的架构其实就是在第六次作业进行拓展的结果,这从侧面验证了该架构的强大可拓展性。

输入 / 调度 / 电梯运作 / 换乘 等部分均 解耦,一旦需求发生变化,并不需要大费周折,只需找到对应的模块进行一定的修改即可。

bug 与测试分析

本次作业没有在强测与互测中出现 bug,性能分丧失了 \(0.25\) 分,原因是有两个 Morning 被卡到了 \(97\) 分。

本地测试中,在第六次作业的评测机基础上进行迭代开发,最后会详细叙述。

值得一提的是,刚写完进行提交时,弱测第三个点老强了,交了两次全死锁了。因为前两次作业中完全没有出现任何多线程的 bug,导致我缺乏处理 bug 的经验,最终经过各种复现操作,终于发现是 waitPersonRequests 中判断是否全部乘客都被送完的部分因为太复杂而出现了离谱 bug,我直接将这部分改为「当前还有多少人没有到达目的地」,每次输入一个乘客请求就令人数 \(+1\),每次一个乘客到达目的地就令人数 \(-1\),这样就避免了冗余的判断,轻松解决一切隐患。

互测中,一位同学出现死锁,但很难复现,刀了无数刀都没中,很遗憾。

度量分析

方法复杂度分析

因为方法过多,这里仅截取复杂度过高的部分方法进行分析。

homework7CMM

类复杂度分析

homework7CCM

类代码行数分析

homework7LOC

度量分析总结

复杂度方面,因为延续第六次作业的架构,一些该红的地方还是红着,scheduler.NightScheduler.updateScheme 这个方法是上述所说的 Night 模式的分配方法,比较面向过程,说实话这一部分很难进行拆分,毕竟 Night 模式是一个极其特殊且具体的过程,此时一味追求 OO 思想反而会适得其反。

代码行数方面,仅增加了 \(50\) 行,Elevator 类的行数略多,原因是加入了乘客能否进出电梯的判断函数,个人认为放在这个这个类中是比较合适的,不能单纯通过行数较多而将其否定,实在不行可以压行。

评测机

以第七次作业所用评测机为例。

UML 类图

checkerUML1

checkerUML2

架构与实现

首先使用 Generator 类生成一组数据。

接着用 Process 类创建一个进程,运行打包好的电梯程序。

然后兵分两路:

  • 一方面,新建一个线程 OutputThread 用于定时投放输入信息给电梯程序,使用的是 PrintWriter 类,同时也将这些信息传给评测器 Checker
  • 另一方面,主线程实例化一个 InputHandler,不断接收电梯程序的输出,使用的是 Scanner 类,并将输出传给 Checker

Checker 根据输入新建数个电梯线程,并根据输出操纵对应的电梯,一旦发现不合法情况,或是程序出现异常,均直接抛出包含错误原因的 WrongAnswerException

另外,主线程也会创建一个定时任务,如果 \(210\) 秒还没完事,直接终止程序,并输出 Time Limit Exceeded!

所有非通过的样例都会将样例和电梯程序输出一并给出。

评测机的编写尽量秉持 OO 的风格,并使用课程组提供的 CheckStyle 进行代码风格的检查,第六次作业评测机到第七次作业评测机的高效迭代,让我又一次体会到了面向对象的好处。

比较遗憾的是没法测 CPU 使用时间,而且因为没有搞明白 Java 的内存分配机制,导致做不到 超多 进程并发,开多了就说内存不够,Java 运行指令加上参数限制也不行,因为时间原因没有再去深究,有时间一定要搞明白。

度量分析

按照惯例,评测机也分析一波。

方法复杂度分析

因为方法过多,这里仅截取复杂度过高的部分方法进行分析。

checkerCMM

类复杂度分析

checkerCCM

类代码行数分析

checkerLOC

度量分析总结

有一说一,比三次电梯作业的数据好看多了,仅有一些确实需要逻辑判断的地方被标红了,代码长度上也很平均。

或许没有了性能分的约束,才能写出更 OO 的代码吧。

总结与收获

这一单元完整地体会了一遍多线程开发,学习了如何维护线程安全,如何用 wait-notify 取代轮询,以及如何避免死锁。

同时,从第六次作业到第七次作业的改动,以及第六次评测机到第七次评测机的迭代,相比上一单元那次大重构,让我再次体会到了架构的重要性所在。

不过,我认为本单元有一些内容还有改进空间,仅代表个人看法:

  • 除了第五次作业的 Night 外,其它情况均不存在所有数据情况下均为最优解的算法,而且大部分情况算法效果很依赖于数据。评判一个调度算法是否足够好,要看这种算法面对足够多足够全面的数据样本的平均表现如何,而不是仅靠 \(20\) 组强测数据,况且这 \(20\) 组数据也没有实现给定其数据生成器或是生成方案。

    这个问题在第一单元也有所体现,财富密码就是拆括号和不拆括号取最短,但其实存在很多样例需要别的优化方法,只是没有放在强测中而已。然而指导书中并没有明确指出样例的类型,因此某种意义上也是在「猜样例」,并不能充分体现出化简算法的优劣。

    一句题外话,大一的数据结构大作业相比之下更是离谱,亘古不变的一组数据,最后演变成枚举哈希模数,完全是本末倒置。

    与其让大家写着虚假的贪心算法,调着不明所以的参数,靠「抽奖」来获取性能分,不如弱化调度优化,仅设置一个最差性能阈值,并用「更多复杂有趣的机制与事件」将其取代。

    当然,还有一种需要耗费大量资源的改进方案,即扩大性能测试样例,甚至允许每位同学提交一个 generator,更加全面、真实地评判每一种算法。这样必然耗费大量的运算资源,而且在 bug 修复进行回归测试时也不方便进行,可以考虑额外准备少量的判定正确性的强测样例,不通过将不进行上述大规模性能测试,回归测试时也仅考虑这些样例。

  • 前两次作业中,性能分的判定竟然是以程序输出的时间戳为准,而不是以程序真实运行时间为准,这给了同学们改变时间戳初始化的位置来「偷时间」的机会,而对此进行追查也需要耗费一定的人力。不如将程序真实运行时间作为判定标准,而时间戳仅仅用于判断正确性,这样就只会用到时间戳的相对差值,不需要考虑时间戳初始化问题了。

  • 第六次作业中,hack 时经常出现输入数据非法的情况,如果可以将 validator 中数据非法的原因反馈给 hack 者可能会更加人性化一些。

posted @ 2021-04-24 17:16  JJLeo  阅读(279)  评论(2编辑  收藏  举报