面向对象设计与构造 Unit3 博客

曾文轩-22373305 学生 2024-05-19 15:03:55

0 前言

本单元的核心内容是通过JML这一工具来描述规格,来理解和体会规格化的程序设计。

而在这一单元的作业中,主要需要完成的有两个核心的工作,一个就是阅读理解JML,对JML进行的设计进行实现,另一个则是进行算法分析,选择合适的实现算法,以满足作业中,对每步操作的复杂度要求大约在$O(n\log^{\alpha}n)$内的限制。

1 架构设计与算法分析

总体架构

这个单元的作业中,由于有JML规格的要求与需要实现的接口的要求,总体架构已经基本帮助我们设计好了。

顶层是Network类,作为一个完整的社交网络,管理着这个社交网络中设计的所有的人Person,消息Message。

Person类是社交网络中的核心,每个人管理着他所有的熟人acquaintances,他的所有群组tags,所有收到的短信messages。

Tag类的一个对象是一个Person的群组,管理着他的熟人acquaintances中的一部分人。

Message类由三个具体的消息种类继承,具有发送者和接收者的属性,先被加入到社交网络中,再被发送。

图结构

作业中的社交网络是一个无向(可有环)的图结构,每个Person就是这个图中的结点,Person之间的熟人关系acquaintance就是图中的无向边。

在JML的设计中,这个图没有直接存储在社交网络Network中,而是分散存储在每个Person的acquaintances中。

在我的实现中也没有增加额外的存图方式,直接用每个Person的acquaintances数组作为邻接表。

算法要求分析

在强测的数据范围限制中,指令的条数为$10^4$条,load_network指令最多使用1次,可以load最多$10^2$个人,相应的,最多$10^4$条边。

而除了load_network之外,加人或加边最多只能一个人一个人加,一条边一条边加。

因此,该网络中,最多有$10^4$数量级的人,$10^4$数量级的边。

而指令最多$10^4$条。因此,每种指令最多都必须在$O(n\log^{\alpha}n)$的复杂度内完成。

如果某条指令达到了$O(n^2)$类的复杂度,则只要让这条指令反复执行达到$10^4$数量级,就会TLE。因此这种情况必须优化掉。

而对于在$O(n\log^{\alpha}n)$复杂度范围内的指令,则在理论上没有必要进行进一步的优化。如果能进行无副作用的优化,如之后提到的在queryBestAcquaintance当中使用最大堆来维护,则可以选择进行优化以使之更漂亮。而对于会产生副作用,如会大幅提高另一些操作的复杂度乃至于超过$O(n\log^{\alpha}n)$,则不如不进行这种“优化”。

数据结构的选择

在核心数据结构上,经过上面的分析,选择了邻接表来存储全图。

而对于邻接表的存储,为了插入、查找、删除的效率,采用HashMap或TreeMap来存储,保证在$O(\log^{\alpha}n)$的复杂度内完成,而不需要$O(n)$的复杂度遍历完成。

同时,采取HashMap或TreeMap,还能直接保证无重复元素。

而对于有加入顺序,并且需要在头部插入的messageList(每个Person收到的消息的列表),采用链表LinkedList存储,实现$O(1)$内完成。

部分指令的算法实现分析

为了满足在$O(n\log^{\alpha}n)$的复杂度内完成每条指令的要求,部分指令需要进行专门的处理,设计合适的算法来实现。

连通性和连通分量

对于Network中的两个方法,isCircle查询两个结点的连通性,以及queryBlockSum查询图中的连通分量个数,很多同学采用了并查集的数据结构来维护。但是我认为这样做没有太大的好处。因为并查集是对于“并”和“查”能维护在$log(n)$复杂度内,但是我们作业中还有“删”的操作,对于“删”的操作,都必须把并查集重建,而重建操作需要$O(n\log n)$的复杂度。

而如果直接进行dfs搜索,已经可以保证稳定的$O(n)$复杂度。

此时,采取并查集的方法,虽然会在一些数据或者说很多数据上效率会更高,但是在最坏复杂度上并没有优势甚至有劣势。只要进行大量的删除操作,劣势就会暴露出来。

就算采用脏位(dirty bit)的方法进行优化,删后查询时才重建,也没有实现复杂度层面的优化。只要采取,删一次查询一次,这两种操作间隔出现,就仍然会暴露出劣势。

