JML初窥与提升 - 2021 面向对象程序设计第三单元总结

第三单元的OO作业也是有一个 共同的主题 —— 即通过实现官方给出的 JML 规格,实现一个社交关系的模拟以及其相关信息增删查询的交互。这个单元和前两个单元的难度相比已经有了明显的下降,因为实现所用的设计思路以及相关方法的规格都已经给好,真正自己写的时候并不需要考虑思路上的问题,而只需要控制一下相关复杂度就可以了。

1. 设计策略

  1. 首先快速阅读作业指导书的各个 方法 的定义。从各个方法的英文全称,或其对应的 JML 规格中的 public normal_behaviorassignable,推断出来每一个方法的实际意义。
  2. 阅读各个 接口 的规格,由于之前已经快速对每个方法的 JML 规格进行了阅读,且 public instance model 段给的是模糊的属性定义(使用的容器类别是需要自己定义的),因此我们可以很快速的定义出来合适结构中存放的需要维护的对象。
  3. 预估即将要写的代码段的复杂度,尽量控制越低越好。如果在写每一个方法的途中发现了可以优化的地方,那么可以直接更改,或者也可以新建属性以添加维护对象,达到类似于缓存的目的。
  4. 依次实现,并完成程序,后通过逻辑构造或通过对拍找 bug。

2. 设计测试

由于本单元作业较为简单,因此这次写作业开始的时候拖到了周六才开始写,那个时候有同学提示过 Junit 的测试很弱,后来我自己也尝试了一下,自己 bug 隐蔽一点就很难测试出来,所以后来抛弃了 Junit。

本着不允许传看同学的文件的原则,我先开始对每一个指令进行 手动测试,整体上来说这个过程也不复杂。由于是社交网络模拟,因此手动构造一个社交关系图,并尝试在社交关系上添加信息、发送信息、删除信息、添加组等等的一些列操作。

再到后来,使用互测限制数据类型集合的 超集 生成测试数据,进行基础输出测试,并和同学的 Jar 包对拍查错误。这个查到的错误就很随机了,一般用到这个查出来的错误都是很隐蔽的错误。

最后检查可能 TLE 方法的复杂度测试,这部分手动构造即可,且单方法在公测数据范围内时间复杂度为 \(O(n)\)\(O(n^2)\) 的效果一目了然,因此测试部分就基本完成了。

3. 容器选择

如 1 中所说的那样,开始补全代码之前,首先要通读接口和相关方法的 JML 规格。由于复杂度还是需要控制一下,因此思考所知的一些数据结构。

显而易见用 Hash 进行插入、删除等的效果是最好的,时间复杂度为 \(O(1)\)。因此我在 MyPerson 类中定义的 acquaintancevalueMyNetwork 中定义的 personNet netgroupSetmessageSetemojiHeatSet,以及所有的异常类的记录构建,由于可以使用 id 对应每一个对象,因此全部都使用 HashMap 容器,对于 emojiSet,使用 HashSet 即可。

另外由于 Person 类中的 Message 会涉及到拷贝和头插问题,因此我们可以使用链表容器 —— LinkedList 来完成该属性定义。

最后一次作业中的 Dijkstra 算法使用了 PriorityQueue 进行堆优化。

这样定义之后,本身容器的查找、删除、插入等的时间消耗已经降到最低。

4. 性能分析与Bug 分析

性能分析

根据相关的时间、指令条数限制,以及对应的方法规格要求,我们很容易发现每一次作业都会有卡时间复杂度导致 TLE 的指令。比如说第一次作业中 isCirclequeryBlockSum 的方法,第二次作业中 getValueSumgetAgeMean 的方法,第三次作业的 sendIndirectMessage 的方法等等。

我相信已经有很多同学写到这些方法的实现方式,以及算法选择。下面我来简单描述一下我的各个卡 TLE 方法的开发过程。

