面向对象第三单元总结

面向对象第三单元总结

一、实现规格的设计策略

JML规格以形式化的方式描述了类和方法的设计要求,所以本单元作业在设计上最主要就是根据规格按部就班地实现要求。

1.类的属性规格

本单元作业以接口规格的形式给出了类的设计要求,JML规格清楚地描述了类的属性个数和数据类型。对于非数组类型的数据,按规格要求来定义即可。对于数组类型的数据,规格中的数组仅仅是一种描述方式,不代表必须要用静态数组来实现。本单元的数组类型的数据都是动态增长的,需要根据实际情况选择合适的容器。例如,Person类中messages数组用于存储一个人收到的所有消息,阅读sendMessage方法规格可知,messages要求其中的元素是有序的,所以不能使用Hashset这样的无序集合。我采用了ArrayList来实现。不过后来发现这并不是最好的选择,因为每发送一条消息度都需要将其加到messages的开头,且messages的访问是顺序进行的,这些特性都暗示我们应该使用链表。类的数组型属性采用何种容器可以有很多种选择,但最有选择往往与后面的方法规格密切相关,需要综合考虑,下文容器选择部分将进一步阐述这一问题。

2.方法规格

  • 简单方法的规格。本单元作业中主要的实体类有PersonNetwork, Message, Group四个,其中除Network以外,大部分的方法的功能都比较简单明确,也不涉及性能优化问题,这些方法的不需要过多地进行设计,只要读懂按规格,按部就班实现即可。在实现方法规格的过程中,我首先关注的是exceptional_behavior部分,先对可能产生异常的情况进行判断并按要求抛出异常。随后实现normal_behavior部分,需要注意assignable语句,不能随意修改只读的数据。
  • 复杂方法的规格。Network类中与图的搜索有关的方法属于本单元作业中相对复杂的方法,其规格篇幅也较多。这些方法不能直接将规格逐句翻译为代码,而要先弄清楚该方法设计的意图。结合方法名称和规格描述确定其功能。实际上,一个方法的功能往往是单一的,清楚其功能之后就可以选择合适的算法来实现。例如,queryCircle方法查询两个节点之间是否连通,queryBlockSum查询连通块的个数,sendIndirectMessage要获取最短路径的长度。这些操作都可以在图论中的经典问题,可以使用一些经典算法来实现(如Dijkstra算法)。另外,在设计的时候还需要考虑重复效率问题。这些经典算法在单次操作中的效率是比较好的,但要考虑到重复查询的情况(在互测中容易出现)。针对同一查询指令重复输入,我们需要使用一点小技巧,例如,保存好上次查询的结果,重复查询时不需要重复跑一边算法。或者可以使每个结点保存好与其连通的结点,采取以空间换时间的策略较少运行时间。

二、基于规格的测试方法和策略

本单元作业我采用了手动构造+自动生成+对拍的方式来开展测试。另外,我对Junit做了简单的尝试,但并没有大量使用,主要还是构造数据再对拍。

1.自动构造数据

我使用python编写随机生成测试数据的程序。采用自动化的方式便于生成大量的数据,进行随机测试。另外,性能测试需要生成特定的大量的指令(例如qbs, qci, qgvs, sim等),非常适合使用自动化生成的方式。自动生成测试数据主要分以下几种情况进行。

  • 针对正确性测试:充分利用自动化生成具有随机性和数量多的特点,混合尽可能多的指令。为了更高效地测试,对指令进行分类。ap, ar, ag, atg, am, sei, arem, anm等添加性的指令可以归类为初始化指令,基本上每次都需要随机生成一定数量的该类指令;qnr, qgam, qgav, qgvs等可以归类为查询统计量型指令,这类指令实现原理并不复杂,但大部分涉及到对群组的遍历,组合大量的此类指令容易造成超时;qci, qbs, sim等指令涉及到对图结构的特征进行查询,是本单元作业中实现难度较大的一类指令,需要有针对性地对正确性进行测试。
  • 针对性能的测试:在测试自己程序性能的时候需要站在一个攻击者的角度想方设法构造数据使其超时。能造成超时指令基本都属于单次执行需要经过遍历的指令。首先对于查询统计量型的指令,需要添加大量的人和少量的群组,且重复多次执行(至少上千次)。对于查询年龄平均数的指令qgam, 如果每次查询都要重新计算平均数,则对同一个群组的大量查询就容易造成超时,解决方法是在加人的时候就计算好平均数并保存,查询时直接返回结果即可。

