面向对象程序设计-第三单元博客

第三单元博客

第三单元是对JML规格的初步接触和练习。其初衷是为了以严格的语言来规范代码的书写,避免二义性,传达清晰的信息。总的来说,本单元作业难度不算特别大,但需要对JML的描述进行转化避免性能过差。因此在JML的规格约束下设计合适的架构,进行合理的选择是整个作业的关键,也是使用形式化语言的关键。

从JML得到代码的设计策略

首先要说的是,我是如何一般性地从JML得到一份代码,效率高而又不出现问题。首先遵循通读原则和从简到繁的原则,拿到JML应该先通读一下属性规格,方法名,从而大致了解一下其功能。然后再读相对简单的规格并进行代码补全,例如getset方法等。最后再综合考虑先前理解和复杂规格将复杂的方法解决。这样既能让人大致了解要完成什么任务,又能将困难的任务集中在理解建立之后以进行正确的编写和充分的优化。

结合课程内容,整理基于JML规格来设计测试的方法和策略

基于JML规格来进行测试,一般推荐Junit,其可以自行设计测试点来对代码进行测试。通过学习,我发现Junit的测试很想我们的操作系统课程的测试方式,即生成一个测试函数写满断言来对各个部分的正确行为进行评估,而操作系统实验的基本任务是补全代码,很类似于我们这里根据JML对代码进行填充。因此模仿我们操作系统实验的测试方法就成了我设计测试策略的重要手段。

大概来说,基于JML规格用Junit来对代码正确性进行测试,可以通过根据JML的几个\requires语句提供的前置条件,利用\ensures语句给出的后置条件来对类的行为进行基本测试,具体而言就是利用assertEquals来对行为设定预期结果进行判定,可以生成类实例,调用方法,塞入合适的参数来进行测试。

而对于异常类的测试,由于涉及到标准输出,因此可以将输出流重定位,例如重定位至ByteArrayOutputStream,将其缓存在这个字节缓存中,然后使用try-fail-catch语句捕获异常并进行printassertEuqals进行比对。

容器的选择经验

在设计中,很重要的一点便是如何选择容器。对于规格中的数组,我基本选择的是HashMap,但并不以累加的下标作key,而是以各自的唯一的id等作key,充分利用hash查找的O(1)的复杂度进行优化。实例有MyNetwork中的people数组/Hashmapvalue数组。people数组即以Person的id做索引。value数组/Hashmap则为了简化调用,将Person作为key而未将其id作为key。但本质上都是利用hash的好处。

而对实在没有能逻辑上构成对的数据,但数据之间不会equals时,则选择HashSet进行存储,这样仍然便于检索查找;而对可重复数据则在ArrayListLinkList中根据查找,插入和删除的使用频率进行选择,如果查找较多或加入删除集中于尾部则使用ArrayList,如果插入删除较多则使用LinkList,例如MyPerson中的messages数组,就是因为收到的消息可能相同,但是仅仅起到记录作用而采用了ArrayList

而对总是需要排序的数据结构,就需要使用TreeMap或者PriorityQueue(堆实现),或者手动实现Comparable接口进行Collection.sort排序(但是较慢,不如红黑树实现的TreeMap)。例如第三次作业中的Dijkstra算法中寻找当前最短路径使用TreeMapPriorityQueue均可以,我采用的是PriorityQueue,因为堆可以较快的维护得到最小值或最大值而又不需要保证完全有序。

针对本单元容易出现的性能问题,总结分析原因如果自己作业没有出现,分析自己的设计为何可以避免

  • 第一次作业

第一次作业容易出现性能问题的方法是queryNameRankisCirclequeryBlockSum。其中queryNameRank相对不容易出问题,因为最慢也是完整扫描一遍people数组,达到O(n)的复杂度(我即是如此做的)。isCircle关键在于设计是否合理,最朴素的做法就是dfs或bfs算法进行搜索,acquantance数组相当于邻接表,这样每次遍历复杂度为O(n),如果查询过多,性能会较差,也不便于queryBlockSum和后续作业顺带对于人与人之间是否有关系的判断。因此从这里开始就可以考虑使用一种新的数据结构进行优化——并查集。因为考虑到并查集主要用来判断元素是否共集合,其满足且恰好满足无向图的连通记录,复杂度低,且寻找父节点时压缩结构可以使插入复杂度为O(1)。我在此处采用的是以HashMap tree保存上级结构,用循环方式查找父节点并压缩,没有递归的复杂调用但是树结构优化不彻底,但是足够使用。而在queryBlockSum中只需要对tree进行一次遍历,记录根的个数即可得到连通分量数,复杂度为O(n)。当然这还可以进一步优化,彻底贯彻修改即更新的原则,每次加入新节点或关系就对连通分量数进行更新,可以使得queryBlockSum复杂度为O(1),但是操作相对复杂分散。

并查集设计:

private HashMap tree = new HashMap<>();//并查集
public boolean isEqualRoot(int id1, int id2, int op);//判断是否在同一个集合
public int getRoot(int id);//寻求集合代表元(根),同时压缩并查集
  • 第二次作业

第二次作业的关键函数为MyNetwork.queryGroupValueSum <-> MyGroup.getValueSumMyNetwork.queryGroupAgeMean <-> MyGroup.getAgeMeanMyNetwork.queryGroupAgeVar <-> MyGroup.getAgeVar。这三组函数都涉及到如何快速求和。一般来说,朴素的办法就是查询才计算,每次都要遍历O(n),特别是value还需要判断是否isLinked,容易变成两重循环O(n^2),查询指令一多就容易超时。而较好的办法同样是每次插入与删除便更新,查询只是查变量,那么就需要提防在那些地方存在对Group内属性的修改。对于value,存在两种情况,一是两个人已经在某些组中而新建立了关系,需要对这些组进行更新,更新的位置在MyNetwork.addRelation;二是两个已有关系的人被加入某些组,也需要对这些组中进行更新,更新位置在MyGroup.addPerson,MyGroup.delPerson。而对于age,需要对age的和与平方和进行维护,维护的地方在MyGroup.addPerson,MyGroup.delPerson。在这些地方更新完毕后,查询就是返回这些对应的和。值得一提的是,getAgeVar所返回的值需要利用数学方法分析得到表达式,即其中要注意的是涉及到的除法是整除,不能够直接使用乘法分配律;不能够拆出去,避免前面出现负数然后合并出现问题(Java整除是去掉小数点而非向下取整)。

$$\text{ageVar} = s^2=(\sum_{i=1}^{n}(x_i-\bar x)^2)/n = (\sum_{i=1}^{n}(x_i^2-2x_i\bar x+\bar x^2))/n=(V-2\bar xS+nS^2)/n
\\
\text{ageMean}=\bar x = (\sum_{i=1}^{n}x_i)/n = S/n
\\
n=\text {size}
\\
S=\text{ageSum}
\\
V=\text{ageSqrSum}$$

  • 第三次作业

第三次作业貌似没有强连通分量的求取,但是仍然有拉性能的新函数——sendIndirectMessagedeleteColdEmoji。其中sendIndirectMessage是寻求最短路,而deleteColdEmoji则是迭代删除。前者比较明显,朴素的做法就是使用Dijkstra算法,复杂度为O(V^2),性能已经较为崩坏,而我采用的是加上堆优化的方法,让复杂度变为Elog(E),当边较少时不会退化为V^2log(V^2),题目限制输入已经保证了该点。deleteColdEmoji朴素方法就是遍历寻找热度小于limit的emojiId,在找到时便同时遍历messages数组将相关的消息全部删除,这样复杂度为O(n^2),复杂度较高。有许多优化方法,可以采用TreeSet与新建节点类对热度进行排序,简化遍历流程,同时将id和与其相关的消息用数组关联,删除时根据这个关联数组从messages数组中删除。但我选择的方法是相对简单的先遍历messages数组查询对应emoji热度进行迭代删除,再删除emoji,可以让复杂度退化为O(n)

代码示意:

public int deleteColdEmoji(int limit) {
        Iterator> messageIterator = messages.entrySet().iterator();
        Iterator> emojiIterator = emojiIdHeatMap.entrySet().iterator();
        while (messageIterator.hasNext()) {
            Map.Entry entry = messageIterator.next();
            if (entry.getValue() instanceof EmojiMessage) {
                if (emojiIdHeatMap.get(((EmojiMessage) entry.getValue()).getEmojiId()) < limit) {
                    messageIterator.remove();
                }
            }
        }
        while  (emojiIterator.hasNext()) {
            Map.Entry entry = emojiIterator.next();
            if (entry.getValue() < limit) {
                emojiIterator.remove();
            }
        }
        return emojiIdHeatMap.size();
    }

梳理自己的作业架构设计,特别是图模型构建与维护策略

我这一单元的作业架构设计并没有太出奇的地方,只是简单地按照对课程组提供的接口的实现来构建代码框架。在此之外,仅仅增加了并查集,Dijkstra算法使用优先队列的Node节点等结构。这样来看,如果不做出合理的设计,一旦涉及到增删指令,操作就会很分散和繁琐,一旦代码量大了,很容易出现遗漏的地方,例如在实时更新valueSum时就需要在三个地方进行更新操作的代码书写来进行操作选择,这样就可以在包装函数中统一进行某些增删控制操作。这里的Runner写死了,也就不太方便。

附三次作业UML图:(不包括异常类)

  • 第一次作业

  • 第二次作业

  • 第三次作业

总结

总体来说,本章作业难度不大,关键是让我们体会一下形式化语言对信息传达的严谨性和形式化测试的完备性,以及复习一下图算法(x)和优化。期待最后一单元作业!

posted @ 2021-05-30 19:13  声东击西  阅读(92)  评论(0编辑  收藏  举报