虽然$O(n\log n)$仍然不会超时,但是已经没有优势了。在有删的情况下使用适用于并查的数据结构,也并不漂亮。

tripleSum维护

对于queryTripleSum操作,如果每次查询,则需要$O(n^3)$复杂度,则必须要进行维护。

所谓维护的实质,就是把一个操作的复杂度均摊到别的操作中,让各操作的耗时之间均衡,都小于$O(n\log^{\alpha}n)$。

对于tripleSum,则是每次在加边前和删边后,都计算会新形成的三角形个数和会破坏的三角形个数,这样做复杂度为$O(n)$,而查询tripleSum复杂度为$O(1)$。

bestAcquaintance维护与coupleSum查询

对于bestAcquaintance的维护,我采取的是维护当前的bestAcquaintance的Person对象,而每次加边或者增加边权时,检查是否超过bestAcquaintance的边权,复杂度$O(1)$;每次删边或减小边权的时候,若操作的是bestAcquaintance,则重新遍历计算bestAcquaintance,复杂度$O(n)$。

这样做一般情况复杂度为$O(1)$,而最坏情况复杂度为$O(n)$,对于特定数据:反复删除与bestAcquaintance的边或者减小与bestAcquaintance的边权,则会出现一直重建,一直$O(n)$,不太漂亮,但是不会超时。

而如果采取最大堆来实现,则可以实现稳定$O(\log n)$的复杂度,比较漂亮。

而由于bestAcquaintance有维护,可以实现$O(1)$的查询,则coupleSum可以直接遍历计算,复杂度$O(n)$。

valueSum维护

valueSum是这个单元中的作业中必须要维护的最复杂的量

首先,如果不对valueSum进行维护,则会需要进行$O(n^2)$复杂度的查询,不能允许。

因此必须进行维护。

而这个维护需要涉及到的问题很多,因为很多操作都会直接影响到valueSum。

首先,增加或删除Tag里的人,会影响到valueSum,直接遍历Tag里的人,加上或减去Tag里所有人与该人的边权乘2。

而改变两个边的边权也会直接影响到很多个Tag的valueSum。

为了实现这个维护,就需要额外存储每个Person所在的Tag,改变边权的时候,遍历其中一方的TagIns,若另一方也在Tag中,则需要相应地改变该Tag的valueSum。

这个过程还要注意不要进行了重复的改变操作。

另外,为了维护每个Person所在的Tag,还需要在addPersonToTagdelPersonFromTagdelTag的时候对被add和del的Person以及被del的Tag里的Person修改TagIns。

性能问题与修复

这个单元的作业中没有出现bug或性能问题。规格与实现相分离的理解在学习体会中有详细谈到。

2 测试

黑箱测试与白箱测试

  • 黑箱测试就是对于程序进行不考虑程序内部的实现,通过把程序当作“黑箱”,给定输入,对比程序的输出和预期的输出,来对程序的行为是否正确进行测试。

  • 而白箱测试就是常说的代码走查,直接对代码本身进行分析,检查代码的实现中是否有问题。用人脑对代码进行执行,检查代码执行过程中的每一个分支是否会出现问题,从而直接发现代码中的问题。

常用的评测机测试就属于黑箱测试,用数据说话,对于经典的“感觉无懈可击啊()”的情况很有力,而且因为可以实现自动化,所以可以用大量的数据来进行大规模的测试。并且“可移植”,构造出了一个强的数据之后,可以无成本的应用到所有人的代码中。但是缺点是理论上就不具有完备性,经过再多的数据测试,也完全不能保证真的完全没有问题了。

(极端情况下,如果代码中写了一个“定时炸弹”,获取系统时间,在很久时间(保证在测试之后)故意运行出错,那么黑箱测试完全从理论上就已经没有办法查出来)

而“瞪眼法”就属于白箱测试,直接看代码是否有问题。优点是如果真的能对代码的每一步逻辑都进行全面的严格的分析乃至证明,理论上可以确保代码的正确性。但是缺点是在具体实践上,人脑考虑不了这么清晰与全面,容易忽略问题、出错误。

