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 @   JJLeo  阅读(279)  评论(2编辑  收藏  举报
编辑推荐:
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
阅读排行:
· winform 绘制太阳,地球,月球 运作规律
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· AI与.NET技术实操系列(五):向量存储与相似性搜索在 .NET 中的实现
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
点击右上角即可分享
微信分享提示
主题色彩