前言:经过第二次jml的学习之后,我对规格有了更深入的理解。然而,这种理解是在作业评测结果公布之后体会到的——大部分新的理解来自于惨痛的经历。本单元的我的作业结果可以说是一败涂地。不过,我还是愿意将自己的经历分享给大家,同时也警示自己不要再犯类似的错误。
作业架构设计
本单元的作业整体逻辑以及接口已经由现有抽象类/接口给出,我们需要实现的是将这些类/接口实例化,根据jml规格实现相关方法,同时让这些类具有恰当的继承及实现关系。
第一次及第二次作业中,我设计的所有类仅仅继承/实现了给定的抽象类和接口,实现的各类之间并无直接继承关系。唯一需要思考的架构是异常类的设计。与去年的作业相比,本单元作业需要给出抛出异常的具体指令信息,并统计出现次数。对于同种异常出现的次数,显然可以看作不同抽象类对象共享的属性,因此可以将该属性定义为static
,初始值为0,每次调用构造方法时自动递增。
第三次作业需要实现四种Message
类,根据jml的理解,它们均属于Message
的子类,在相关方法中加以区别,需要用到多态的知识进行设计。整体类图如下图所示:
![](https://img2020.cnblogs.com/blog/1962896/202105/1962896-20210530133421894-2141459522.png)
jml分析及测试策略
本单元作业中一个重要的内容是对给出的jml规格进行分析。我们知道,面向对象方法是一个抽象过程,需求者仅需关注方法的规格。规格是对一个方法/类/程序的外部可感知行为(语义)的抽象表示,内部细节无需在规格中表示 , 同时保证规格实现的无二义性。
对于初学者来说,不结合其他信息,直接逐一分析jml的内容是比较困难的,有些规格比较难以理解。针对jml的特点,我们可以进行先猜想、后验证的过程。由于java中类的设计遵循一定的规则,譬如对给定的属性应有相应的访问和修改规则;对于给定的方法往往有其他的方法与之关联。同时,方法的命名是重要的信息来源之一。比如说第一次作业的queryStrongLink
方法,给出的规格不易理解,但我们从方法的名字可以猜想到它是强连通分量的意思。在对于方法的内容有一定猜想之后,再根据规格来验证,这样在有相关思路的情况下分析规格就容易多了。
总而言之,分析jml绝不能当作一个独立的过程,一定要结合实际,这样才能大大提高效率。
jml测试的工具主要包括openJML、JMLunitNG以及JUnit单元测试。openJML是一款检查JML语法的工具,可以检查常见JML中的语法错误。我去年使用过这款工具,体验比较差。该插件配置过程并不容易,且对很多高级表达(例如\forall)均不支持,大概率是由于openJML工具多年未维护所致,因此今年我没有再去使用这款工具。JMLunitNG同理,生成的测试数据很多是边缘数据,既不能保证发现bug,也不能保证测试的普遍性,因此我也没有使用。
不过对于Java工程的开发来说,JUnit是必须掌握的测试工具,该工具我在软件工程课程中也多次用到,配置并不复杂(只需要导入两个包)。JUnit几乎提供了所有类型的对比输出以及异常的方法,同时不需要运行整个程序,使用起来十分方便。同时,该工具很大的一个优点是可以比对出肉言不可见或容易忽视的不同(例如不可见字符)。遗憾的是,由于期望输出仍然需要我们自己给出,并且第三次作业我理解错了规格,给出的期望输出本身就是错的,因此也没能发现bug。
容器设计策略
经过两年类似主题、6次作业的实现,我对容器设计有了新的理解。在此过程中,我尝试过多种容器,也为此修过相应的bug,最后得出了个人总结:
能用一个容器存储就不要用两个容器,能用Map就不要用List,同时使用的容器尽可能统一。
对于减少容器数量,作业中的一个相关例子是EmojiIdList
和EmojiHeatList
。很显然,如果使用一个HashMap,键值为Id, 对应值为Heat来存储,就可以将其合并为同一个容器。减少容器数量对于代码逻辑的简化、减少维护时bug的产生是很有帮助的。
“能用Map就不用List”是我在尝试多种容器之后得出的结论。个人经验表明,在绝大多数情况下Map完全能够胜任给定容器所需的功能。同时,使用Map来访问时不仅逻辑上简单,也能够大大减少时间复杂度,而List根据索引访问简便的优点很难得以发挥,因此在本单元作业中能用Map实现的容器就没必要用List。
容器统一是指对于不同类相关联属性的存储尽可能采用相同的结构。比如说,在Group
和Network
中存储人的Id时不要List和Map混用,同时在没有需求的情况下也不要再同一个类中混用TreeMap以及HashMap。不同的容器往往具有相同的方法,但内部实现以及逻辑完全不同,如果混用不同类别的容器容易因惯性思维导致bug(我本人就是因为第二次作业误将ArrayList当作HashMap使用,结果强测直接爆0)。
当然,以上分析纯属我的经验之谈,如有谬误欢迎指正。
算法及性能分析
本单元作业中算法设计最重要的是图结构的维护及相关算法的实现。
正如前面提到的,人物关系的图结构全部由HashMap实现。考虑到HashMap查询算法的时间复杂度为O(1)或O(n),对于整体性能的影响并不大,因此对于一些查询较为简单的方法,我并没有设计缓存来随时更新(经验表明,缓存变量太多极易出现bug),我设置的唯一缓存变量是blockSum
,用于记录连通块的个数。
对于规格中的方法,有些显然不能直接按照规格的描述来实现。isCircle
方法是第一个需要设计算法的方法,这里我采用的是dfs,时间复杂度为O(n)。基于该方法也能实现queryBlockSum
方法,具体的是通过维护blockSum
缓存,初始值为0,每次人数增加操作时递增,添加关系时调用isCircle
,判断关系两端的人原本是否可达,若原本可达,那么对连通块的个数不影响;若原本不可达,则连通块数目递减。
对于第二次作业组内成员平均年龄的均值和方差,由于每次查询时都要遍历并计算,时间复杂度较高,因此需要使用缓存来计算。需要注意的是,虽然方差计算可以使用恒等式展开来简化运算,但由于本单元作业中方差做了取整处理,因此不能直接展开,这也是容易出现bug的地方。
第三次作业中比较追求性能的方法是sendIndirectMessage
,涉及到最短路径的计算。由于方法查询的是两点之间的最短路径,使用弗洛伊德算法显然不合适。我原本直接采用了Dijskra算法,但强测结果TLE了两个点,因此这种方法是不可行的。我在bug修复阶段采用了堆优化的Dijskra算法,解决了超时的问题。这里需要用到的结构为PriorityQueue
,需要自定义一个类用于存储各点的Id和最短距离,并实现Comparable
接口(或构造相应Comparator
)。每次寻找当前的距离最小值点时,仅需采用取出队首元素的方法,同时在更新距离时,并不需要做比较的操作,这是因为优先队列的结构保证了我们在依次取出元素时对于相同Id的点一定能够先得到最小的距离,整体算法的时间复杂度为O(nlogn)。
前两个单元的作业可以说是相当顺利,但第三单元的作业最终是这种结果,实在让我没有想到。不过不管怎么说,本单元作业的失败让我学到了很多,无论是在设计还是在测试方面,我都认识到了自己还有相当欠缺的地方,这也算是让我明确了今后努力的方向吧。