目录
这一单元主要指导我们契约式编程的学习,具体使用JML来规定规格,我们则根据规格去实现具体的代码设计。以下是个人的总结:
Part 1 第九次作业
架构设计
在架构设计上,我遵守规格的制定,实现了规格提供的Network,Person接口和4个异常继承,并且为了实现规格要求的异常技术功能额外增加了MyCounter类用于计数。同时为了优化图的性能额外实现了并查集类,对图的联通进行维护。
图模型构建
模型构建主要采用了规格所形成的天然的图节点,MyPerson类,每一个Person相当于一个节点,他有自己的邻接表,即规格中的acquaintance数组。
具体实现而言,由于规格的要求,id与Person唯一对应,所以我用HashMap<Integer,Myperson>在MyNetwork存储节点,便于加速访问。并且把acquaintance与value合二为一成HashMap<Integer,Integer>,key为acquaintance的id,value为两人之间的值。这样也加速了访问。
维护策略
由于指令驱动图的维护,我们以指令为基础谈对图的维护:
指令 | 维护策略 | 时间复杂度 |
qbs | 并查集 + 动态维护 | O(n) |
qts | 动态维护 | O(1) |
qci | 并查集 + 动态维护 | O(1) ~ O(logn) |
qbs为寻找联通块的个数,qci为查找两点是否可达,因此对图上关于这两个指令的维护可以通过并查集完成,每次加边加点时维护一下并查集。
调用qbs时只需要遍历每一个节点,看看它们的父节点是不是同一个,不同就+1.
调用qci时只需要查找这两点的父节点是不是同一个,就可以知道它们的父节点是不是同一个。
至于为什么qci的时间复杂度不确定,这是由于并查集的优化:按秩合并和路径压缩。
多次查找会不断压缩路径,直到压缩到O(1)的时间就能查出来。
qts为查找三元环,这个可以动态维护,每次加边时以这个边为基准,遍历找不在边上的点,看看能不能组成三角形,能就结果+1.
性能问题及修复
由于提前做了一些功课,知道了并查集的算法,所以在算法上没有遇到性能问题。
但是刚开始不懂jml的规格与实现分离的概念,使用数组实现了很多结构,导致查询O(n),性能较差,后面全部换成了HashMap和HashSet改善了性能。
中测,强测,互测均未发现bug。
测试
本单元秉承前两单元的传统艺能,我继续编写了自己的评测程序,找了一位同学对拍。
数据构造策略
- 观察JML规格,可以看出一些异常的Case,因此在构造策略时,要顺带构造异常情况,触发这一段代码来检测正确性。具体可以采用random的方法,当值小于0.5时构造异常情况,大于时构造正常数据。
- 对于一些性能容易出现问题的指令,比如qts,可以增大生成比例,观察自己代码的性能。
- 对于OKTEST要覆盖所有的Ensure,构造出每一个Ensure成立不成立的情况,做到全覆盖。
- 评测要量大管饱,强测1w条,那自己对拍就10w,100w条,看看这种极限情况下和对拍程序会不会出现分歧,可以很好的保证正确性,毕竟错误是累积的。
Part 2 第十次作业
架构设计
在架构设计上,我同样首先遵守规格,继承或者实现接口。本次没有新加一些类来辅助设计。
图模型构建
抛开现象谈本质,本次作业Group的引入就是天然引入了一种子图,认为的将总图的一部分划分为一个子图,而Message只是在图上的一些操作,道理上比较简单。
维护策略
让我们继续结合指令来谈图的维护
指令 | 维护策略 | 时间复杂度 |
qgvs | 动态维护 | O(1) |
qgav | 静态更新 | O(1)或O(n) |
qba | 动态维护 | O(1) |
qcs | 静态更新 | O(1)或O(n) |
qsv | 动态维护 | O(1) |
qrm | \ | O(1) |
以上是涉及到了优化的指令,其余指令都是O(1),不包含任何算法及优化,按照JML直接实现即可。
本次作业相比于第九次,对于图的维护没有新增大算法的支持,更多的是一些比较简单高效的优化:
- 动态维护:在对这些指令产生影响的地方动态维护这些值,每次查询时即可O(1)返回结果。
- 静态更新:每次查询时看看能不能利用上一次查询的结果,如果不能,就重新计算更新结果
动态维护的典型qgvs,可以在加边,改边,向组内加人,删人时动态更新值。查询时O(1)直接返回答案。
静态更新的典型qgav,维护一个bool变量,标记上次查询答案是否仍旧可用,如果在上次查询后,没有对这个值产生影响的指令出现,那答案仍然可用,直接O(1)返回,否则就重新计算O(n)并更新答案。
比较值得一提的是改边值维护图时需要注意:如果边因为改边值被删,就要重新维护并查集的正确性,这个维护比较消耗性能,大概是O(n)的级别。
主要操作是bfs被删边的一点A,将bfs到的点的父节点都改成该点,检查边的另一点B的父节点是否也被改成了该点。
如果被改了,那说明删掉这条边这两点仍可达,不影响连通性。
如果没被改,那说明删掉这条边这两点不可达,原先的联通快分裂成了两个联通快,此时已经维护好了A所在的联通块,再次bfs节点B,将bfs到的点的父节点都改成B点,就维护好了B所在的联通块。
性能问题及修复
主要性能瓶颈就是在刚才提到的这些指令上,如果不做任何维护,来一次算一次的话,计算代价是巨大的,所以根据指令的特性有了动态维护/静态更新。
其次就是删边,并查集并不支持删边,这让处理删边变得十分的困难,如果每次删边直接重新生成新的并查集,正确性是有的,就是性能太差。所以最后采用了O(n)的bfs更新法。
中测,强测,互测均未发现bug。
测试
我在HW9的基础上更新了HW10的评测程序,主要是数据生成,这次的数据生成只能用十分复杂来形容。
数据构造策略
- 首先依旧是老生常谈的根据JML构造数据生成,覆盖每一个质量的所有分支和异常情况
- 本次最复杂的构造是OKTEST,21个ensure和4个异常简直是毁灭性的打击,在此基础上还要保证数据的正确性,不然会被Runner过滤,构造策略如下:
- 由于返回最小的特性,要同时生成多个错误,同时保证正确性,检验OKTEST是否有返回最小值的特性。
- 对于ensure 9.10的构造,要完全破坏正确性,按照符合逻辑的数据是无法测9.10的,这也就是为什么指导书不要求afterData的正确性:
- 仍然要善用if-else保证每个ensure覆盖的全面,每个异常也要检测全面。
- 构造时不能纯随机,要智能构造,即记录自己随机下的边和点,有选择的改动边和点产生符合OKTEST正确性检验的错误数据。
Part 3 第十一次作业
架构设计
架构设计上,依旧是首先按照JML的规格实现接口或继承类。
除此之外,为了设计方便,本次引入了两个辅助类,一个是edge类,将从HW9一直提到的边具体化存储,一个是MyHeapRecord,仅仅为了返回值可以是两个,避免引入pair.
为了算法性能考虑,我弃用了Java自带的小顶堆——优先队列,自己手写了一个小顶堆,并用类封装,便于对dijkstra算法进行更高效的堆优化。
图模型构建
第十一次作业在图模型上的更新几乎没有,重点是在图的操作扩展上,即图的维护。
维护策略
让我们继续结合指令来谈图的维护
指令 | 维护策略 | 时间复杂度 |
cn | \ | O(n) |
qp | \ | O(1) |
dce | 建立索引+动态维护 | O(n) |
qm | \ | O(1) |
qlm | 堆优化dijkstra+边遍历 | O((m+n)logn + m) |
对图的维护针对重点是在cn、dce和qlm上:
- 对于cn:不需要额外维护图,暴力在我看来就已经是最优,即检索该person的信息队列,删除notice message即可,复杂度为O(n)
- 对于dce:为了优化,我额外维护了图中的一个元素:HashMap<Integer,HashSet<Integer>>,其中key为emojiId,hashset存储所有Message的emojiid为key的message.在每次addMessage和sendMessage时动态维护。这样在删冷门表情时,无需再次遍历message数组,只要确认某emojiid为冷门,直接根据自己建立的索引删掉hashset中对应的message即可,复杂度为O(n)
- 对于qlm:对图做了一个很大的维护,找经过某一点的最小环.
- 首先进行了堆优化的dijikstra,由于java不能直接修改堆中值,可以间接维护,比如说先删再加,但删的速度据说是O(n),为了防止性能过低,我手写了一个小根堆,即为了维护图的最小值建立了mysmallheap类。改值的时间复杂度为O(logn)。
- 之后利用并查集,只对同一联通块里面的点进行dijk,进行了dijk的剪枝,得到结果后,仍然需要利用dijk的结果求解环的最小值。
- 对此,我维护了一个新的类,edge正式在第三次作业将前两次虚拟的边实际化,动态维护边数组之后求环最小值时直接枚举边,得到最终结果。
性能问题和修复
性能主要出现在qlm上,起初我在求环最小值时枚举点,是一个O(n^2)的复杂度,由于我枚举的是组内点,我不认为性能会很差.
但对拍时发现性能远远差于另一位同学,经过定位发现是这个问题,所以最后动态维护了边集,由枚举点变成枚举边,O(n^2)变O(m)。
中测,强测,互测均未发现bug。
测试
我在HW10的基础上更新了HW11的评测程序,主要是数据生成,相比于HW10,这一次的工作量会比较少。
数据构造策略
- 基础仍然是根据JML构造数据,覆盖normal_behavior和exceptional_behavior
- 比较复杂的构造是在qlm中,由于qlm需要查找的是经过某一点的最短环,所以在生成数据时,还是要写一个并查集边生成边记录一下联通块,保证生成的时候不盲目生成,而是以较大概率选择可以成环的点。否则完全随机会很难生成可以成环的数据。
- 其次是在这一次的OKTEST,这一次的OKTEST相比HW10还是简单了很多,ensure比较少,进行枚举即可。仍然是要保证生成多个错误并保证可以通过runner的检测。这样才能测试最小值的约束。
Part 4 总结
对黑箱、白箱测试的理解
按照课上老师所言,黑箱测试就是给定输入,看输出是否正确,白箱测试就是通过真正的阅读代码去发现问题和缺陷。
更深入的了解,黑箱测试检测代码能否按照规格的规定正常工作,是否有功能遗漏;
白箱根据程序内部的结构进行测试,检验程序中的每条通路是否都有能按预定要求正确工作。也因此叫“穷举路径测试”,而不太兼顾功能。
所以两者之间互为补充,缺一不可,这也是为什么老师经常强调互测时不能只是做黑箱测试,而是也要做白盒测试,多看别人的代码。
对单元、功能、集成、压力、回归测试的理解
- 单元测试:单元测试是针对软件中最小的测试单元进行的测试,这里最小的测试单元可以指一个函数、方法或者类,根据实际需要而定,主要目的是单独测试这些模块是否可以正常工作且工作结果是否符合预期。千里之行,始于足下,单元测试在我看来是十分重要的,只有把每一个模块测试好,真正组合模块时的测试将会事半功倍,不用再担心模块内部的错误。
- 功能测试:功能测试是验证程序能否按照规格真正实现了功能,关注的是程序的输入和输出。
- 集成测试:集成测试是测试程序中不同模块之间能否协同交互正常工作。重点测试模块之间的问题,比如接口问题、数据传递问题等等。显然,它一般是在保证模块正确之后进行,即进行单元测试之后进行。
- 压力测试:就是对程序施加压力,比如大数据量输入下观察程序的运行情况。目的是评估程序的性能和稳定性,放到这一单元,我对拍时输入10w,100w的数据量就是一种压力测试。而压力测试可以帮助我们很好的发现性能问题。
- 回归测试:在程序修改后,我们要保证原先能够通过的测试点仍然能够通过,所以要用之前的测试用例重新测试新的程序,这就是回归测试。只有这样才能大致保证我们没有拆东墙补西墙。
- 这五个测试在工程开发的不同阶段进行,每一个测试都很重要,综合利用它们可以帮助我们很好的开发出优良的程序。
工具的使用
首先这一单元我并没有使用一些针对于JML的工具,比如老师十分推荐的Junit......,这是由于Junit仍然需要自己编写,比较麻烦,且我调研了一下过去的学长们的反响,感觉不太好用就没有进行,而是继续采用了Python编写了自己的数据生成器和对拍器进行了黑箱测试。
但是我也得承认,Junit所提供的单元测试作为白箱测试的一种,有其不可替代的作用。如果时间允许,我也一定会使用其进行白箱测试,进一步保证程序的正确性。
对规格与实现分离的理解
这是一种软件设计原则,对于给定的规格,并不是一定要按着规格的定义去写代码,我们真正要做的是找一个算法或者好的方法完成规格要求即可,而不是去翻译规格的定义。
这就是规格与实现分离,好比程序设计中的一道编程题,题目只是给出输入输出和要求,用什么方法,什么结构都是不限定的,只要结果正确,也就是满足规格的前置后置不变式即可。
而且规格的作用本身就是描述系统应该做什么,而不是限定了实现方式。
这样做保证了灵活性和可维护性,规格的改变不会影响直接去影响实现,实现的变化也不受规格的过多拘束。显然提高了系统的灵活性和可维护性。
对OK测试的总结
OKTEST对于检验代码实现与规格的一致性的作用是十分巨大的,因为其一条一条检测了ensure,采用穷举的办法检验代码实现是否严格按照规格实现,在我看来在一致性检验上是十分完备的。这确实是一种最为稳妥、稳定的方法。
所以2023的OO课程引入OKTEST我是十分认同的,编写这个方法让我深刻体会到了第三单元的重点——契约式编程,十分符合第三单元的主旨所在。
OKTEST确实是一个优秀的学习契约编程思路,如果一定要提出建议的话,我希望对于OKTEST的编写应该循序渐进,这一单元我个人认为OKTEST的难度:2>3>1,这个顺序是不太符合迭代思路的,也就是我认为难度梯度应该是3>2>1。
学习体会
第三单元是契约式编程,体现核心一是课程租给出JML,我们根据JML这个契约来实现方法,二是OKTEST。
以JML为例,契约有着方法中的前置条件,后置条件的约束,变量有着不变式、状态变化等约束,这些约束帮助我们很好理解程序的预期行为,对实现目标有了一定的概念,可靠性高。且这些约束又可帮助我们在编写完毕后进行测试,可以说是魅力十足。当然,三次作业下来,我也不敢说自己完全掌握了契约式编程,以后还需要更加多的接触和尝试。
或许是为了让我们深刻体会到规格与实现分离的思想,同时也引入了一些性能的考察,在这种情况下,如果只是去翻译JML规格,会因为性能过差而损失许多分数。对我来说,这个策略确实是有效的,这一个单元结束,我深刻体会到了规格与实现分离的重要性,并且也会牢记于心,在日后工作时用到这个思想来设计程序。
也正是因为此,我也借此学习到了一些新的算法,比如并查集+按秩合并+路径压缩,堆优化的dijkstra等算法。对我的算法学习也是大有帮助。
最后,面对时常有大佬分享评测程序的情况,我仍然坚持编写了属于自己的评测机,在编写的过程中,我也再一次加深了对于JML的理解,因为数据生成规则就是针对JML的各种情况对应编写而来的,也算是再次给了我锻炼的机会。让我从测试的角度体会到了契约式编程。
总而言之,虽然努力学习算法是痛苦的,虽然按照规格写代码有时候规格太长,读起来也是痛苦的,但这一单元收获显然是巨大的。