2.手动构造数据

手动构造的数据数量十分有限,主要针对可能出错的特定点进行检查,错误定位比较简单。例如,检查抛异常就可以手动构造少量数据,检查在应该产生异常的某些情况下是否正确输出了异常处理信息。在编写图查询的相关算法时也可以手动构造少量数据,做简单的正确性测试,帮助自己寻找bug。

三、容器的选择和使用经验

本单元的类属性中需要使用不少容器,尽管用不同的容器甚至静态数组都能解决问题,但合理选择容器有助于提高程序的性能,也能让编写代码更加顺利。下面用本单元作业中的实力来说明容器的选择问题。

  • Person类的acquaintancevalue是两个元素构成一一映射的容器,这意味着两个容器的增删必须是同步进行的,一但某个地方遗忘就会其中的元素不匹配。这种特性决定了其适合采用Hashmap来实现。实际作业中我用了2个ArrayList,所幸没有在这里出现错误,但这不是一个较好的选择。相同的情况还出现在Network类的emojiIdListemojiHeatList
  • Person类的Messages数组适合采用链表来实现,正如上文所说,Messages每次都在开头插入,使用链表能够避免每次大量的元素移动。这里主要是运行性能问题,使用其他容器在编码难度上区别并不大。
  • Group中的people数组适合采用Hashset来实现。观察Group内部的各个方法规格可以发现Group内保存人的数组并不需要有序性,此时,维护无序的集合比维护有序的ArrayList代价更小。
  • 其他没有明显特征的数组选择最熟悉的ArrayList实现就已经很方便了。

综上所述,容器的选择主要考虑2个因素:便于编写代码和提高性能。前者很容易想到,因为容器选择不当会给代码的编写造成阻碍,出现使用不便的时候我们往往会反思容器是否选择不合理。但性能问题就容易被忽略,当我们顺利地用ArrayList解决完问题以后就不太愿意去思考更好的方式。有时候简洁的代码会隐藏其内部性能的低下。

四、性能问题

本单元作业对性能的要求比较高。尤其是在互测中有针对性地构造测试数据时候容易出现性能问题。

第9次作业

造成性能问题的指令

本次作业的性能问题主要是由qbsqci指令造成的。qci指令用于查询两个结点之间是否连通,qbs用于获取整个关系网络中连通块的个数。开始写这次作业的时候我没有关注CPU时间等限制,就使用了简单的深度优先搜索(DFS)算法来实现qci指令,又在queryBlockSum函数中直接翻译规格描述,遍历结点,调用qci判断两个结点之间的连通性,计算连通块的个数。首先,这种做法效率十分低下,当图比较庞大的时候,深度优先搜索的复杂度为O(n^2),遍历过程的复杂度为O(n),时间开销很大。第二,如果图本身没有变化,又有大量的qbs指令查询,每次都会重复遍历且调用DFS。事实上,图的连通块个数是一定的,重复上千次计算同一个问题是完全没有必要的。

解决方法

  • 首先在Network类中添加blockNum属性,用于保存图的连通块个数,相当于Network的一个状态。影响blockNum的指令只有2条:在添加结点(addPerson)后,图的连通分量增加1;当添加关系(addRelation)的时候,若两个结点之间原本不连通,则blockNum减1,否则blockNum不变。这相当于在修改图的时候实时更新联通分量个数,免去了遍历过程,有效提高了效率。但局限在于ar指令的性能有所下降。
  • 针对深度优先搜索比较耗时的问题,仍然可以采用类似的思路,让每一个结点都能访问保存了与之连通的所有结点的一个容器。这其实也是一种“空间换时间”的策略。我为每个Person增加了一个HashSet属性,初始化为空,用于保存与之连通的所有结点。为了提升性能,并不是每个结点都独立拥有一个HashSet,而是同一个连通分量中的所有结点共享一个HashSet,在添加关系的时候只需修改一次。这样,qci指令就变成了在一个集合中查找一个特定元素的过程,不再需要图的遍历和搜索。

第10次作业

造成性能问题的指令