在第一次作业中,我在 NetWork 类全局使用了普通的并查集路径压缩,即没有使用 秩优化 的并查集,虽然会稍微慢一些,但基本上够用。对于 queryBlockSum 我却是使用了 \(O(n^3)\) 的时间复杂度进行区域计算,费时费力。queryNameRank 方法我知道是 \(O(n)\) 的操作,但分析了一下,包括后面 3 次作业,如果单方法刚刚好够 \(O(n)\) 就基本不会 TLE,所以没有进行优化。实际上想要优化,可以构建二叉查找树,插入的时候更新沿途节点的序号,不过也是有限的优化,因为不确定最后树形成的样子,最差的情况下复杂度也就是 \(O(n)\)

在第二次作业中,我把第一次的 queryBlockSum 改掉了,变成了增加点和连同块合并的时候同步维护 \(±1\),做到了 \(O(1)\) 的复杂度。getValueSum 方法我直接在加、减人以及添加边的时候就进行维护,这样避免了每次查询都需要通过 \(O(n^2)\) 的复杂度完成操作,该方式同步适用于 getAgeMean

在第三次作业中,由于使用可利用 lambda 表达式的 removeIf 方法,最后导致了时间的异常偏大。后来由于无法忍受,我分别使用过迭代器和 foreach 重新构建了 deleteColdEmoji 方法,发现速度已经可以达到理论范围,同时 sqc 同学同步发布了帖子,证明了时间开销的问题,最后留下了 foreach 版本。之后就是 sendIndirectMessage 方法中用到的 dijkstra 算法,我个人使用了堆优化,PriorityQueue

所有的一切都做好了之后,由于 第一单元 的教训,为了防止函数栈过于深,每一次调用的对象需要嵌套多个方法且需要多次访问该对象时,会使用新声明变量直接进行代替,防止每次访问该对象导致的时间耗费过多的问题,经过测试,这个优化还是应当做的。

Bug分析

本单元的 bug 产生于自己的编程习惯。

第一次作业中,由于周日才开始写作业,慌乱之中 Person 类中维护单源关系的 HashMap 被我无情的套上了 static,导致了非常猛烈的 bug 轰炸。

第二次作业和大佬们都在一个屋子,没有办法hack,也没有办法被hack。

第三次作业中,NoticeMessage 自带的 socialValue 被我惨烈的初始成了 \(0\),原因却是因为切换文件的时候快捷键按的太快了,导致原来改的被 Ctrl+z 撤回了,自己竟然浑然不知。对拍的时候一直 qsv 指令错误,以为自己发生了什么奇幻 bug,让自己感觉到天快塌下来了,最后也没能检查出来是这里错误了(对拍开始的过晚)。

互测的时候尝试疯狂 hack 人,基本除了抛出异常的错误之外,其余的都会发生在时间复杂度过高的情况,如上文描述的一般。

两个问题

这次的性能分析中我遇到了 2 个问题,但没有找到确切的原因,俗话说的好,重写解决一切问题,所以我也对出现问题的方法进行了重写,下面是我的疑问:

① 有同学在研讨课上强调 HashMap.containsKey()HashMap.get() 会有差异,前者用得次数很多时,会导致超时问题,对此我表示质疑,两者同时调用 getNode(),为何会出现性能相差一倍的效果(前者查找 2 次,后者查找 1 次)?

② 在我进行并查集优化的时候,曾经写下了一行仿 C 语言的代码:net.put(x,find(h.get(x)))。这句代码直接写是可以执行的,但放入 HashMap 中的数据时再次加入其它逻辑就会出现问题,推测应该是 HashMap 的自身问题,但是百思不得其解。

5. 学习心得

不论怎么讲,对于一个工程来说,接口的定义是毋庸置疑很重要的,但对于 JML 来讲,似乎并不适用于工程的快速迭代,而更像是训练程序员的编程结构性,以及对于方法自定义的严谨性。

JML 提供语义来描述 Java 模块的行为,防止模块设计者的意图与编程者的理解产生歧义。但即使可以被形式化验证结果,效率也是极低的。

因此我们练习 JML 更多的关注了实现方式,或者代码的规范,而不是像前几个单元一样自己设计各个类之间的方法或接口的规则。

不知不觉 OO 课程来到了最后一个单元 UML,希望自身学习可以善始善终。加油!

posted @ 2021-06-01 00:19  LD曲率空间  阅读(69)  评论(0编辑  收藏  举报