单元测试、功能测试、集成测试、压力测试、回归测试

  • 单元测试:对每个最小的可测试单元(即方法、函数、类)都进行一一的测试。这种测试方法拆解了测试整个大系统的复杂性,通过一一保证各个单元的正确性,来一步步保证整个系统的正确性。

  • 功能测试:对系统的功能进行验证,确保要实现的每一个功能都能按照预期进行工作,实现预期结果。

  • 集成测试:在把系统的每个模块组合起来的时候,对各个单元模块进行组合测试,验证各个模块之间的交互行为是否正确,来检测模块之间的接口和交互是否能正常工作。

  • 压力测试:在超负荷条件下测试软件系统,观察系统的稳定性和性能,确定系统在极限条件下的行为,并找出其崩溃点或性能瓶颈。

  • 回归测试:在软件修改或更新后,重新测试系统以确保未引入新的错误,确认新代码的改动没有破坏已有功能。

数据构造策略

在这个单元的作业的测试数据构造当中,要把,构造大量数据来进行覆盖性测试,以及专门构造特殊情况下的数据来对临界情况进行测试,结合起来。

比如对于queryTripleSum的测试当中,构造数据时就需要考虑到实现queryTripleSum的各种情况。包括不维护的计算和维护的各种实现方式,充分考虑到代码中各种可能忽略的问题。比如删边的时候未进行正确的维护等等。

因此,构造的数据就需要全面包含加边、改边、删边等各种情况。

而对于deleteColdEmoji的测试当中,构造数据时的一个重要的点就是,需要考虑到各种极限情况,比如参数limit需要包含0和heat的最大值,充分测试到各种极端情况下的执行情况。

基于JML规格的Junit测试

在JML规格中,关键的是\requires\assignable\ensures\signal

首先,对于构造出来的数据进行\requires检查,测试的标准程序要按照数据满足的\requires条件下的逻辑进行执行。若不满足任何一个\requires,则不提交给该方法。

其次,要对\ensures保证的输出或者修改进行检查。对于\assignable允许修改的量,检查是否进行了对应的修改。

很关键的一点是需要对\assignable进行检查,即没被\assignable声明的量,或者声明了\pure方法的所有量,检查方法执行前后是否发生了变化。

3 学习体会

在这一单元的学习中,我确实体会到了被反复强调的“设计与实现分离”的思想,或者更具体的说就是体会到了,“设计”这一步的意义。

在我们平时的代码编写当中,往往是设计与实现相耦合的,不会有一个专门的具体的设计过程,最多的一个接近于“设计”的过程就是,所谓的想”思路“,想”架构“的过程。

而这个单元的作业,就是真正把设计这个过程,用规格的方式,用JML的语言呈现出来。效果上就是,提供给我们的东西,是一个已经完成了设计这个过程,剩下实现这个过程的东西。

有一个点在写作业的时候没有感觉,但是回头看才有感觉的,就是,虽然这次作业感觉比前两个单元以及OOpre轻松很多,但是仔细看之后发现,这次作业的代码量,比以前事实上是多了很多的。我的Source Code Lines为2083行,省掉一些因为接口定义造成的冗余的行数,也有至少1800行。这次的情景,作为一个图的问题,也没有特别的简单。所以,设计这个工作的完成,确实给代码编写降低了很多的难度。这个单元的作业让我体会到了,设计这个我们平时不注意的步骤,在整个项目的复杂程度中占有了很大的比例。

同时,我们从这次作业中出现的问题也可以看出,设计这个步骤是很容易出问题的。同时,这样一个“设计与实现相分离”的分工结构,也增加了非常多的冗余工作量,比如,设计者设计出的思想,需要花时间转换成JML,实现者,又要花时间去阅读和理解JML。但是,JML的严谨性,确实可以在分工合作当中,实现很好的“问责制度”,因为没有歧义,所以是设计者的问题还是实现者的问题,可以非常清晰,不会产生耦合,也就是不会因为歧义导致双方各执一辞,双方都不觉得错了。所以从这个角度可以理解成是,JML相当于是以冗余作为代价,实现了一个设计者与实现者之间的清晰无耦合的接口。

除此之外,这个单元的作业还让我体会到了许多时间复杂度控制的思想,比如用维护的手段,把时间复杂度均摊到各个操作中。

...全文
77 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

301

社区成员

发帖
与我相关
我的任务
社区描述
2023年北航面向对象设计与构造
学习 高校
社区管理员
  • YannaZhang
  • CajZella
  • C_ecelia
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