本次作业添加了Group类,对性能造成主要影响的是查询Group的统计量的指令,例如,查询平均年龄(qgam)、年龄的方差(qgav)和关系权值总和(qgvs)。这些指令操作类似,都需要遍历整个Group并计算。与前面的qbs类似,当出现大量重复查询的时候做了很多不必要的遍历。

解决方法

总体思路与之前类似,添加类属性保存查询结果。但方差的计算不便于在添加群成员(addPerson)的时候更新,因为一旦有新的成员加入,平均数也会随之改变,需要重新计算结果。对此,我们可以记录方差变量的状态——已修改或未修改。在查询方差的时候,若已修改则重新计算一遍,保存新值,更改状态为未修改并返回;若状态为未修改,则直接返回保存的值即可。针对qgvs指令也可以用同样的方法。

第11次作业

造成性能问题的指令

sendIndirectMessage中,需要计算2个结点之间的最短路径。大量的sim指令容易造成超时。Dijstra算法是常用的最短路径算法,可以采用,但如果不做优化,仅用原始的Dijstra算法会造成超时。

堆优化

要解决超时问题,可以使用堆优化的Dijstra算法。基本思路是:访问某个结点,需要更新起始结点到其邻接结点的最短距离时,将更新的结点加入一个优先级队列。在寻找下一个要访问的结点时,直接从优先级队列中弹出距离最短的结点。Java提供了PriorityQueue,可以很方便地实现优先级队列。相比之下,朴素的Dijstra算法在寻找下一个结点时需要遍历所有未访问的结点,时间开销比较大。此外,堆优化之后仅在同一个连通分量之中搜索最短路径,减少了很多不必要的访问。

五、设计架构

第9次作业

  • 整体架构Person类模拟人的3个属性,用容器保存与其有联系其他人。提供几个简单接口供外界访问。Network类模拟很多人的社交关系网络,相当于一个带权无向图。
  • 图模型:在addRelation时,使每个结点保存与其邻接的结点,模拟带权无向图。Network类提供查询两点连通性的方法以及计算连通分量个数的方法。查询连通性使用深度优先搜索。查询连通分量个数时不能直接采用规格中所描述的循环遍历,处于性能考虑,应该用变量blockNum计数连通分量个数,在addPersonaddRelation时更新blockNum

第10次作业

本次作业新增GrroupMessage类,增加了查询Group特性的指令。Group模拟社交平台上的群聊。sendMessage方法需要实现发送2中类型的消息,类似于私聊和群发。在sendMessage的过程中需要维护每个人的socialValue

本次作业的图模型与第9次作业相同,没有增加与图有关的操作。

第11次作业

  • 整体设计:新增3种消息类型,实现发送间接消息的机制。另外,使用容器保存emojiId,发送emojiMessage之前需要保证emojiId存在,删除emojiId时将对应的消息也从消息容器中删除。

  • 图模型:图的性质没有发生变化,但增加了寻找最短路径的操作。我采用了Dijkstra算法查找最短路径。开始使用的是朴素的Dijkstra算法,出现了超时;后来改进为堆优化的Dijkstra算法。具体思路如下:

    • (1) 初始化:优先级队列存放起始结点(距离为0),notVisited集合存放未被访问的结点,dst数组(HashMap类型)存放起始结点到各个结点的最短距离。
    • (2) 判断优先级队列是否为空,若为空则结束循环。
    • (3) 从优先级队列中弹出一个结点,遍历与其邻接的结点,更新起点到这些结点的最短路径长度(修改dst数组),将已更新的结点加入优先级队列。
    • (4) 将当前结点移出notVisited,回到步骤(2)。
    • (5) 结束循环后已计算出起点到同一个连通分量中所有结点的最短路径长度,返回结果即可。

    为了避免重复查询的数据点造成超时,再次采取“空间换时间”的办法:用3个下标一一对应的ArrayList容器保存结点之间的最短路径长度。实际上,每次执行Dijkstra算法的时候都计算出了起点到与之连通的所有结点的最短路径长度,将这些有价值的信息及时保存下来,此后若查询到计算出结果,则直接返回即可。当然,addRelation的时候图的边会增加,必须将已保存的信息清空。

posted @ 2021-05-31 22:33  李雨东  阅读(74)  评论(0)    收藏  举报